[Infra] Implement dashboard tab UI in host details (#178518)

Closes #176070
Closes #178319
Closes [#175447](https://github.com/elastic/kibana/issues/175447)

## Summary 

This PR adds a dashboard tab to the asset details view. This is the
first version of the tab and it looks similar to the APM solution in
service overview.

⚠️ After [this PR](https://github.com/elastic/kibana/pull/179576) was
merged the structure of the API changed and the saved object is now one
per linked dashboard (not one per asset type as before). Those changes
will give us more flexibility in the future and the endpoints allow us
to edit/link/unlink a dashboard easier than before (and we don't have to
get and iterate through all dashboards when updating/deleting)

The new structure of the saved object is now : 
```javascript
    "properties": {
       "assetType": {
         "type": "keyword"
       },
       "dashboardSavedObjectId": {
         "type": "keyword"
       },
       "dashboardFilterAssetIdEnabled": {
         "type": "boolean"
       }
     }
```

This initial implementation will show the dashboard tab **ONLY** if the
feature flag (`enableInfrastructureAssetCustomDashboards`) is enabled
(this will change)

## Updates: 
- #178319 New splash screen

<img width="1909" alt="image"
src="e595c4bb-fdbb-415b-b778-3dc39af20b54">

- The new API endpoints added in [this
PR](https://github.com/elastic/kibana/pull/179576) are now used
- Fix switching dashboards not refreshing the content and not applying
filters issue

## Next steps 

- [ ] [[Infra] Dashboard locator |
kibana#178520](https://github.com/elastic/kibana/issues/178520)
- [ ] [[Infra] Dashboard feature activation |
kibana#175542](https://github.com/elastic/kibana/issues/175542)


## Testing 
- Generate some hosts with metrics: 
- `node scripts/synthtrace --clean --scenarioOpts.numServices=5
infra_hosts_with_apm_hosts.ts`
   - or use metricbeat/remote cluster

- Enable the `enableInfrastructureAssetCustomDashboards` feature flag

- Go to Hosts view flyout / Go to Asset details page
   - Link a dashboard
   - Edit the linked dashboard (enable/disable filter by hostname)
   

ad6b87aa-e2de-42fa-9565-4bfe32ffd146

   - Unlink a dashboard: In case of unlinking: 
       - single dashboard -> empty state 
       - multiple dashboards -> other dashboard


4f39f3aa-b7fa-407d-8991-79d19d3ee076

- Navigation between Hosts view flyout / Asset details page (Click `Open
as page`) and persisting the state:


98756cb0-7675-4bc0-9e14-0fb8d95cce30

- Link custom dashboard (create a dashboard and link it after)

<img width="1914" alt="image"
src="9739f355-39b9-4b1d-b98e-0a47db9095df">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
jennypavlova 2024-04-10 12:45:25 +02:00 committed by GitHub
parent 674736d927
commit d3fe5434b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1435 additions and 9 deletions

View file

@ -282,7 +282,7 @@ function getEditSuccessToastLabels(dashboardName: string) {
}
),
text: i18n.translate('xpack.apm.serviceDashboards.editSuccess.toast.text', {
defaultMessage: 'Your dashboard link have been updated',
defaultMessage: 'Your dashboard link has been updated',
}),
};
}

View file

@ -18,3 +18,7 @@ export interface InfraCustomDashboard {
export interface InfraSavedCustomDashboard extends InfraCustomDashboard {
id: string;
}
export interface DashboardItemWithTitle extends InfraSavedCustomDashboard {
title: string;
}

View file

@ -21,7 +21,7 @@ const SavedObjectIdRT = rt.type({
id: rt.string,
});
const InfraCustomDashboardRT = rt.intersection([AssetTypeRT, PayloadRT, SavedObjectIdRT]);
export const InfraCustomDashboardRT = rt.intersection([AssetTypeRT, PayloadRT, SavedObjectIdRT]);
/**
GET endpoint
@ -59,3 +59,4 @@ export const InfraDeleteCustomDashboardsRequestParamsRT = rt.intersection([
AssetTypeRT,
SavedObjectIdRT,
]);
export const InfraDeleteCustomDashboardsResponseBodyRT = rt.string;

View file

@ -16,6 +16,7 @@
"data",
"dataViews",
"dataViewEditor",
"dashboard",
"discover",
"embeddable",
"features",

View file

@ -51,4 +51,10 @@ export const commonFlyoutTabs: Tab[] = [
defaultMessage: 'Osquery',
}),
},
{
id: ContentTabIds.DASHBOARDS,
name: i18n.translate('xpack.infra.infra.nodeDetails.tabs.dashboards', {
defaultMessage: 'Dashboards',
}),
},
];

View file

@ -9,7 +9,16 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { DatePicker } from '../date_picker/date_picker';
import { useTabSwitcherContext } from '../hooks/use_tab_switcher';
import { Anomalies, Logs, Metadata, Osquery, Overview, Processes, Profiling } from '../tabs';
import {
Anomalies,
Dashboards,
Logs,
Metadata,
Osquery,
Overview,
Processes,
Profiling,
} from '../tabs';
import { ContentTabIds } from '../types';
export const Content = () => {
@ -23,6 +32,7 @@ export const Content = () => {
ContentTabIds.METADATA,
ContentTabIds.PROCESSES,
ContentTabIds.ANOMALIES,
ContentTabIds.DASHBOARDS,
]}
/>
</EuiFlexItem>
@ -48,6 +58,9 @@ export const Content = () => {
<TabPanel activeWhen={ContentTabIds.PROFILING}>
<Profiling />
</TabPanel>
<TabPanel activeWhen={ContentTabIds.DASHBOARDS}>
<Dashboards />
</TabPanel>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -59,6 +59,7 @@ const TabIdRT = rt.union([
rt.literal(ContentTabIds.LOGS),
rt.literal(ContentTabIds.ANOMALIES),
rt.literal(ContentTabIds.OSQUERY),
rt.literal(ContentTabIds.DASHBOARDS),
]);
const AlertStatusRT = rt.union([
@ -84,6 +85,7 @@ const AssetDetailsUrlStateRT = rt.partial({
logsSearch: rt.string,
profilingSearch: rt.string,
alertStatus: AlertStatusRT,
dashboardId: rt.string,
});
const AssetDetailsUrlRT = rt.union([AssetDetailsUrlStateRT, rt.null]);

View file

@ -0,0 +1,211 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback } from 'react';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useKibanaContextForPlugin } from '../../../hooks/use_kibana';
import { useTrackedPromise } from '../../../utils/use_tracked_promise';
import type {
InfraCustomDashboard,
InfraSavedCustomDashboard,
InfraCustomDashboardAssetType,
} from '../../../../common/custom_dashboards';
import {
InfraCustomDashboardRT,
InfraDeleteCustomDashboardsResponseBodyRT,
} from '../../../../common/http_api/custom_dashboards_api';
import { throwErrors, createPlainError } from '../../../../common/runtime_types';
type ActionType = 'create' | 'update' | 'delete';
const errorMessages: Record<ActionType, string> = {
create: i18n.translate('xpack.infra.linkDashboards.addFailure.toast.title', {
defaultMessage: 'Error while linking dashboards',
}),
update: i18n.translate('xpack.infra.updatingLinkedDashboards.addFailure.toast.title', {
defaultMessage: 'Error while updating linked dashboards',
}),
delete: i18n.translate('xpack.infra.deletingLinkedDashboards.addFailure.toast.title', {
defaultMessage: 'Error while deleting linked dashboards',
}),
};
const decodeResponse = (response: any) => {
return pipe(
InfraCustomDashboardRT.decode(response),
fold(throwErrors(createPlainError), identity)
);
};
export const useUpdateCustomDashboard = () => {
const { services } = useKibanaContextForPlugin();
const { notifications } = useKibana();
const onError = useCallback(
(errorMessage: string) => {
if (errorMessage) {
notifications.toasts.danger({
title: errorMessages.update,
body: errorMessage,
});
}
},
[notifications.toasts]
);
const [updateCustomDashboardRequest, updateCustomDashboard] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async ({
assetType,
id,
dashboardSavedObjectId,
dashboardFilterAssetIdEnabled,
}: InfraSavedCustomDashboard) => {
const rawResponse = await services.http.fetch(
`/api/infra/${assetType}/custom-dashboards/${id}`,
{
method: 'PUT',
body: JSON.stringify({
assetType,
dashboardSavedObjectId,
dashboardFilterAssetIdEnabled,
}),
}
);
return decodeResponse(rawResponse);
},
onResolve: (response) => response,
onReject: (e: Error | unknown) => onError((e as Error)?.message),
},
[]
);
const isUpdateLoading = updateCustomDashboardRequest.state === 'pending';
const hasUpdateError = updateCustomDashboardRequest.state === 'rejected';
return {
updateCustomDashboard,
isUpdateLoading,
hasUpdateError,
};
};
export const useDeleteCustomDashboard = () => {
const { services } = useKibanaContextForPlugin();
const { notifications } = useKibana();
const decodeDeleteResponse = (response: any) => {
return pipe(
InfraDeleteCustomDashboardsResponseBodyRT.decode(response),
fold(throwErrors(createPlainError), identity)
);
};
const onError = useCallback(
(errorMessage: string) => {
if (errorMessage) {
notifications.toasts.danger({
title: errorMessages.delete,
body: errorMessage,
});
}
},
[notifications.toasts]
);
const [deleteCustomDashboardRequest, deleteCustomDashboard] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async ({
assetType,
id,
}: {
assetType: InfraCustomDashboardAssetType;
id: string;
}) => {
const rawResponse = await services.http.fetch(
`/api/infra/${assetType}/custom-dashboards/${id}`,
{
method: 'DELETE',
}
);
return decodeDeleteResponse(rawResponse);
},
onResolve: (response) => response,
onReject: (e: Error | unknown) => onError((e as Error)?.message),
},
[]
);
const isDeleteLoading = deleteCustomDashboardRequest.state === 'pending';
const hasDeleteError = deleteCustomDashboardRequest.state === 'rejected';
return {
deleteCustomDashboard,
isDeleteLoading,
hasDeleteError,
};
};
export const useCreateCustomDashboard = () => {
const { services } = useKibanaContextForPlugin();
const { notifications } = useKibana();
const onError = useCallback(
(errorMessage: string) => {
if (errorMessage) {
notifications.toasts.danger({
title: errorMessages.delete,
body: errorMessage,
});
}
},
[notifications.toasts]
);
const [createCustomDashboardRequest, createCustomDashboard] = useTrackedPromise(
{
cancelPreviousOn: 'resolution',
createPromise: async ({
assetType,
dashboardSavedObjectId,
dashboardFilterAssetIdEnabled,
}: InfraCustomDashboard) => {
const rawResponse = await services.http.fetch(`/api/infra/${assetType}/custom-dashboards`, {
method: 'POST',
body: JSON.stringify({
dashboardSavedObjectId,
dashboardFilterAssetIdEnabled,
}),
});
return decodeResponse(rawResponse);
},
onResolve: (response) => response,
onReject: (e: Error | unknown) => onError((e as Error)?.message),
},
[]
);
const isCreateLoading = createCustomDashboardRequest.state === 'pending';
const hasCreateError = createCustomDashboardRequest.state === 'rejected';
return {
createCustomDashboard,
isCreateLoading,
hasCreateError,
};
};

View file

@ -0,0 +1,69 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useState, useEffect } from 'react';
import type { SearchDashboardsResponse } from '@kbn/dashboard-plugin/public/services/dashboard_content_management/lib/find_dashboards';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { useKibanaContextForPlugin } from '../../../hooks/use_kibana';
export enum FETCH_STATUS {
LOADING = 'loading',
SUCCESS = 'success',
FAILURE = 'failure',
NOT_INITIATED = 'not_initiated',
}
export interface SearchDashboardsResult {
data: SearchDashboardsResponse['hits'];
status: FETCH_STATUS;
}
export function useDashboardFetcher(query = ''): SearchDashboardsResult {
const {
services: { dashboard },
} = useKibanaContextForPlugin();
const { notifications } = useKibana();
const [result, setResult] = useState<SearchDashboardsResult>({
data: [],
status: FETCH_STATUS.NOT_INITIATED,
});
useEffect(() => {
const getDashboards = async () => {
setResult({
data: [],
status: FETCH_STATUS.LOADING,
});
try {
const findDashboardsService = await dashboard?.findDashboardsService();
const data = await findDashboardsService.search({
search: query,
size: 1000,
});
setResult({
data: data.hits,
status: FETCH_STATUS.SUCCESS,
});
} catch (error) {
setResult({
data: [],
status: FETCH_STATUS.FAILURE,
});
notifications.toasts.danger({
title: i18n.translate('xpack.infra.fetchingDashboards.addFailure.toast.title', {
defaultMessage: 'Error while fetching dashboards',
}),
body: error.message,
});
}
};
getDashboards();
}, [dashboard, notifications.toasts, query]);
return result;
}

View file

@ -0,0 +1,57 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect } from 'react';
import { fold } from 'fp-ts/lib/Either';
import { identity } from 'fp-ts/lib/function';
import { pipe } from 'fp-ts/lib/pipeable';
import type { InventoryItemType } from '@kbn/metrics-data-access-plugin/common';
import type { InfraSavedCustomDashboard } from '../../../../common/custom_dashboards';
import { InfraGetCustomDashboardsResponseBodyRT } from '../../../../common/http_api/custom_dashboards_api';
import { useHTTPRequest } from '../../../hooks/use_http_request';
import { throwErrors, createPlainError } from '../../../../common/runtime_types';
import { useRequestObservable } from './use_request_observable';
interface UseDashboardProps {
assetType: InventoryItemType;
}
export function useFetchCustomDashboards({ assetType }: UseDashboardProps) {
const { request$ } = useRequestObservable();
const decodeResponse = (response: any) => {
return pipe(
InfraGetCustomDashboardsResponseBodyRT.decode(response),
fold(throwErrors(createPlainError), identity)
);
};
const { error, loading, response, makeRequest } = useHTTPRequest<InfraSavedCustomDashboard[]>(
`/api/infra/${assetType}/custom-dashboards`,
'GET',
undefined,
decodeResponse,
undefined,
undefined,
true
);
useEffect(() => {
if (request$) {
request$.next(makeRequest);
} else {
makeRequest();
}
}, [makeRequest, request$]);
return {
error: (error && error.message) || null,
loading,
dashboards: response,
reload: makeRequest,
};
}

View file

@ -12,6 +12,8 @@ import {
type EuiPageHeaderProps,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
import { enableInfrastructureAssetCustomDashboards } from '@kbn/observability-plugin/common';
import { useLinkProps } from '@kbn/observability-shared-plugin/public';
import { capitalize } from 'lodash';
import React, { useCallback, useMemo } from 'react';
@ -111,13 +113,17 @@ const useRightSideItems = (links?: LinkOptions[]) => {
const useFeatureFlagTabs = () => {
const { featureFlags } = usePluginConfig();
const isProfilingEnabled = useProfilingIntegrationSetting();
const isInfrastructureAssetCustomDashboardsEnabled = useUiSetting<boolean>(
enableInfrastructureAssetCustomDashboards
);
const featureFlagControlledTabs: Partial<Record<ContentTabIds, boolean>> = useMemo(
() => ({
[ContentTabIds.OSQUERY]: featureFlags.osqueryEnabled,
[ContentTabIds.PROFILING]: isProfilingEnabled,
[ContentTabIds.DASHBOARDS]: isInfrastructureAssetCustomDashboardsEnabled,
}),
[featureFlags.osqueryEnabled, isProfilingEnabled]
[featureFlags.osqueryEnabled, isInfrastructureAssetCustomDashboardsEnabled, isProfilingEnabled]
);
const isTabEnabled = useCallback(

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState, useCallback } from 'react';
import type {
DashboardItemWithTitle,
InfraCustomDashboardAssetType,
} from '../../../../../../common/custom_dashboards';
import { SaveDashboardModal } from './save_dashboard_modal';
export function EditDashboard({
onRefresh,
currentDashboard,
assetType,
}: {
onRefresh: () => void;
currentDashboard: DashboardItemWithTitle;
assetType: InfraCustomDashboardAssetType;
}) {
const [isModalVisible, setIsModalVisible] = useState(false);
const onClick = useCallback(() => setIsModalVisible(!isModalVisible), [isModalVisible]);
return (
<>
<EuiButtonEmpty
color="text"
size="s"
iconType="pencil"
data-test-subj="infraEditCustomDashboardMenu"
onClick={onClick}
>
{i18n.translate('xpack.infra.customDashboards.editEmptyButtonLabel', {
defaultMessage: 'Edit dashboard link',
})}
</EuiButtonEmpty>
{isModalVisible && (
<SaveDashboardModal
onClose={onClick}
onRefresh={onRefresh}
currentDashboard={currentDashboard}
assetType={assetType}
/>
)}
</>
);
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import type { DashboardItemWithTitle } from '../../../../../../common/custom_dashboards';
import { useKibanaContextForPlugin } from '../../../../../hooks/use_kibana';
export function GotoDashboardLink({
currentDashboard,
}: {
currentDashboard: DashboardItemWithTitle;
}) {
const {
services: {
dashboard: { locator: dashboardLocator },
},
} = useKibanaContextForPlugin();
const url = dashboardLocator?.getRedirectUrl({
dashboardId: currentDashboard?.dashboardSavedObjectId,
});
return (
<EuiButtonEmpty
data-test-subj="infraGotoDashboardGoToDashboardButton"
color="text"
size="s"
iconType="visGauge"
href={url}
>
{i18n.translate('xpack.infra.customDashboards.contextMenu.goToDashboard', {
defaultMessage: 'Go to dashboard',
})}
</EuiButtonEmpty>
);
}

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { LinkDashboard } from './link_dashboard';
export { GotoDashboardLink } from './goto_dashboard_link';
export { EditDashboard } from './edit_dashboard';
export { UnlinkDashboard } from './unlink_dashboard';

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton, EuiButtonEmpty } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState, useCallback } from 'react';
import type {
DashboardItemWithTitle,
InfraCustomDashboardAssetType,
} from '../../../../../../common/custom_dashboards';
import { SaveDashboardModal } from './save_dashboard_modal';
export function LinkDashboard({
onRefresh,
newDashboardButton = false,
customDashboards,
assetType,
}: {
onRefresh: () => void;
newDashboardButton?: boolean;
customDashboards?: DashboardItemWithTitle[];
assetType: InfraCustomDashboardAssetType;
}) {
const [isModalVisible, setIsModalVisible] = useState(false);
const onClick = useCallback(() => setIsModalVisible(true), []);
const onClose = useCallback(() => setIsModalVisible(false), []);
return (
<>
{newDashboardButton ? (
<EuiButtonEmpty
color="text"
size="s"
iconType="plusInCircle"
data-test-subj="infraLinkDashboardMenu"
onClick={onClick}
>
{i18n.translate('xpack.infra.assetDetails.dashboards.linkNewDashboardButtonLabel', {
defaultMessage: 'Link new dashboard',
})}
</EuiButtonEmpty>
) : (
<EuiButton data-test-subj="infraAddDashboard" onClick={onClick}>
{i18n.translate('xpack.infra.assetDetails.dashboards.linkButtonLabel', {
defaultMessage: 'Link dashboard',
})}
</EuiButton>
)}
{isModalVisible && (
<SaveDashboardModal
onClose={onClose}
onRefresh={onRefresh}
customDashboards={customDashboards}
assetType={assetType}
/>
)}
</>
);
}

View file

@ -0,0 +1,275 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import {
EuiButton,
EuiModal,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSwitch,
EuiModalBody,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFlexGroup,
EuiToolTip,
EuiIcon,
EuiButtonEmpty,
useEuiTheme,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DashboardItem } from '@kbn/dashboard-plugin/common/content_management';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type {
DashboardItemWithTitle,
InfraCustomDashboardAssetType,
} from '../../../../../../common/custom_dashboards';
import { useDashboardFetcher, FETCH_STATUS } from '../../../hooks/use_dashboards_fetcher';
import {
useUpdateCustomDashboard,
useCreateCustomDashboard,
} from '../../../hooks/use_custom_dashboards';
import { useAssetDetailsUrlState } from '../../../hooks/use_asset_details_url_state';
interface Props {
onClose: () => void;
onRefresh: () => void;
currentDashboard?: DashboardItemWithTitle;
customDashboards?: DashboardItemWithTitle[];
assetType: InfraCustomDashboardAssetType;
}
export function SaveDashboardModal({
onClose,
onRefresh,
currentDashboard,
customDashboards,
assetType,
}: Props) {
const { notifications } = useKibana();
const { data: allAvailableDashboards, status } = useDashboardFetcher();
const [, setUrlState] = useAssetDetailsUrlState();
const { euiTheme } = useEuiTheme();
const [assetNameEnabled, setAssetNameFiltersEnabled] = useState(
currentDashboard?.dashboardFilterAssetIdEnabled ?? true
);
const [selectedDashboard, setSelectedDashboard] = useState<
Array<EuiComboBoxOptionOption<string>>
>(
currentDashboard
? [{ label: currentDashboard.title, value: currentDashboard.dashboardSavedObjectId }]
: []
);
const { isUpdateLoading, updateCustomDashboard } = useUpdateCustomDashboard();
const { isCreateLoading, createCustomDashboard } = useCreateCustomDashboard();
const isEditMode = !!currentDashboard?.id;
const loading = isUpdateLoading || isCreateLoading;
const options = useMemo(
() =>
allAvailableDashboards?.map((dashboardItem: DashboardItem) => ({
label: dashboardItem.attributes.title,
value: dashboardItem.id,
disabled:
customDashboards?.some(
({ dashboardSavedObjectId }) => dashboardItem.id === dashboardSavedObjectId
) ?? false,
})),
[allAvailableDashboards, customDashboards]
);
const onChange = useCallback(
() => setAssetNameFiltersEnabled(!assetNameEnabled),
[assetNameEnabled]
);
const onSelect = useCallback((newSelection) => setSelectedDashboard(newSelection), []);
const onClickSave = useCallback(
async function () {
const [newDashboard] = selectedDashboard;
try {
if (!newDashboard.value) {
return;
}
const dashboardParams = {
assetType,
dashboardSavedObjectId: newDashboard.value,
dashboardFilterAssetIdEnabled: assetNameEnabled,
};
const result =
isEditMode && currentDashboard?.id
? await updateCustomDashboard({
...dashboardParams,
id: currentDashboard.id,
})
: await createCustomDashboard(dashboardParams);
const getToastLabels = isEditMode ? getEditSuccessToastLabels : getLinkSuccessToastLabels;
if (result && !(isEditMode ? isUpdateLoading : isCreateLoading)) {
notifications.toasts.success(getToastLabels(newDashboard.label));
}
setUrlState({ dashboardId: newDashboard.value });
onRefresh();
} catch (error) {
notifications.toasts.danger({
title: i18n.translate('xpack.infra.customDashboards.addFailure.toast.title', {
defaultMessage: 'Error while adding "{dashboardName}" dashboard',
values: { dashboardName: newDashboard.label },
}),
body: error.message,
});
}
onClose();
},
[
selectedDashboard,
onClose,
isEditMode,
setUrlState,
onRefresh,
updateCustomDashboard,
assetType,
currentDashboard?.id,
assetNameEnabled,
isUpdateLoading,
notifications.toasts,
createCustomDashboard,
isCreateLoading,
]
);
return (
<EuiModal onClose={onClose} data-test-subj="infraSelectCustomDashboard">
<EuiModalHeader>
<EuiModalHeaderTitle>
{isEditMode
? i18n.translate('xpack.infra.customDashboards.selectDashboard.modalTitle.edit', {
defaultMessage: 'Edit dashboard',
})
: i18n.translate('xpack.infra.customDashboards.selectDashboard.modalTitle.link', {
defaultMessage: 'Select dashboard',
})}
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiFlexGroup direction="column" justifyContent="center">
<EuiComboBox
isLoading={status === FETCH_STATUS.LOADING}
isDisabled={status === FETCH_STATUS.LOADING || isEditMode}
placeholder={i18n.translate(
'xpack.infra.customDashboards.selectDashboard.placeholder',
{
defaultMessage: 'Select dashboard',
}
)}
singleSelection={{ asPlainText: true }}
options={options}
selectedOptions={selectedDashboard}
onChange={onSelect}
isClearable
/>
<EuiSwitch
css={{ alignItems: 'center' }}
compressed
label={
<p>
<span css={{ marginRight: euiTheme.size.xs }}>
{i18n.translate(
'xpack.infra.customDashboard.addDashboard.useContextFilterLabel',
{
defaultMessage: 'Filter by host name',
}
)}
</span>
<EuiToolTip
position="bottom"
content={i18n.translate(
'xpack.infra.customDashboard.addDashboard.useContextFilterLabel.tooltip',
{
defaultMessage:
'Enabling this option will apply filters to the dashboard based on your chosen host.',
}
)}
>
<EuiIcon
type="questionInCircle"
title={i18n.translate(
'xpack.infra.saveDashboardModal.euiIcon.iconWithTooltipLabel',
{ defaultMessage: 'Icon with tooltip' }
)}
/>
</EuiToolTip>
</p>
}
onChange={onChange}
checked={assetNameEnabled}
/>
</EuiFlexGroup>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty
data-test-subj="infraSelectDashboardCancelButton"
onClick={onClose}
isDisabled={loading}
>
{i18n.translate('xpack.infra.customDashboards.selectDashboard.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
<EuiButton
data-test-subj="infraSelectDashboardButton"
onClick={onClickSave}
isLoading={loading}
fill
>
{isEditMode
? i18n.translate('xpack.infra.customDashboards.selectDashboard.edit', {
defaultMessage: 'Save',
})
: i18n.translate('xpack.infra.customDashboards.selectDashboard.add', {
defaultMessage: 'Link dashboard',
})}
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
}
function getLinkSuccessToastLabels(dashboardName: string) {
return {
title: i18n.translate('xpack.infra.customDashboards.linkSuccess.toast.title', {
defaultMessage: 'Added "{dashboardName}" dashboard',
values: { dashboardName },
}),
body: i18n.translate('xpack.infra.customDashboards.linkSuccess.toast.text', {
defaultMessage: 'Your dashboard is now visible in the asset details page.',
}),
};
}
function getEditSuccessToastLabels(dashboardName: string) {
return {
title: i18n.translate('xpack.infra.customDashboards.editSuccess.toast.title', {
defaultMessage: 'Edited "{dashboardName}" dashboard',
values: { dashboardName },
}),
body: i18n.translate('xpack.infra.customDashboards.editSuccess.toast.text', {
defaultMessage: 'Your dashboard link has been updated',
}),
};
}

View file

@ -0,0 +1,129 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButtonEmpty, EuiConfirmModal } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useState } from 'react';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type {
DashboardItemWithTitle,
InfraCustomDashboardAssetType,
} from '../../../../../../common/custom_dashboards';
import { useDeleteCustomDashboard } from '../../../hooks/use_custom_dashboards';
import { useFetchCustomDashboards } from '../../../hooks/use_fetch_custom_dashboards';
import { useAssetDetailsUrlState } from '../../../hooks/use_asset_details_url_state';
export function UnlinkDashboard({
currentDashboard,
onRefresh,
assetType,
}: {
currentDashboard: DashboardItemWithTitle;
onRefresh: () => void;
assetType: InfraCustomDashboardAssetType;
}) {
const [isModalVisible, setIsModalVisible] = useState(false);
const { notifications } = useKibana();
const [, setUrlState] = useAssetDetailsUrlState();
const { deleteCustomDashboard, isDeleteLoading } = useDeleteCustomDashboard();
const { dashboards, loading } = useFetchCustomDashboards({ assetType });
const onClick = useCallback(() => setIsModalVisible(true), []);
const onCancel = useCallback(() => setIsModalVisible(false), []);
const onError = useCallback(() => setIsModalVisible(!isModalVisible), [isModalVisible]);
const onConfirm = useCallback(
async function () {
try {
const linkedDashboards = (dashboards ?? []).filter(
({ dashboardSavedObjectId }) =>
dashboardSavedObjectId !== currentDashboard.dashboardSavedObjectId
);
const result = await deleteCustomDashboard({
assetType,
id: currentDashboard.id,
});
setUrlState({ dashboardId: linkedDashboards[0]?.dashboardSavedObjectId });
if (result) {
notifications.toasts.success({
title: i18n.translate('xpack.infra.customDashboards.unlinkSuccess.toast.title', {
defaultMessage: 'Unlinked "{dashboardName}" dashboard',
values: { dashboardName: currentDashboard?.title },
}),
});
onRefresh();
}
} catch (error) {
notifications.toasts.danger({
title: i18n.translate('xpack.infra.customDashboards.unlinkFailure.toast.title', {
defaultMessage: 'Error while unlinking "{dashboardName}" dashboard',
values: { dashboardName: currentDashboard?.title },
}),
body: error.body.message,
});
}
onError();
},
[
onError,
dashboards,
deleteCustomDashboard,
assetType,
currentDashboard.id,
currentDashboard.dashboardSavedObjectId,
currentDashboard?.title,
setUrlState,
notifications.toasts,
onRefresh,
]
);
return (
<>
<EuiButtonEmpty
color="danger"
size="s"
iconType="unlink"
data-test-subj="infraUnLinkCustomDashboardMenu"
onClick={onClick}
>
{i18n.translate('xpack.infra.customDashboards.unlinkEmptyButtonLabel', {
defaultMessage: 'Unlink dashboard',
})}
</EuiButtonEmpty>
{isModalVisible && (
<EuiConfirmModal
title={i18n.translate(
'xpack.infra.customDashboards.unlinkEmptyButtonLabel.confirm.title',
{
defaultMessage: 'Unlink Dashboard',
}
)}
onCancel={onCancel}
onConfirm={onConfirm}
confirmButtonText={i18n.translate(
'xpack.infra.customDashboards.unlinkEmptyButtonLabel.confirm.button',
{
defaultMessage: 'Unlink dashboard',
}
)}
buttonColor="danger"
defaultFocusedButton="confirm"
isLoading={loading || isDeleteLoading}
>
<p>
{i18n.translate('xpack.infra.customDashboards.unlinkEmptyButtonLabel.confirm.body', {
defaultMessage: `You are about to unlink the dashboard from the {assetType} context`,
values: { assetType },
})}
</p>
</EuiConfirmModal>
)}
</>
);
}

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import React from 'react';
import { EuiButtonIcon, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui';
import { useBoolean } from '../../../../hooks/use_boolean';
interface Props {
items: React.ReactNode[];
}
export function ContextMenu({ items }: Props) {
const [isPopoverOpen, { off: closePopover, toggle: togglePopover }] = useBoolean(false);
return (
<EuiPopover
button={
<EuiButtonIcon
data-test-subj="infraDashboardsContextMenuButton"
display="base"
size="s"
iconType="boxesVertical"
aria-label={i18n.translate('xpack.infra.assetDetails.dashboards.contextMenu.moreLabel', {
defaultMessage: 'More',
})}
onClick={togglePopover}
/>
}
isOpen={isPopoverOpen}
closePopover={closePopover}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiContextMenuPanel
size="s"
items={items.map((item, index) => (
<EuiContextMenuItem key={index} size="s">
{item}
</EuiContextMenuItem>
))}
/>
</EuiPopover>
);
}

View file

@ -0,0 +1,88 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useEffect, useState, useCallback } from 'react';
import useMount from 'react-use/lib/useMount';
import { EuiComboBox } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { DashboardItemWithTitle } from '../../../../../common/custom_dashboards';
import { useAssetDetailsUrlState } from '../../hooks/use_asset_details_url_state';
interface Props {
customDashboards: DashboardItemWithTitle[];
currentDashboardId?: string;
setCurrentDashboard: (newDashboard: DashboardItemWithTitle) => void;
onRefresh: () => void;
}
export function DashboardSelector({
customDashboards,
currentDashboardId,
setCurrentDashboard,
onRefresh,
}: Props) {
const [, setUrlState] = useAssetDetailsUrlState();
const [selectedDashboard, setSelectedDashboard] = useState<DashboardItemWithTitle>();
useMount(() => {
if (!currentDashboardId) {
setUrlState({ dashboardId: customDashboards[0]?.dashboardSavedObjectId });
}
});
useEffect(() => {
const preselectedDashboard = customDashboards.find(
({ dashboardSavedObjectId }) => dashboardSavedObjectId === currentDashboardId
);
// preselect dashboard
if (preselectedDashboard) {
setSelectedDashboard(preselectedDashboard);
setCurrentDashboard(preselectedDashboard);
}
}, [customDashboards, currentDashboardId, setCurrentDashboard]);
const onChange = useCallback(
(newDashboardId?: string) => {
setUrlState({ dashboardId: newDashboardId });
onRefresh();
},
[onRefresh, setUrlState]
);
return (
<EuiComboBox
compressed
style={{ minWidth: '200px' }}
placeholder={i18n.translate('xpack.infra.customDashboards.selectDashboard.placeholder', {
defaultMessage: 'Select dashboard',
})}
prepend={i18n.translate('xpack.infra.customDashboards.selectDashboard.prepend', {
defaultMessage: 'Dashboard',
})}
singleSelection={{ asPlainText: true }}
options={customDashboards.map(({ dashboardSavedObjectId, title }) => {
return {
label: title,
value: dashboardSavedObjectId,
};
})}
selectedOptions={
selectedDashboard
? [
{
value: selectedDashboard.dashboardSavedObjectId,
label: selectedDashboard.title,
},
]
: []
}
onChange={([newItem]) => onChange(newItem.value)}
isClearable={false}
/>
);
}

View file

@ -0,0 +1,209 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiPanel,
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiSpacer,
EuiEmptyPrompt,
EuiLoadingLogo,
} from '@elastic/eui';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import {
AwaitingDashboardAPI,
DashboardCreationOptions,
DashboardRenderer,
} from '@kbn/dashboard-plugin/public';
import type { DashboardItem } from '@kbn/dashboard-plugin/common/content_management';
import { buildAssetIdFilter } from '../../../../utils/filters/build';
import type {
InfraSavedCustomDashboard,
DashboardItemWithTitle,
} from '../../../../../common/custom_dashboards';
import { EmptyDashboards } from './empty_dashboards';
import { EditDashboard, GotoDashboardLink, LinkDashboard, UnlinkDashboard } from './actions';
import { useFetchCustomDashboards } from '../../hooks/use_fetch_custom_dashboards';
import { useDatePickerContext } from '../../hooks/use_date_picker';
import { useAssetDetailsRenderPropsContext } from '../../hooks/use_asset_details_render_props';
import { FETCH_STATUS, useDashboardFetcher } from '../../hooks/use_dashboards_fetcher';
import { useDataViewsContext } from '../../hooks/use_data_views';
import { DashboardSelector } from './dashboard_selector';
import { ContextMenu } from './context_menu';
import { useAssetDetailsUrlState } from '../../hooks/use_asset_details_url_state';
export function Dashboards() {
const { dateRange } = useDatePickerContext();
const { asset } = useAssetDetailsRenderPropsContext();
const [dashboard, setDashboard] = useState<AwaitingDashboardAPI>();
const [customDashboards, setCustomDashboards] = useState<DashboardItemWithTitle[]>([]);
const [currentDashboard, setCurrentDashboard] = useState<DashboardItemWithTitle>();
const { data: allAvailableDashboards, status } = useDashboardFetcher();
const { metrics } = useDataViewsContext();
const [urlState, setUrlState] = useAssetDetailsUrlState();
const { dashboards, loading, reload } = useFetchCustomDashboards({ assetType: asset.type });
useEffect(() => {
const allAvailableDashboardsMap = new Map<string, DashboardItem>();
allAvailableDashboards.forEach((availableDashboard) => {
allAvailableDashboardsMap.set(availableDashboard.id, availableDashboard);
});
const filteredCustomDashboards =
dashboards?.reduce<DashboardItemWithTitle[]>(
(result: DashboardItemWithTitle[], customDashboard: InfraSavedCustomDashboard) => {
const matchedDashboard = allAvailableDashboardsMap.get(
customDashboard.dashboardSavedObjectId
);
if (matchedDashboard) {
result.push({
title: matchedDashboard.attributes.title,
...customDashboard,
});
}
return result;
},
[]
) ?? [];
setCustomDashboards(filteredCustomDashboards);
// set a default dashboard if there is no selected dashboard
if (!urlState?.dashboardId) {
setUrlState({
dashboardId:
currentDashboard?.dashboardSavedObjectId ??
filteredCustomDashboards[0]?.dashboardSavedObjectId,
});
}
}, [
allAvailableDashboards,
currentDashboard?.dashboardSavedObjectId,
dashboards,
setUrlState,
urlState?.dashboardId,
]);
const getCreationOptions = useCallback((): Promise<DashboardCreationOptions> => {
const getInitialInput = () => ({
viewMode: ViewMode.VIEW,
timeRange: { from: dateRange.from, to: dateRange.to },
});
return Promise.resolve<DashboardCreationOptions>({ getInitialInput });
}, [dateRange.from, dateRange.to]);
useEffect(() => {
if (!dashboard) return;
dashboard.updateInput({
filters:
metrics.dataView && currentDashboard?.dashboardFilterAssetIdEnabled
? buildAssetIdFilter(asset.name, asset.type, metrics.dataView)
: [],
timeRange: { from: dateRange.from, to: dateRange.to },
});
}, [
metrics.dataView,
asset.name,
dashboard,
dateRange.from,
dateRange.to,
currentDashboard,
asset.type,
]);
if (loading || status === FETCH_STATUS.LOADING) {
return (
<EuiPanel hasBorder>
<EuiEmptyPrompt
icon={<EuiLoadingLogo logo="logoObservability" size="xl" />}
title={
<h4>
{i18n.translate('xpack.infra.customDashboards.loadingCustomDashboards', {
defaultMessage: 'Loading dashboard',
})}
</h4>
}
/>
</EuiPanel>
);
}
return (
<EuiPanel hasBorder>
{!!dashboards?.length ? (
<>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="xs" alignItems="center">
<EuiFlexItem grow>
<EuiTitle size="s">
<h3>{currentDashboard?.title}</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<DashboardSelector
currentDashboardId={urlState?.dashboardId}
customDashboards={customDashboards}
setCurrentDashboard={setCurrentDashboard}
onRefresh={reload}
/>
</EuiFlexItem>
{currentDashboard && (
<EuiFlexItem grow={false}>
<ContextMenu
items={[
<LinkDashboard
newDashboardButton
onRefresh={reload}
customDashboards={customDashboards}
assetType={asset.type}
/>,
<GotoDashboardLink currentDashboard={currentDashboard} />,
<EditDashboard
currentDashboard={currentDashboard}
onRefresh={reload}
assetType={asset.type}
/>,
<UnlinkDashboard
currentDashboard={currentDashboard}
onRefresh={reload}
assetType={asset.type}
/>,
]}
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiFlexItem grow>
<EuiSpacer size="l" />
{urlState?.dashboardId && (
<DashboardRenderer
savedObjectId={urlState?.dashboardId}
getCreationOptions={getCreationOptions}
ref={setDashboard}
/>
)}
</EuiFlexItem>
</>
) : (
<EmptyDashboards
actions={
<LinkDashboard
onRefresh={reload}
customDashboards={customDashboards}
assetType={asset.type}
/>
}
/>
)}
</EuiPanel>
);
}

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiEmptyPrompt, EuiImage } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { dashboardsDark, dashboardsLight } from '@kbn/shared-svg';
import { useIsDarkMode } from '../../../../hooks/use_is_dark_mode';
interface Props {
actions: React.ReactNode;
}
export function EmptyDashboards({ actions }: Props) {
const isDarkMode = useIsDarkMode();
return (
<EuiEmptyPrompt
hasShadow={false}
hasBorder={false}
icon={
<EuiImage size="fullWidth" src={isDarkMode ? dashboardsDark : dashboardsLight} alt="" />
}
title={
<h2>
{i18n.translate('xpack.infra.assetDetails.dashboards.emptyTitle', {
defaultMessage: 'Want your own view?',
})}
</h2>
}
layout="horizontal"
color="plain"
body={
<>
<ul>
<li>
{i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody.first', {
defaultMessage: 'Link your own dashboard to this view',
})}
</li>
<li>
{i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody.second', {
defaultMessage: 'Provide the best visualizations relevant to your business',
})}
</li>
<li>
{i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody', {
defaultMessage: 'Add or remove them at any time',
})}
</li>
</ul>
<p>
{i18n.translate('xpack.infra.assetDetails.dashboards.emptyBody.getStarted', {
defaultMessage: 'To get started, add your dashboard',
})}
</p>
</>
}
actions={actions}
/>
);
}

View file

@ -12,3 +12,4 @@ export { Profiling } from './profiling/profiling';
export { Osquery } from './osquery/osquery';
export { Logs } from './logs/logs';
export { Overview } from './overview/overview';
export { Dashboards } from './dashboards/dashboards';

View file

@ -26,6 +26,7 @@ export enum ContentTabIds {
OSQUERY = 'osquery',
LOGS = 'logs',
LINK_TO_APM = 'linkToApm',
DASHBOARDS = 'dashboards',
}
export type TabIds = `${ContentTabIds}`;

View file

@ -17,7 +17,7 @@ export const useSavedViewsNotifier = () => {
title:
message ||
i18n.translate('xpack.infra.savedView.errorOnDelete.title', {
defaultMessage: `An error occured deleting the view.`,
defaultMessage: `An error occurred deleting the view.`,
}),
});
};
@ -39,7 +39,7 @@ export const useSavedViewsNotifier = () => {
title:
message ||
i18n.translate('xpack.infra.savedView.errorOnCreate.title', {
defaultMessage: `An error occured saving view.`,
defaultMessage: `An error occurred saving view.`,
}),
});
};

View file

@ -48,6 +48,7 @@ import { ObservabilityAIAssistantPublicStart } from '@kbn/observability-ai-assis
import type { CloudSetup } from '@kbn/cloud-plugin/public';
import type { LicenseManagementUIPluginSetup } from '@kbn/license-management-plugin/public';
import type { ServerlessPluginStart } from '@kbn/serverless/public';
import type { DashboardStart } from '@kbn/dashboard-plugin/public';
import type { UnwrapPromise } from '../common/utility_types';
import { InventoryViewsServiceStart } from './services/inventory_views';
import { MetricsExplorerViewsServiceStart } from './services/metrics_explorer_views';
@ -90,6 +91,7 @@ export interface InfraClientStartDeps {
data: DataPublicPluginStart;
dataViews: DataViewsPublicPluginStart;
discover: DiscoverStart;
dashboard: DashboardStart;
embeddable?: EmbeddableStart;
lens: LensPublicStart;
logsShared: LogsSharedClientStartExports;

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { stubLogstashDataView as dataView } from '@kbn/data-views-plugin/common/data_view.stub';
import { buildAssetIdFilter } from './build';
describe('buildAssetIdFilter', function () {
it('should build a host id filter', () => {
dataView.getFieldByName = jest.fn().mockReturnValue({
name: 'host.id',
});
const result = buildAssetIdFilter('host1', 'host', dataView);
expect(result[0]).toMatchObject({ query: { match_phrase: { 'host.id': 'host1' } } });
});
it('should build a pod id filter', () => {
dataView.getFieldByName = jest.fn().mockReturnValue({
name: 'kubernetes.pod.uid',
});
const result = buildAssetIdFilter('pod1', 'pod', dataView);
expect(result[0]).toMatchObject({ query: { match_phrase: { 'kubernetes.pod.uid': 'pod1' } } });
});
it('should return an empty array if the field id is not found', () => {
dataView.getFieldByName = jest.fn().mockReturnValue(undefined);
const result = buildAssetIdFilter('host1', 'host', dataView);
expect(result).toStrictEqual([]);
});
});

View file

@ -9,10 +9,12 @@ import {
BooleanRelation,
buildCombinedFilter,
buildPhraseFilter,
Filter,
type Filter,
isCombinedFilter,
} from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/common';
import { findInventoryFields } from '@kbn/metrics-data-access-plugin/common';
import type { InfraCustomDashboardAssetType } from '../../../common/custom_dashboards';
export const buildCombinedHostsFilter = ({
field,
@ -52,3 +54,12 @@ export const retrieveFieldsFromFilter = (filters: Filter[], fields: string[] = [
return fields;
};
export const buildAssetIdFilter = (
assetId: string,
assetType: InfraCustomDashboardAssetType,
dataView: DataView
): Filter[] => {
const assetIdField = dataView.getFieldByName(findInventoryFields(assetType).id);
return assetIdField ? [buildPhraseFilter(assetIdField, assetId, dataView)] : [];
};

View file

@ -94,6 +94,8 @@
"@kbn/serverless",
"@kbn/core-lifecycle-server",
"@kbn/elastic-agent-utils",
"@kbn/dashboard-plugin",
"@kbn/shared-svg",
"@kbn/aiops-log-rate-analysis"
],
"exclude": [

View file

@ -7,10 +7,13 @@
import moment from 'moment';
import expect from '@kbn/expect';
import { enableInfrastructureHostsView } from '@kbn/observability-plugin/common';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import {
enableInfrastructureAssetCustomDashboards,
enableInfrastructureHostsView,
} from '@kbn/observability-plugin/common';
import { ALERT_STATUS_ACTIVE, ALERT_STATUS_RECOVERED } from '@kbn/rule-data-utils';
import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import { FtrProviderContext } from '../../ftr_provider_context';
import {
DATES,
@ -121,6 +124,9 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const setHostViewEnabled = (value: boolean = true) =>
kibanaServer.uiSettings.update({ [enableInfrastructureHostsView]: value });
const setCustomDashboardsEnabled = (value: boolean = true) =>
kibanaServer.uiSettings.update({ [enableInfrastructureAssetCustomDashboards]: value });
const returnTo = async (path: string, timeout = 2000) =>
retry.waitForWithTimeout('returned to hosts view', timeout, async () => {
await browser.goBack();
@ -189,6 +195,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
describe('#Single Host Flyout', () => {
before(async () => {
await setHostViewEnabled(true);
await setCustomDashboardsEnabled(true);
await pageObjects.common.navigateToApp(HOSTS_VIEW_PATH);
await pageObjects.header.waitUntilLoadingHasFinished();
});
@ -318,6 +325,16 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
});
describe('Dashboards Tab', () => {
before(async () => {
await pageObjects.assetDetails.clickDashboardsTab();
});
it('should render dashboards tab splash screen with option to add dashboard', async () => {
await pageObjects.assetDetails.addDashboardExists();
});
});
describe('Flyout links', () => {
it('should navigate to Host Details page after click', async () => {
await pageObjects.assetDetails.clickOpenAsPageLink();

View file

@ -254,6 +254,15 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) {
return testSubjects.click('infraAssetDetailsOsqueryTab');
},
// Dashboards
async clickDashboardsTab() {
return testSubjects.click('infraAssetDetailsDashboardsTab');
},
async addDashboardExists() {
await testSubjects.existOrFail('infraAddDashboard');
},
// APM Tab link
async clickApmTabLink() {
return testSubjects.click('infraAssetDetailsApmServicesLinkTab');