Skip to content

Commit

Permalink
[SecuritySolution] Setup dashboard view page (#153040)
Browse files Browse the repository at this point in the history
## Summary

#152955

Demo link:
https://kibana-pr-153040.kb.us-west2.gcp.elastic-cloud.com:9243/app/security/dashboards/6294c960-ce35-11ed-b8ca-51636b04063c?sourcerer=(default:(id:security-solution-default,selectedPatterns:!(%27auditbeat-*%27,%27logs-*%27)))


https://p.elstc.co/paste/pOFVo-fV#Zgp3hnsnijsDHbki4y9Cy5F+apet-hYEcedpDzsc+f7

This create a single dashboard view under Security Solution:

- Add dashboard view path: `/app/security/dashboards/:dashboardId`
- Move the dashboards landing page to this new sub-plugin.
- Check users' read permission to render a dashboard.
- Render a dashboard with the given saved object id.
- Show the dashboard name in the breadcrumbs.

Dashboard not found:

<img width="2363" alt="Screenshot 2023-03-23 at 13 44 01"
src="https://user-images.githubusercontent.com/6295984/227477728-8d4984f2-3d8f-4f92-88ae-3337e6b3e5be.png">

Dashboard rendered:

<img width="2539" alt="Screenshot 2023-03-23 at 13 44 28"
src="https://user-images.githubusercontent.com/6295984/227477761-b1301b5c-1c4f-4970-bf8f-e077342c317f.png">


Interact with filters and query:


https://user-images.githubusercontent.com/6295984/227477735-dc53bb85-31fb-4043-8355-22866296ebf9.mov




Interact with `Open in Lens` and `Investigate in timeline`



https://user-images.githubusercontent.com/6295984/228217900-5055a5d1-46f2-4d2f-98a8-289eb0f1939a.mov




**Steps to verify**:
1. Create a dashboard from `/app/dashboards#/list`, save it and copy the
dashboard saved object id from url.
2. Visit `/app/security/dashboards/:dashboardId`



### Checklist

Delete any items that are not applicable to this PR.

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: Stratoula Kalafateli <[email protected]>
Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
3 people authored Apr 11, 2023
1 parent 6294b1b commit 67af39a
Show file tree
Hide file tree
Showing 42 changed files with 1,070 additions and 207 deletions.
15 changes: 15 additions & 0 deletions src/plugins/unified_search/public/search_bar/search_bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export interface SearchBarOwnProps<QT extends AggregateQuery | Query = Query> {
onSaved?: (savedQuery: SavedQuery) => void;
// User has modified the saved query, your app should persist the update
onSavedQueryUpdated?: (savedQuery: SavedQuery) => void;
// Execute whenever time range is updated.
onTimeRangeChange?: (payload: { dateRange: TimeRange }) => void;
// User has cleared the active query, your app should clear the entire query bar
onClearSavedQuery?: () => void;

Expand Down Expand Up @@ -187,6 +189,19 @@ class SearchBarUI<QT extends (Query | AggregateQuery) | Query = Query> extends C
if (nextDateRange) {
nextState.dateRangeFrom = nextDateRange.dateRangeFrom;
nextState.dateRangeTo = nextDateRange.dateRangeTo;

/**
* Some applications do not rely on the _g url parameter to update the time. The onTimeRangeChange
* callback can be used in these cases to notify the consumer for the time change.
*/
if (nextDateRange.dateRangeFrom && nextDateRange.dateRangeTo) {
nextProps?.onTimeRangeChange?.({
dateRange: {
from: nextDateRange.dateRangeFrom,
to: nextDateRange.dateRangeTo,
},
});
}
}
return nextState;
}
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/security_solution/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export enum SecurityPageName {
* All cloud defend page names must match `CloudDefendPageId` in x-pack/plugins/cloud_defend/public/common/navigation/types.ts
*/
cloudDefendPolicies = 'cloud_defend-policies',
dashboardsLanding = 'dashboards',
dashboards = 'dashboards',
dataQuality = 'data_quality',
detections = 'detections',
detectionAndResponse = 'detection_response',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [
],
},
{
id: SecurityPageName.dashboardsLanding,
id: SecurityPageName.dashboards,
title: DASHBOARDS,
path: OVERVIEW_PATH,
navLinkStatus: AppNavLinkStatus.visible,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../mock';
import { DashboardRenderer } from './dashboard_renderer';

jest.mock('@kbn/dashboard-plugin/public', () => {
const actual = jest.requireActual('@kbn/dashboard-plugin/public');
return {
...actual,
LazyDashboardContainerRenderer: jest
.fn()
.mockImplementation(() => <div data-test-subj="dashboardRenderer" />),
};
});

jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
return {
...actual,
useParams: jest.fn().mockReturnValue({
detailName: '2d50f100-be6f-11ed-964a-ffa67304840e',
}),
};
});

describe('DashboardRenderer', () => {
const props = {
canReadDashboard: true,
id: 'dashboard-savedObjectId',
savedObjectId: 'savedObjectId',
timeRange: {
from: '2023-03-10T00:00:00.000Z',
to: '2023-03-10T23:59:59.999Z',
},
};

it('renders', () => {
const { queryByTestId } = render(<DashboardRenderer {...props} />, { wrapper: TestProviders });
expect(queryByTestId(`dashboardRenderer`)).toBeInTheDocument();
});

it('does not render when No Read Permission', () => {
const testProps = {
...props,
canReadDashboard: false,
};
const { queryByTestId } = render(<DashboardRenderer {...testProps} />, {
wrapper: TestProviders,
});
expect(queryByTestId(`dashboardRenderer`)).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/*
* 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 type { DashboardContainer } from '@kbn/dashboard-plugin/public';
import { LazyDashboardContainerRenderer } from '@kbn/dashboard-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { Filter, Query } from '@kbn/es-query';

import { useDispatch } from 'react-redux';
import { InputsModelId } from '../../store/inputs/constants';
import { inputsActions } from '../../store/inputs';

const DashboardRendererComponent = ({
canReadDashboard,
filters,
id,
inputId = InputsModelId.global,
onDashboardContainerLoaded,
query,
savedObjectId,
timeRange,
}: {
canReadDashboard: boolean;
filters?: Filter[];
id: string;
inputId?: InputsModelId.global | InputsModelId.timeline;
onDashboardContainerLoaded?: (dashboardContainer: DashboardContainer) => void;
query?: Query;
savedObjectId: string | undefined;
timeRange: {
from: string;
fromStr?: string | undefined;
to: string;
toStr?: string | undefined;
};
}) => {
const dispatch = useDispatch();
const [dashboardContainer, setDashboardContainer] = useState<DashboardContainer>();

const getCreationOptions = useCallback(
() =>
Promise.resolve({
overrideInput: { timeRange, viewMode: ViewMode.VIEW, query, filters },
}),
[filters, query, timeRange]
);

const refetchByForceRefresh = useCallback(() => {
dashboardContainer?.forceRefresh();
}, [dashboardContainer]);

useEffect(() => {
dispatch(
inputsActions.setQuery({
inputId,
id,
refetch: refetchByForceRefresh,
loading: false,
inspect: null,
})
);
return () => {
dispatch(inputsActions.deleteOneQuery({ inputId, id }));
};
}, [dispatch, id, inputId, refetchByForceRefresh]);

useEffect(() => {
dashboardContainer?.updateInput({ timeRange, query, filters });
}, [dashboardContainer, filters, query, timeRange]);

const handleDashboardLoaded = useCallback(
(container: DashboardContainer) => {
setDashboardContainer(container);
onDashboardContainerLoaded?.(container);
},
[onDashboardContainerLoaded]
);
return savedObjectId && canReadDashboard ? (
<LazyDashboardContainerRenderer
savedObjectId={savedObjectId}
getCreationOptions={getCreationOptions}
onDashboardContainerLoaded={handleDashboardLoaded}
/>
) : null;
};
DashboardRendererComponent.displayName = 'DashboardRendererComponent';
export const DashboardRenderer = React.memo(DashboardRendererComponent);
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { getTrailingBreadcrumbs as getCSPBreadcrumbs } from '../../../../cloud_s
import { getTrailingBreadcrumbs as getUsersBreadcrumbs } from '../../../../explore/users/pages/details/utils';
import { getTrailingBreadcrumbs as getKubernetesBreadcrumbs } from '../../../../kubernetes/pages/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getAlertDetailBreadcrumbs } from '../../../../detections/pages/alert_details/utils/breadcrumbs';
import { getTrailingBreadcrumbs as getDashboardBreadcrumbs } from '../../../../dashboards/pages/utils';
import { SecurityPageName } from '../../../../app/types';
import type { RouteSpyState } from '../../../utils/route/types';
import { timelineActions } from '../../../../timelines/store/timeline';
Expand Down Expand Up @@ -134,6 +135,8 @@ const getTrailingBreadcrumbsForRoutes = (
return getAlertDetailBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.cloudSecurityPostureBenchmarks:
return getCSPBreadcrumbs(spyState, getSecuritySolutionUrl);
case SecurityPageName.dashboards:
return getDashboardBreadcrumbs(spyState);
}

return [];
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,23 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
}, []);

const indexPatterns = useMemo(() => [indexPattern], [indexPattern]);

const onTimeRangeChange = useCallback(
({ query, dateRange }) => {
const isQuickSelection = dateRange.from.includes('now') || dateRange.to.includes('now');
updateSearch({
end: dateRange.to,
filterManager,
id,
isInvalid: false,
isQuickSelection,
query,
setTablesActivePageToZero,
start: dateRange.from,
updateTime: true,
});
},
[filterManager, id, setTablesActivePageToZero, updateSearch]
);
return (
<div data-test-subj={`${id}DatePicker`}>
<SearchBar
Expand All @@ -307,6 +323,7 @@ export const SearchBarComponent = memo<SiemSearchBarProps & PropsFromRedux>(
onQuerySubmit={onQuerySubmit}
onRefresh={onRefresh}
onSaved={onSaved}
onTimeRangeChange={onTimeRangeChange}
onSavedQueryUpdated={onSavedQueryUpdated}
savedQuery={savedQuery}
showFilterBar={!hideFilterBar}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ import {
useSecurityDashboardsTableItems,
} from './use_security_dashboards_table';
import * as telemetry from '../../lib/telemetry';
import { SecurityPageName } from '../../../../common/constants';
import * as linkTo from '../../components/link_to';
import type { DashboardTableItem } from './types';

jest.mock('../../lib/kibana');

const spyUseGetSecuritySolutionUrl = jest.spyOn(linkTo, 'useGetSecuritySolutionUrl');
const spyTrack = jest.spyOn(telemetry, 'track');

const TAG_ID = 'securityTagId';
Expand Down Expand Up @@ -202,4 +206,20 @@ describe('Security Dashboards Table hooks', () => {
telemetry.TELEMETRY_EVENT.DASHBOARD
);
});

it('should land on SecuritySolution dashboard view page when dashboard title clicked', async () => {
const mockGetSecuritySolutionUrl = jest.fn();
spyUseGetSecuritySolutionUrl.mockImplementation(() => mockGetSecuritySolutionUrl);
const { result: itemsResult } = await renderUseSecurityDashboardsTableItems();
const { result: columnsResult } = renderUseDashboardsTableColumns();

render(<EuiBasicTable items={itemsResult.current.items} columns={columnsResult.current} />, {
wrapper: TestProviders,
});

expect(mockGetSecuritySolutionUrl).toHaveBeenCalledWith({
deepLinkId: SecurityPageName.dashboards,
path: 'dashboardId1',
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { useKibana, useNavigateTo } from '../../lib/kibana';
import * as i18n from './translations';
import { useFetch, REQUEST_NAMES } from '../../hooks/use_fetch';
import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry';
import { SecurityPageName } from '../../../../common/constants';
import { useGetSecuritySolutionUrl } from '../../components/link_to';

import type { DashboardTableItem } from './types';

const EMPTY_DESCRIPTION = '-' as const;
Expand Down Expand Up @@ -49,8 +52,9 @@ export const useSecurityDashboardsTableItems = () => {
export const useSecurityDashboardsTableColumns = (): Array<
EuiBasicTableColumn<DashboardTableItem>
> => {
const { savedObjectsTagging, dashboard } = useKibana().services;
const { savedObjectsTagging } = useKibana().services;
const { navigateTo } = useNavigateTo();
const getSecuritySolutionUrl = useGetSecuritySolutionUrl();

const getNavigationHandler = useCallback(
(href: string): MouseEventHandler =>
Expand All @@ -69,7 +73,10 @@ export const useSecurityDashboardsTableColumns = (): Array<
name: i18n.DASHBOARD_TITLE,
sortable: true,
render: (title: string, { id }) => {
const href = dashboard?.locator?.getRedirectUrl({ dashboardId: id });
const href = `${getSecuritySolutionUrl({
deepLinkId: SecurityPageName.dashboards,
path: id,
})}`;
return href ? (
<LinkAnchor href={href} onClick={getNavigationHandler(href)}>
{title}
Expand All @@ -90,7 +97,7 @@ export const useSecurityDashboardsTableColumns = (): Array<
// adds the tags table column based on the saved object items
...(savedObjectsTagging ? [savedObjectsTagging.ui.getTableColumnDefinition()] : []),
],
[getNavigationHandler, dashboard, savedObjectsTagging]
[savedObjectsTagging, getSecuritySolutionUrl, getNavigationHandler]
);

return columns;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ import { links as detectionLinks } from '../../detections/links';
import { links as timelinesLinks } from '../../timelines/links';
import { getCasesLinkItems } from '../../cases/links';
import { links as managementLinks, getManagementFilteredLinks } from '../../management/links';
import { dashboardsLandingLinks, threatHuntingLandingLinks } from '../../landing_pages/links';
import { threatHuntingLandingLinks } from '../../landing_pages/links';
import { gettingStartedLinks } from '../../overview/links';
import { rootLinks as cloudSecurityPostureRootLinks } from '../../cloud_security_posture/links';
import type { StartPlugins } from '../../types';
import { dashboardsLandingLinks } from '../../dashboards/links';

const casesLinks = getCasesLinkItems();

Expand Down
Loading

0 comments on commit 67af39a

Please sign in to comment.