mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[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 dashboard4f39f3aa
-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:
parent
674736d927
commit
d3fe5434b3
30 changed files with 1435 additions and 9 deletions
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -18,3 +18,7 @@ export interface InfraCustomDashboard {
|
|||
export interface InfraSavedCustomDashboard extends InfraCustomDashboard {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface DashboardItemWithTitle extends InfraSavedCustomDashboard {
|
||||
title: string;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
"data",
|
||||
"dataViews",
|
||||
"dataViewEditor",
|
||||
"dashboard",
|
||||
"discover",
|
||||
"embeddable",
|
||||
"features",
|
||||
|
|
|
@ -51,4 +51,10 @@ export const commonFlyoutTabs: Tab[] = [
|
|||
defaultMessage: 'Osquery',
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: ContentTabIds.DASHBOARDS,
|
||||
name: i18n.translate('xpack.infra.infra.nodeDetails.tabs.dashboards', {
|
||||
defaultMessage: 'Dashboards',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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]);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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';
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -26,6 +26,7 @@ export enum ContentTabIds {
|
|||
OSQUERY = 'osquery',
|
||||
LOGS = 'logs',
|
||||
LINK_TO_APM = 'linkToApm',
|
||||
DASHBOARDS = 'dashboards',
|
||||
}
|
||||
|
||||
export type TabIds = `${ContentTabIds}`;
|
||||
|
|
|
@ -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.`,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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([]);
|
||||
});
|
||||
});
|
|
@ -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)] : [];
|
||||
};
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue