diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.import.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.import.md index 1ca6058e7d742..f30ddeddba92d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.import.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.import.md @@ -9,14 +9,14 @@ Import saved objects from given stream. See the [options](./kibana-plugin-core-s Signature: ```typescript -import({ readStream, createNewCopies, namespace, overwrite, }: SavedObjectsImportOptions): Promise; +import({ readStream, createNewCopies, namespace, overwrite, refresh, }: SavedObjectsImportOptions): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| { readStream, createNewCopies, namespace, overwrite, } | SavedObjectsImportOptions | | +| { readStream, createNewCopies, namespace, overwrite, refresh, } | SavedObjectsImportOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.md index 18ce27ca2c0dc..b1035bc247ad1 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimporter.md @@ -21,6 +21,6 @@ export declare class SavedObjectsImporter | Method | Modifiers | Description | | --- | --- | --- | -| [import({ readStream, createNewCopies, namespace, overwrite, })](./kibana-plugin-core-server.savedobjectsimporter.import.md) | | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | +| [import({ readStream, createNewCopies, namespace, overwrite, refresh, })](./kibana-plugin-core-server.savedobjectsimporter.import.md) | | Import saved objects from given stream. See the [options](./kibana-plugin-core-server.savedobjectsimportoptions.md) for more detailed information. | | [resolveImportErrors({ readStream, createNewCopies, namespace, retries, })](./kibana-plugin-core-server.savedobjectsimporter.resolveimporterrors.md) | | Resolve and return saved object import errors. See the [options](./kibana-plugin-core-server.savedobjectsresolveimporterrorsoptions.md) for more detailed information. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md index 58d0f4bf982c3..775f3a4c9acb3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.md @@ -20,4 +20,5 @@ export interface SavedObjectsImportOptions | [namespace?](./kibana-plugin-core-server.savedobjectsimportoptions.namespace.md) | string | (Optional) if specified, will import in given namespace, else will import as global object | | [overwrite](./kibana-plugin-core-server.savedobjectsimportoptions.overwrite.md) | boolean | If true, will override existing object if present. Note: this has no effect when used with the createNewCopies option. | | [readStream](./kibana-plugin-core-server.savedobjectsimportoptions.readstream.md) | Readable | The stream of [saved objects](./kibana-plugin-core-server.savedobject.md) to import | +| [refresh?](./kibana-plugin-core-server.savedobjectsimportoptions.refresh.md) | boolean \| 'wait\_for' | (Optional) Refresh setting, defaults to wait_for | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.refresh.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.refresh.md new file mode 100644 index 0000000000000..cc7e36354647a --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsimportoptions.refresh.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsImportOptions](./kibana-plugin-core-server.savedobjectsimportoptions.md) > [refresh](./kibana-plugin-core-server.savedobjectsimportoptions.refresh.md) + +## SavedObjectsImportOptions.refresh property + +Refresh setting, defaults to `wait_for` + +Signature: + +```typescript +refresh?: boolean | 'wait_for'; +``` diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 4c83bc19f1cda..f5a6744845188 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -122,7 +122,7 @@ pageLoadAssetSize: sessionView: 77750 cloudSecurityPosture: 19109 visTypeGauge: 24113 - unifiedSearch: 104869 + unifiedSearch: 71059 data: 454087 expressionXY: 26500 eventAnnotation: 19334 diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index 0631d97b58a72..9e9f5f8b050dc 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -35,6 +35,8 @@ export interface ImportSavedObjectsOptions { objectLimit: number; /** If true, will override existing object if present. Note: this has no effect when used with the `createNewCopies` option. */ overwrite: boolean; + /** Refresh setting, defaults to `wait_for` */ + refresh?: boolean | 'wait_for'; /** {@link SavedObjectsClientContract | client} to use to perform the import operation */ savedObjectsClient: SavedObjectsClientContract; /** The registry of all known saved object types */ @@ -62,6 +64,7 @@ export async function importSavedObjectsFromStream({ typeRegistry, importHooks, namespace, + refresh, }: ImportSavedObjectsOptions): Promise { let errorAccumulator: SavedObjectsImportFailure[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -141,6 +144,7 @@ export async function importSavedObjectsFromStream({ importStateMap, overwrite, namespace, + refresh, }; const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; diff --git a/src/core/server/saved_objects/import/lib/create_saved_objects.ts b/src/core/server/saved_objects/import/lib/create_saved_objects.ts index bf58b2bb4b00e..d6c7cbe934b51 100644 --- a/src/core/server/saved_objects/import/lib/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/lib/create_saved_objects.ts @@ -18,6 +18,7 @@ export interface CreateSavedObjectsParams { importStateMap: ImportStateMap; namespace?: string; overwrite?: boolean; + refresh?: boolean | 'wait_for'; } export interface CreateSavedObjectsResult { createdObjects: Array>; @@ -35,6 +36,7 @@ export const createSavedObjects = async ({ importStateMap, namespace, overwrite, + refresh, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( @@ -87,6 +89,7 @@ export const createSavedObjects = async ({ const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { namespace, overwrite, + refresh, }); expectedResults = bulkCreateResponse.saved_objects; } diff --git a/src/core/server/saved_objects/import/saved_objects_importer.ts b/src/core/server/saved_objects/import/saved_objects_importer.ts index f4572e58d6fad..e9c54f7b44deb 100644 --- a/src/core/server/saved_objects/import/saved_objects_importer.ts +++ b/src/core/server/saved_objects/import/saved_objects_importer.ts @@ -66,12 +66,14 @@ export class SavedObjectsImporter { createNewCopies, namespace, overwrite, + refresh, }: SavedObjectsImportOptions): Promise { return importSavedObjectsFromStream({ readStream, createNewCopies, namespace, overwrite, + refresh, objectLimit: this.#importSizeLimit, savedObjectsClient: this.#savedObjectsClient, typeRegistry: this.#typeRegistry, diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index ccf58c99f1ad8..d3a38b48e92cb 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -155,6 +155,8 @@ export interface SavedObjectsImportOptions { namespace?: string; /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ createNewCopies: boolean; + /** Refresh setting, defaults to `wait_for` */ + refresh?: boolean | 'wait_for'; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 2228c8fee8794..ea83e210cc4e1 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2541,7 +2541,7 @@ export class SavedObjectsImporter { typeRegistry: ISavedObjectTypeRegistry; importSizeLimit: number; }); - import({ readStream, createNewCopies, namespace, overwrite, }: SavedObjectsImportOptions): Promise; + import({ readStream, createNewCopies, namespace, overwrite, refresh, }: SavedObjectsImportOptions): Promise; resolveImportErrors({ readStream, createNewCopies, namespace, retries, }: SavedObjectsResolveImportErrorsOptions): Promise; } @@ -2604,6 +2604,7 @@ export interface SavedObjectsImportOptions { namespace?: string; overwrite: boolean; readStream: Readable; + refresh?: boolean | 'wait_for'; } // @public diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx index 1281fb17bd990..648df546b2992 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.test.tsx @@ -12,7 +12,8 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import type { Datatable } from '@kbn/expressions-plugin/public'; -import { shallow, mount } from 'enzyme'; +import { shallow } from 'enzyme'; +import { mountWithIntl } from '@kbn/test-jest-helpers'; import { findTestSubject } from '@elastic/eui/lib/test'; import { act } from 'react-dom/test-utils'; import PartitionVisComponent, { PartitionVisComponentProps } from './partition_vis_component'; @@ -143,7 +144,7 @@ describe('PartitionVisComponent', function () { }); it('renders the legend toggle component', async () => { - const component = mount(); + const component = mountWithIntl(); await actWithTimeout(async () => { await component.update(); }); @@ -154,7 +155,7 @@ describe('PartitionVisComponent', function () { }); it('hides the legend if the legend toggle is clicked', async () => { - const component = mount(); + const component = mountWithIntl(); await actWithTimeout(async () => { await component.update(); }); @@ -233,7 +234,7 @@ describe('PartitionVisComponent', function () { ], } as unknown as Datatable; const newProps = { ...wrapperProps, visData: newVisData }; - const component = mount(); + const component = mountWithIntl(); expect(findTestSubject(component, 'partitionVisEmptyValues').text()).toEqual( 'No results found' ); @@ -264,7 +265,7 @@ describe('PartitionVisComponent', function () { ], } as unknown as Datatable; const newProps = { ...wrapperProps, visData: newVisData }; - const component = mount(); + const component = mountWithIntl(); expect(findTestSubject(component, 'partitionVisNegativeValues').text()).toEqual( "Pie chart can't render with negative values." ); diff --git a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap index 23354c7bdc786..a90577d8bca03 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap +++ b/src/plugins/chart_expressions/expression_xy/public/components/__snapshots__/xy_chart.test.tsx.snap @@ -289,6 +289,14 @@ exports[`XYChart component it renders area 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), @@ -524,6 +532,14 @@ exports[`XYChart component it renders bar 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), @@ -771,6 +787,14 @@ exports[`XYChart component it renders horizontal bar 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), @@ -1018,6 +1042,14 @@ exports[`XYChart component it renders line 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), @@ -1253,6 +1285,14 @@ exports[`XYChart component it renders stacked area 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), @@ -1496,6 +1536,14 @@ exports[`XYChart component it renders stacked bar 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), @@ -1751,6 +1799,14 @@ exports[`XYChart component it renders stacked horizontal bar 1`] = ` ariaUseDefaultSummary={true} baseTheme={Object {}} debugState={false} + externalPointerEvents={ + Object { + "tooltip": Object { + "placement": "right", + "visible": false, + }, + } + } legendAction={ Object { "$$typeof": Symbol(react.memo), diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx index fc93f48a594ad..10f53ec2572a8 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.test.tsx @@ -121,6 +121,7 @@ describe('XYChart component', () => { onClickValue, onSelectRange, syncColors: false, + syncTooltips: false, useLegacyTimeAxis: false, eventAnnotationService: eventAnnotationServiceMock, }; diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 72fc7d05eb13d..faac9076e8a8a 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -35,6 +35,7 @@ import { BarSeriesProps, LineSeriesProps, ColorVariant, + Placement, } from '@elastic/charts'; import { IconType } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -99,6 +100,7 @@ export type XYChartRenderProps = XYChartProps & { onSelectRange: (data: BrushEvent['data']) => void; renderMode: RenderMode; syncColors: boolean; + syncTooltips: boolean; eventAnnotationService: EventAnnotationServiceType; }; @@ -144,6 +146,7 @@ export function XYChart({ onSelectRange, interactive = true, syncColors, + syncTooltips, useLegacyTimeAxis, }: XYChartRenderProps) { const { @@ -520,6 +523,9 @@ export function XYChart({ {' '} diff --git a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx index ee4367bc87898..4fd7bd83014ce 100644 --- a/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx +++ b/src/plugins/dashboard/public/application/embeddable/dashboard_container.tsx @@ -79,6 +79,7 @@ export interface InheritedChildInput extends IndexSignature { id: string; searchSessionId?: string; syncColors?: boolean; + syncTooltips?: boolean; executionContext?: KibanaExecutionContext; } @@ -314,6 +315,7 @@ export class DashboardContainer extends Container): DashboardState => { hidePanelTitles: false, useMargins: true, syncColors: false, + syncTooltips: false, }, panels: { panel_1: { @@ -97,6 +98,7 @@ describe('Dashboard state diff function', () => { hidePanelTitles: false, useMargins: false, syncColors: false, + syncTooltips: false, }, }) ).toEqual(['options']); @@ -108,6 +110,7 @@ describe('Dashboard state diff function', () => { options: { useMargins: true, syncColors: undefined, + syncTooltips: undefined, } as unknown as DashboardOptions, }) ).toEqual([]); diff --git a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts index 35f9789023ec5..50ba66d1b781f 100644 --- a/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts +++ b/src/plugins/dashboard/public/application/state/dashboard_state_slice.ts @@ -54,6 +54,9 @@ export const dashboardStateSlice = createSlice({ setSyncColors: (state, action: PayloadAction) => { state.options.syncColors = action.payload; }, + setSyncTooltips: (state, action: PayloadAction) => { + state.options.syncTooltips = action.payload; + }, setHidePanelTitles: (state, action: PayloadAction) => { state.options.hidePanelTitles = action.payload; }, @@ -114,6 +117,7 @@ export const { setTimeRestore, setTimeRange, setSyncColors, + setSyncTooltips, setUseMargins, setViewMode, setFilters, diff --git a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx index baf519846cf63..7095ad34cd189 100644 --- a/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/top_nav/dashboard_top_nav.tsx @@ -48,6 +48,7 @@ import { setSavedQueryId, setStateFromSaveModal, setSyncColors, + setSyncTooltips, setUseMargins, setViewMode, useDashboardDispatch, @@ -396,6 +397,10 @@ export function DashboardTopNav({ onSyncColorsChange: (isChecked: boolean) => { dispatchDashboardStateChange(setSyncColors(isChecked)); }, + syncTooltips: Boolean(currentState.options.syncTooltips), + onSyncTooltipsChange: (isChecked: boolean) => { + dispatchDashboardStateChange(setSyncTooltips(isChecked)); + }, hidePanelTitles: currentState.options.hidePanelTitles, onHidePanelTitlesChange: (isChecked: boolean) => { dispatchDashboardStateChange(setHidePanelTitles(isChecked)); diff --git a/src/plugins/dashboard/public/application/top_nav/options.tsx b/src/plugins/dashboard/public/application/top_nav/options.tsx index 48d1914e50d6d..3b20da4c7c1a3 100644 --- a/src/plugins/dashboard/public/application/top_nav/options.tsx +++ b/src/plugins/dashboard/public/application/top_nav/options.tsx @@ -18,12 +18,15 @@ interface Props { onHidePanelTitlesChange: (hideTitles: boolean) => void; syncColors: boolean; onSyncColorsChange: (syncColors: boolean) => void; + syncTooltips: boolean; + onSyncTooltipsChange: (syncTooltips: boolean) => void; } interface State { useMargins: boolean; hidePanelTitles: boolean; syncColors: boolean; + syncTooltips: boolean; } export class OptionsMenu extends Component { @@ -31,6 +34,7 @@ export class OptionsMenu extends Component { useMargins: this.props.useMargins, hidePanelTitles: this.props.hidePanelTitles, syncColors: this.props.syncColors, + syncTooltips: this.props.syncTooltips, }; constructor(props: Props) { @@ -55,6 +59,12 @@ export class OptionsMenu extends Component { this.setState({ syncColors: isChecked }); }; + handleSyncTooltipsChange = (evt: any) => { + const isChecked = evt.target.checked; + this.props.onSyncTooltipsChange(isChecked); + this.setState({ syncTooltips: isChecked }); + }; + render() { return ( @@ -90,6 +100,17 @@ export class OptionsMenu extends Component { data-test-subj="dashboardSyncColorsCheckbox" /> + + + + ); } diff --git a/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx b/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx index 8f5e86b4dab6b..d6bd186af12c4 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_options_popover.tsx @@ -29,6 +29,8 @@ export interface ShowOptionsPopoverProps { onUseMarginsChange: (useMargins: boolean) => void; syncColors: boolean; onSyncColorsChange: (syncColors: boolean) => void; + syncTooltips: boolean; + onSyncTooltipsChange: (syncTooltips: boolean) => void; hidePanelTitles: boolean; onHidePanelTitlesChange: (hideTitles: boolean) => void; theme$: CoreStart['theme']['theme$']; @@ -42,6 +44,8 @@ export function showOptionsPopover({ onHidePanelTitlesChange, syncColors, onSyncColorsChange, + syncTooltips, + onSyncTooltipsChange, theme$, }: ShowOptionsPopoverProps) { if (isOpen) { @@ -68,6 +72,8 @@ export function showOptionsPopover({ onHidePanelTitlesChange={onHidePanelTitlesChange} syncColors={syncColors} onSyncColorsChange={onSyncColorsChange} + syncTooltips={syncTooltips} + onSyncTooltipsChange={onSyncTooltipsChange} /> diff --git a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx index 184d25081d4fa..37c9a4c5027a9 100644 --- a/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx +++ b/src/plugins/dashboard/public/application/top_nav/show_share_modal.test.tsx @@ -117,6 +117,7 @@ describe('ShowShareModal', () => { hidePanelTitles: true, useMargins: true, syncColors: true, + syncTooltips: true, }, filters: [ { diff --git a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts index cf79354719dbc..ed3a7fdf4b21f 100644 --- a/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts +++ b/src/plugins/dashboard/public/saved_dashboards/saved_dashboard.ts @@ -52,6 +52,7 @@ const defaults = { // for BWC reasons we can't default dashboards that already exist without this setting to true. useMargins: true, syncColors: false, + syncTooltips: false, hidePanelTitles: false, } as DashboardOptions), version: 1, diff --git a/src/plugins/dashboard/public/types.ts b/src/plugins/dashboard/public/types.ts index e250e44d5abce..5da9608dbb1fb 100644 --- a/src/plugins/dashboard/public/types.ts +++ b/src/plugins/dashboard/public/types.ts @@ -91,6 +91,7 @@ export interface DashboardContainerInput extends ContainerInput { description?: string; useMargins: boolean; syncColors?: boolean; + syncTooltips?: boolean; viewMode: ViewMode; filters: Filter[]; title: string; @@ -154,6 +155,7 @@ export type DashboardOptions = { hidePanelTitles: boolean; useMargins: boolean; syncColors: boolean; + syncTooltips: boolean; }; export type DashboardRedirect = (props: RedirectToProps) => void; diff --git a/src/plugins/embeddable/common/index.ts b/src/plugins/embeddable/common/index.ts index 4eed6531cf7d5..ad362a0d8dc7c 100644 --- a/src/plugins/embeddable/common/index.ts +++ b/src/plugins/embeddable/common/index.ts @@ -12,6 +12,7 @@ export type { EmbeddableStateWithType, PanelState, EmbeddablePersistableStateService, + EmbeddableRegistryDefinition, } from './types'; export { ViewMode } from './types'; export type { SavedObjectEmbeddableInput } from './lib'; diff --git a/src/plugins/embeddable/common/types.ts b/src/plugins/embeddable/common/types.ts index b7cdc9bd08df3..c37b3ee2b720c 100644 --- a/src/plugins/embeddable/common/types.ts +++ b/src/plugins/embeddable/common/types.ts @@ -8,7 +8,11 @@ import type { SerializableRecord } from '@kbn/utility-types'; import type { KibanaExecutionContext } from '@kbn/core/public'; -import { PersistableStateService, PersistableState } from '@kbn/kibana-utils-plugin/common'; +import type { + PersistableStateService, + PersistableState, + PersistableStateDefinition, +} from '@kbn/kibana-utils-plugin/common'; export enum ViewMode { EDIT = 'edit', @@ -54,6 +58,11 @@ export type EmbeddableInput = { */ syncColors?: boolean; + /** + * Flag whether tooltips should be synced with other panels on hover + */ + syncTooltips?: boolean; + executionContext?: KibanaExecutionContext; }; @@ -69,6 +78,12 @@ export interface PanelState extends PersistableStateDefinition

{ + id: string; +} + export type EmbeddablePersistableStateService = PersistableStateService; export interface CommonEmbeddableStartContract { diff --git a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts index 00c4de5ff426f..1dfd056bc75c0 100644 --- a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.test.ts @@ -20,6 +20,7 @@ const getGenericEmbeddableState = (state?: Partial): Embeddable disableTriggers: false, enhancements: undefined, syncColors: false, + syncTooltips: false, viewMode: ViewMode.VIEW, title: 'So Very Generic', id: 'soVeryGeneric', @@ -44,6 +45,7 @@ test('Omitting generic embeddable input omits all generic input keys', () => { 'disableTriggers', 'enhancements', 'syncColors', + 'syncTooltips', 'viewMode', 'title', 'id', diff --git a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts index a396ed324a949..a66294d08bdc4 100644 --- a/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts +++ b/src/plugins/embeddable/public/lib/embeddables/diff_embeddable_input.ts @@ -20,6 +20,7 @@ const allGenericInputKeys: Readonly> = [ 'disableTriggers', 'enhancements', 'syncColors', + 'syncTooltips', 'viewMode', 'title', 'id', @@ -31,6 +32,7 @@ const genericInputKeysToCompare = [ 'disableTriggers', 'enhancements', 'syncColors', + 'syncTooltips', 'title', 'id', ] as const; diff --git a/src/plugins/embeddable/server/index.ts b/src/plugins/embeddable/server/index.ts index 8d88f35a4be22..4b93f0838c649 100644 --- a/src/plugins/embeddable/server/index.ts +++ b/src/plugins/embeddable/server/index.ts @@ -10,6 +10,8 @@ import { EmbeddableServerPlugin, EmbeddableSetup, EmbeddableStart } from './plug export type { EmbeddableSetup, EmbeddableStart }; -export type { EnhancementRegistryDefinition, EmbeddableRegistryDefinition } from './types'; +export type { EnhancementRegistryDefinition } from './types'; + +export type { EmbeddableRegistryDefinition } from '../common'; export const plugin = () => new EmbeddableServerPlugin(); diff --git a/src/plugins/embeddable/server/plugin.ts b/src/plugins/embeddable/server/plugin.ts index 51fa1edb2c634..2260d6b34c8e8 100644 --- a/src/plugins/embeddable/server/plugin.ts +++ b/src/plugins/embeddable/server/plugin.ts @@ -19,7 +19,6 @@ import { EnhancementsRegistry, EnhancementRegistryDefinition, EnhancementRegistryItem, - EmbeddableRegistryDefinition, } from './types'; import { getExtractFunction, @@ -27,7 +26,11 @@ import { getMigrateFunction, getTelemetryFunction, } from '../common/lib'; -import { EmbeddableStateWithType, CommonEmbeddableStartContract } from '../common/types'; +import { + EmbeddableStateWithType, + CommonEmbeddableStartContract, + EmbeddableRegistryDefinition, +} from '../common/types'; import { getAllMigrations } from '../common/lib/get_all_migrations'; export interface EmbeddableSetup extends PersistableStateService { diff --git a/src/plugins/embeddable/server/types.ts b/src/plugins/embeddable/server/types.ts index 9b0479d6bc25d..bd78265bea6b1 100644 --- a/src/plugins/embeddable/server/types.ts +++ b/src/plugins/embeddable/server/types.ts @@ -23,12 +23,6 @@ export interface EnhancementRegistryItem

extends PersistableStateDefinition

{ - id: string; -} - export interface EmbeddableRegistryItem

extends PersistableState

{ id: string; diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts index 17b6338a48f89..ef0a268ca314f 100644 --- a/src/plugins/expressions/common/execution/execution.ts +++ b/src/plugins/expressions/common/execution/execution.ts @@ -224,6 +224,7 @@ export class Execution< inspectorAdapters.tables[name] = datatable; }, isSyncColorsEnabled: () => execution.params.syncColors!, + isSyncTooltipsEnabled: () => execution.params.syncTooltips!, ...execution.executor.context, getExecutionContext: () => execution.params.executionContext, }; diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts index 44d6fef6f79a6..686ade0869171 100644 --- a/src/plugins/expressions/common/execution/types.ts +++ b/src/plugins/expressions/common/execution/types.ts @@ -66,6 +66,11 @@ export interface ExecutionContext< */ isSyncColorsEnabled?: () => boolean; + /** + * Returns the state (true|false) of the sync tooltips across panels switch. + */ + isSyncTooltipsEnabled?: () => boolean; + /** * Contains the meta-data about the source of the expression. */ diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index 1e40ba2a65fff..06d3617d74784 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -91,6 +91,8 @@ export interface IInterpreterRenderHandlers { isInteractive(): boolean; isSyncColorsEnabled(): boolean; + + isSyncTooltipsEnabled(): boolean; /** * This uiState interface is actually `PersistedState` from the visualizations plugin, * but expressions cannot know about vis or it creates a mess of circular dependencies. diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts index d873a1957cd1f..d4bac702bd6e0 100644 --- a/src/plugins/expressions/common/service/expressions_services.ts +++ b/src/plugins/expressions/common/service/expressions_services.ts @@ -152,6 +152,8 @@ export interface ExpressionExecutionParams { syncColors?: boolean; + syncTooltips?: boolean; + inspectorAdapters?: Adapters; executionContext?: KibanaExecutionContext; diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 6482da0af21ee..7f7a96fde6f1a 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -58,6 +58,7 @@ export class ExpressionLoader { onRenderError: params && params.onRenderError, renderMode: params?.renderMode, syncColors: params?.syncColors, + syncTooltips: params?.syncTooltips, hasCompatibleActions: params?.hasCompatibleActions, }); this.render$ = this.renderHandler.render$; @@ -142,6 +143,7 @@ export class ExpressionLoader { searchSessionId: params.searchSessionId, debug: params.debug, syncColors: params.syncColors, + syncTooltips: params.syncTooltips, executionContext: params.executionContext, }); this.subscription = this.execution @@ -182,6 +184,7 @@ export class ExpressionLoader { this.params.searchSessionId = params.searchSessionId; } this.params.syncColors = params.syncColors; + this.params.syncTooltips = params.syncTooltips; this.params.debug = Boolean(params.debug); this.params.partial = Boolean(params.partial); this.params.throttle = Number(params.throttle ?? 1000); diff --git a/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts index 033d50d7faf0d..7daa4b3626fa7 100644 --- a/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts +++ b/src/plugins/expressions/public/react_expression_renderer/use_expression_renderer.ts @@ -111,6 +111,7 @@ export function useExpressionRenderer( debouncedLoaderParams.interactive, debouncedLoaderParams.renderMode, debouncedLoaderParams.syncColors, + debouncedLoaderParams.syncTooltips, ]); useEffect(() => { diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 25bffdca089ee..1d90a795a03a4 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -28,6 +28,7 @@ export interface ExpressionRenderHandlerParams { onRenderError?: RenderErrorHandlerFnType; renderMode?: RenderMode; syncColors?: boolean; + syncTooltips?: boolean; interactive?: boolean; hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; } @@ -54,6 +55,7 @@ export class ExpressionRenderHandler { onRenderError, renderMode, syncColors, + syncTooltips, interactive, hasCompatibleActions = async () => false, }: ExpressionRenderHandlerParams = {} @@ -94,6 +96,9 @@ export class ExpressionRenderHandler { isSyncColorsEnabled: () => { return syncColors || false; }, + isSyncTooltipsEnabled: () => { + return syncTooltips || false; + }, isInteractive: () => { return interactive ?? true; }, diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 322c2895f9bb5..b035daf4deefc 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -50,6 +50,7 @@ export interface IExpressionLoaderParams { searchSessionId?: string; renderMode?: RenderMode; syncColors?: boolean; + syncTooltips?: boolean; hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; executionContext?: KibanaExecutionContext; diff --git a/src/plugins/presentation_util/public/__stories__/render.tsx b/src/plugins/presentation_util/public/__stories__/render.tsx index bd6463ca254d6..7e24606ca5331 100644 --- a/src/plugins/presentation_util/public/__stories__/render.tsx +++ b/src/plugins/presentation_util/public/__stories__/render.tsx @@ -13,6 +13,7 @@ import { ExpressionRenderDefinition, IInterpreterRenderHandlers } from '@kbn/exp export const defaultHandlers: IInterpreterRenderHandlers = { getRenderMode: () => 'view', isSyncColorsEnabled: () => false, + isSyncTooltipsEnabled: () => false, isInteractive: () => true, done: action('done'), onDestroy: action('onDestroy'), diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx index 139405f6af9f4..723b7e6896229 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/field.tsx @@ -8,7 +8,6 @@ import { IFieldType, indexPatterns as indexPatternsUtils } from '@kbn/data-plugin/public'; import { flatten } from 'lodash'; -import { escapeKuery } from './lib/escape_kuery'; import { sortPrefixFirst } from './sort_prefix_first'; import { QuerySuggestionField, QuerySuggestionTypes } from '../query_suggestion_provider'; import { KqlQuerySuggestionProvider } from './types'; @@ -27,7 +26,7 @@ const keywordComparator = (first: IFieldType, second: IFieldType) => { export const setupGetFieldSuggestions: KqlQuerySuggestionProvider = ( core ) => { - return ({ indexPatterns }, { start, end, prefix, suffix, nestedPath = '' }) => { + return async ({ indexPatterns }, { start, end, prefix, suffix, nestedPath = '' }) => { const allFields = flatten( indexPatterns.map((indexPattern) => { return indexPattern.fields.filter(indexPatternsUtils.isFilterable); @@ -42,7 +41,7 @@ export const setupGetFieldSuggestions: KqlQuerySuggestionProvider { const remainingPath = field.subType && field.subType.nested diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/index.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/index.ts index 1002863fec7f4..cd022ec371e65 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/index.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/index.ts @@ -9,7 +9,6 @@ import { CoreSetup } from '@kbn/core/public'; import { $Keys } from 'utility-types'; import { flatten, uniqBy } from 'lodash'; -import { fromKueryExpression } from '@kbn/es-query'; import { setupGetFieldSuggestions } from './field'; import { setupGetValueSuggestions } from './value'; import { setupGetOperatorSuggestions } from './operator'; @@ -38,11 +37,12 @@ export const setupKqlQuerySuggestionProvider = ( conjunction: setupGetConjunctionSuggestions(core), }; - const getSuggestionsByType = ( + const getSuggestionsByType = async ( cursoredQuery: string, querySuggestionsArgs: QuerySuggestionGetFnArgs - ): Array> | [] => { + ): Promise> | []> => { try { + const { fromKueryExpression } = await import('@kbn/es-query'); const cursorNode = fromKueryExpression(cursoredQuery, { cursorSymbol, parseCursor: true, @@ -56,13 +56,13 @@ export const setupKqlQuerySuggestionProvider = ( } }; - return (querySuggestionsArgs) => { + return async (querySuggestionsArgs): Promise => { const { query, selectionStart, selectionEnd } = querySuggestionsArgs; const cursoredQuery = `${query.substr(0, selectionStart)}${cursorSymbol}${query.substr( selectionEnd )}`; - return Promise.all(getSuggestionsByType(cursoredQuery, querySuggestionsArgs)).then( + return Promise.all(await getSuggestionsByType(cursoredQuery, querySuggestionsArgs)).then( (suggestionsByType) => dedup(flatten(suggestionsByType)) ); }; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts index 6636f9b602687..20a20797c15e2 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/lib/escape_kuery.ts @@ -6,8 +6,6 @@ * Side Public License, v 1. */ -import { escapeKuery } from '@kbn/es-query'; - /** * Escapes backslashes and double-quotes. (Useful when putting a string in quotes to use as a value * in a KQL expression. See the QuotedCharacter rule in kuery.peg.) @@ -15,6 +13,3 @@ import { escapeKuery } from '@kbn/es-query'; export function escapeQuotes(str: string) { return str.replace(/[\\"]/g, '\\$&'); } - -// Re-export this function from the @kbn/es-query package to avoid refactoring -export { escapeKuery }; diff --git a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/types.ts b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/types.ts index e9ca34e546f0b..d7b8b3315fafd 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/types.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/kql_query_suggestion/types.ts @@ -6,10 +6,10 @@ * Side Public License, v 1. */ -import { CoreSetup } from '@kbn/core/public'; -import { KueryNode } from '@kbn/es-query'; +import type { KueryNode } from '@kbn/es-query'; +import type { CoreSetup } from '@kbn/core/public'; import type { UnifiedSearchPublicPluginStart } from '../../../types'; -import { QuerySuggestionBasic, QuerySuggestionGetFnArgs } from '../query_suggestion_provider'; +import type { QuerySuggestionBasic, QuerySuggestionGetFnArgs } from '../query_suggestion_provider'; export type KqlQuerySuggestionProvider = ( core: CoreSetup diff --git a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts index 054a243064329..2c25fe0230501 100644 --- a/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/plugins/unified_search/public/autocomplete/providers/value_suggestion_provider.ts @@ -8,7 +8,6 @@ import { CoreSetup } from '@kbn/core/public'; import dateMath from '@kbn/datemath'; -import { buildQueryFromFilters } from '@kbn/es-query'; import { memoize } from 'lodash'; import { IIndexPattern, @@ -124,6 +123,7 @@ export const setupValueSuggestionProvider = ( const timeFilter = useTimeRange ? getAutocompleteTimefilter(timefilter, indexPattern) : undefined; + const { buildQueryFromFilters } = await import('@kbn/es-query'); const filterQuery = timeFilter ? buildQueryFromFilters([timeFilter], indexPattern).filter : []; const filters = [...(boolFilter ? boolFilter : []), ...filterQuery]; try { diff --git a/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx b/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx index e66c5d52770f5..7d4b597129847 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_editor/value_input_type.tsx @@ -26,6 +26,13 @@ interface Props { } class ValueInputTypeUI extends Component { + private getValueForNumberField = (value?: string | number): string | number | undefined => { + if (typeof value === 'string') { + const parsedValue = parseFloat(value); + return isNaN(parsedValue) ? value : parsedValue; + } + return value; + }; public render() { const value = this.props.value; const type = this.props.field.type; @@ -50,7 +57,7 @@ class ValueInputTypeUI extends Component { ); } - +// Needed for React.lazy // eslint-disable-next-line import/no-default-export export default FilterItem; diff --git a/src/plugins/unified_search/public/index.ts b/src/plugins/unified_search/public/index.ts index 316f33a876a59..bc7974b42efb3 100755 --- a/src/plugins/unified_search/public/index.ts +++ b/src/plugins/unified_search/public/index.ts @@ -5,6 +5,7 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + import { PluginInitializerContext } from '@kbn/core/public'; import { ConfigSchema } from '../config'; export type { IndexPatternSelectProps } from './index_pattern_select'; @@ -30,7 +31,7 @@ export type { AutocompleteStart, } from './autocomplete'; -export { QuerySuggestionTypes } from './autocomplete'; +export { QuerySuggestionTypes } from './autocomplete/providers/query_suggestion_provider'; import { UnifiedSearchPublicPlugin } from './plugin'; diff --git a/src/plugins/unified_search/public/plugin.ts b/src/plugins/unified_search/public/plugin.ts index 1b3f3ae66ace7..26727b56094a0 100755 --- a/src/plugins/unified_search/public/plugin.ts +++ b/src/plugins/unified_search/public/plugin.ts @@ -11,7 +11,7 @@ import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { APPLY_FILTER_TRIGGER } from '@kbn/data-plugin/public'; import { ConfigSchema } from '../config'; import { setIndexPatterns, setTheme, setOverlays, setAutocomplete } from './services'; -import { AutocompleteService } from './autocomplete'; +import { AutocompleteService } from './autocomplete/autocomplete_service'; import { createSearchBar } from './search_bar'; import { createIndexPatternSelect } from './index_pattern_select'; import type { diff --git a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx index 8137d8defd18c..b31256dd4fbb7 100644 --- a/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx +++ b/src/plugins/vis_types/timelion/public/components/timelion_vis_component.tsx @@ -16,6 +16,7 @@ import { TooltipType, LegendPositionConfig, LayoutDirection, + Placement, } from '@elastic/charts'; import { EuiTitle } from '@elastic/eui'; import { RangeFilterParams } from '@kbn/es-query'; @@ -57,6 +58,7 @@ interface TimelionVisComponentProps { onBrushEvent: (rangeFilterParams: RangeFilterParams) => void; renderComplete: IInterpreterRenderHandlers['done']; ariaLabel?: string; + syncTooltips?: boolean; } const DefaultYAxis = () => ( @@ -101,6 +103,7 @@ export const TimelionVisComponent = ({ renderComplete, onBrushEvent, ariaLabel, + syncTooltips, }: TimelionVisComponentProps) => { const kibana = useKibana(); const chartRef = useRef(null); @@ -201,6 +204,9 @@ export const TimelionVisComponent = ({ legendPosition={legend.legendPosition} onRenderChange={onRenderChange} onPointerUpdate={handleCursorUpdate} + externalPointerEvents={{ + tooltip: { visible: syncTooltips, placement: Placement.Right }, + }} theme={chartTheme} baseTheme={chartBaseTheme} tooltip={{ @@ -208,7 +214,6 @@ export const TimelionVisComponent = ({ headerFormatter: ({ value }) => tickFormat(value), type: TooltipType.VerticalCursor, }} - externalPointerEvents={{ tooltip: { visible: false } }} ariaLabel={ariaLabel} ariaUseDefaultSummary={!ariaLabel} /> diff --git a/src/plugins/vis_types/timelion/public/timelion_vis_fn.ts b/src/plugins/vis_types/timelion/public/timelion_vis_fn.ts index a7afb338c8edc..6f92d02682b53 100644 --- a/src/plugins/vis_types/timelion/public/timelion_vis_fn.ts +++ b/src/plugins/vis_types/timelion/public/timelion_vis_fn.ts @@ -21,6 +21,7 @@ export interface TimelionRenderValue { visData?: TimelionSuccessResponse; visType: 'timelion'; visParams: TimelionVisParams; + syncTooltips: boolean; } export interface TimelionVisParams { @@ -68,7 +69,13 @@ export const getTimelionVisualizationConfig = ( async fn( input, args, - { getSearchSessionId, getExecutionContext, variables, abortSignal: expressionAbortSignal } + { + getSearchSessionId, + getExecutionContext, + variables, + abortSignal: expressionAbortSignal, + isSyncTooltipsEnabled, + } ) { const { getTimelionRequestHandler } = await import('./async_services'); const visParams = { @@ -106,6 +113,7 @@ export const getTimelionVisualizationConfig = ( visParams, visType: TIMELION_VIS_NAME, visData, + syncTooltips: isSyncTooltipsEnabled?.() ?? false, }, }; }, diff --git a/src/plugins/vis_types/timelion/public/timelion_vis_renderer.tsx b/src/plugins/vis_types/timelion/public/timelion_vis_renderer.tsx index 5e2c40d51217c..0d148a4c56c96 100644 --- a/src/plugins/vis_types/timelion/public/timelion_vis_renderer.tsx +++ b/src/plugins/vis_types/timelion/public/timelion_vis_renderer.tsx @@ -28,7 +28,7 @@ export const getTimelionVisRenderer: ( name: 'timelion_vis', displayName: 'Timelion visualization', reuseDomNode: true, - render: (domNode, { visData, visParams }, handlers) => { + render: (domNode, { visData, visParams, syncTooltips }, handlers) => { handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); @@ -69,6 +69,7 @@ export const getTimelionVisRenderer: ( seriesList={seriesList} renderComplete={handlers.done} onBrushEvent={onBrushEvent} + syncTooltips={syncTooltips} /> )} diff --git a/src/plugins/vis_types/timelion/server/lib/build_target.js b/src/plugins/vis_types/timelion/server/lib/build_target.js index b7967bbb48e41..98e73be3419d3 100644 --- a/src/plugins/vis_types/timelion/server/lib/build_target.js +++ b/src/plugins/vis_types/timelion/server/lib/build_target.js @@ -6,22 +6,30 @@ * Side Public License, v 1. */ -import moment from 'moment'; +import moment from 'moment-timezone'; import splitInterval from './split_interval'; export default function (tlConfig) { - const min = moment(tlConfig.time.from); - const max = moment(tlConfig.time.to); - - const intervalParts = splitInterval(tlConfig.time.interval); + const targetSeries = []; + // The code between this call and the reset in the finally block is not allowed to get async, + // otherwise the timezone setting can leak out of this function. + const defaultTimezone = moment().zoneName(); + try { + moment.tz.setDefault(tlConfig.time.timezone); + const min = moment(tlConfig.time.from); + const max = moment(tlConfig.time.to); - let current = min.startOf(intervalParts.unit); + const intervalParts = splitInterval(tlConfig.time.interval); - const targetSeries = []; + let current = min.startOf(intervalParts.unit); - while (current.valueOf() < max.valueOf()) { - targetSeries.push(current.valueOf()); - current = current.add(intervalParts.count, intervalParts.unit); + while (current.valueOf() < max.valueOf()) { + targetSeries.push(current.valueOf()); + current = current.add(intervalParts.count, intervalParts.unit); + } + } finally { + // reset default moment timezone + moment.tz.setDefault(defaultTimezone); } return targetSeries; diff --git a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx index 6784943b11188..e29d47844950e 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx +++ b/src/plugins/vis_types/timeseries/public/application/components/timeseries_visualization.tsx @@ -37,6 +37,7 @@ interface TimeseriesVisualizationProps { visData: TimeseriesVisData; uiState: PersistedState; syncColors: boolean; + syncTooltips: boolean; } function TimeseriesVisualization({ @@ -46,6 +47,7 @@ function TimeseriesVisualization({ uiState, getConfig, syncColors, + syncTooltips, }: TimeseriesVisualizationProps) { const [indexPattern, setIndexPattern] = useState(null); const [palettesService, setPalettesService] = useState(null); @@ -188,6 +190,7 @@ function TimeseriesVisualization({ onFilterClick={handleFilterClick} onUiState={handleUiState} syncColors={syncColors} + syncTooltips={syncTooltips} palettesService={palettesService} indexPattern={indexPattern} fieldFormatMap={indexPattern?.fieldFormatMap} diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/index.ts b/src/plugins/vis_types/timeseries/public/application/components/vis_types/index.ts index bbc4dcddf6128..68be45ff6eec0 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/index.ts +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/index.ts @@ -61,6 +61,7 @@ export interface TimeseriesVisProps { visData: TimeseriesVisData; getConfig: IUiSettingsClient['get']; syncColors: boolean; + syncTooltips: boolean; palettesService: PaletteRegistry; indexPattern?: FetchedIndexPattern['indexPattern']; /** @deprecated please use indexPattern.fieldFormatMap instead **/ diff --git a/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js index 007e763997e13..e79c3466b0bb2 100644 --- a/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js +++ b/src/plugins/vis_types/timeseries/public/application/components/vis_types/timeseries/vis.js @@ -162,6 +162,7 @@ class TimeseriesVisualization extends Component { onBrush, onFilterClick, syncColors, + syncTooltips, palettesService, fieldFormatMap, getConfig, @@ -272,6 +273,7 @@ class TimeseriesVisualization extends Component { xAxisFormatter={this.xAxisFormatter(interval)} annotations={this.prepareAnnotations()} syncColors={syncColors} + syncTooltips={syncTooltips} palettesService={palettesService} interval={interval} useLegacyTimeAxis={getConfig(LEGACY_TIME_AXIS, false)} diff --git a/src/plugins/vis_types/timeseries/public/application/visualizations/views/timeseries/index.js b/src/plugins/vis_types/timeseries/public/application/visualizations/views/timeseries/index.js index 3fff0f2cce6cb..aab3b45b34447 100644 --- a/src/plugins/vis_types/timeseries/public/application/visualizations/views/timeseries/index.js +++ b/src/plugins/vis_types/timeseries/public/application/visualizations/views/timeseries/index.js @@ -20,6 +20,7 @@ import { LineAnnotation, TooltipType, StackMode, + Placement, } from '@elastic/charts'; import { EuiIcon } from '@elastic/eui'; import { getTimezone } from '../../../lib/get_timezone'; @@ -73,6 +74,7 @@ export const TimeSeries = ({ xAxisFormatter, annotations, syncColors, + syncTooltips, palettesService, interval, isLastBucketDropped, @@ -213,7 +215,9 @@ export const TimeSeries = ({ boundary: document.getElementById('app-fixed-viewport') ?? undefined, headerFormatter: tooltipFormatter, }} - externalPointerEvents={{ tooltip: { visible: false } }} + externalPointerEvents={{ + tooltip: { visible: syncTooltips, placement: Placement.Right }, + }} /> {annotations.map(({ id, data, icon, color }) => { diff --git a/src/plugins/vis_types/timeseries/public/metrics_fn.ts b/src/plugins/vis_types/timeseries/public/metrics_fn.ts index 58b1f581b4a65..9562e19f3f0ec 100644 --- a/src/plugins/vis_types/timeseries/public/metrics_fn.ts +++ b/src/plugins/vis_types/timeseries/public/metrics_fn.ts @@ -26,6 +26,7 @@ export interface TimeseriesRenderValue { visData: TimeseriesVisData | {}; visParams: TimeseriesVisParams; syncColors: boolean; + syncTooltips: boolean; } export type TimeseriesExpressionFunctionDefinition = ExpressionFunctionDefinition< @@ -60,6 +61,7 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ { getSearchSessionId, isSyncColorsEnabled, + isSyncTooltipsEnabled, getExecutionContext, inspectorAdapters, abortSignal: expressionAbortSignal, @@ -68,6 +70,7 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ const visParams: TimeseriesVisParams = JSON.parse(args.params); const uiState = JSON.parse(args.uiState); const syncColors = isSyncColorsEnabled?.() ?? false; + const syncTooltips = isSyncTooltipsEnabled?.() ?? false; const response = await metricsRequestHandler({ input, @@ -86,6 +89,7 @@ export const createMetricsFn = (): TimeseriesExpressionFunctionDefinition => ({ visParams, visData: response, syncColors, + syncTooltips, }, }; }, diff --git a/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx b/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx index 5c4e124c92d31..3e2b5a9c9e6a3 100644 --- a/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx +++ b/src/plugins/vis_types/timeseries/public/timeseries_vis_renderer.tsx @@ -50,7 +50,7 @@ export const getTimeseriesVisRenderer: (deps: { handlers.onDestroy(() => { unmountComponentAtNode(domNode); }); - const { visParams: model, visData, syncColors } = config; + const { visParams: model, visData, syncColors, syncTooltips } = config; const showNoResult = !checkIfDataExists(visData, model); @@ -70,6 +70,7 @@ export const getTimeseriesVisRenderer: (deps: { model={model} visData={visData as TimeseriesVisData} syncColors={syncColors} + syncTooltips={syncTooltips} uiState={handlers.uiState! as PersistedState} /> diff --git a/src/plugins/vis_types/vega/public/data_model/search_api.ts b/src/plugins/vis_types/vega/public/data_model/search_api.ts index 042bc34dea0f1..530449da9aa26 100644 --- a/src/plugins/vis_types/vega/public/data_model/search_api.ts +++ b/src/plugins/vis_types/vega/public/data_model/search_api.ts @@ -30,7 +30,7 @@ export const extendSearchParamsWithRuntimeFields = async ( let runtimeMappings = requestParams.body?.runtime_mappings; if (!runtimeMappings) { - const indexPattern = (await indexPatterns.find(indexPatternString)).find( + const indexPattern = (await indexPatterns.find(indexPatternString, 1)).find( (index) => index.title === indexPatternString ); runtimeMappings = indexPattern?.getRuntimeMappings(); diff --git a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts index 7a52ca254f7ea..c8335030aac19 100644 --- a/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts +++ b/src/plugins/vis_types/vega/public/lib/extract_index_pattern.ts @@ -26,7 +26,7 @@ export const extractIndexPatternsFromSpec = async (spec: VegaSpec) => { await Promise.all( data.reduce>>((accumulator, currentValue) => { if (currentValue.url?.index) { - accumulator.push(dataViews.find(currentValue.url.index)); + accumulator.push(dataViews.find(currentValue.url.index, 1)); } return accumulator; diff --git a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js index c993874466791..90112e294d56b 100644 --- a/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_types/vega/public/vega_view/vega_base_view.js @@ -162,7 +162,7 @@ export class VegaBaseView { let idxObj; if (index) { - [idxObj] = await dataViews.find(index); + [idxObj] = await dataViews.find(index, 1); if (!idxObj) { throw new Error( i18n.translate('visTypeVega.vegaParser.baseView.indexNotFoundErrorMessage', { diff --git a/src/plugins/vis_types/xy/public/components/xy_settings.tsx b/src/plugins/vis_types/xy/public/components/xy_settings.tsx index 836a46236e228..f934a2c203196 100644 --- a/src/plugins/vis_types/xy/public/components/xy_settings.tsx +++ b/src/plugins/vis_types/xy/public/components/xy_settings.tsx @@ -51,6 +51,7 @@ type XYSettingsProps = Pick< | 'orderBucketsBySum' > & { onPointerUpdate: SettingsProps['onPointerUpdate']; + externalPointerEvents: SettingsProps['externalPointerEvents']; xDomain?: DomainRange; adjustedXDomain?: DomainRange; showLegend: boolean; @@ -91,6 +92,7 @@ export const XYSettings: FC = ({ showLegend, onElementClick, onPointerUpdate, + externalPointerEvents, onBrushEnd, onRenderChange, legendAction, @@ -163,6 +165,7 @@ export const XYSettings: FC = ({ ({ visConfig, visData: context, syncColors: handlers?.isSyncColorsEnabled?.() ?? false, + syncTooltips: handlers?.isSyncTooltipsEnabled?.() ?? false, }, }; }, diff --git a/src/plugins/vis_types/xy/public/vis_component.tsx b/src/plugins/vis_types/xy/public/vis_component.tsx index 3bc7eb4a77c52..7c0636ab284fb 100644 --- a/src/plugins/vis_types/xy/public/vis_component.tsx +++ b/src/plugins/vis_types/xy/public/vis_component.tsx @@ -20,6 +20,7 @@ import { AccessorFn, Accessor, XYBrushEvent, + Placement, } from '@elastic/charts'; import { compact } from 'lodash'; @@ -65,6 +66,7 @@ export interface VisComponentProps { fireEvent: IInterpreterRenderHandlers['event']; renderComplete: IInterpreterRenderHandlers['done']; syncColors: boolean; + syncTooltips: boolean; useLegacyTimeAxis: boolean; } @@ -210,7 +212,7 @@ const VisComponent = (props: VisComponentProps) => { [props.uiState] ); - const { visData, visParams, syncColors } = props; + const { visData, visParams, syncColors, syncTooltips } = props; const isDarkMode = getThemeService().useDarkMode(); const config = getConfig(visData, visParams, props.useLegacyTimeAxis, isDarkMode); @@ -355,6 +357,9 @@ const VisComponent = (props: VisComponentProps) => { maxLegendLines={visParams.maxLegendLines} showLegend={showLegend} onPointerUpdate={handleCursorUpdate} + externalPointerEvents={{ + tooltip: { visible: syncTooltips, placement: Placement.Right }, + }} legendPosition={legendPosition} legendSize={visParams.legendSize} xDomain={xDomain} diff --git a/src/plugins/vis_types/xy/public/vis_renderer.tsx b/src/plugins/vis_types/xy/public/vis_renderer.tsx index abdb4c0cca6cf..271d9147b280b 100644 --- a/src/plugins/vis_types/xy/public/vis_renderer.tsx +++ b/src/plugins/vis_types/xy/public/vis_renderer.tsx @@ -38,7 +38,7 @@ export const getXYVisRenderer: (deps: { name: visName, displayName: 'XY visualization', reuseDomNode: true, - render: async (domNode, { visData, visConfig, visType, syncColors }, handlers) => { + render: async (domNode, { visData, visConfig, visType, syncColors, syncTooltips }, handlers) => { const showNoResult = shouldShowNoResultsMessage(visData, visType); handlers.onDestroy(() => unmountComponentAtNode(domNode)); @@ -53,6 +53,7 @@ export const getXYVisRenderer: (deps: { fireEvent={handlers.event} uiState={handlers.uiState as PersistedState} syncColors={syncColors} + syncTooltips={syncTooltips} useLegacyTimeAxis={uiSettings.get(LEGACY_TIME_AXIS, false)} /> diff --git a/src/plugins/visualizations/common/types.ts b/src/plugins/visualizations/common/types.ts index 978e424477be6..8aa03470b2094 100644 --- a/src/plugins/visualizations/common/types.ts +++ b/src/plugins/visualizations/common/types.ts @@ -6,10 +6,9 @@ * Side Public License, v 1. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SavedObjectAttributes } from '@kbn/core/server'; import type { SerializableRecord } from '@kbn/utility-types'; -import { AggConfigSerialized, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import type { AggConfigSerialized, SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import type { SavedObjectAttributes } from '@kbn/core/types'; export interface VisParams { [key: string]: any; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index efb60c16b4bf9..8cff4ccdeb4e9 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -93,6 +93,7 @@ export class VisualizeEmbeddable private filters?: Filter[]; private searchSessionId?: string; private syncColors?: boolean; + private syncTooltips?: boolean; private embeddableTitle?: string; private visCustomizations?: Pick; private subscriptions: Subscription[] = []; @@ -135,6 +136,7 @@ export class VisualizeEmbeddable this.deps = deps; this.timefilter = timefilter; this.syncColors = this.input.syncColors; + this.syncTooltips = this.input.syncTooltips; this.searchSessionId = this.input.searchSessionId; this.query = this.input.query; this.embeddableTitle = this.getTitle(); @@ -257,6 +259,11 @@ export class VisualizeEmbeddable dirty = true; } + if (this.syncTooltips !== this.input.syncTooltips) { + this.syncTooltips = this.input.syncTooltips; + dirty = true; + } + if (this.embeddableTitle !== this.getTitle()) { this.embeddableTitle = this.getTitle(); dirty = true; @@ -418,6 +425,7 @@ export class VisualizeEmbeddable }, searchSessionId: this.input.searchSessionId, syncColors: this.input.syncColors, + syncTooltips: this.input.syncTooltips, uiState: this.vis.uiState, interactive: !this.input.disableTriggers, inspectorAdapters: this.inspectorAdapters, diff --git a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.test.ts b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.test.ts index 42fcc34984b4d..d8a1fc51cd6db 100644 --- a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.test.ts +++ b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.test.ts @@ -9,9 +9,8 @@ import semverGte from 'semver/functions/gte'; import { makeVisualizeEmbeddableFactory } from './make_visualize_embeddable_factory'; import { getAllMigrations } from '../migrations/visualization_saved_object_migrations'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SerializedSearchSourceFields } from '@kbn/data-plugin/public'; -import { GetMigrationFunctionObjectFn } from '@kbn/kibana-utils-plugin/common'; +import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import type { GetMigrationFunctionObjectFn } from '@kbn/kibana-utils-plugin/common'; describe('embeddable migrations', () => { test('should have same versions registered as saved object migrations versions (>7.13.0)', () => { diff --git a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts index e9dd45e1f84fc..d92810743bed4 100644 --- a/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts +++ b/src/plugins/visualizations/server/embeddable/make_visualize_embeddable_factory.ts @@ -7,10 +7,9 @@ */ import { flow, mapValues } from 'lodash'; -import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; +import type { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; import type { SerializableRecord } from '@kbn/utility-types'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { SerializedSearchSourceFields } from '@kbn/data-plugin/public'; +import type { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { mergeMigrationFunctionMaps, MigrateFunctionsObject, diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx index 38d1d502704e2..cd82ca43c6264 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/__stories__/render.tsx @@ -15,6 +15,7 @@ export const defaultHandlers: RendererHandlers = { getFilter: () => 'filter', getRenderMode: () => 'view', isSyncColorsEnabled: () => false, + isSyncTooltipsEnabled: () => false, isInteractive: () => true, onComplete: (fn) => undefined, onEmbeddableDestroyed: action('onEmbeddableDestroyed'), diff --git a/x-pack/plugins/canvas/public/lib/create_handlers.ts b/x-pack/plugins/canvas/public/lib/create_handlers.ts index 66993c0d39bae..3e7d8a0242bd4 100644 --- a/x-pack/plugins/canvas/public/lib/create_handlers.ts +++ b/x-pack/plugins/canvas/public/lib/create_handlers.ts @@ -27,6 +27,7 @@ export const createBaseHandlers = (): IInterpreterRenderHandlers => ({ onDestroy() {}, getRenderMode: () => 'view', isSyncColorsEnabled: () => false, + isSyncTooltipsEnabled: () => false, isInteractive: () => true, }); diff --git a/x-pack/plugins/cases/common/api/metrics/case.ts b/x-pack/plugins/cases/common/api/metrics/case.ts index c42887462ae7d..503d602cf6729 100644 --- a/x-pack/plugins/cases/common/api/metrics/case.ts +++ b/x-pack/plugins/cases/common/api/metrics/case.ts @@ -7,7 +7,10 @@ import * as rt from 'io-ts'; -export type CaseMetricsResponse = rt.TypeOf; +export type SingleCaseMetricsRequest = rt.TypeOf; +export type SingleCaseMetricsResponse = rt.TypeOf; +export type CasesMetricsRequest = rt.TypeOf; +export type CasesMetricsResponse = rt.TypeOf; export type AlertHostsMetrics = rt.TypeOf; export type AlertUsersMetrics = rt.TypeOf; export type StatusInfo = rt.TypeOf; @@ -69,7 +72,43 @@ const AlertUsersMetricsRt = rt.type({ ), }); -export const CaseMetricsResponseRt = rt.partial( +export const SingleCaseMetricsRequestRt = rt.type({ + /** + * The ID of the case. + */ + caseId: rt.string, + /** + * The metrics to retrieve. + */ + features: rt.array(rt.string), +}); + +export const CasesMetricsRequestRt = rt.intersection([ + rt.type({ + /** + * The metrics to retrieve. + */ + features: rt.array(rt.string), + }), + rt.partial({ + /** + * A KQL date. If used all cases created after (gte) the from date will be returned + */ + from: rt.string, + /** + * A KQL date. If used all cases created before (lte) the to date will be returned. + */ + to: rt.string, + /** + * The owner(s) to filter by. The user making the request must have privileges to retrieve cases of that + * ownership or they will be ignored. If no owner is included, then all ownership types will be included in the response + * that the user has access to. + */ + owner: rt.union([rt.array(rt.string), rt.string]), + }), +]); + +export const SingleCaseMetricsResponseRt = rt.partial( rt.type({ alerts: rt.partial( rt.type({ @@ -142,3 +181,9 @@ export const CaseMetricsResponseRt = rt.partial( }), }).props ); + +export const CasesMetricsResponseRt = rt.partial( + rt.type({ + mttr: rt.number, + }).props +); diff --git a/x-pack/plugins/cases/common/constants.ts b/x-pack/plugins/cases/common/constants.ts index 29a8029dda063..cc2cfb5e873ff 100644 --- a/x-pack/plugins/cases/common/constants.ts +++ b/x-pack/plugins/cases/common/constants.ts @@ -69,6 +69,7 @@ export const CASE_USER_ACTIONS_URL = `${CASE_DETAILS_URL}/user_actions` as const export const CASE_ALERTS_URL = `${CASES_URL}/alerts/{alert_id}` as const; export const CASE_DETAILS_ALERTS_URL = `${CASE_DETAILS_URL}/alerts` as const; +export const CASE_METRICS_URL = `${CASES_URL}/metrics` as const; export const CASE_METRICS_DETAILS_URL = `${CASES_URL}/metrics/{case_id}` as const; /** diff --git a/x-pack/plugins/cases/common/ui/types.ts b/x-pack/plugins/cases/common/ui/types.ts index 280cbfbfb2cd5..4e5671a946506 100644 --- a/x-pack/plugins/cases/common/ui/types.ts +++ b/x-pack/plugins/cases/common/ui/types.ts @@ -13,7 +13,7 @@ import { ActionConnector, CaseExternalServiceBasic, CaseUserActionResponse, - CaseMetricsResponse, + SingleCaseMetricsResponse, CommentResponse, CaseResponse, CommentResponseAlertsType, @@ -24,7 +24,7 @@ type DeepRequired = { [K in keyof T]: DeepRequired } & Required; export interface CasesContextFeatures { alerts: { sync?: boolean; enabled?: boolean }; - metrics: CaseMetricsFeature[]; + metrics: SingleCaseMetricsFeature[]; } export type CasesFeaturesAllRequired = DeepRequired; @@ -97,8 +97,8 @@ export interface AllCases extends CasesStatus { total: number; } -export type CaseMetrics = CaseMetricsResponse; -export type CaseMetricsFeature = +export type SingleCaseMetrics = SingleCaseMetricsResponse; +export type SingleCaseMetricsFeature = | 'alerts.count' | 'alerts.users' | 'alerts.hosts' diff --git a/x-pack/plugins/cases/public/common/navigation/__mocks__/hooks.ts b/x-pack/plugins/cases/public/common/navigation/__mocks__/hooks.ts index 93f202994918d..38f57a9ef45bd 100644 --- a/x-pack/plugins/cases/public/common/navigation/__mocks__/hooks.ts +++ b/x-pack/plugins/cases/public/common/navigation/__mocks__/hooks.ts @@ -26,3 +26,8 @@ export const useConfigureCasesNavigation = jest.fn().mockReturnValue({ getConfigureCasesUrl: jest.fn().mockReturnValue('/app/security/cases/configure'), navigateToConfigureCases: jest.fn(), }); + +export const useUrlParams = jest.fn().mockReturnValue({ + urlParams: {}, + toUrlParams: jest.fn(), +}); diff --git a/x-pack/plugins/cases/public/common/navigation/hooks.ts b/x-pack/plugins/cases/public/common/navigation/hooks.ts index c5488b4060795..1197ef0c5cf11 100644 --- a/x-pack/plugins/cases/public/common/navigation/hooks.ts +++ b/x-pack/plugins/cases/public/common/navigation/hooks.ts @@ -5,9 +5,10 @@ * 2.0. */ -import { useCallback } from 'react'; -import { useParams } from 'react-router-dom'; +import { useCallback, useEffect, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { parse, stringify } from 'query-string'; import { APP_ID } from '../../../common/constants'; import { useNavigation } from '../lib/kibana'; import { useCasesContext } from '../../components/cases_context/use_cases_context'; @@ -16,11 +17,28 @@ import { CASES_CONFIGURE_PATH, CASES_CREATE_PATH, CaseViewPathParams, + CaseViewPathSearchParams, generateCaseViewPath, } from './paths'; export const useCaseViewParams = () => useParams(); +export function useUrlParams() { + const { search } = useLocation(); + const [urlParams, setUrlParams] = useState(() => parse(search)); + const toUrlParams = useCallback( + (params: CaseViewPathSearchParams = urlParams) => stringify(params), + [urlParams] + ); + useEffect(() => { + setUrlParams(parse(search)); + }, [search]); + return { + urlParams, + toUrlParams, + }; +} + type GetCasesUrl = (absolute?: boolean) => string; type NavigateToCases = () => void; type UseCasesNavigation = [GetCasesUrl, NavigateToCases]; diff --git a/x-pack/plugins/cases/public/common/navigation/paths.ts b/x-pack/plugins/cases/public/common/navigation/paths.ts index a8660b5cf63ab..857f832f7aed3 100644 --- a/x-pack/plugins/cases/public/common/navigation/paths.ts +++ b/x-pack/plugins/cases/public/common/navigation/paths.ts @@ -6,17 +6,26 @@ */ import { generatePath } from 'react-router-dom'; +import { CASE_VIEW_PAGE_TABS } from '../../components/case_view/types'; export const DEFAULT_BASE_PATH = '/cases'; -export interface CaseViewPathParams { + +export interface CaseViewPathSearchParams { + tabId?: CASE_VIEW_PAGE_TABS; +} + +export type CaseViewPathParams = { detailName: string; commentId?: string; -} +} & CaseViewPathSearchParams; export const CASES_CREATE_PATH = '/create' as const; export const CASES_CONFIGURE_PATH = '/configure' as const; export const CASE_VIEW_PATH = '/:detailName' as const; export const CASE_VIEW_COMMENT_PATH = `${CASE_VIEW_PATH}/:commentId` as const; +export const CASE_VIEW_ALERT_TABLE_PATH = + `${CASE_VIEW_PATH}/?tabId=${CASE_VIEW_PAGE_TABS.ALERTS}` as const; +export const CASE_VIEW_TAB_PATH = `${CASE_VIEW_PATH}/?tabId=:tabId` as const; const normalizePath = (path: string): string => path.replaceAll('//', '/'); @@ -30,12 +39,19 @@ export const getCaseViewWithCommentPath = (casesBasePath: string) => normalizePath(`${casesBasePath}${CASE_VIEW_COMMENT_PATH}`); export const generateCaseViewPath = (params: CaseViewPathParams): string => { - const { commentId } = params; + const { commentId, tabId } = params; // Cast for generatePath argument type constraint const pathParams = params as unknown as { [paramName: string]: string }; + // paths with commentId have their own specific path. + // Effectively overwrites the tabId if (commentId) { return normalizePath(generatePath(CASE_VIEW_COMMENT_PATH, pathParams)); } + + if (tabId !== undefined) { + return normalizePath(generatePath(CASE_VIEW_TAB_PATH, pathParams)); + } + return normalizePath(generatePath(CASE_VIEW_PATH, pathParams)); }; diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx index 639d0617ddb74..fd0f7eebe0095 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.test.tsx @@ -5,29 +5,29 @@ * 2.0. */ -import React from 'react'; +import { act, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { mount } from 'enzyme'; -import { waitFor } from '@testing-library/react'; - +import React from 'react'; +import { ConnectorTypes } from '../../../common/api'; +import { AppMockRenderer, createAppMockRenderer, TestProviders } from '../../common/mock'; import '../../common/mock/match_media'; -import { CaseViewPage } from './case_view_page'; -import { CaseViewPageProps } from './types'; +import { useCaseViewNavigation, useUrlParams } from '../../common/navigation/hooks'; +import { useConnectors } from '../../containers/configure/use_connectors'; import { basicCaseClosed, basicCaseMetrics, caseUserActions, - getAlertUserAction, connectorsMock, + getAlertUserAction, } from '../../containers/mock'; -import { TestProviders } from '../../common/mock'; -import { useUpdateCase } from '../../containers/use_update_case'; +import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; - -import { useConnectors } from '../../containers/configure/use_connectors'; import { usePostPushToService } from '../../containers/use_post_push_to_service'; -import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; -import { ConnectorTypes } from '../../../common/api'; -import { caseViewProps, caseData } from './index.test'; +import { useUpdateCase } from '../../containers/use_update_case'; +import { CaseViewPage } from './case_view_page'; +import { caseData, caseViewProps } from './index.test'; +import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; jest.mock('../../containers/use_update_case'); jest.mock('../../containers/use_get_case_metrics'); @@ -37,7 +37,10 @@ jest.mock('../../containers/configure/use_connectors'); jest.mock('../../containers/use_post_push_to_service'); jest.mock('../user_actions/timestamp'); jest.mock('../../common/navigation/hooks'); +jest.mock('../../common/hooks'); +const useUrlParamsMock = useUrlParams as jest.Mock; +const useCaseViewNavigationMock = useCaseViewNavigation as jest.Mock; const useUpdateCaseMock = useUpdateCase as jest.Mock; const useGetCaseMetricsMock = useGetCaseMetrics as jest.Mock; const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; @@ -575,4 +578,108 @@ describe('CaseViewPage', () => { }); }); }); + + describe('Tabs', () => { + let appMockRender: AppMockRenderer; + beforeEach(() => { + appMockRender = createAppMockRenderer(); + }); + + // unskip when alerts tab is activated + it.skip('renders tabs correctly', async () => { + const result = appMockRender.render(); + await act(async () => { + expect(result.getByTestId('case-view-tab-title-alerts')).toBeTruthy(); + expect(result.getByTestId('case-view-tab-title-activity')).toBeTruthy(); + }); + }); + + it('renders the activity tab by default', async () => { + const result = appMockRender.render(); + await act(async () => { + expect(result.getByTestId('case-view-tab-content-activity')).toBeTruthy(); + }); + }); + + it('renders the alerts tab when the query parameter tabId has alerts', async () => { + useUrlParamsMock.mockReturnValue({ + urlParams: { + tabId: CASE_VIEW_PAGE_TABS.ALERTS, + }, + }); + const result = appMockRender.render(); + await act(async () => { + expect(result.getByTestId('case-view-tab-content-alerts')).toBeTruthy(); + }); + }); + + it('renders the activity tab when the query parameter tabId has activity', async () => { + useUrlParamsMock.mockReturnValue({ + urlParams: { + tabId: CASE_VIEW_PAGE_TABS.ACTIVITY, + }, + }); + const result = appMockRender.render(); + await act(async () => { + expect(result.getByTestId('case-view-tab-content-activity')).toBeTruthy(); + }); + }); + + it('renders the activity tab when the query parameter tabId has an unknown value', async () => { + useUrlParamsMock.mockReturnValue({ + urlParams: { + tabId: 'what-is-love', + }, + }); + const result = appMockRender.render(); + await act(async () => { + expect(result.getByTestId('case-view-tab-content-activity')).toBeTruthy(); + expect(result.queryByTestId('case-view-tab-content-alerts')).toBeFalsy(); + }); + }); + + it('navigates to the activity tab when the activity tab is clicked', async () => { + const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-view-tab-title-activity')); + await act(async () => { + expect(navigateToCaseViewMock).toHaveBeenCalledWith({ + detailName: caseData.id, + tabId: CASE_VIEW_PAGE_TABS.ACTIVITY, + }); + }); + }); + + // unskip when alerts tab is activated + it.skip('navigates to the alerts tab when the alerts tab is clicked', async () => { + const navigateToCaseViewMock = useCaseViewNavigationMock().navigateToCaseView; + const result = appMockRender.render(); + userEvent.click(result.getByTestId('case-view-tab-title-alerts')); + await act(async () => { + expect(navigateToCaseViewMock).toHaveBeenCalledWith({ + detailName: caseData.id, + tabId: CASE_VIEW_PAGE_TABS.ALERTS, + }); + }); + }); + + // unskip when alerts tab is activated + it.skip('should display the alerts tab when the feature is enabled', async () => { + appMockRender = createAppMockRenderer({ features: { alerts: { enabled: true } } }); + const result = appMockRender.render(); + await act(async () => { + expect(result.queryByTestId('case-view-tab-title-activity')).toBeTruthy(); + expect(result.queryByTestId('case-view-tab-title-alerts')).toBeTruthy(); + }); + }); + + it('should not display the alerts tab when the feature is disabled', async () => { + appMockRender = createAppMockRenderer({ features: { alerts: { enabled: false } } }); + const result = appMockRender.render(); + await act(async () => { + expect(result.queryByTestId('case-view-tab-title-activity')).toBeTruthy(); + expect(result.queryByTestId('case-view-tab-title-alerts')).toBeFalsy(); + }); + }); + }); }); diff --git a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx index d95eecde876fa..b6f22e9c5fb4d 100644 --- a/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx +++ b/x-pack/plugins/cases/public/components/case_view/case_view_page.tsx @@ -5,24 +5,38 @@ * 2.0. */ -import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; - +import { + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingLogo, + EuiSpacer, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Case, UpdateKey } from '../../../common/ui'; -import { EditableTitle } from '../header_page/editable_title'; -import { ContentWrapper, WhitePageWrapper } from '../wrappers'; -import { CaseActionBar } from '../case_action_bar'; +import { useCaseViewNavigation, useUrlParams } from '../../common/navigation'; +import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; import { useGetCaseUserActions } from '../../containers/use_get_case_user_actions'; -import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { useCasesContext } from '../cases_context/use_cases_context'; +import { useCasesFeatures } from '../cases_context/use_cases_features'; +import { CaseActionBar } from '../case_action_bar'; import { HeaderPage } from '../header_page'; +import { EditableTitle } from '../header_page/editable_title'; +import { useTimelineContext } from '../timeline_context/use_timeline_context'; import { useCasesTitleBreadcrumbs } from '../use_breadcrumbs'; -import { useGetCaseMetrics } from '../../containers/use_get_case_metrics'; +import { WhitePageWrapperNoBorder } from '../wrappers'; +import { CaseViewActivity } from './components/case_view_activity'; import { CaseViewMetrics } from './metrics'; -import type { CaseViewPageProps } from './types'; -import { useCasesFeatures } from '../cases_context/use_cases_features'; +import { ACTIVITY_TAB, ALERTS_TAB } from './translations'; +import { CaseViewPageProps, CASE_VIEW_PAGE_TABS } from './types'; import { useOnUpdateField } from './use_on_update_field'; -import { CaseViewActivity } from './components/case_view_activity'; + +// This hardcoded constant is left here intentionally +// as a way to hide a wip functionality +// that will be merge in the 8.3 release. +const ENABLE_ALERTS_TAB = false; export const CaseViewPage = React.memo( ({ @@ -37,10 +51,19 @@ export const CaseViewPage = React.memo( showAlertDetails, useFetchAlertData, }) => { - const { userCanCrud } = useCasesContext(); + const { userCanCrud, features } = useCasesContext(); const { metricsFeatures } = useCasesFeatures(); useCasesTitleBreadcrumbs(caseData.title); + const { navigateToCaseView } = useCaseViewNavigation(); + const { urlParams } = useUrlParams(); + const activeTabId = useMemo(() => { + if (urlParams.tabId && Object.values(CASE_VIEW_PAGE_TABS).includes(urlParams.tabId)) { + return urlParams.tabId; + } + return CASE_VIEW_PAGE_TABS.ACTIVITY; + }, [urlParams.tabId]); + const [initLoadingData, setInitLoadingData] = useState(true); const init = useRef(true); const timelineUi = useTimelineContext()?.ui; @@ -146,9 +169,76 @@ export const CaseViewPage = React.memo( } }, [onComponentInitialized]); + const tabs = useMemo( + () => [ + { + id: CASE_VIEW_PAGE_TABS.ACTIVITY, + name: ACTIVITY_TAB, + content: ( + + ), + }, + ...(features.alerts.enabled && ENABLE_ALERTS_TAB + ? [ + { + id: CASE_VIEW_PAGE_TABS.ALERTS, + name: ALERTS_TAB, + content: ( + } + title={

{'Alerts table placeholder'}

} + /> + ), + }, + ] + : []), + ], + [ + actionsNavigation, + caseData, + caseId, + features.alerts.enabled, + fetchCaseMetrics, + getCaseUserActions, + initLoadingData, + ruleDetailsNavigation, + showAlertDetails, + updateCase, + useFetchAlertData, + ] + ); + const selectedTabContent = useMemo(() => { + return tabs.find((obj) => obj.id === activeTabId)?.content; + }, [activeTabId, tabs]); + + const renderTabs = useCallback(() => { + return tabs.map((tab, index) => ( + navigateToCaseView({ detailName: caseId, tabId: tab.id })} + isSelected={tab.id === activeTabId} + > + {tab.name} + + )); + }, [activeTabId, caseId, navigateToCaseView, tabs]); + return ( <> ( /> - - - - - {!initLoadingData && metricsFeatures.length > 0 ? ( + + {!initLoadingData && metricsFeatures.length > 0 ? ( + <> + + - ) : null} - - - - - - - - + + + + + ) : null} + {renderTabs()} + + + {selectedTabContent} + + {timelineUi?.renderTimelineDetailsPanel ? timelineUi.renderTimelineDetailsPanel() : null} ); diff --git a/x-pack/plugins/cases/public/components/case_view/metrics/index.test.tsx b/x-pack/plugins/cases/public/components/case_view/metrics/index.test.tsx index f816d39e8c0e0..cecf49ee5d1a3 100644 --- a/x-pack/plugins/cases/public/components/case_view/metrics/index.test.tsx +++ b/x-pack/plugins/cases/public/components/case_view/metrics/index.test.tsx @@ -13,7 +13,7 @@ import { basicCaseStatusFeatures, } from '../../../containers/mock'; import { CaseViewMetrics } from '.'; -import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui'; +import { SingleCaseMetrics, SingleCaseMetricsFeature } from '../../../../common/ui'; import { TestProviders } from '../../../common/mock'; const renderCaseMetrics = ({ @@ -21,8 +21,8 @@ const renderCaseMetrics = ({ features = [...basicCaseNumericValueFeatures, ...basicCaseStatusFeatures], isLoading = false, }: { - metrics?: CaseMetrics; - features?: CaseMetricsFeature[]; + metrics?: SingleCaseMetrics; + features?: SingleCaseMetricsFeature[]; isLoading?: boolean; } = {}) => { return render( @@ -33,7 +33,7 @@ const renderCaseMetrics = ({ }; interface FeatureTest { - feature: CaseMetricsFeature; + feature: SingleCaseMetricsFeature; items: Array<{ title: string; value: string | number; diff --git a/x-pack/plugins/cases/public/components/case_view/metrics/status.tsx b/x-pack/plugins/cases/public/components/case_view/metrics/status.tsx index df19d1776d86a..d86c5534e1e40 100644 --- a/x-pack/plugins/cases/public/components/case_view/metrics/status.tsx +++ b/x-pack/plugins/cases/public/components/case_view/metrics/status.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import prettyMilliseconds from 'pretty-ms'; import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiSpacer } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui'; +import { SingleCaseMetrics, SingleCaseMetricsFeature } from '../../../../common/ui'; import { CASE_CREATED, CASE_IN_PROGRESS_DURATION, @@ -90,10 +90,10 @@ export const CaseStatusMetrics: React.FC { - return useMemo(() => { + metrics: SingleCaseMetrics | null, + features: SingleCaseMetricsFeature[] +): SingleCaseMetrics['lifespan'] | undefined => { + return useMemo(() => { const lifespan = metrics?.lifespan ?? { closeDate: '', creationDate: '', diff --git a/x-pack/plugins/cases/public/components/case_view/metrics/totals.tsx b/x-pack/plugins/cases/public/components/case_view/metrics/totals.tsx index 39b3f8613120a..77a0852901b9c 100644 --- a/x-pack/plugins/cases/public/components/case_view/metrics/totals.tsx +++ b/x-pack/plugins/cases/public/components/case_view/metrics/totals.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { euiStyled } from '@kbn/kibana-react-plugin/common'; -import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui'; +import { SingleCaseMetrics, SingleCaseMetricsFeature } from '../../../../common/ui'; import { ASSOCIATED_HOSTS_METRIC, ASSOCIATED_USERS_METRIC, @@ -50,8 +50,8 @@ interface MetricItem { type MetricItems = MetricItem[]; const useGetTitleValueMetricItems = ( - metrics: CaseMetrics | null, - features: CaseMetricsFeature[] + metrics: SingleCaseMetrics | null, + features: SingleCaseMetricsFeature[] ): MetricItems => { const { alerts, actions, connectors } = metrics ?? {}; const totalConnectors = connectors?.total ?? 0; @@ -61,7 +61,7 @@ const useGetTitleValueMetricItems = ( const totalIsolatedHosts = calculateTotalIsolatedHosts(actions); const metricItems = useMemo(() => { - const items: Array<[CaseMetricsFeature, Omit]> = [ + const items: Array<[SingleCaseMetricsFeature, Omit]> = [ ['alerts.count', { title: TOTAL_ALERTS_METRIC, value: alertsCount }], ['alerts.users', { title: ASSOCIATED_USERS_METRIC, value: totalAlertUsers }], ['alerts.hosts', { title: ASSOCIATED_HOSTS_METRIC, value: totalAlertHosts }], @@ -88,7 +88,7 @@ const useGetTitleValueMetricItems = ( return metricItems; }; -const calculateTotalIsolatedHosts = (actions: CaseMetrics['actions']) => { +const calculateTotalIsolatedHosts = (actions: SingleCaseMetrics['actions']) => { if (!actions?.isolateHost) { return 0; } diff --git a/x-pack/plugins/cases/public/components/case_view/metrics/types.ts b/x-pack/plugins/cases/public/components/case_view/metrics/types.ts index 5a00aed38dd8a..20138a482cb36 100644 --- a/x-pack/plugins/cases/public/components/case_view/metrics/types.ts +++ b/x-pack/plugins/cases/public/components/case_view/metrics/types.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { CaseMetrics, CaseMetricsFeature } from '../../../../common/ui'; +import { SingleCaseMetrics, SingleCaseMetricsFeature } from '../../../../common/ui'; export interface CaseViewMetricsProps { - metrics: CaseMetrics | null; - features: CaseMetricsFeature[]; + metrics: SingleCaseMetrics | null; + features: SingleCaseMetricsFeature[]; isLoading: boolean; } diff --git a/x-pack/plugins/cases/public/components/case_view/translations.ts b/x-pack/plugins/cases/public/components/case_view/translations.ts index 761cecb1121ca..94c19165e515b 100644 --- a/x-pack/plugins/cases/public/components/case_view/translations.ts +++ b/x-pack/plugins/cases/public/components/case_view/translations.ts @@ -155,3 +155,11 @@ export const DOES_NOT_EXIST_DESCRIPTION = (caseId: string) => export const DOES_NOT_EXIST_BUTTON = i18n.translate('xpack.cases.caseView.doesNotExist.button', { defaultMessage: 'Back to Cases', }); + +export const ACTIVITY_TAB = i18n.translate('xpack.cases.caseView.tabs.activity', { + defaultMessage: 'Activity', +}); + +export const ALERTS_TAB = i18n.translate('xpack.cases.caseView.tabs.alerts', { + defaultMessage: 'Alerts', +}); diff --git a/x-pack/plugins/cases/public/components/case_view/types.ts b/x-pack/plugins/cases/public/components/case_view/types.ts index 3d436a7db3186..2b806e5f804cd 100644 --- a/x-pack/plugins/cases/public/components/case_view/types.ts +++ b/x-pack/plugins/cases/public/components/case_view/types.ts @@ -41,3 +41,8 @@ export interface OnUpdateFields { onSuccess?: () => void; onError?: () => void; } + +export enum CASE_VIEW_PAGE_TABS { + ALERTS = 'alerts', + ACTIVITY = 'activity', +} diff --git a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx index 6241e81e419b9..b0316b5ff9665 100644 --- a/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx +++ b/x-pack/plugins/cases/public/components/cases_context/use_cases_features.tsx @@ -6,13 +6,13 @@ */ import { useMemo } from 'react'; -import { CaseMetricsFeature } from '../../containers/types'; +import { SingleCaseMetricsFeature } from '../../containers/types'; import { useCasesContext } from './use_cases_context'; export interface UseCasesFeatures { isAlertsEnabled: boolean; isSyncAlertsEnabled: boolean; - metricsFeatures: CaseMetricsFeature[]; + metricsFeatures: SingleCaseMetricsFeature[]; } export const useCasesFeatures = (): UseCasesFeatures => { diff --git a/x-pack/plugins/cases/public/components/wrappers/index.tsx b/x-pack/plugins/cases/public/components/wrappers/index.tsx index d412ef34451b2..54c575ab95316 100644 --- a/x-pack/plugins/cases/public/components/wrappers/index.tsx +++ b/x-pack/plugins/cases/public/components/wrappers/index.tsx @@ -13,6 +13,10 @@ export const WhitePageWrapper = styled.div` flex: 1 1 auto; `; +export const WhitePageWrapperNoBorder = styled.div` + background-color: ${({ theme }) => theme.eui.euiColorEmptyShade}; + flex: 1 1 auto; +`; export const SectionWrapper = styled.div` box-sizing: content-box; margin: 0 auto; diff --git a/x-pack/plugins/cases/public/containers/__mocks__/api.ts b/x-pack/plugins/cases/public/containers/__mocks__/api.ts index 1f5c1652edfff..3906997349357 100644 --- a/x-pack/plugins/cases/public/containers/__mocks__/api.ts +++ b/x-pack/plugins/cases/public/containers/__mocks__/api.ts @@ -36,7 +36,7 @@ import { CommentRequest, User, CaseStatuses, - CaseMetricsResponse, + SingleCaseMetricsResponse, } from '../../../common/api'; export const getCase = async ( @@ -51,10 +51,10 @@ export const resolveCase = async ( signal: AbortSignal ): Promise => Promise.resolve(basicResolvedCase); -export const getCaseMetrics = async ( +export const getSingleCaseMetrics = async ( caseId: string, signal: AbortSignal -): Promise => Promise.resolve(basicCaseMetrics); +): Promise => Promise.resolve(basicCaseMetrics); export const getCasesStatus = async (signal: AbortSignal): Promise => Promise.resolve(casesStatus); diff --git a/x-pack/plugins/cases/public/containers/api.ts b/x-pack/plugins/cases/public/containers/api.ts index d4593dd1f2813..a33f0e2501ac0 100644 --- a/x-pack/plugins/cases/public/containers/api.ts +++ b/x-pack/plugins/cases/public/containers/api.ts @@ -26,8 +26,8 @@ import { getCasePushUrl, getCaseUserActionUrl, User, - CaseMetricsResponse, getCaseCommentDeleteUrl, + SingleCaseMetricsResponse, } from '../../common/api'; import { CASE_REPORTERS_URL, @@ -45,8 +45,8 @@ import { AllCases, BulkUpdateStatus, Case, - CaseMetrics, - CaseMetricsFeature, + SingleCaseMetrics, + SingleCaseMetricsFeature, CasesStatus, FetchCasesProps, SortFieldCase, @@ -63,7 +63,7 @@ import { decodeCasesStatusResponse, decodeCaseUserActionsResponse, decodeCaseResolveResponse, - decodeCaseMetricsResponse, + decodeSingleCaseMetricsResponse, } from './utils'; export const getCase = async ( @@ -129,12 +129,12 @@ export const getReporters = async (signal: AbortSignal, owner: string[]): Promis return response ?? []; }; -export const getCaseMetrics = async ( +export const getSingleCaseMetrics = async ( caseId: string, - features: CaseMetricsFeature[], + features: SingleCaseMetricsFeature[], signal: AbortSignal -): Promise => { - const response = await KibanaServices.get().http.fetch( +): Promise => { + const response = await KibanaServices.get().http.fetch( getCaseDetailsMetricsUrl(caseId), { method: 'GET', @@ -142,7 +142,9 @@ export const getCaseMetrics = async ( query: { features: JSON.stringify(features) }, } ); - return convertToCamelCase(decodeCaseMetricsResponse(response)); + return convertToCamelCase( + decodeSingleCaseMetricsResponse(response) + ); }; export const getCaseUserActions = async ( diff --git a/x-pack/plugins/cases/public/containers/mock.ts b/x-pack/plugins/cases/public/containers/mock.ts index 97572b535fc01..8c45fd5b083e0 100644 --- a/x-pack/plugins/cases/public/containers/mock.ts +++ b/x-pack/plugins/cases/public/containers/mock.ts @@ -9,8 +9,8 @@ import { ActionLicense, AllCases, Case, CasesStatus, CaseUserActions, Comment } import type { ResolvedCase, - CaseMetrics, - CaseMetricsFeature, + SingleCaseMetrics, + SingleCaseMetricsFeature, AlertComment, } from '../../common/ui/types'; import { @@ -189,7 +189,7 @@ export const basicResolvedCase: ResolvedCase = { aliasTargetId: `${basicCase.id}_2`, }; -export const basicCaseNumericValueFeatures: CaseMetricsFeature[] = [ +export const basicCaseNumericValueFeatures: SingleCaseMetricsFeature[] = [ 'alerts.count', 'alerts.users', 'alerts.hosts', @@ -197,9 +197,9 @@ export const basicCaseNumericValueFeatures: CaseMetricsFeature[] = [ 'connectors', ]; -export const basicCaseStatusFeatures: CaseMetricsFeature[] = ['lifespan']; +export const basicCaseStatusFeatures: SingleCaseMetricsFeature[] = ['lifespan']; -export const basicCaseMetrics: CaseMetrics = { +export const basicCaseMetrics: SingleCaseMetrics = { alerts: { count: 12, hosts: { diff --git a/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx b/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx index 73c69ec388977..4c7d446f4f27f 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_metrics.test.tsx @@ -6,7 +6,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { CaseMetricsFeature } from '../../common/ui'; +import { SingleCaseMetricsFeature } from '../../common/ui'; import { useGetCaseMetrics, UseGetCaseMetrics } from './use_get_case_metrics'; import { basicCase, basicCaseMetrics } from './mock'; import * as api from './api'; @@ -16,7 +16,7 @@ jest.mock('../common/lib/kibana'); describe('useGetCaseMetrics', () => { const abortCtrl = new AbortController(); - const features: CaseMetricsFeature[] = ['alerts.count']; + const features: SingleCaseMetricsFeature[] = ['alerts.count']; beforeEach(() => { jest.clearAllMocks(); @@ -38,8 +38,8 @@ describe('useGetCaseMetrics', () => { }); }); - it('calls getCaseMetrics with correct arguments', async () => { - const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + it('calls getSingleCaseMetrics with correct arguments', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getSingleCaseMetrics'); await act(async () => { const { waitForNextUpdate } = renderHook(() => useGetCaseMetrics(basicCase.id, features) @@ -50,8 +50,8 @@ describe('useGetCaseMetrics', () => { }); }); - it('does not call getCaseMetrics if empty feature parameter passed', async () => { - const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + it('does not call getSingleCaseMetrics if empty feature parameter passed', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getSingleCaseMetrics'); await act(async () => { const { waitForNextUpdate } = renderHook(() => useGetCaseMetrics(basicCase.id, []) @@ -78,7 +78,7 @@ describe('useGetCaseMetrics', () => { }); it('refetch case metrics', async () => { - const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + const spyOnGetCaseMetrics = jest.spyOn(api, 'getSingleCaseMetrics'); await act(async () => { const { result, waitForNextUpdate } = renderHook(() => useGetCaseMetrics(basicCase.id, features) @@ -116,8 +116,8 @@ describe('useGetCaseMetrics', () => { }); }); - it('returns an error when getCaseMetrics throws', async () => { - const spyOnGetCaseMetrics = jest.spyOn(api, 'getCaseMetrics'); + it('returns an error when getSingleCaseMetrics throws', async () => { + const spyOnGetCaseMetrics = jest.spyOn(api, 'getSingleCaseMetrics'); spyOnGetCaseMetrics.mockImplementation(() => { throw new Error('Something went wrong'); }); diff --git a/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx b/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx index 411b43e050abf..774ecfd2371b6 100644 --- a/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx +++ b/x-pack/plugins/cases/public/containers/use_get_case_metrics.tsx @@ -7,20 +7,20 @@ import { useEffect, useReducer, useCallback, useRef } from 'react'; -import { CaseMetrics, CaseMetricsFeature } from './types'; +import { SingleCaseMetrics, SingleCaseMetricsFeature } from './types'; import * as i18n from './translations'; import { useToasts } from '../common/lib/kibana'; -import { getCaseMetrics } from './api'; +import { getSingleCaseMetrics } from './api'; interface CaseMeticsState { - metrics: CaseMetrics | null; + metrics: SingleCaseMetrics | null; isLoading: boolean; isError: boolean; } type Action = | { type: 'FETCH_INIT'; payload: { silent: boolean } } - | { type: 'FETCH_SUCCESS'; payload: CaseMetrics } + | { type: 'FETCH_SUCCESS'; payload: SingleCaseMetrics } | { type: 'FETCH_FAILURE' }; const dataFetchReducer = (state: CaseMeticsState, action: Action): CaseMeticsState => { @@ -59,7 +59,7 @@ export interface UseGetCaseMetrics extends CaseMeticsState { export const useGetCaseMetrics = ( caseId: string, - features: CaseMetricsFeature[] + features: SingleCaseMetricsFeature[] ): UseGetCaseMetrics => { const [state, dispatch] = useReducer(dataFetchReducer, { metrics: null, @@ -81,7 +81,7 @@ export const useGetCaseMetrics = ( abortCtrlRef.current = new AbortController(); dispatch({ type: 'FETCH_INIT', payload: { silent } }); - const response: CaseMetrics = await getCaseMetrics( + const response: SingleCaseMetrics = await getSingleCaseMetrics( caseId, features, abortCtrlRef.current.signal diff --git a/x-pack/plugins/cases/public/containers/utils.ts b/x-pack/plugins/cases/public/containers/utils.ts index cd1682c0cd988..deafcda2d24ea 100644 --- a/x-pack/plugins/cases/public/containers/utils.ts +++ b/x-pack/plugins/cases/public/containers/utils.ts @@ -32,8 +32,8 @@ import { CasePatchRequest, CaseResolveResponse, CaseResolveResponseRt, - CaseMetricsResponse, - CaseMetricsResponseRt, + SingleCaseMetricsResponse, + SingleCaseMetricsResponseRt, } from '../../common/api'; import { AllCases, Case, UpdateByKey } from './types'; import * as i18n from './translations'; @@ -96,9 +96,9 @@ export const decodeCaseResolveResponse = (respCase?: CaseResolveResponse) => fold(throwErrors(createToasterPlainError), identity) ); -export const decodeCaseMetricsResponse = (respCase?: CaseMetricsResponse) => +export const decodeSingleCaseMetricsResponse = (respCase?: SingleCaseMetricsResponse) => pipe( - CaseMetricsResponseRt.decode(respCase), + SingleCaseMetricsResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity) ); diff --git a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap index 0ec6dffee02ea..bbeb9ce05445b 100644 --- a/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap +++ b/x-pack/plugins/cases/server/authorization/__snapshots__/audit_logger.test.ts.snap @@ -1344,6 +1344,90 @@ Object { } `; +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCasesMetrics" with an error and entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "cases_get_metrics", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "1", + "type": "cases", + }, + }, + "message": "Failed attempt to access cases [id=1] as owner \\"awesome\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCasesMetrics" with an error but no entity 1`] = ` +Object { + "error": Object { + "code": "Error", + "message": "an error", + }, + "event": Object { + "action": "cases_get_metrics", + "category": Array [ + "database", + ], + "outcome": "failure", + "type": Array [ + "access", + ], + }, + "message": "Failed attempt to access a cases as any owners", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCasesMetrics" without an error but with an entity 1`] = ` +Object { + "event": Object { + "action": "cases_get_metrics", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "kibana": Object { + "saved_object": Object { + "id": "5", + "type": "cases", + }, + }, + "message": "User has accessed cases [id=5] as owner \\"super\\"", +} +`; + +exports[`audit_logger log function event structure creates the correct audit event for operation: "getCasesMetrics" without an error or entity 1`] = ` +Object { + "event": Object { + "action": "cases_get_metrics", + "category": Array [ + "database", + ], + "outcome": "success", + "type": Array [ + "access", + ], + }, + "message": "User has accessed a cases as any owners", +} +`; + exports[`audit_logger log function event structure creates the correct audit event for operation: "getComment" with an error and entity 1`] = ` Object { "error": Object { diff --git a/x-pack/plugins/cases/server/authorization/index.ts b/x-pack/plugins/cases/server/authorization/index.ts index cd3ceebf02f92..122eb90f44dc1 100644 --- a/x-pack/plugins/cases/server/authorization/index.ts +++ b/x-pack/plugins/cases/server/authorization/index.ts @@ -126,6 +126,14 @@ const CaseOperations = { docType: 'case', savedObjectType: CASE_SAVED_OBJECT, }, + [ReadOperations.GetCasesMetrics]: { + ecsType: EVENT_TYPES.access, + name: ACCESS_CASE_OPERATION, + action: 'cases_get_metrics', + verbs: accessVerbs, + docType: 'cases', + savedObjectType: CASE_SAVED_OBJECT, + }, [WriteOperations.CreateCase]: { ecsType: EVENT_TYPES.creation, name: WriteOperations.CreateCase as const, diff --git a/x-pack/plugins/cases/server/authorization/types.ts b/x-pack/plugins/cases/server/authorization/types.ts index 8c672ffb9d245..81c3d0746aa33 100644 --- a/x-pack/plugins/cases/server/authorization/types.ts +++ b/x-pack/plugins/cases/server/authorization/types.ts @@ -43,6 +43,7 @@ export enum ReadOperations { GetAlertsAttachedToCase = 'getAlertsAttachedToCase', GetAttachmentMetrics = 'getAttachmentMetrics', GetCaseMetrics = 'getCaseMetrics', + GetCasesMetrics = 'getCasesMetrics', GetUserActionMetrics = 'getUserActionMetrics', } diff --git a/x-pack/plugins/cases/server/client/metrics/actions/actions.ts b/x-pack/plugins/cases/server/client/metrics/actions/actions.ts index c700c3998e503..4eecc37339c2d 100644 --- a/x-pack/plugins/cases/server/client/metrics/actions/actions.ts +++ b/x-pack/plugins/cases/server/client/metrics/actions/actions.ts @@ -6,30 +6,32 @@ */ import { merge } from 'lodash'; -import { CaseMetricsResponse } from '../../../../common/api'; +import { SingleCaseMetricsResponse } from '../../../../common/api'; import { Operations } from '../../../authorization'; import { createCaseError } from '../../../common/error'; -import { AggregationHandler } from '../aggregation_handler'; -import { AggregationBuilder, BaseHandlerCommonOptions } from '../types'; +import { SingleCaseAggregationHandler } from '../single_case_aggregation_handler'; +import { AggregationBuilder, SingleCaseBaseHandlerCommonOptions } from '../types'; import { IsolateHostActions } from './aggregations/isolate_host'; -export class Actions extends AggregationHandler { - constructor(options: BaseHandlerCommonOptions) { +export class Actions extends SingleCaseAggregationHandler { + constructor(options: SingleCaseBaseHandlerCommonOptions) { super( options, - new Map([['actions.isolateHost', new IsolateHostActions()]]) + new Map>([ + ['actions.isolateHost', new IsolateHostActions()], + ]) ); } - public async compute(): Promise { + public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, attachmentService, logger } = this.options.clientArgs; - const { caseId, casesClient } = this.options; + const { casesClient } = this.options; try { // This will perform an authorization check to ensure the user has access to the parent case const theCase = await casesClient.cases.get({ - id: caseId, + id: this.caseId, includeComments: false, }); @@ -48,13 +50,13 @@ export class Actions extends AggregationHandler { aggregations, }); - return this.aggregationBuilders.reduce( + return this.aggregationBuilders.reduce( (acc, aggregator) => merge(acc, aggregator.formatResponse(response)), {} ); } catch (error) { throw createCaseError({ - message: `Failed to compute actions attached case id: ${caseId}: ${error}`, + message: `Failed to compute actions attached case id: ${this.caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/actions/aggregations/isolate_host.ts b/x-pack/plugins/cases/server/client/metrics/actions/aggregations/isolate_host.ts index f0cf670a105db..479de16bc262f 100644 --- a/x-pack/plugins/cases/server/client/metrics/actions/aggregations/isolate_host.ts +++ b/x-pack/plugins/cases/server/client/metrics/actions/aggregations/isolate_host.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { IsolateHostActionType } from '../../../../../common/api'; +import { IsolateHostActionType, SingleCaseMetricsResponse } from '../../../../../common/api'; import { CASE_COMMENT_SAVED_OBJECT } from '../../../../../common/constants'; import { AggregationBuilder, AggregationResponse } from '../../types'; @@ -16,7 +16,7 @@ interface ActionsAggregation { } type ActionsAggregationResponse = ActionsAggregation | undefined; -export class IsolateHostActions implements AggregationBuilder { +export class IsolateHostActions implements AggregationBuilder { // uniqueValuesLimit should not be lower than the number of actions.type values (currently 2) or some information could be lost constructor(private readonly uniqueValuesLimit: number = 10) {} diff --git a/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts b/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts index 382faa354db59..e70c7add20f5e 100644 --- a/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts +++ b/x-pack/plugins/cases/server/client/metrics/aggregation_handler.ts @@ -5,15 +5,16 @@ * 2.0. */ +import { merge } from 'lodash'; import { BaseHandler } from './base_handler'; -import { AggregationBuilder, BaseHandlerCommonOptions } from './types'; +import { AggregationBuilder, AggregationResponse, BaseHandlerCommonOptions } from './types'; -export abstract class AggregationHandler extends BaseHandler { - protected aggregationBuilders: AggregationBuilder[] = []; +export abstract class AggregationHandler extends BaseHandler { + protected aggregationBuilders: Array> = []; constructor( options: BaseHandlerCommonOptions, - private readonly aggregations: Map + protected readonly aggregations: Map> ) { super(options); } @@ -28,4 +29,11 @@ export abstract class AggregationHandler extends BaseHandler { this.aggregationBuilders.push(aggregation); } } + + public formatResponse(aggregationsResponse?: AggregationResponse): F { + return this.aggregationBuilders.reduce( + (acc, feature) => merge(acc, feature.formatResponse(aggregationsResponse)), + {} as F + ); + } } diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/hosts.ts b/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/hosts.ts index dc2a1162bd9de..a9052e2e2a9ce 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/hosts.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/hosts.ts @@ -8,6 +8,7 @@ import { get } from 'lodash'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { SingleCaseMetricsResponse } from '../../../../../common/api'; import { AggregationBuilder, AggregationResponse } from '../../types'; type HostsAggregate = HostsAggregateResponse | undefined; @@ -30,7 +31,7 @@ interface FieldAggregateBucket { const hostName = 'host.name'; const hostId = 'host.id'; -export class AlertHosts implements AggregationBuilder { +export class AlertHosts implements AggregationBuilder { constructor(private readonly uniqueValuesLimit: number = 10) {} build() { diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/users.ts b/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/users.ts index 46db6c665327a..8d068e354693b 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/users.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/aggregations/users.ts @@ -5,9 +5,10 @@ * 2.0. */ +import { SingleCaseMetricsResponse } from '../../../../../common/api'; import { AggregationBuilder, AggregationResponse } from '../../types'; -export class AlertUsers implements AggregationBuilder { +export class AlertUsers implements AggregationBuilder { constructor(private readonly uniqueValuesLimit: number = 10) {} build() { diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/count.test.ts b/x-pack/plugins/cases/server/client/metrics/alerts/count.test.ts new file mode 100644 index 0000000000000..58776f7bdb77e --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/alerts/count.test.ts @@ -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 { CaseResponse } from '../../../../common/api'; +import { createCasesClientMock } from '../../mocks'; +import { CasesClientArgs } from '../../types'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { createAttachmentServiceMock } from '../../../services/mocks'; + +import { AlertsCount } from './count'; + +const clientMock = createCasesClientMock(); +const attachmentService = createAttachmentServiceMock(); + +const logger = loggingSystemMock.createLogger(); +const getAuthorizationFilter = jest.fn().mockResolvedValue({}); + +const clientArgs = { + logger, + attachmentService, + authorization: { getAuthorizationFilter }, +} as unknown as CasesClientArgs; + +const constructorOptions = { caseId: 'test-id', casesClient: clientMock, clientArgs }; + +describe('AlertsCount', () => { + beforeAll(() => { + getAuthorizationFilter.mockResolvedValue({}); + clientMock.cases.get.mockResolvedValue({ id: 'test-id' } as unknown as CaseResponse); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty values when attachment services returns undefined', async () => { + attachmentService.countAlertsAttachedToCase.mockResolvedValue(undefined); + const handler = new AlertsCount(constructorOptions); + expect(await handler.compute()).toEqual({ alerts: { count: 0 } }); + }); + + it('returns values when the attachment service returns a value', async () => { + attachmentService.countAlertsAttachedToCase.mockResolvedValue(5); + const handler = new AlertsCount(constructorOptions); + + expect(await handler.compute()).toEqual({ alerts: { count: 5 } }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/count.ts b/x-pack/plugins/cases/server/client/metrics/alerts/count.ts index 10fb1b97b4511..2afbd41863a11 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/count.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/count.ts @@ -5,27 +5,27 @@ * 2.0. */ -import { CaseMetricsResponse } from '../../../../common/api'; +import { SingleCaseMetricsResponse } from '../../../../common/api'; import { Operations } from '../../../authorization'; import { createCaseError } from '../../../common/error'; -import { BaseHandler } from '../base_handler'; -import { BaseHandlerCommonOptions } from '../types'; +import { SingleCaseBaseHandler } from '../single_case_base_handler'; +import { SingleCaseBaseHandlerCommonOptions } from '../types'; -export class AlertsCount extends BaseHandler { - constructor(options: BaseHandlerCommonOptions) { +export class AlertsCount extends SingleCaseBaseHandler { + constructor(options: SingleCaseBaseHandlerCommonOptions) { super(options, ['alerts.count']); } - public async compute(): Promise { + public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, attachmentService, logger } = this.options.clientArgs; - const { caseId, casesClient } = this.options; + const { casesClient } = this.options; try { // This will perform an authorization check to ensure the user has access to the parent case const theCase = await casesClient.cases.get({ - id: caseId, + id: this.caseId, includeComments: false, }); @@ -46,7 +46,7 @@ export class AlertsCount extends BaseHandler { }; } catch (error) { throw createCaseError({ - message: `Failed to count alerts attached case id: ${caseId}: ${error}`, + message: `Failed to count alerts attached case id: ${this.caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts b/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts index 6f8a7b284d1eb..a8f5cda3501c4 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/details.test.ts @@ -11,13 +11,13 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { AlertDetails } from './details'; import { mockAlertsService } from '../test_utils/alerts'; -import { BaseHandlerCommonOptions } from '../types'; +import { SingleCaseBaseHandlerCommonOptions } from '../types'; describe('AlertDetails', () => { let client: CasesClientMock; let mockServices: ReturnType['mockServices']; let clientArgs: ReturnType['clientArgs']; - let constructorOptions: BaseHandlerCommonOptions; + let constructorOptions: SingleCaseBaseHandlerCommonOptions; beforeEach(() => { client = createMockClient(); diff --git a/x-pack/plugins/cases/server/client/metrics/alerts/details.ts b/x-pack/plugins/cases/server/client/metrics/alerts/details.ts index eec21d23c4639..87cb0fc3be2ac 100644 --- a/x-pack/plugins/cases/server/client/metrics/alerts/details.ts +++ b/x-pack/plugins/cases/server/client/metrics/alerts/details.ts @@ -5,33 +5,31 @@ * 2.0. */ -import { merge } from 'lodash'; - -import { CaseMetricsResponse } from '../../../../common/api'; +import { SingleCaseMetricsResponse } from '../../../../common/api'; import { createCaseError } from '../../../common/error'; -import { AggregationHandler } from '../aggregation_handler'; -import { AggregationBuilder, AggregationResponse, BaseHandlerCommonOptions } from '../types'; +import { SingleCaseAggregationHandler } from '../single_case_aggregation_handler'; +import { AggregationBuilder, SingleCaseBaseHandlerCommonOptions } from '../types'; import { AlertHosts, AlertUsers } from './aggregations'; -export class AlertDetails extends AggregationHandler { - constructor(options: BaseHandlerCommonOptions) { +export class AlertDetails extends SingleCaseAggregationHandler { + constructor(options: SingleCaseBaseHandlerCommonOptions) { super( options, - new Map([ + new Map>([ ['alerts.hosts', new AlertHosts()], ['alerts.users', new AlertUsers()], ]) ); } - public async compute(): Promise { + public async compute(): Promise { const { alertsService, logger } = this.options.clientArgs; - const { caseId, casesClient } = this.options; + const { casesClient } = this.options; try { const alerts = await casesClient.attachments.getAllAlertsAttachToCase({ - caseId, + caseId: this.caseId, }); if (alerts.length <= 0 || this.aggregationBuilders.length <= 0) { @@ -43,20 +41,13 @@ export class AlertDetails extends AggregationHandler { alerts, }); - return this.formatResponse(aggregationsResponse); + return this.formatResponse(aggregationsResponse); } catch (error) { throw createCaseError({ - message: `Failed to retrieve alerts details attached case id: ${caseId}: ${error}`, + message: `Failed to retrieve alerts details attached case id: ${this.caseId}: ${error}`, error, logger, }); } } - - private formatResponse(aggregationsResponse?: AggregationResponse): CaseMetricsResponse { - return this.aggregationBuilders.reduce( - (acc, feature) => merge(acc, feature.formatResponse(aggregationsResponse)), - {} - ); - } } diff --git a/x-pack/plugins/cases/server/client/metrics/all_cases/aggregations/avg_duration.test.ts b/x-pack/plugins/cases/server/client/metrics/all_cases/aggregations/avg_duration.test.ts new file mode 100644 index 0000000000000..1e63332fd419b --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/all_cases/aggregations/avg_duration.test.ts @@ -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 { AverageDuration } from './avg_duration'; + +describe('AverageDuration', () => { + it('returns the correct aggregation', async () => { + const agg = new AverageDuration(); + + expect(agg.build()).toEqual({ + mttr: { + avg: { + field: 'cases.attributes.duration', + }, + }, + }); + }); + + it('formats the response correctly', async () => { + const agg = new AverageDuration(); + const res = agg.formatResponse({ mttr: { value: 5 } }); + expect(res).toEqual({ mttr: 5 }); + }); + + it('formats the response correctly if the res is undefined', async () => { + const agg = new AverageDuration(); + // @ts-expect-error + const res = agg.formatResponse(); + expect(res).toEqual({ mttr: 0 }); + }); + + it('formats the response correctly if the mttr is not defined', async () => { + const agg = new AverageDuration(); + const res = agg.formatResponse({}); + expect(res).toEqual({ mttr: 0 }); + }); + + it('formats the response correctly if the value is not defined', async () => { + const agg = new AverageDuration(); + const res = agg.formatResponse({ mttr: {} }); + expect(res).toEqual({ mttr: 0 }); + }); + + it('gets the name correctly', async () => { + const agg = new AverageDuration(); + expect(agg.getName()).toBe('mttr'); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/all_cases/aggregations/avg_duration.ts b/x-pack/plugins/cases/server/client/metrics/all_cases/aggregations/avg_duration.ts new file mode 100644 index 0000000000000..afa0638a2cf0a --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/all_cases/aggregations/avg_duration.ts @@ -0,0 +1,42 @@ +/* + * 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 { CASE_SAVED_OBJECT } from '../../../../../common/constants'; +import { CasesMetricsResponse } from '../../../../../common/api'; +import { AggregationBuilder, AggregationResponse } from '../../types'; + +export class AverageDuration implements AggregationBuilder { + build() { + return { + mttr: { + avg: { + field: `${CASE_SAVED_OBJECT}.attributes.duration`, + }, + }, + }; + } + + formatResponse(aggregations: AggregationResponse) { + const aggs = aggregations as MTTRAggregate; + + const mttr = aggs?.mttr?.value ?? 0; + + return { mttr }; + } + + getName() { + return 'mttr'; + } +} + +type MTTRAggregate = MTTRAggregateResponse | undefined; + +interface MTTRAggregateResponse { + mttr?: { + value: number; + }; +} diff --git a/x-pack/plugins/cases/server/client/metrics/all_cases/mttr.test.ts b/x-pack/plugins/cases/server/client/metrics/all_cases/mttr.test.ts new file mode 100644 index 0000000000000..e133082e69756 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/all_cases/mttr.test.ts @@ -0,0 +1,160 @@ +/* + * 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 { CaseResponse } from '../../../../common/api'; +import { createCasesClientMock } from '../../mocks'; +import { CasesClientArgs } from '../../types'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { createCaseServiceMock } from '../../../services/mocks'; + +import { MTTR } from './mttr'; + +const clientMock = createCasesClientMock(); +const caseService = createCaseServiceMock(); + +const logger = loggingSystemMock.createLogger(); +const getAuthorizationFilter = jest.fn().mockResolvedValue({}); + +const clientArgs = { + logger, + caseService, + authorization: { getAuthorizationFilter }, +} as unknown as CasesClientArgs; + +const constructorOptions = { casesClient: clientMock, clientArgs }; + +describe('MTTR', () => { + beforeAll(() => { + getAuthorizationFilter.mockResolvedValue({}); + clientMock.cases.get.mockResolvedValue({ id: '' } as unknown as CaseResponse); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns empty values when no features set up', async () => { + caseService.executeAggregations.mockResolvedValue(undefined); + const handler = new MTTR(constructorOptions); + expect(await handler.compute()).toEqual({}); + }); + + it('returns zero values when aggregation returns undefined', async () => { + caseService.executeAggregations.mockResolvedValue(undefined); + const handler = new MTTR(constructorOptions); + handler.setupFeature('mttr'); + + expect(await handler.compute()).toEqual({ mttr: 0 }); + }); + + it('returns zero values when aggregation returns empty object', async () => { + caseService.executeAggregations.mockResolvedValue({}); + const handler = new MTTR(constructorOptions); + handler.setupFeature('mttr'); + + expect(await handler.compute()).toEqual({ mttr: 0 }); + }); + + it('returns zero values when aggregation returns empty mttr object', async () => { + caseService.executeAggregations.mockResolvedValue({ mttr: {} }); + const handler = new MTTR(constructorOptions); + handler.setupFeature('mttr'); + + expect(await handler.compute()).toEqual({ mttr: 0 }); + }); + + it('returns values when there is a mttr value', async () => { + caseService.executeAggregations.mockResolvedValue({ mttr: { value: 5 } }); + const handler = new MTTR(constructorOptions); + handler.setupFeature('mttr'); + + expect(await handler.compute()).toEqual({ mttr: 5 }); + }); + + it('passes the query options correctly', async () => { + caseService.executeAggregations.mockResolvedValue({ mttr: { value: 5 } }); + const handler = new MTTR({ + ...constructorOptions, + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', + owner: 'cases', + }); + + handler.setupFeature('mttr'); + await handler.compute(); + + expect(caseService.executeAggregations.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "aggregationBuilders": Array [ + AverageDuration {}, + ], + "options": Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.created_at", + }, + "gte", + Object { + "type": "literal", + "value": "2022-04-28T15:18:00.000Z", + }, + ], + "function": "range", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.created_at", + }, + "lte", + Object { + "type": "literal", + "value": "2022-04-28T15:22:00.000Z", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "cases", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + }, + } + `); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/all_cases/mttr.ts b/x-pack/plugins/cases/server/client/metrics/all_cases/mttr.ts new file mode 100644 index 0000000000000..69cacb7e2318e --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/all_cases/mttr.ts @@ -0,0 +1,53 @@ +/* + * 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 { CasesMetricsResponse } from '../../../../common/api'; +import { Operations } from '../../../authorization'; +import { createCaseError } from '../../../common/error'; +import { constructQueryOptions } from '../../utils'; +import { AllCasesAggregationHandler } from '../all_cases_aggregation_handler'; +import { AggregationBuilder, AllCasesBaseHandlerCommonOptions } from '../types'; +import { AverageDuration } from './aggregations/avg_duration'; + +export class MTTR extends AllCasesAggregationHandler { + constructor(options: AllCasesBaseHandlerCommonOptions) { + super( + options, + new Map>([['mttr', new AverageDuration()]]) + ); + } + + public async compute(): Promise { + const { authorization, caseService, logger } = this.options.clientArgs; + + try { + const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( + Operations.getCasesMetrics + ); + + const caseQueryOptions = constructQueryOptions({ + from: this.from, + to: this.to, + owner: this.owner, + authorizationFilter, + }); + + const aggregationsResponse = await caseService.executeAggregations({ + aggregationBuilders: this.aggregationBuilders, + options: { filter: caseQueryOptions.filter }, + }); + + return this.formatResponse(aggregationsResponse); + } catch (error) { + throw createCaseError({ + message: `Failed to calculate average mttr: ${error}`, + error, + logger, + }); + } + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/all_cases_aggregation_handler.ts b/x-pack/plugins/cases/server/client/metrics/all_cases_aggregation_handler.ts new file mode 100644 index 0000000000000..3a5a259c28296 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/all_cases_aggregation_handler.ts @@ -0,0 +1,28 @@ +/* + * 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 { CasesMetricsResponse } from '../../../common/api'; +import { AggregationHandler } from './aggregation_handler'; +import { AggregationBuilder, AllCasesBaseHandlerCommonOptions } from './types'; + +export abstract class AllCasesAggregationHandler extends AggregationHandler { + protected readonly from?: string; + protected readonly to?: string; + protected readonly owner?: string | string[]; + + constructor( + options: AllCasesBaseHandlerCommonOptions, + aggregations: Map> + ) { + const { owner, from, to, ...restOptions } = options; + super(restOptions, aggregations); + + this.from = from; + this.to = to; + this.owner = owner; + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/all_cases_base_handler.ts b/x-pack/plugins/cases/server/client/metrics/all_cases_base_handler.ts new file mode 100644 index 0000000000000..de9f1f089c8c8 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/all_cases_base_handler.ts @@ -0,0 +1,21 @@ +/* + * 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 { CasesMetricsResponse } from '../../../common/api'; +import { BaseHandler } from './base_handler'; +import { AllCasesBaseHandlerCommonOptions } from './types'; + +export abstract class AllCasesBaseHandler extends BaseHandler { + protected readonly owner?: string | string[]; + + constructor(options: AllCasesBaseHandlerCommonOptions, features?: string[]) { + const { owner, ...restOptions } = options; + super(restOptions, features); + + this.owner = owner; + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/base_handler.ts b/x-pack/plugins/cases/server/client/metrics/base_handler.ts index bf76be05f58b3..6525de35bc00c 100644 --- a/x-pack/plugins/cases/server/client/metrics/base_handler.ts +++ b/x-pack/plugins/cases/server/client/metrics/base_handler.ts @@ -5,10 +5,9 @@ * 2.0. */ -import { CaseMetricsResponse } from '../../../common/api'; import { BaseHandlerCommonOptions, MetricsHandler } from './types'; -export abstract class BaseHandler implements MetricsHandler { +export abstract class BaseHandler implements MetricsHandler { constructor( protected readonly options: BaseHandlerCommonOptions, private readonly features?: string[] @@ -18,5 +17,5 @@ export abstract class BaseHandler implements MetricsHandler { return new Set(this.features); } - abstract compute(): Promise; + abstract compute(): Promise; } diff --git a/x-pack/plugins/cases/server/client/metrics/client.ts b/x-pack/plugins/cases/server/client/metrics/client.ts index 8fbb30486bc41..e2e0dfb5c9415 100644 --- a/x-pack/plugins/cases/server/client/metrics/client.ts +++ b/x-pack/plugins/cases/server/client/metrics/client.ts @@ -5,19 +5,27 @@ * 2.0. */ -import { CaseMetricsResponse, CasesStatusRequest, CasesStatusResponse } from '../../../common/api'; +import { + SingleCaseMetricsResponse, + CasesMetricsRequest, + CasesStatusRequest, + CasesStatusResponse, + SingleCaseMetricsRequest, + CasesMetricsResponse, +} from '../../../common/api'; import { CasesClient } from '../client'; import { CasesClientArgs } from '../types'; -import { getStatusTotalsByType } from './get_cases_metrics'; - -import { getCaseMetrics, CaseMetricsParams } from './get_case_metrics'; +import { getStatusTotalsByType } from './get_status_totals'; +import { getCaseMetrics } from './get_case_metrics'; +import { getCasesMetrics } from './get_cases_metrics'; /** * API for interacting with the metrics. */ export interface MetricsSubClient { - getCaseMetrics(params: CaseMetricsParams): Promise; + getCaseMetrics(params: SingleCaseMetricsRequest): Promise; + getCasesMetrics(params: CasesMetricsRequest): Promise; /** * Retrieves the total number of open, closed, and in-progress cases. */ @@ -34,7 +42,10 @@ export const createMetricsSubClient = ( casesClient: CasesClient ): MetricsSubClient => { const casesSubClient: MetricsSubClient = { - getCaseMetrics: (params: CaseMetricsParams) => getCaseMetrics(params, casesClient, clientArgs), + getCaseMetrics: (params: SingleCaseMetricsRequest) => + getCaseMetrics(params, casesClient, clientArgs), + getCasesMetrics: (params: CasesMetricsRequest) => + getCasesMetrics(params, casesClient, clientArgs), getStatusTotalsByType: (params: CasesStatusRequest) => getStatusTotalsByType(params, clientArgs), }; diff --git a/x-pack/plugins/cases/server/client/metrics/connectors.ts b/x-pack/plugins/cases/server/client/metrics/connectors.ts index 3dd29b8b6dda7..1701cef3b8cf9 100644 --- a/x-pack/plugins/cases/server/client/metrics/connectors.ts +++ b/x-pack/plugins/cases/server/client/metrics/connectors.ts @@ -5,30 +5,28 @@ * 2.0. */ -import { CaseMetricsResponse } from '../../../common/api'; +import { SingleCaseMetricsResponse } from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; -import { BaseHandler } from './base_handler'; -import { BaseHandlerCommonOptions } from './types'; +import { SingleCaseBaseHandler } from './single_case_base_handler'; +import { SingleCaseBaseHandlerCommonOptions } from './types'; -export class Connectors extends BaseHandler { - constructor(options: BaseHandlerCommonOptions) { +export class Connectors extends SingleCaseBaseHandler { + constructor(options: SingleCaseBaseHandlerCommonOptions) { super(options, ['connectors']); } - public async compute(): Promise { + public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, userActionService, logger } = this.options.clientArgs; - const { caseId } = this.options; - const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( Operations.getUserActionMetrics ); const uniqueConnectors = await userActionService.getUniqueConnectors({ unsecuredSavedObjectsClient, - caseId, + caseId: this.caseId, filter: authorizationFilter, }); @@ -38,7 +36,7 @@ export class Connectors extends BaseHandler { }; } catch (error) { throw createCaseError({ - message: `Failed to retrieve total connectors metrics for case id: ${caseId}: ${error}`, + message: `Failed to retrieve total connectors metrics for case id: ${this.caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts index 03b03eafa7d97..51353d9558c1b 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.test.ts @@ -5,22 +5,23 @@ * 2.0. */ +import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; +import { SavedObject } from '@kbn/core/server'; + import { getCaseMetrics } from './get_case_metrics'; import { CaseAttributes, CaseResponse, CaseStatuses } from '../../../common/api'; import { CasesClientMock, createCasesClientMock } from '../mocks'; import { CasesClientArgs } from '../types'; import { createAuthorizationMock } from '../../authorization/mock'; -import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { createAttachmentServiceMock, createCaseServiceMock, createUserActionServiceMock, } from '../../services/mocks'; -import { SavedObject } from '@kbn/core/server'; import { mockAlertsService } from './test_utils/alerts'; import { createStatusChangeSavedObject } from './test_utils/lifespan'; -describe('getMetrics', () => { +describe('getCaseMetrics', () => { const inProgressStatusChangeTimestamp = new Date('2021-11-23T20:00:43Z'); const currentTime = new Date('2021-11-23T20:01:43Z'); diff --git a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts index d2ce8c03edeb7..e3132b4a590f7 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_case_metrics.ts @@ -5,36 +5,23 @@ * 2.0. */ import { merge } from 'lodash'; -import Boom from '@hapi/boom'; -import { CaseMetricsResponseRt, CaseMetricsResponse } from '../../../common/api'; +import { + SingleCaseMetricsRequest, + SingleCaseMetricsResponse, + SingleCaseMetricsResponseRt, +} from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; import { CasesClient } from '../client'; import { CasesClientArgs } from '../types'; -import { AlertsCount } from './alerts/count'; -import { AlertDetails } from './alerts/details'; -import { Actions } from './actions'; -import { Connectors } from './connectors'; -import { Lifespan } from './lifespan'; -import { MetricsHandler } from './types'; - -export interface CaseMetricsParams { - /** - * The ID of the case. - */ - caseId: string; - /** - * The metrics to retrieve. - */ - features: string[]; -} +import { buildHandlers } from './utils'; export const getCaseMetrics = async ( - params: CaseMetricsParams, + params: SingleCaseMetricsRequest, casesClient: CasesClient, clientArgs: CasesClientArgs -): Promise => { +): Promise => { const { logger } = clientArgs; try { @@ -49,9 +36,9 @@ export const getCaseMetrics = async ( const mergedResults = computedMetrics.reduce((acc, metric) => { return merge(acc, metric); - }, {}); + }, {}) as SingleCaseMetricsResponse; - return CaseMetricsResponseRt.encode(mergedResults); + return SingleCaseMetricsResponseRt.encode(mergedResults); } catch (error) { throw createCaseError({ logger, @@ -61,50 +48,10 @@ export const getCaseMetrics = async ( } }; -const buildHandlers = ( - params: CaseMetricsParams, - casesClient: CasesClient, +const checkAuthorization = async ( + params: SingleCaseMetricsRequest, clientArgs: CasesClientArgs -): Set => { - const handlers: MetricsHandler[] = [AlertsCount, AlertDetails, Actions, Connectors, Lifespan].map( - (ClassName) => new ClassName({ caseId: params.caseId, casesClient, clientArgs }) - ); - - const uniqueFeatures = new Set(params.features); - const handlerFeatures = new Set(); - const handlersToExecute = new Set(); - for (const handler of handlers) { - for (const handlerFeature of handler.getFeatures()) { - if (uniqueFeatures.has(handlerFeature)) { - handler.setupFeature?.(handlerFeature); - handlersToExecute.add(handler); - } - - handlerFeatures.add(handlerFeature); - } - } - - checkAndThrowIfInvalidFeatures(params, handlerFeatures); - - return handlersToExecute; -}; - -const checkAndThrowIfInvalidFeatures = ( - params: CaseMetricsParams, - handlerFeatures: Set ) => { - const invalidFeatures = params.features.filter((feature) => !handlerFeatures.has(feature)); - if (invalidFeatures.length > 0) { - const invalidFeaturesAsString = invalidFeatures.join(', '); - const validFeaturesAsString = [...handlerFeatures.keys()].sort().join(', '); - - throw Boom.badRequest( - `invalid features: [${invalidFeaturesAsString}], please only provide valid features: [${validFeaturesAsString}]` - ); - } -}; - -const checkAuthorization = async (params: CaseMetricsParams, clientArgs: CasesClientArgs) => { const { caseService, authorization } = clientArgs; const caseInfo = await caseService.getCase({ diff --git a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts new file mode 100644 index 0000000000000..3e94f58a2ba05 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.test.ts @@ -0,0 +1,120 @@ +/* + * 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 { CasesClientMock } from '../mocks'; +import { getCasesMetrics } from './get_cases_metrics'; +import { createMockClientArgs, createMockClient } from './test_utils/client'; + +describe('getCasesMetrics', () => { + let client: CasesClientMock; + let mockServices: ReturnType['mockServices']; + let clientArgs: ReturnType['clientArgs']; + + beforeEach(() => { + client = createMockClient(); + ({ mockServices, clientArgs } = createMockClientArgs()); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('MTTR', () => { + beforeEach(() => { + mockServices.caseService.executeAggregations.mockResolvedValue({ mttr: { value: 5 } }); + }); + + it('returns the mttr metric', async () => { + const metrics = await getCasesMetrics({ features: ['mttr'] }, client, clientArgs); + expect(metrics).toEqual({ mttr: 5 }); + }); + + it('calls the executeAggregations correctly', async () => { + await getCasesMetrics( + { + features: ['mttr'], + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', + owner: 'cases', + }, + client, + clientArgs + ); + expect(mockServices.caseService.executeAggregations.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "aggregationBuilders": Array [ + AverageDuration {}, + ], + "options": Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.created_at", + }, + "gte", + Object { + "type": "literal", + "value": "2022-04-28T15:18:00.000Z", + }, + ], + "function": "range", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.created_at", + }, + "lte", + Object { + "type": "literal", + "value": "2022-04-28T15:22:00.000Z", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "cases", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts index e02f882820fa7..c7cb0673db42e 100644 --- a/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts +++ b/x-pack/plugins/cases/server/client/metrics/get_cases_metrics.ts @@ -5,57 +5,55 @@ * 2.0. */ +import { merge } from 'lodash'; import Boom from '@hapi/boom'; import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { - CasesStatusRequest, - CasesStatusResponse, - excess, - CasesStatusRequestRt, + CasesMetricsRequest, + CasesMetricsRequestRt, + CasesMetricsResponse, + CasesMetricsResponseRt, throwErrors, - CasesStatusResponseRt, } from '../../../common/api'; -import { CasesClientArgs } from '../types'; -import { Operations } from '../../authorization'; -import { constructQueryOptions } from '../utils'; import { createCaseError } from '../../common/error'; +import { CasesClient } from '../client'; +import { CasesClientArgs } from '../types'; +import { buildHandlers } from './utils'; -export async function getStatusTotalsByType( - params: CasesStatusRequest, +export const getCasesMetrics = async ( + params: CasesMetricsRequest, + casesClient: CasesClient, clientArgs: CasesClientArgs -): Promise { - const { caseService, logger, authorization } = clientArgs; +): Promise => { + const { logger } = clientArgs; + + const queryParams = pipe( + CasesMetricsRequestRt.decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); try { - const queryParams = pipe( - excess(CasesStatusRequestRt).decode(params), - fold(throwErrors(Boom.badRequest), identity) - ); + const handlers = buildHandlers(queryParams, casesClient, clientArgs); - const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( - Operations.getCaseStatuses + const computedMetrics = await Promise.all( + Array.from(handlers).map(async (handler) => { + return handler.compute(); + }) ); - const options = constructQueryOptions({ - owner: queryParams.owner, - from: queryParams.from, - to: queryParams.to, - authorizationFilter, - }); + const mergedResults = computedMetrics.reduce((acc, metric) => { + return merge(acc, metric); + }, {}) as CasesMetricsResponse; - const statusStats = await caseService.getCaseStatusStats({ - searchOptions: options, - }); - - return CasesStatusResponseRt.encode({ - count_open_cases: statusStats.open, - count_in_progress_cases: statusStats['in-progress'], - count_closed_cases: statusStats.closed, - }); + return CasesMetricsResponseRt.encode(mergedResults); } catch (error) { - throw createCaseError({ message: `Failed to get status stats: ${error}`, error, logger }); + throw createCaseError({ + logger, + message: `Failed to retrieve metrics within client for cases: ${error}`, + error, + }); } -} +}; diff --git a/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts b/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts new file mode 100644 index 0000000000000..775a6904783bf --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/get_status_totals.test.ts @@ -0,0 +1,122 @@ +/* + * 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 { getStatusTotalsByType } from './get_status_totals'; +import { createMockClientArgs } from './test_utils/client'; + +describe('getStatusTotalsByType', () => { + let mockServices: ReturnType['mockServices']; + let clientArgs: ReturnType['clientArgs']; + + beforeEach(() => { + ({ mockServices, clientArgs } = createMockClientArgs()); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('MTTR', () => { + beforeEach(() => { + mockServices.caseService.getCaseStatusStats.mockResolvedValue({ + open: 1, + 'in-progress': 2, + closed: 1, + }); + }); + + it('returns the status correctly', async () => { + const metrics = await getStatusTotalsByType({}, clientArgs); + expect(metrics).toEqual({ + count_closed_cases: 1, + count_in_progress_cases: 2, + count_open_cases: 1, + }); + }); + + it('calls the executeAggregations correctly', async () => { + await getStatusTotalsByType( + { + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', + owner: 'cases', + }, + clientArgs + ); + + expect(mockServices.caseService.getCaseStatusStats.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "searchOptions": Object { + "filter": Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.created_at", + }, + "gte", + Object { + "type": "literal", + "value": "2022-04-28T15:18:00.000Z", + }, + ], + "function": "range", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.created_at", + }, + "lte", + Object { + "type": "literal", + "value": "2022-04-28T15:22:00.000Z", + }, + ], + "function": "range", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + Object { + "arguments": Array [ + Object { + "type": "literal", + "value": "cases.attributes.owner", + }, + Object { + "type": "literal", + "value": "cases", + }, + Object { + "type": "literal", + "value": false, + }, + ], + "function": "is", + "type": "function", + }, + ], + "function": "and", + "type": "function", + }, + "sortField": "created_at", + }, + } + `); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts b/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts new file mode 100644 index 0000000000000..e02f882820fa7 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/get_status_totals.ts @@ -0,0 +1,61 @@ +/* + * 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 Boom from '@hapi/boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { + CasesStatusRequest, + CasesStatusResponse, + excess, + CasesStatusRequestRt, + throwErrors, + CasesStatusResponseRt, +} from '../../../common/api'; +import { CasesClientArgs } from '../types'; +import { Operations } from '../../authorization'; +import { constructQueryOptions } from '../utils'; +import { createCaseError } from '../../common/error'; + +export async function getStatusTotalsByType( + params: CasesStatusRequest, + clientArgs: CasesClientArgs +): Promise { + const { caseService, logger, authorization } = clientArgs; + + try { + const queryParams = pipe( + excess(CasesStatusRequestRt).decode(params), + fold(throwErrors(Boom.badRequest), identity) + ); + + const { filter: authorizationFilter } = await authorization.getAuthorizationFilter( + Operations.getCaseStatuses + ); + + const options = constructQueryOptions({ + owner: queryParams.owner, + from: queryParams.from, + to: queryParams.to, + authorizationFilter, + }); + + const statusStats = await caseService.getCaseStatusStats({ + searchOptions: options, + }); + + return CasesStatusResponseRt.encode({ + count_open_cases: statusStats.open, + count_in_progress_cases: statusStats['in-progress'], + count_closed_cases: statusStats.closed, + }); + } catch (error) { + throw createCaseError({ message: `Failed to get status stats: ${error}`, error, logger }); + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/lifespan.ts b/x-pack/plugins/cases/server/client/metrics/lifespan.ts index 6198886036471..d5acf266dd9a0 100644 --- a/x-pack/plugins/cases/server/client/metrics/lifespan.ts +++ b/x-pack/plugins/cases/server/client/metrics/lifespan.ts @@ -7,9 +7,9 @@ import { SavedObject } from '@kbn/core/server'; import { - CaseMetricsResponse, CaseStatuses, CaseUserActionResponse, + SingleCaseMetricsResponse, StatusInfo, StatusUserAction, StatusUserActionRt, @@ -17,22 +17,22 @@ import { } from '../../../common/api'; import { Operations } from '../../authorization'; import { createCaseError } from '../../common/error'; -import { BaseHandler } from './base_handler'; -import { BaseHandlerCommonOptions } from './types'; +import { SingleCaseBaseHandler } from './single_case_base_handler'; +import { SingleCaseBaseHandlerCommonOptions } from './types'; -export class Lifespan extends BaseHandler { - constructor(options: BaseHandlerCommonOptions) { +export class Lifespan extends SingleCaseBaseHandler { + constructor(options: SingleCaseBaseHandlerCommonOptions) { super(options, ['lifespan']); } - public async compute(): Promise { + public async compute(): Promise { const { unsecuredSavedObjectsClient, authorization, userActionService, logger } = this.options.clientArgs; - const { caseId, casesClient } = this.options; + const { casesClient } = this.options; try { - const caseInfo = await casesClient.cases.get({ id: caseId }); + const caseInfo = await casesClient.cases.get({ id: this.caseId }); const caseOpenTimestamp = new Date(caseInfo.created_at); if (!isDateValid(caseOpenTimestamp)) { @@ -47,7 +47,7 @@ export class Lifespan extends BaseHandler { const statusUserActions = await userActionService.findStatusChanges({ unsecuredSavedObjectsClient, - caseId, + caseId: this.caseId, filter: authorizationFilter, }); @@ -62,7 +62,7 @@ export class Lifespan extends BaseHandler { }; } catch (error) { throw createCaseError({ - message: `Failed to retrieve lifespan metrics for case id: ${caseId}: ${error}`, + message: `Failed to retrieve lifespan metrics for case id: ${this.caseId}: ${error}`, error, logger, }); diff --git a/x-pack/plugins/cases/server/client/metrics/single_case_aggregation_handler.ts b/x-pack/plugins/cases/server/client/metrics/single_case_aggregation_handler.ts new file mode 100644 index 0000000000000..509a2f0125ec6 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/single_case_aggregation_handler.ts @@ -0,0 +1,24 @@ +/* + * 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 { SingleCaseMetricsResponse } from '../../../common/api'; +import { AggregationHandler } from './aggregation_handler'; +import { AggregationBuilder, SingleCaseBaseHandlerCommonOptions } from './types'; + +export abstract class SingleCaseAggregationHandler extends AggregationHandler { + protected readonly caseId: string; + + constructor( + options: SingleCaseBaseHandlerCommonOptions, + aggregations: Map> + ) { + const { caseId, ...restOptions } = options; + super(restOptions, aggregations); + + this.caseId = caseId; + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/single_case_base_handler.ts b/x-pack/plugins/cases/server/client/metrics/single_case_base_handler.ts new file mode 100644 index 0000000000000..d11af800186b0 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/single_case_base_handler.ts @@ -0,0 +1,21 @@ +/* + * 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 { SingleCaseMetricsResponse } from '../../../common/api'; +import { BaseHandler } from './base_handler'; +import { SingleCaseBaseHandlerCommonOptions } from './types'; + +export abstract class SingleCaseBaseHandler extends BaseHandler { + protected readonly caseId: string; + + constructor(options: SingleCaseBaseHandlerCommonOptions, features?: string[]) { + const { caseId, ...restOptions } = options; + super(restOptions, features); + + this.caseId = caseId; + } +} diff --git a/x-pack/plugins/cases/server/client/metrics/test_utils/alerts.ts b/x-pack/plugins/cases/server/client/metrics/test_utils/alerts.ts index 6412f7eb27959..73d22fb575f27 100644 --- a/x-pack/plugins/cases/server/client/metrics/test_utils/alerts.ts +++ b/x-pack/plugins/cases/server/client/metrics/test_utils/alerts.ts @@ -12,7 +12,11 @@ import { AlertHosts, AlertUsers } from '../alerts/aggregations'; export function mockAlertsService() { const alertsService = createAlertServiceMock(); alertsService.executeAggregations.mockImplementation( - async ({ aggregationBuilders }: { aggregationBuilders: AggregationBuilder[] }) => { + async ({ + aggregationBuilders, + }: { + aggregationBuilders: Array>; + }) => { let result = {}; for (const builder of aggregationBuilders) { switch (builder.constructor) { diff --git a/x-pack/plugins/cases/server/client/metrics/test_utils/client.ts b/x-pack/plugins/cases/server/client/metrics/test_utils/client.ts new file mode 100644 index 0000000000000..b132503d41458 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/test_utils/client.ts @@ -0,0 +1,39 @@ +/* + * 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 { savedObjectsClientMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { createAuthorizationMock } from '../../../authorization/mock'; +import { createCaseServiceMock } from '../../../services/mocks'; +import { createCasesClientMock } from '../../mocks'; +import { CasesClientArgs } from '../../types'; + +export function createMockClient() { + const client = createCasesClientMock(); + + return client; +} + +export function createMockClientArgs() { + const authorization = createAuthorizationMock(); + authorization.getAuthorizationFilter.mockImplementation(async () => { + return { filter: undefined, ensureSavedObjectsAreAuthorized: () => {} }; + }); + + const soClient = savedObjectsClientMock.create(); + + const caseService = createCaseServiceMock(); + const logger = loggingSystemMock.createLogger(); + + const clientArgs = { + authorization, + unsecuredSavedObjectsClient: soClient, + caseService, + logger, + }; + + return { mockServices: clientArgs, clientArgs: clientArgs as unknown as CasesClientArgs }; +} diff --git a/x-pack/plugins/cases/server/client/metrics/types.ts b/x-pack/plugins/cases/server/client/metrics/types.ts index 6773ab59b0b02..35bdbc0933fbc 100644 --- a/x-pack/plugins/cases/server/client/metrics/types.ts +++ b/x-pack/plugins/cases/server/client/metrics/types.ts @@ -6,26 +6,34 @@ */ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { CaseMetricsResponse } from '../../../common/api'; import { CasesClient } from '../client'; import { CasesClientArgs } from '../types'; -export interface MetricsHandler { +export interface MetricsHandler { getFeatures(): Set; - compute(): Promise; + compute(): Promise; setupFeature?(feature: string): void; } -export interface AggregationBuilder { +export interface AggregationBuilder { build(): Record; - formatResponse(aggregations: AggregationResponse): CaseMetricsResponse; + formatResponse(aggregations: AggregationResponse): R; getName(): string; } export type AggregationResponse = Record | undefined; export interface BaseHandlerCommonOptions { - caseId: string; casesClient: CasesClient; clientArgs: CasesClientArgs; } + +export interface SingleCaseBaseHandlerCommonOptions extends BaseHandlerCommonOptions { + caseId: string; +} + +export interface AllCasesBaseHandlerCommonOptions extends BaseHandlerCommonOptions { + from?: string; + to?: string; + owner?: string | string[]; +} diff --git a/x-pack/plugins/cases/server/client/metrics/utils.test.ts b/x-pack/plugins/cases/server/client/metrics/utils.test.ts new file mode 100644 index 0000000000000..d376ed56dc232 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/utils.test.ts @@ -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 { createMockClient, createMockClientArgs } from './test_utils/client'; +import { buildHandlers } from './utils'; + +describe('utils', () => { + describe('buildHandlers', () => { + const casesClient = createMockClient(); + const clientArgs = createMockClientArgs(); + const SINGLE_CASE_FEATURES = [ + 'alerts.count', + 'alerts.users', + 'alerts.hosts', + 'actions.isolateHost', + 'connectors', + 'lifespan', + ]; + + const CASES_FEATURES = ['mttr']; + + it('returns the correct single case handlers', async () => { + const handlers = buildHandlers( + { + caseId: 'test-case-id', + features: SINGLE_CASE_FEATURES, + }, + casesClient, + clientArgs.clientArgs + ); + + handlers.forEach((handler) => { + // @ts-expect-error + expect(handler.caseId).toBe('test-case-id'); + expect( + Array.from(handler.getFeatures().values()).every((feature) => + SINGLE_CASE_FEATURES.includes(feature) + ) + ).toBe(true); + }); + }); + + it('returns the correct cases handlers', async () => { + const handlers = buildHandlers( + { + features: CASES_FEATURES, + from: '2022-04-28T15:18:00.000Z', + to: '2022-04-28T15:22:00.000Z', + owner: 'cases', + }, + casesClient, + clientArgs.clientArgs + ); + + handlers.forEach((handler) => { + // @ts-expect-error + expect(handler.from).toBe('2022-04-28T15:18:00.000Z'); + // @ts-expect-error + expect(handler.to).toBe('2022-04-28T15:22:00.000Z'); + // @ts-expect-error + expect(handler.owner).toBe('cases'); + + expect( + Array.from(handler.getFeatures().values()).every((feature) => + CASES_FEATURES.includes(feature) + ) + ).toBe(true); + }); + }); + + it.each([ + [ + { caseId: 'test-case-id' }, + 'invalid features: [not-exists], please only provide valid features: [actions.isolateHost, alerts.count, alerts.hosts, alerts.users, connectors, lifespan]', + ], + [ + { caseId: null }, + 'invalid features: [not-exists], please only provide valid features: [mttr]', + ], + ])('throws if the feature is not supported: %s', async (opts, msg) => { + expect(() => + buildHandlers( + { + ...opts, + features: ['not-exists'], + }, + casesClient, + clientArgs.clientArgs + ) + ).toThrow(msg); + }); + + it('filters the handlers correctly', async () => { + const handlers = buildHandlers( + { + caseId: 'test-case-id', + features: ['alerts.count'], + }, + casesClient, + clientArgs.clientArgs + ); + + const handler = Array.from(handlers)[0]; + // @ts-expect-error + expect(handler.caseId).toBe('test-case-id'); + expect(Array.from(handler.getFeatures().values())).toEqual(['alerts.count']); + }); + + it('set up the feature correctly', async () => { + const handlers = buildHandlers( + { + caseId: 'test-case-id', + features: ['alerts.hosts'], + }, + casesClient, + clientArgs.clientArgs + ); + + const handler = Array.from(handlers)[0]; + // @ts-expect-error + const aggregationBuilder = handler.aggregationBuilders[0]; + expect(aggregationBuilder.getName()).toBe('hosts'); + }); + }); +}); diff --git a/x-pack/plugins/cases/server/client/metrics/utils.ts b/x-pack/plugins/cases/server/client/metrics/utils.ts new file mode 100644 index 0000000000000..9d6634d888d71 --- /dev/null +++ b/x-pack/plugins/cases/server/client/metrics/utils.ts @@ -0,0 +1,81 @@ +/* + * 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 Boom from '@hapi/boom'; +import { CasesMetricsRequest, SingleCaseMetricsRequest } from '../../../common/api'; +import { CasesClient } from '../client'; +import { CasesClientArgs } from '../types'; +import { AlertsCount } from './alerts/count'; +import { AlertDetails } from './alerts/details'; +import { Actions } from './actions'; +import { Connectors } from './connectors'; +import { Lifespan } from './lifespan'; +import { MetricsHandler } from './types'; +import { MTTR } from './all_cases/mttr'; + +const isSingleCaseMetrics = ( + params: SingleCaseMetricsRequest | CasesMetricsRequest +): params is SingleCaseMetricsRequest => (params as SingleCaseMetricsRequest).caseId != null; + +export const buildHandlers = ( + params: SingleCaseMetricsRequest | CasesMetricsRequest, + casesClient: CasesClient, + clientArgs: CasesClientArgs +): Set> => { + let handlers: Array> = []; + + if (isSingleCaseMetrics(params)) { + handlers = [AlertsCount, AlertDetails, Actions, Connectors, Lifespan].map( + (ClassName) => new ClassName({ caseId: params.caseId, casesClient, clientArgs }) + ); + } else { + handlers = [MTTR].map( + (ClassName) => + new ClassName({ + owner: params.owner, + from: params.from, + to: params.to, + casesClient, + clientArgs, + }) + ); + } + + const uniqueFeatures = new Set(params.features); + const handlerFeatures = new Set(); + const handlersToExecute = new Set>(); + + for (const handler of handlers) { + for (const handlerFeature of handler.getFeatures()) { + if (uniqueFeatures.has(handlerFeature)) { + handler.setupFeature?.(handlerFeature); + handlersToExecute.add(handler); + } + + handlerFeatures.add(handlerFeature); + } + } + + checkAndThrowIfInvalidFeatures(params, handlerFeatures); + + return handlersToExecute; +}; + +const checkAndThrowIfInvalidFeatures = ( + params: SingleCaseMetricsRequest | CasesMetricsRequest, + handlerFeatures: Set +) => { + const invalidFeatures = params.features.filter((feature) => !handlerFeatures.has(feature)); + if (invalidFeatures.length > 0) { + const invalidFeaturesAsString = invalidFeatures.join(', '); + const validFeaturesAsString = [...handlerFeatures.keys()].sort().join(', '); + + throw Boom.badRequest( + `invalid features: [${invalidFeaturesAsString}], please only provide valid features: [${validFeaturesAsString}]` + ); + } +}; diff --git a/x-pack/plugins/cases/server/client/mocks.ts b/x-pack/plugins/cases/server/client/mocks.ts index 6ad4663f1e5ea..a5842cf9137ba 100644 --- a/x-pack/plugins/cases/server/client/mocks.ts +++ b/x-pack/plugins/cases/server/client/mocks.ts @@ -37,6 +37,7 @@ type MetricsSubClientMock = jest.Mocked; const createMetricsSubClientMock = (): MetricsSubClientMock => { return { getCaseMetrics: jest.fn(), + getCasesMetrics: jest.fn(), getStatusTotalsByType: jest.fn(), }; }; diff --git a/x-pack/plugins/cases/server/routes/api/get_external_routes.ts b/x-pack/plugins/cases/server/routes/api/get_external_routes.ts index 7908e4eb84359..7b7a18cc7c83c 100644 --- a/x-pack/plugins/cases/server/routes/api/get_external_routes.ts +++ b/x-pack/plugins/cases/server/routes/api/get_external_routes.ts @@ -30,6 +30,7 @@ import { patchCaseConfigureRoute } from './configure/patch_configure'; import { postCaseConfigureRoute } from './configure/post_configure'; import { getAllAlertsAttachedToCaseRoute } from './comments/get_alerts'; import { getCaseMetricRoute } from './metrics/get_case_metrics'; +import { getCasesMetricRoute } from './metrics/get_cases_metrics'; export const getExternalRoutes = () => [ @@ -58,4 +59,5 @@ export const getExternalRoutes = () => postCaseConfigureRoute, getAllAlertsAttachedToCaseRoute, getCaseMetricRoute, + getCasesMetricRoute, ] as CaseRoute[]; diff --git a/x-pack/plugins/cases/server/routes/api/metrics/get_cases_metrics.ts b/x-pack/plugins/cases/server/routes/api/metrics/get_cases_metrics.ts new file mode 100644 index 0000000000000..3eb9ec26a9297 --- /dev/null +++ b/x-pack/plugins/cases/server/routes/api/metrics/get_cases_metrics.ts @@ -0,0 +1,41 @@ +/* + * 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 { schema } from '@kbn/config-schema'; + +import { CASE_METRICS_URL } from '../../../../common/constants'; +import { createCaseError } from '../../../common/error'; +import { createCasesRoute } from '../create_cases_route'; + +export const getCasesMetricRoute = createCasesRoute({ + method: 'get', + path: CASE_METRICS_URL, + params: { + query: schema.object({ + features: schema.arrayOf(schema.string({ minLength: 1 })), + owner: schema.maybe(schema.oneOf([schema.arrayOf(schema.string()), schema.string()])), + from: schema.maybe(schema.string()), + to: schema.maybe(schema.string()), + }), + }, + handler: async ({ context, request, response }) => { + try { + const caseContext = await context.cases; + const client = await caseContext.getCasesClient(); + return response.ok({ + body: await client.metrics.getCasesMetrics({ + ...request.query, + }), + }); + } catch (error) { + throw createCaseError({ + message: `Failed to get cases metrics in route: ${error}`, + error, + }); + } + }, +}); diff --git a/x-pack/plugins/cases/server/services/alerts/index.ts b/x-pack/plugins/cases/server/services/alerts/index.ts index 69c44b30fec28..b219c50964d39 100644 --- a/x-pack/plugins/cases/server/services/alerts/index.ts +++ b/x-pack/plugins/cases/server/services/alerts/index.ts @@ -30,7 +30,7 @@ export class AlertService { aggregationBuilders, alerts, }: { - aggregationBuilders: AggregationBuilder[]; + aggregationBuilders: Array>; alerts: AlertIdIndex[]; }): Promise { try { diff --git a/x-pack/plugins/cases/server/services/cases/index.test.ts b/x-pack/plugins/cases/server/services/cases/index.test.ts index 5666b102dda55..84c580c8800e3 100644 --- a/x-pack/plugins/cases/server/services/cases/index.test.ts +++ b/x-pack/plugins/cases/server/services/cases/index.test.ts @@ -20,6 +20,7 @@ import { SavedObject, SavedObjectReference, SavedObjectsCreateOptions, + SavedObjectsFindResponse, SavedObjectsFindResult, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, @@ -1134,4 +1135,71 @@ describe('CasesService', () => { }); }); }); + + describe('executeAggregations', () => { + const aggregationBuilders = [ + { + build: () => ({ + myAggregation: { avg: { field: 'avg-field' } }, + }), + getName: () => 'avg-test-builder', + formatResponse: () => {}, + }, + { + build: () => ({ + myAggregation: { min: { field: 'min-field' } }, + }), + getName: () => 'min-test-builder', + formatResponse: () => {}, + }, + ]; + + it('returns an aggregation correctly', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + page: 1, + per_page: 1, + aggregations: { myAggregation: { value: 0 } }, + } as SavedObjectsFindResponse); + + const res = await service.executeAggregations({ aggregationBuilders }); + expect(res).toEqual({ myAggregation: { value: 0 } }); + }); + + it('calls find correctly', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValue({ + saved_objects: [], + total: 0, + page: 1, + per_page: 1, + aggregations: { myAggregation: { value: 0 } }, + } as SavedObjectsFindResponse); + + await service.executeAggregations({ aggregationBuilders, options: { perPage: 20 } }); + expect(unsecuredSavedObjectsClient.find.mock.calls[0][0]).toMatchInlineSnapshot(` + Object { + "aggs": Object { + "myAggregation": Object { + "min": Object { + "field": "min-field", + }, + }, + }, + "perPage": 20, + "sortField": "created_at", + "type": "cases", + } + `); + }); + + it('throws an error correctly', async () => { + expect.assertions(1); + unsecuredSavedObjectsClient.find.mockRejectedValue(new Error('Aggregation error')); + + await expect(service.executeAggregations({ aggregationBuilders })).rejects.toThrow( + 'Failed to execute aggregations [avg-test-builder,min-test-builder]: Error: Aggregation error' + ); + }); + }); }); diff --git a/x-pack/plugins/cases/server/services/cases/index.ts b/x-pack/plugins/cases/server/services/cases/index.ts index 26557d4ea7748..f75e52e63dca9 100644 --- a/x-pack/plugins/cases/server/services/cases/index.ts +++ b/x-pack/plugins/cases/server/services/cases/index.ts @@ -16,6 +16,7 @@ import { SavedObjectsBulkUpdateResponse, SavedObjectsUpdateResponse, SavedObjectsResolveResponse, + SavedObjectsFindOptions, } from '@kbn/core/server'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; @@ -52,6 +53,8 @@ import { } from './transform'; import { ESCaseAttributes } from './types'; import { AttachmentService } from '../attachments'; +import { AggregationBuilder, AggregationResponse } from '../../client/metrics/types'; +import { createCaseError } from '../../common/error'; interface GetCaseIdsByAlertIdArgs { alertId: string; @@ -626,4 +629,38 @@ export class CasesService { throw error; } } + + public async executeAggregations({ + aggregationBuilders, + options, + }: { + aggregationBuilders: Array>; + options?: Omit; + }): Promise { + try { + const builtAggs = aggregationBuilders.reduce((acc, agg) => { + return { ...acc, ...agg.build() }; + }, {}); + + const res = await this.unsecuredSavedObjectsClient.find< + ESCaseAttributes, + AggregationResponse + >({ + sortField: defaultSortField, + ...options, + aggs: builtAggs, + type: CASE_SAVED_OBJECT, + }); + + return res.aggregations; + } catch (error) { + const aggregationNames = aggregationBuilders.map((agg) => agg.getName()); + + throw createCaseError({ + message: `Failed to execute aggregations [${aggregationNames.join(',')}]: ${error}`, + error, + logger: this.log, + }); + } + } } diff --git a/x-pack/plugins/cases/server/services/mocks.ts b/x-pack/plugins/cases/server/services/mocks.ts index a9f3d427bba65..acd19506277c1 100644 --- a/x-pack/plugins/cases/server/services/mocks.ts +++ b/x-pack/plugins/cases/server/services/mocks.ts @@ -39,6 +39,7 @@ export const createCaseServiceMock = (): CaseServiceMock => { patchCases: jest.fn(), findCasesGroupedByID: jest.fn(), getCaseStatusStats: jest.fn(), + executeAggregations: jest.fn(), }; // the cast here is required because jest.Mocked tries to include private members and would throw an error diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts index 8b1e49762c87d..e4afcd3306576 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_navigate_findings.ts @@ -7,7 +7,7 @@ import { useHistory } from 'react-router-dom'; import { Query } from '@kbn/es-query'; -import { allNavigationItems } from '../navigation/constants'; +import { findingsNavigation } from '../navigation/constants'; import { encodeQuery } from '../navigation/query_utils'; import { FindingsBaseURLQuery } from '../../pages/findings/types'; @@ -37,7 +37,7 @@ export const useNavigateFindings = () => { return (query?: Query['query']) => { history.push({ - pathname: allNavigationItems.findings.path, + pathname: findingsNavigation.findings_default.path, ...(query && { search: encodeQuery(getFindingsQuery(query)) }), }); }; diff --git a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts index 69055de1a78af..2eea943f757cf 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/hooks/use_url_query.ts @@ -6,6 +6,7 @@ */ import { useEffect, useCallback, useMemo } from 'react'; import { useHistory, useLocation } from 'react-router-dom'; +import type { RisonObject } from 'rison-node'; import { decodeQuery, encodeQuery } from '../navigation/query_utils'; /** @@ -14,7 +15,7 @@ import { decodeQuery, encodeQuery } from '../navigation/query_utils'; * @note shallow-merges default, current and next query */ export const useUrlQuery = (getDefaultQuery: () => T) => { - const { push } = useHistory(); + const { push, replace } = useHistory(); const { search, key } = useLocation(); const urlQuery = useMemo( @@ -35,8 +36,8 @@ export const useUrlQuery = (getDefaultQuery: () => T) => { // TODO: condition should be if decoding failed if (search) return; - setUrlQuery(getDefaultQuery()); - }, [getDefaultQuery, search, setUrlQuery]); + replace({ search: encodeQuery(getDefaultQuery() as RisonObject) }); + }, [getDefaultQuery, search, replace]); return { key, diff --git a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index 1132b7a348b5d..b9089b4b5a58d 100644 --- a/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts +++ b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts @@ -24,3 +24,9 @@ export const allNavigationItems: Record = { disabled: !INTERNAL_FEATURE_FLAGS.showBenchmarks, }, }; + +export const findingsNavigation = { + findings_default: { name: TEXT.FINDINGS, path: '/findings/default' }, + findings_by_resource: { name: TEXT.FINDINGS, path: '/findings/resource' }, + resource_findings: { name: TEXT.FINDINGS, path: '/findings/resource/:resourceId' }, +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx index 29d68bc4c6258..4e7b582f00e31 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.test.tsx @@ -40,7 +40,7 @@ const Wrapper = ({ ); -describe('', () => { +describe.skip('', () => { it("renders the success state component when 'latest findings' DataView exists and request status is 'success'", async () => { const data = dataPluginMock.createStartContract(); const unifiedSearch = unifiedSearchPluginMock.createStartContract(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx index da3c1b32fd217..4fa5c33903477 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings.tsx @@ -5,19 +5,47 @@ * 2.0. */ import React from 'react'; +import { Redirect, Switch, Route, useLocation } from 'react-router-dom'; import { useLatestFindingsDataView } from '../../common/api/use_latest_findings_data_view'; -import { allNavigationItems } from '../../common/navigation/constants'; -import { useCspBreadcrumbs } from '../../common/navigation/use_csp_breadcrumbs'; -import { FindingsContainer } from './findings_container'; +import { allNavigationItems, findingsNavigation } from '../../common/navigation/constants'; import { CspPageTemplate } from '../../components/csp_page_template'; +import { FindingsByResourceContainer } from './latest_findings_by_resource/findings_by_resource_container'; +import { LatestFindingsContainer } from './latest_findings/latest_findings_container'; export const Findings = () => { + const location = useLocation(); const dataViewQuery = useLatestFindingsDataView(); - useCspBreadcrumbs([allNavigationItems.findings]); + + if (!dataViewQuery.data) return ; return ( - {dataViewQuery.data && } + + ( + + )} + /> + } + /> + } + /> + } + /> + ); }; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx deleted file mode 100644 index 213592d50e069..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/* - * 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, { useMemo } from 'react'; -import { EuiComboBoxOptionOption, EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; -import { css } from '@emotion/react'; -import { FormattedMessage } from '@kbn/i18n-react'; -import { i18n } from '@kbn/i18n'; -import type { DataView } from '@kbn/data-plugin/common'; -import { SortDirection } from '@kbn/data-plugin/common'; -import { buildEsQuery } from '@kbn/es-query'; -import { FindingsTable } from './findings_table'; -import { FindingsSearchBar } from './findings_search_bar'; -import * as TEST_SUBJECTS from './test_subjects'; -import { useUrlQuery } from '../../common/hooks/use_url_query'; -import { useFindings } from './use_findings'; -import type { FindingsGroupByNoneQuery } from './use_findings'; -import type { FindingsBaseURLQuery } from './types'; -import { useFindingsByResource } from './use_findings_by_resource'; -import { FindingsGroupBySelector } from './findings_group_by_selector'; -import { INTERNAL_FEATURE_FLAGS } from '../../../common/constants'; -import { useFindingsCounter } from './use_findings_count'; -import { FindingsDistributionBar } from './findings_distribution_bar'; -import { FindingsByResourceTable } from './findings_by_resource_table'; - -// TODO: define this as a schema with default values -export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({ - query: { language: 'kuery', query: '' }, - filters: [], - sort: [{ ['@timestamp']: SortDirection.desc }], - from: 0, - size: 10, - groupBy: 'none', -}); - -const getGroupByOptions = (): Array> => [ - { - value: 'none', - label: i18n.translate('xpack.csp.findings.groupBySelector.groupByNoneLabel', { - defaultMessage: 'None', - }), - }, - { - value: 'resource', - label: i18n.translate('xpack.csp.findings.groupBySelector.groupByResourceIdLabel', { - defaultMessage: 'Resource', - }), - }, -]; - -export const FindingsContainer = ({ dataView }: { dataView: DataView }) => { - const { euiTheme } = useEuiTheme(); - const groupByOptions = useMemo(getGroupByOptions, []); - const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); - - const baseEsQuery = useMemo( - () => ({ - index: dataView.title, - // TODO: this will throw for malformed query - // page will display an error boundary with the JS error - // will be accounted for before releasing the feature - query: buildEsQuery(dataView, urlQuery.query, urlQuery.filters), - }), - [dataView, urlQuery] - ); - - const findingsGroupByResource = useFindingsByResource({ - ...baseEsQuery, - enabled: urlQuery.groupBy === 'resource', - }); - - const findingsCount = useFindingsCounter({ - ...baseEsQuery, - enabled: urlQuery.groupBy === 'none', - }); - - const findingsGroupByNone = useFindings({ - ...baseEsQuery, - enabled: urlQuery.groupBy === 'none', - size: urlQuery.size, - from: urlQuery.from, - sort: urlQuery.sort, - }); - - return ( -
- -
- - - {INTERNAL_FEATURE_FLAGS.showFindingsGroupBy && ( - setUrlQuery({ groupBy: type[0]?.value })} - options={groupByOptions} - /> - )} - - {urlQuery.groupBy === 'none' && ( - <> - - - - - )} - {urlQuery.groupBy === 'resource' && ( - <> - - - )} -
-
- ); -}; - -const PageTitle = () => ( - -

- -

-
-); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_group_by_selector.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_group_by_selector.tsx deleted file mode 100644 index 84adf919a5fe1..0000000000000 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_group_by_selector.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { EuiComboBox, EuiFormLabel, type EuiComboBoxOptionOption } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; -import type { FindingsGroupByKind } from './types'; - -interface Props { - type: FindingsGroupByKind; - options: Array>; - onChange(selectedOptions: Array>): void; -} - -export const FindingsGroupBySelector = ({ type, options, onChange }: Props) => ( - } - singleSelection={{ asPlainText: true }} - options={options} - selectedOptions={options.filter((o) => o.value === type)} - onChange={onChange} - /> -); - -const GroupByLabel = () => ( - - - -); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx similarity index 75% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.test.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx index 132a64976a041..ae980c1e492bb 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_container.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.test.tsx @@ -6,21 +6,21 @@ */ import React from 'react'; import { render } from '@testing-library/react'; -import { FindingsContainer, getDefaultQuery } from './findings_container'; +import { LatestFindingsContainer, getDefaultQuery } from './latest_findings_container'; import { createStubDataView } from '@kbn/data-views-plugin/common/mocks'; -import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../common/constants'; +import { CSP_LATEST_FINDINGS_DATA_VIEW } from '../../../../common/constants'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { TestProvider } from '../../test/test_provider'; -import { getFindingsQuery } from './use_findings'; -import { encodeQuery } from '../../common/navigation/query_utils'; +import { TestProvider } from '../../../test/test_provider'; +import { getFindingsQuery } from './use_latest_findings'; +import { encodeQuery } from '../../../common/navigation/query_utils'; import { useLocation } from 'react-router-dom'; import { RisonObject } from 'rison-node'; import { buildEsQuery } from '@kbn/es-query'; -import { getFindingsCountAggQuery } from './use_findings_count'; +import { getFindingsCountAggQuery } from '../use_findings_count'; -jest.mock('../../common/api/use_latest_findings_data_view'); -jest.mock('../../common/api/use_cis_kubernetes_integration'); +jest.mock('../../../common/api/use_latest_findings_data_view'); +jest.mock('../../../common/api/use_cis_kubernetes_integration'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -32,7 +32,7 @@ beforeEach(() => { jest.restoreAllMocks(); }); -describe('', () => { +describe('', () => { it('data#search.search fn called with URL query', () => { const query = getDefaultQuery(); const dataMock = dataPluginMock.createStartContract(); @@ -53,7 +53,7 @@ describe('', () => { unifiedSearch: unifiedSearchPluginMock.createStartContract(), }} > - + ); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx new file mode 100644 index 0000000000000..78a1fd758b6ee --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx @@ -0,0 +1,87 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiSpacer } from '@elastic/eui'; +import type { DataView } from '@kbn/data-plugin/common'; +import { SortDirection } from '@kbn/data-plugin/common'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { FindingsTable } from './latest_findings_table'; +import { FindingsSearchBar } from '../layout/findings_search_bar'; +import * as TEST_SUBJECTS from '../test_subjects'; +import { useUrlQuery } from '../../../common/hooks/use_url_query'; +import { useLatestFindings } from './use_latest_findings'; +import type { FindingsGroupByNoneQuery } from './use_latest_findings'; +import type { FindingsBaseURLQuery } from '../types'; +import { useFindingsCounter } from '../use_findings_count'; +import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; +import { getBaseQuery } from '../utils'; +import { PageWrapper, PageTitle, PageTitleText } from '../layout/findings_layout'; +import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; +import { useCspBreadcrumbs } from '../../../common/navigation/use_csp_breadcrumbs'; +import { findingsNavigation } from '../../../common/navigation/constants'; + +export const getDefaultQuery = (): FindingsBaseURLQuery & FindingsGroupByNoneQuery => ({ + query: { language: 'kuery', query: '' }, + filters: [], + sort: [{ ['@timestamp']: SortDirection.desc }], + from: 0, + size: 10, +}); + +export const LatestFindingsContainer = ({ dataView }: { dataView: DataView }) => { + useCspBreadcrumbs([findingsNavigation.findings_default]); + const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); + const baseEsQuery = useMemo( + () => getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }), + [dataView, urlQuery.filters, urlQuery.query] + ); + + const findingsCount = useFindingsCounter(baseEsQuery); + const findingsGroupByNone = useLatestFindings({ + ...baseEsQuery, + size: urlQuery.size, + from: urlQuery.from, + sort: urlQuery.sort, + }); + + return ( +
+ + + + + } + /> + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx similarity index 85% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx index b287a73469d51..d01af2fa96e94 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.test.tsx @@ -6,11 +6,12 @@ */ import React from 'react'; import { render, screen } from '@testing-library/react'; -import * as TEST_SUBJECTS from './test_subjects'; -import { FindingsTable } from './findings_table'; +import * as TEST_SUBJECTS from '../test_subjects'; +import { FindingsTable } from './latest_findings_table'; import type { PropsOf } from '@elastic/eui'; import Chance from 'chance'; -import type { CspFinding } from './types'; +import type { CspFinding } from '../types'; +import { TestProvider } from '../../../test/test_provider'; const chance = new Chance(); @@ -75,7 +76,11 @@ describe('', () => { setQuery: jest.fn, }; - render(); + render( + + + + ); expect(screen.getByTestId(TEST_SUBJECTS.FINDINGS_TABLE_ZERO_STATE)).toBeInTheDocument(); }); @@ -94,7 +99,11 @@ describe('', () => { setQuery: jest.fn, }; - render(); + render( + + + + ); data.forEach((item) => { expect(screen.getByText(item.rule.name)).toBeInTheDocument(); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx similarity index 93% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx index 60e521be7997f..26a5b3d0ffe79 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_table.tsx @@ -17,13 +17,13 @@ import { import moment from 'moment'; import { SortDirection } from '@kbn/data-plugin/common'; import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_table/table_types'; -import { extractErrorMessage } from '../../../common/utils/helpers'; -import * as TEST_SUBJECTS from './test_subjects'; -import * as TEXT from './translations'; -import type { CspFinding } from './types'; -import { CspEvaluationBadge } from '../../components/csp_evaluation_badge'; -import type { FindingsGroupByNoneQuery, CspFindingsResult } from './use_findings'; -import { FindingsRuleFlyout } from './findings_flyout/findings_flyout'; +import { extractErrorMessage } from '../../../../common/utils/helpers'; +import * as TEST_SUBJECTS from '../test_subjects'; +import * as TEXT from '../translations'; +import type { CspFinding } from '../types'; +import { CspEvaluationBadge } from '../../../components/csp_evaluation_badge'; +import type { FindingsGroupByNoneQuery, CspFindingsResult } from './use_latest_findings'; +import { FindingsRuleFlyout } from '../findings_flyout/findings_flyout'; interface BaseFindingsTableProps extends FindingsGroupByNoneQuery { setQuery(query: Partial): void; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts similarity index 80% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts index 1f7e8dae483bf..608f400953c86 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/use_latest_findings.ts @@ -10,16 +10,13 @@ import { lastValueFrom } from 'rxjs'; import type { EsQuerySortValue, IEsSearchResponse } from '@kbn/data-plugin/common'; import type { CoreStart } from '@kbn/core/public'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { extractErrorMessage } from '../../../common/utils/helpers'; -import * as TEXT from './translations'; -import type { CspFinding, FindingsQueryResult } from './types'; -import { useKibana } from '../../common/hooks/use_kibana'; -import type { FindingsBaseEsQuery, FindingsQueryStatus } from './types'; +import { extractErrorMessage } from '../../../../common/utils/helpers'; +import * as TEXT from '../translations'; +import type { CspFinding, FindingsQueryResult } from '../types'; +import { useKibana } from '../../../common/hooks/use_kibana'; +import type { FindingsBaseEsQuery } from '../types'; -interface UseFindingsOptions - extends FindingsBaseEsQuery, - FindingsGroupByNoneQuery, - FindingsQueryStatus {} +interface UseFindingsOptions extends FindingsBaseEsQuery, FindingsGroupByNoneQuery {} export interface FindingsGroupByNoneQuery { from: NonNullable; @@ -65,13 +62,7 @@ export const showErrorToast = ( else toasts.addDanger(extractErrorMessage(error, TEXT.SEARCH_FAILED)); }; -export const getFindingsQuery = ({ - index, - query, - size, - from, - sort, -}: Omit) => ({ +export const getFindingsQuery = ({ index, query, size, from, sort }: UseFindingsOptions) => ({ index, query, size, @@ -79,7 +70,7 @@ export const getFindingsQuery = ({ sort: mapEsQuerySortKey(sort), }); -export const useFindings = ({ enabled, index, query, sort, from, size }: UseFindingsOptions) => { +export const useLatestFindings = ({ index, query, sort, from, size }: UseFindingsOptions) => { const { data, notifications: { toasts }, @@ -94,7 +85,6 @@ export const useFindings = ({ enabled, index, query, sort, from, size }: UseFind }) ), { - enabled, select: ({ rawResponse: { hits } }) => ({ page: hits.hits.map((hit) => hit._source!), total: number.is(hits.total) ? hits.total : 0, diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx new file mode 100644 index 0000000000000..4b9ec040d4346 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_container.tsx @@ -0,0 +1,79 @@ +/* + * 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 type { DataView } from '@kbn/data-plugin/common'; +import { Route, Switch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { FindingsSearchBar } from '../layout/findings_search_bar'; +import * as TEST_SUBJECTS from '../test_subjects'; +import { useUrlQuery } from '../../../common/hooks/use_url_query'; +import type { FindingsBaseURLQuery } from '../types'; +import { useFindingsByResource } from './use_findings_by_resource'; +import { FindingsByResourceTable } from './findings_by_resource_table'; +import { getBaseQuery } from '../utils'; +import { PageTitle, PageTitleText, PageWrapper } from '../layout/findings_layout'; +import { FindingsGroupBySelector } from '../layout/findings_group_by_selector'; +import { findingsNavigation } from '../../../common/navigation/constants'; +import { useCspBreadcrumbs } from '../../../common/navigation/use_csp_breadcrumbs'; +import { ResourceFindings } from './resource_findings/resource_findings_container'; + +export const getDefaultQuery = (): FindingsBaseURLQuery => ({ + query: { language: 'kuery', query: '' }, + filters: [], +}); + +export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => ( + + } + /> + } + /> + +); + +const LatestFindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => { + useCspBreadcrumbs([findingsNavigation.findings_by_resource]); + const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); + const findingsGroupByResource = useFindingsByResource( + getBaseQuery({ dataView, filters: urlQuery.filters, query: urlQuery.query }) + ); + + return ( +
+ + + + + } + /> + + + + +
+ ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_by_resource_table.test.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx similarity index 85% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_by_resource_table.test.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx index 3018de31aeb58..f51be5f7a43e1 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_by_resource_table.test.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.test.tsx @@ -6,12 +6,13 @@ */ import React from 'react'; import { render, screen, within } from '@testing-library/react'; -import * as TEST_SUBJECTS from './test_subjects'; +import * as TEST_SUBJECTS from '../test_subjects'; import { FindingsByResourceTable, formatNumber, getResourceId } from './findings_by_resource_table'; -import * as TEXT from './translations'; +import * as TEXT from '../translations'; import type { PropsOf } from '@elastic/eui'; import Chance from 'chance'; import numeral from '@elastic/numeral'; +import { TestProvider } from '../../../test/test_provider'; const chance = new Chance(); @@ -35,7 +36,11 @@ describe('', () => { error: null, }; - render(); + render( + + + + ); expect(screen.getByText(TEXT.NO_FINDINGS)).toBeInTheDocument(); }); @@ -49,7 +54,11 @@ describe('', () => { error: null, }; - render(); + render( + + + + ); data.forEach((item, i) => { const row = screen.getByTestId( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_by_resource_table.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx similarity index 87% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_by_resource_table.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx index d2acee177686a..ef7b3da67fbb4 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_by_resource_table.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/findings_by_resource_table.tsx @@ -12,14 +12,15 @@ import { EuiTextColor, EuiFlexGroup, EuiFlexItem, - EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import numeral from '@elastic/numeral'; -import { extractErrorMessage } from '../../../common/utils/helpers'; -import * as TEST_SUBJECTS from './test_subjects'; -import * as TEXT from './translations'; +import { Link, generatePath } from 'react-router-dom'; +import { extractErrorMessage } from '../../../../common/utils/helpers'; +import * as TEST_SUBJECTS from '../test_subjects'; +import * as TEXT from '../translations'; import type { CspFindingsByResourceResult } from './use_findings_by_resource'; +import { findingsNavigation } from '../../../common/navigation/constants'; export const formatNumber = (value: number) => value < 1000 ? value : numeral(value).format('0.0a'); @@ -62,7 +63,11 @@ const columns: Array> = [ defaultMessage="Resource ID" /> ), - render: (resourceId: CspFindingsByResource['resource_id']) => {resourceId}, + render: (resourceId: CspFindingsByResource['resource_id']) => ( + + {resourceId} + + ), }, { field: 'cis_section', diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx new file mode 100644 index 0000000000000..e693ea02cb13a --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/resource_findings/resource_findings_container.tsx @@ -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 React from 'react'; +import { EuiSpacer, EuiButtonEmpty } from '@elastic/eui'; +import type { DataView } from '@kbn/data-plugin/common'; +import { Link, useParams } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useEuiTheme } from '@elastic/eui'; +import { generatePath } from 'react-router-dom'; +import * as TEST_SUBJECTS from '../../test_subjects'; +import { PageWrapper, PageTitle, PageTitleText } from '../../layout/findings_layout'; +import { useCspBreadcrumbs } from '../../../../common/navigation/use_csp_breadcrumbs'; +import { findingsNavigation } from '../../../../common/navigation/constants'; + +const BackToResourcesButton = () => { + return ( + + + + + + ); +}; + +export const ResourceFindings = ({ dataView }: { dataView: DataView }) => { + useCspBreadcrumbs([findingsNavigation.findings_default]); + const { euiTheme } = useEuiTheme(); + const params = useParams<{ resourceId: string }>(); + + return ( +
+ + + + + +
+ } + /> + + + + + ); +}; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_by_resource.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts similarity index 84% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_by_resource.ts rename to x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts index 36d7ef7b32c94..6fec85531b196 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_by_resource.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings_by_resource/use_findings_by_resource.ts @@ -8,11 +8,9 @@ import { useQuery } from 'react-query'; import { lastValueFrom } from 'rxjs'; import { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/common'; import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; -import { useKibana } from '../../common/hooks/use_kibana'; -import { showErrorToast } from './use_findings'; -import type { FindingsBaseEsQuery, FindingsQueryResult, FindingsQueryStatus } from './types'; - -interface UseFindingsByResourceOptions extends FindingsBaseEsQuery, FindingsQueryStatus {} +import { useKibana } from '../../../common/hooks/use_kibana'; +import { showErrorToast } from '../latest_findings/use_latest_findings'; +import type { FindingsBaseEsQuery, FindingsQueryResult } from '../types'; type FindingsAggRequest = IKibanaSearchRequest; type FindingsAggResponse = IKibanaSearchResponse< @@ -43,7 +41,7 @@ interface FindingsAggBucket { export const getFindingsByResourceAggQuery = ({ index, query, -}: Omit): estypes.SearchRequest => ({ +}: FindingsBaseEsQuery): estypes.SearchRequest => ({ index, size: 0, body: { @@ -68,7 +66,7 @@ export const getFindingsByResourceAggQuery = ({ }, }); -export const useFindingsByResource = ({ enabled, index, query }: UseFindingsByResourceOptions) => { +export const useFindingsByResource = ({ index, query }: FindingsBaseEsQuery) => { const { data, notifications: { toasts }, @@ -83,7 +81,6 @@ export const useFindingsByResource = ({ enabled, index, query }: UseFindingsByRe }) ), { - enabled, select: ({ rawResponse }) => ({ page: rawResponse.aggregations?.groupBy.buckets.map(createFindingsByResource) || [], }), diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_distribution_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx similarity index 100% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_distribution_bar.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_distribution_bar.tsx diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_group_by_selector.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_group_by_selector.tsx new file mode 100644 index 0000000000000..efbda89eb3e4d --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_group_by_selector.tsx @@ -0,0 +1,77 @@ +/* + * 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, { useMemo } from 'react'; +import { EuiComboBox, EuiFormLabel, EuiSpacer, type EuiComboBoxOptionOption } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useHistory } from 'react-router-dom'; +import { i18n } from '@kbn/i18n'; +import { INTERNAL_FEATURE_FLAGS } from '../../../../common/constants'; +import type { FindingsGroupByKind } from '../types'; +import { findingsNavigation } from '../../../common/navigation/constants'; + +const getGroupByOptions = (): Array> => [ + { + value: 'default', + label: i18n.translate('xpack.csp.findings.groupBySelector.groupByNoneLabel', { + defaultMessage: 'None', + }), + }, + { + value: 'resource', + label: i18n.translate('xpack.csp.findings.groupBySelector.groupByResourceIdLabel', { + defaultMessage: 'Resource', + }), + }, +]; + +interface Props { + type: FindingsGroupByKind; +} + +const getFindingsGroupPath = (opts: Array>) => { + const [firstOption] = opts; + + switch (firstOption?.value) { + case 'resource': + return findingsNavigation.findings_by_resource.path; + case 'default': + default: + return findingsNavigation.findings_default.path; + } +}; + +export const FindingsGroupBySelector = ({ type }: Props) => { + const groupByOptions = useMemo(getGroupByOptions, []); + const history = useHistory(); + + if (!INTERNAL_FEATURE_FLAGS.showFindingsGroupBy) return null; + + const onChange = (options: Array>) => + history.push({ pathname: getFindingsGroupPath(options) }); + + return ( +
+ } + singleSelection={{ asPlainText: true }} + options={groupByOptions} + selectedOptions={groupByOptions.filter((o) => o.value === type)} + onChange={onChange} + /> + +
+ ); +}; + +const GroupByLabel = () => ( + + + +); diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx new file mode 100644 index 0000000000000..337e1237d9287 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx @@ -0,0 +1,33 @@ +/* + * 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 { EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; +import { css } from '@emotion/react'; + +export const PageWrapper: React.FC = ({ children }) => { + const { euiTheme } = useEuiTheme(); + return ( +
+ {children} +
+ ); +}; + +export const PageTitle: React.FC = ({ children }) => ( + +
+ {children} + +
+
+); + +export const PageTitleText = ({ title }: { title: React.ReactNode }) =>

{title}

; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx similarity index 83% rename from x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx rename to x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx index 89673dabbf91f..ba8309ea57261 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/findings_search_bar.tsx +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_search_bar.tsx @@ -9,18 +9,17 @@ import { css } from '@emotion/react'; import { EuiThemeComputed, useEuiTheme } from '@elastic/eui'; import { useKibana } from '@kbn/kibana-react-plugin/public'; import type { DataView } from '@kbn/data-plugin/common'; -import * as TEST_SUBJECTS from './test_subjects'; -import type { CspFindingsResult } from './use_findings'; -import type { FindingsBaseURLQuery } from './types'; -import type { CspClientPluginStartDeps } from '../../types'; -import { PLUGIN_NAME } from '../../../common'; -import { FINDINGS_SEARCH_PLACEHOLDER } from './translations'; +import * as TEST_SUBJECTS from '../test_subjects'; +import type { FindingsBaseURLQuery } from '../types'; +import type { CspClientPluginStartDeps } from '../../../types'; +import { PLUGIN_NAME } from '../../../../common'; +import { FINDINGS_SEARCH_PLACEHOLDER } from '../translations'; type SearchBarQueryProps = Pick; interface FindingsSearchBarProps extends SearchBarQueryProps { setQuery(v: Partial): void; - loading: CspFindingsResult['loading']; + loading: boolean; } export const FindingsSearchBar = ({ diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts index 158bbefc422ef..9fed484a88128 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/types.ts @@ -7,10 +7,9 @@ import type { BoolQuery, Filter, Query } from '@kbn/es-query'; import { UseQueryResult } from 'react-query'; -export type FindingsGroupByKind = 'none' | 'resource'; +export type FindingsGroupByKind = 'default' | 'resource'; export interface FindingsBaseURLQuery { - groupBy: FindingsGroupByKind; query: Query; filters: Filter[]; } @@ -22,10 +21,6 @@ export interface FindingsBaseEsQuery { }; } -export interface FindingsQueryStatus { - enabled: boolean; -} - export interface FindingsQueryResult { loading: UseQueryResult['isLoading']; error: TError; diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts index 6ed56ea1d4397..f48e630b489d4 100644 --- a/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/use_findings_count.ts @@ -9,10 +9,8 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; import { lastValueFrom } from 'rxjs'; import type { IKibanaSearchRequest, IKibanaSearchResponse } from '@kbn/data-plugin/public'; import { useKibana } from '../../common/hooks/use_kibana'; -import { showErrorToast } from './use_findings'; -import type { FindingsBaseEsQuery, FindingsQueryStatus } from './types'; - -interface UseFindingsCountOptions extends FindingsBaseEsQuery, FindingsQueryStatus {} +import { showErrorToast } from './latest_findings/use_latest_findings'; +import type { FindingsBaseEsQuery } from './types'; type FindingsAggRequest = IKibanaSearchRequest; type FindingsAggResponse = IKibanaSearchResponse>; @@ -35,7 +33,7 @@ export const getFindingsCountAggQuery = ({ index, query }: FindingsBaseEsQuery) }, }); -export const useFindingsCounter = ({ enabled, index, query }: UseFindingsCountOptions) => { +export const useFindingsCounter = ({ index, query }: FindingsBaseEsQuery) => { const { data, notifications: { toasts }, @@ -50,7 +48,6 @@ export const useFindingsCounter = ({ enabled, index, query }: UseFindingsCountOp }) ), { - enabled, onError: (err) => showErrorToast(toasts, err), select: (response) => Object.fromEntries( diff --git a/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts b/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts new file mode 100644 index 0000000000000..d3281a1a0dbc8 --- /dev/null +++ b/x-pack/plugins/cloud_security_posture/public/pages/findings/utils.ts @@ -0,0 +1,22 @@ +/* + * 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 { buildEsQuery } from '@kbn/es-query'; +import type { DataView } from '@kbn/data-plugin/common'; +import type { FindingsBaseEsQuery, FindingsBaseURLQuery } from './types'; + +export const getBaseQuery = ({ + dataView, + query, + filters, +}: FindingsBaseURLQuery & { dataView: DataView }): FindingsBaseEsQuery => ({ + index: dataView.title, + // TODO: this will throw for malformed query + // page will display an error boundary with the JS error + // will be accounted for before releasing the feature + query: buildEsQuery(dataView, query, filters), +}); diff --git a/x-pack/plugins/lens/common/embeddable_factory/index.ts b/x-pack/plugins/lens/common/embeddable_factory/index.ts index c4b03ae280778..8ddddf654b017 100644 --- a/x-pack/plugins/lens/common/embeddable_factory/index.ts +++ b/x-pack/plugins/lens/common/embeddable_factory/index.ts @@ -7,9 +7,10 @@ import { SerializableRecord, Serializable } from '@kbn/utility-types'; import { SavedObjectReference } from '@kbn/core/types'; -import { EmbeddableStateWithType } from '@kbn/embeddable-plugin/common'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; +import type { + EmbeddableStateWithType, + EmbeddableRegistryDefinition, +} from '@kbn/embeddable-plugin/common'; export type LensEmbeddablePersistableState = EmbeddableStateWithType & { attributes: SerializableRecord; diff --git a/x-pack/plugins/lens/common/expressions/counter_rate/counter_rate.test.ts b/x-pack/plugins/lens/common/expressions/counter_rate/counter_rate.test.ts index 32a37e0cf949e..9f19b5d052c68 100644 --- a/x-pack/plugins/lens/common/expressions/counter_rate/counter_rate.test.ts +++ b/x-pack/plugins/lens/common/expressions/counter_rate/counter_rate.test.ts @@ -7,8 +7,7 @@ import { counterRate, CounterRateArgs } from '.'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Datatable } from '@kbn/expressions-plugin/public'; +import { Datatable } from '@kbn/expressions-plugin/common'; import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils'; describe('lens_counter_rate', () => { diff --git a/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts b/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts index 63e32ffbf1df6..d0db49f4afaae 100644 --- a/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts +++ b/x-pack/plugins/lens/common/expressions/format_column/format_column.test.ts @@ -5,8 +5,7 @@ * 2.0. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/public'; +import type { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils'; import { formatColumn } from '.'; diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts index 4c8a3bf9aa310..4558bdfe68661 100644 --- a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts +++ b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts @@ -7,8 +7,7 @@ import moment from 'moment'; import { mergeTables } from '.'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { ExpressionValueSearchContext } from '@kbn/data-plugin/public'; +import type { ExpressionValueSearchContext } from '@kbn/data-plugin/common'; import { Datatable, ExecutionContext, diff --git a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts index dd3e18c720c0a..e7fdd720d075c 100644 --- a/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts +++ b/x-pack/plugins/lens/common/expressions/time_scale/time_scale.test.ts @@ -6,10 +6,8 @@ */ import moment from 'moment'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { Datatable } from '@kbn/expressions-plugin/public'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import type { TimeRange } from '@kbn/data-plugin/public'; +import type { Datatable } from '@kbn/expressions-plugin/common'; +import type { TimeRange } from '@kbn/data-plugin/common'; import { functionWrapper } from '@kbn/expressions-plugin/common/expression_functions/specs/tests/utils'; // mock the specific inner variable: diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index fe26e635542aa..52f5902b90367 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -529,6 +529,7 @@ export class Embeddable interactive={!input.disableTriggers} renderMode={input.renderMode} syncColors={input.syncColors} + syncTooltips={input.syncTooltips} hasCompatibleActions={this.hasCompatibleActions} className={input.className} style={input.style} diff --git a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx index c2b9d1d2dbb31..27094d154efd2 100644 --- a/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/embeddable/expression_wrapper.tsx @@ -38,6 +38,7 @@ export interface ExpressionWrapperProps { onRender$: () => void; renderMode?: RenderMode; syncColors?: boolean; + syncTooltips?: boolean; hasCompatibleActions?: ReactExpressionRendererProps['hasCompatibleActions']; style?: React.CSSProperties; className?: string; @@ -110,6 +111,7 @@ export function ExpressionWrapper({ onRender$, renderMode, syncColors, + syncTooltips, hasCompatibleActions, style, className, @@ -138,6 +140,7 @@ export function ExpressionWrapper({ inspectorAdapters={lensInspector.adapters} renderMode={renderMode} syncColors={syncColors} + syncTooltips={syncTooltips} executionContext={executionContext} renderError={(errorMessage, error) => { onRuntimeError(); diff --git a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index f5fa4b27447ac..f76381406b132 100644 --- a/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts +++ b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts @@ -13,9 +13,7 @@ import { SavedObjectReference, SavedObjectUnsanitizedDoc, } from '@kbn/core/server'; -import { Filter } from '@kbn/es-query'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Query } from '@kbn/data-plugin/public'; +import type { Query, Filter } from '@kbn/es-query'; import { mergeSavedObjectMigrationMaps } from '@kbn/core/server'; import { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import { PersistableFilter } from '../../common'; diff --git a/x-pack/plugins/lens/server/migrations/types.ts b/x-pack/plugins/lens/server/migrations/types.ts index 9b66f7023cdeb..5bdc332668621 100644 --- a/x-pack/plugins/lens/server/migrations/types.ts +++ b/x-pack/plugins/lens/server/migrations/types.ts @@ -6,9 +6,7 @@ */ import type { PaletteOutput, CustomPaletteParams } from '@kbn/coloring'; -import { Filter } from '@kbn/es-query'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { Query } from '@kbn/data-plugin/public'; +import type { Query, Filter } from '@kbn/es-query'; import type { MigrateFunctionsObject } from '@kbn/kibana-utils-plugin/common'; import type { LayerType, PersistableFilter } from '../../common'; diff --git a/x-pack/plugins/maps/common/embeddable/extract.ts b/x-pack/plugins/maps/common/embeddable/extract.ts index e73b1566c0289..d329aefe7cff6 100644 --- a/x-pack/plugins/maps/common/embeddable/extract.ts +++ b/x-pack/plugins/maps/common/embeddable/extract.ts @@ -5,8 +5,7 @@ * 2.0. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; +import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; import { MapEmbeddablePersistableState } from './types'; import { MapSavedObjectAttributes } from '../map_saved_object_type'; import { extractReferences } from '../migrations/references'; diff --git a/x-pack/plugins/maps/common/embeddable/inject.ts b/x-pack/plugins/maps/common/embeddable/inject.ts index 2e1892b95a0f1..4bb26dd00d28d 100644 --- a/x-pack/plugins/maps/common/embeddable/inject.ts +++ b/x-pack/plugins/maps/common/embeddable/inject.ts @@ -5,10 +5,9 @@ * 2.0. */ -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/server'; -import { MapEmbeddablePersistableState } from './types'; -import { MapSavedObjectAttributes } from '../map_saved_object_type'; +import type { EmbeddableRegistryDefinition } from '@kbn/embeddable-plugin/common'; +import type { MapEmbeddablePersistableState } from './types'; +import type { MapSavedObjectAttributes } from '../map_saved_object_type'; import { extractReferences, injectReferences } from '../migrations/references'; export const inject: EmbeddableRegistryDefinition['inject'] = (state, references) => { diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/action_bar/action_bar.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/action_bar/action_bar.tsx index 3bd6be5eb9750..80e614eb4d77f 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/action_bar/action_bar.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/action_bar/action_bar.tsx @@ -65,7 +65,7 @@ export const ActionBar = ({ } return setMonitor({ monitor, - id: monitorId ? Buffer.from(monitorId, 'base64').toString('utf8') : undefined, + id: monitorId, }); }, [monitor, monitorId, isValid, isSaving]); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.test.tsx index ccfc1312cbf25..04879cd0da65b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.test.tsx @@ -9,7 +9,16 @@ import { defaultCore, WrappedHelper } from '../../../lib/helper/rtl_helpers'; import { renderHook } from '@testing-library/react-hooks'; import { useMonitorName } from './use_monitor_name'; -import * as hooks from '../../../hooks/use_monitor'; +import * as reactRouter from 'react-router-dom'; + +const mockRouter = { + ...reactRouter, +}; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn().mockReturnValue({}), +})); describe('useMonitorName', () => { it('returns expected results', () => { @@ -56,7 +65,7 @@ describe('useMonitorName', () => { }, }); - jest.spyOn(hooks, 'useMonitorId').mockReturnValue('test-id'); + jest.spyOn(mockRouter, 'useParams').mockReturnValue({ monitorId: 'test-id' }); const { result, waitForNextUpdate } = renderHook(() => useMonitorName({ search: 'Test' }), { wrapper: WrappedHelper, diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.ts b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.ts index c72c266bb6939..e8d3848856a2b 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.ts +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_config/use_monitor_name.ts @@ -8,8 +8,8 @@ import { useEffect, useState } from 'react'; import { useFetcher } from '@kbn/observability-plugin/public'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useParams } from 'react-router-dom'; import { syntheticsMonitorType } from '../../../../../common/types/saved_objects'; -import { useMonitorId } from '../../../hooks'; interface AggsResponse { monitorNames: { @@ -22,7 +22,7 @@ interface AggsResponse { export const useMonitorName = ({ search = '' }: { search?: string }) => { const [values, setValues] = useState([]); - const monitorId = useMonitorId(); + const { monitorId } = useParams<{ monitorId: string }>(); const { savedObjects } = useKibana().services; diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx index f60d54e9cb4f6..af2e54503d0b1 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.test.tsx @@ -19,7 +19,7 @@ describe('', () => { expect(screen.getByLabelText('Edit monitor')).toHaveAttribute( 'href', - '/app/uptime/edit-monitor/dGVzdC1pZA==' + '/app/uptime/edit-monitor/test-id' ); }); }); diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx index a6fa489be89ca..ddd0d0cc0a63e 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/actions.tsx @@ -45,7 +45,7 @@ export const Actions = ({ id, name, onUpdate, isDisabled, errorSummaries, monito diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx index 37a23bdcff83b..fbd21fe09c1ec 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/components/monitor_management/monitor_list/monitor_list.tsx @@ -120,11 +120,7 @@ export const MonitorManagementList = ({ }), sortable: true, render: (name: string, { id }: EncryptedSyntheticsMonitorWithId) => ( - - {name} - + {name} ), }, { diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/pages/monitor_management/edit_monitor.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/pages/monitor_management/edit_monitor.tsx index cb92de595d378..d0397de8be960 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/pages/monitor_management/edit_monitor.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/pages/monitor_management/edit_monitor.tsx @@ -31,7 +31,7 @@ export const EditMonitorPage: React.FC = () => { const { data, status } = useFetcher< Promise >(() => { - return getMonitor({ id: Buffer.from(monitorId, 'base64').toString('utf8') }); + return getMonitor({ id: monitorId }); }, [monitorId]); const monitor = data?.attributes as MonitorFields; diff --git a/x-pack/test/cases_api_integration/common/lib/utils.ts b/x-pack/test/cases_api_integration/common/lib/utils.ts index ec0f9074df099..bb8f31abefd47 100644 --- a/x-pack/test/cases_api_integration/common/lib/utils.ts +++ b/x-pack/test/cases_api_integration/common/lib/utils.ts @@ -48,9 +48,10 @@ import { ConnectorMappings, CasesByAlertId, CaseResolveResponse, - CaseMetricsResponse, + SingleCaseMetricsResponse, BulkCreateCommentRequest, CommentType, + CasesMetricsResponse, } from '@kbn/cases-plugin/common/api'; import { getCaseUserActionUrl } from '@kbn/cases-plugin/common/api/helpers'; import { SignalHit } from '@kbn/security-solution-plugin/server/lib/detection_engine/signals/types'; @@ -1012,7 +1013,7 @@ export const getCaseMetrics = async ({ features: string[]; expectedHttpCode?: number; auth?: { user: User; space: string | null }; -}): Promise => { +}): Promise => { const { body: metricsResponse } = await supertest .get(`${getSpaceUrlPrefix(auth?.space)}${CASES_URL}/metrics/${caseId}`) .query({ features: JSON.stringify(features) }) @@ -1267,3 +1268,25 @@ export const calculateDuration = (closedAt: string | null, createdAt: string | n return Math.floor(Math.abs((closedAtMillis - createdAtMillis) / 1000)); }; + +export const getCasesMetrics = async ({ + supertest, + features, + query = {}, + expectedHttpCode = 200, + auth = { user: superUser, space: null }, +}: { + supertest: SuperTest.SuperTest; + features: string[]; + query?: Record; + expectedHttpCode?: number; + auth?: { user: User; space: string | null }; +}): Promise => { + const { body: metricsResponse } = await supertest + .get(`${getSpaceUrlPrefix(auth?.space)}${CASES_URL}/metrics`) + .query({ features: JSON.stringify(features), ...query }) + .auth(auth.user.username, auth.user.password) + .expect(expectedHttpCode); + + return metricsResponse; +}; diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts index 25f39164f7c28..93bb948265ba0 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/index.ts @@ -37,6 +37,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./metrics/get_case_metrics_alerts')); loadTestFile(require.resolve('./metrics/get_case_metrics_actions')); loadTestFile(require.resolve('./metrics/get_case_metrics_connectors')); + loadTestFile(require.resolve('./metrics/get_cases_metrics')); /** * Internal routes diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_cases_metrics.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_cases_metrics.ts new file mode 100644 index 0000000000000..c1abfada39dd2 --- /dev/null +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/metrics/get_cases_metrics.ts @@ -0,0 +1,233 @@ +/* + * 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 expect from '@kbn/expect'; +import { CaseStatuses } from '@kbn/cases-plugin/common/api'; +import { + secOnly, + obsOnlyRead, + secOnlyRead, + noKibanaPrivileges, + superUser, + globalRead, + obsSecRead, + obsSec, +} from '../../../../common/lib/authentication/users'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + createCase, + deleteAllCaseItems, + getCasesMetrics, + updateCase, +} from '../../../../common/lib/utils'; +import { getPostCaseRequest } from '../../../../common/lib/mock'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const kibanaServer = getService('kibanaServer'); + + describe('all cases metrics', () => { + describe('MTTR', () => { + it('responses with zero if there are no cases', async () => { + const metrics = await getCasesMetrics({ + supertest, + features: ['mttr'], + }); + + expect(metrics).to.eql({ mttr: 0 }); + }); + + it('responses with zero if there are only open case and in-progress cases', async () => { + await createCase(supertest, getPostCaseRequest()); + const theCase = await createCase(supertest, getPostCaseRequest()); + + await updateCase({ + supertest, + params: { + cases: [ + { + id: theCase.id, + version: theCase.version, + status: CaseStatuses['in-progress'], + }, + ], + }, + }); + + const metrics = await getCasesMetrics({ + supertest, + features: ['mttr'], + }); + + expect(metrics).to.eql({ mttr: 0 }); + }); + + describe('closed and open cases from kbn archive', () => { + before(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json' + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json' + ); + await deleteAllCaseItems(es); + }); + + it('should calculate the mttr correctly across all cases', async () => { + const metrics = await getCasesMetrics({ + supertest, + features: ['mttr'], + }); + + expect(metrics).to.eql({ mttr: 220 }); + }); + + it('should respects the range parameters', async () => { + const metrics = await getCasesMetrics({ + supertest, + features: ['mttr'], + query: { + from: '2022-04-28', + to: '2022-04-29', + }, + }); + + expect(metrics).to.eql({ mttr: 90 }); + }); + }); + }); + + describe('rbac', () => { + before(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json', + { space: 'space1' } + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json', + { space: 'space1' } + ); + await deleteAllCaseItems(es); + }); + + it('should calculate the mttr correctly only for the cases the user has access to', async () => { + for (const scenario of [ + { + user: globalRead, + expectedMetrics: { mttr: 220 }, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: superUser, + expectedMetrics: { mttr: 220 }, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + { + user: secOnlyRead, + expectedMetrics: { mttr: 250 }, + owners: ['securitySolutionFixture'], + }, + { user: obsOnlyRead, expectedMetrics: { mttr: 160 }, owners: ['observabilityFixture'] }, + { + user: obsSecRead, + expectedMetrics: { mttr: 220 }, + owners: ['securitySolutionFixture', 'observabilityFixture'], + }, + ]) { + const metrics = await getCasesMetrics({ + supertest: supertestWithoutAuth, + features: ['mttr'], + auth: { + user: scenario.user, + space: 'space1', + }, + }); + + expect(metrics).to.eql(scenario.expectedMetrics); + } + }); + + for (const scenario of [ + { user: noKibanaPrivileges, space: 'space1' }, + { user: secOnly, space: 'space2' }, + ]) { + it(`User ${scenario.user.username} with role(s) ${scenario.user.roles.join()} and space ${ + scenario.space + } - should NOT read a case`, async () => { + // user should not be able to read cases at the appropriate space + await getCasesMetrics({ + supertest: supertestWithoutAuth, + features: ['mttr'], + auth: { + user: scenario.user, + space: scenario.space, + }, + expectedHttpCode: 403, + }); + }); + } + + it('should respect the owner filter when having permissions', async () => { + const metrics = await getCasesMetrics({ + supertest: supertestWithoutAuth, + features: ['mttr'], + query: { + owner: 'securitySolutionFixture', + }, + auth: { + user: obsSec, + space: 'space1', + }, + }); + + expect(metrics).to.eql({ mttr: 250 }); + }); + + it('should return the correct cases when trying to exploit RBAC through the owner query parameter', async () => { + const metrics = await getCasesMetrics({ + supertest: supertestWithoutAuth, + features: ['mttr'], + query: { + owner: ['securitySolutionFixture', 'observabilityFixture'], + }, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + expect(metrics).to.eql({ mttr: 250 }); + }); + + it('should respect the owner filter when using range queries', async () => { + const metrics = await getCasesMetrics({ + supertest: supertestWithoutAuth, + features: ['mttr'], + query: { + from: '2022-04-20', + to: '2022-04-30', + }, + auth: { + user: secOnly, + space: 'space1', + }, + }); + + expect(metrics).to.eql({ mttr: 250 }); + }); + }); + }); +}; diff --git a/x-pack/test/cases_api_integration/spaces_only/tests/common/index.ts b/x-pack/test/cases_api_integration/spaces_only/tests/common/index.ts index 0b18a56bdcd11..a180d46d45edb 100644 --- a/x-pack/test/cases_api_integration/spaces_only/tests/common/index.ts +++ b/x-pack/test/cases_api_integration/spaces_only/tests/common/index.ts @@ -29,6 +29,8 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./configure/get_configure')); loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/post_configure')); + loadTestFile(require.resolve('./configure/post_configure')); + loadTestFile(require.resolve('./metrics/get_cases_metrics')); /** * Internal routes diff --git a/x-pack/test/cases_api_integration/spaces_only/tests/common/metrics/get_cases_metrics.ts b/x-pack/test/cases_api_integration/spaces_only/tests/common/metrics/get_cases_metrics.ts new file mode 100644 index 0000000000000..66fb3f4343e58 --- /dev/null +++ b/x-pack/test/cases_api_integration/spaces_only/tests/common/metrics/get_cases_metrics.ts @@ -0,0 +1,73 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { + deleteAllCaseItems, + getAuthWithSuperUser, + getCasesMetrics, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const es = getService('es'); + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + const authSpace1 = getAuthWithSuperUser(); + + describe('all cases metrics', () => { + before(async () => { + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json', + { space: 'space1' } + ); + + await kibanaServer.importExport.load( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json', + { space: 'space2' } + ); + }); + + after(async () => { + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json', + { space: 'space1' } + ); + + await kibanaServer.importExport.unload( + 'x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json', + { space: 'space2' } + ); + await deleteAllCaseItems(es); + }); + + describe('MTTR', () => { + it('should calculate the mttr correctly on space 1', async () => { + const metrics = await getCasesMetrics({ + supertest, + features: ['mttr'], + auth: authSpace1, + }); + + expect(metrics).to.eql({ mttr: 220 }); + }); + + it('should calculate the mttr correctly on space 2', async () => { + const authSpace2 = getAuthWithSuperUser('space2'); + const metrics = await getCasesMetrics({ + supertest, + features: ['mttr'], + auth: authSpace2, + }); + + expect(metrics).to.eql({ mttr: 220 }); + }); + }); + }); +}; diff --git a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts index 9179373cf610c..a1f0e3db2c187 100644 --- a/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts +++ b/x-pack/test/functional/apps/graph/feature_controls/graph_security.ts @@ -16,8 +16,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const appsMenu = getService('appsMenu'); const globalNav = getService('globalNav'); - // FLAKY https://github.com/elastic/kibana/issues/109564 - describe.skip('security', () => { + describe('security', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/empty_kibana'); // ensure we're logged out so we can login as the appropriate users diff --git a/x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json b/x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json new file mode 100644 index 0000000000000..f677d7624692c --- /dev/null +++ b/x-pack/test/functional/fixtures/kbn_archiver/cases/8.3.0/all_cases_metrics.json @@ -0,0 +1,182 @@ +{ + "attributes": { + "closed_at": "2022-04-29T13:24:44.448Z", + "closed_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "connector": { + "fields": [], + "name": "none", + "type": ".none" + }, + "created_at": "2022-04-28T13:24:24.011Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "description": "test mttr", + "duration": 20, + "external_service": null, + "owner": "securitySolutionFixture", + "settings": { + "syncAlerts": true + }, + "status": "closed", + "tags": [], + "title": "test mttr", + "updated_at": "2022-04-29T13:24:44.448Z", + "updated_by": { + "email": null, + "full_name": null, + "username": "elastic" + } + }, + "coreMigrationVersion": "8.3.0", + "id": "af948570-c7bf-11ec-9771-d5eef9232089", + "migrationVersion": { + "cases": "8.3.0" + }, + "references": [], + "type": "cases", + "updated_at": "2022-04-29T13:24:44.449Z", + "version": "WzE0NjgsMV0=" +} + +{ + "attributes": { + "closed_at": "2022-04-30T13:32:00.448Z", + "closed_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "connector": { + "fields": [], + "name": "none", + "type": ".none" + }, + "created_at": "2022-04-30T13:24:00.011Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "description": "test mttr", + "duration": 480, + "external_service": null, + "owner": "securitySolutionFixture", + "settings": { + "syncAlerts": true + }, + "status": "closed", + "tags": [], + "title": "test mttr", + "updated_at": "2022-04-29T13:24:44.448Z", + "updated_by": { + "email": null, + "full_name": null, + "username": "elastic" + } + }, + "coreMigrationVersion": "8.3.0", + "id": "bf948570-c7bf-11ec-9771-d5eef9232089", + "migrationVersion": { + "cases": "8.3.0" + }, + "references": [], + "type": "cases", + "updated_at": "2022-04-29T13:24:44.449Z", + "version": "WzE0NjgsMV0=" +} + +{ + "attributes": { + "closed_at": "2022-04-29T13:32:00.448Z", + "closed_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "connector": { + "fields": [], + "name": "none", + "type": ".none" + }, + "created_at": "2022-04-29T13:24:00.011Z", + "created_by": { + "email": null, + "full_name": null, + "username": "elastic" + }, + "description": "test mttr", + "duration": 160, + "external_service": null, + "owner": "observabilityFixture", + "settings": { + "syncAlerts": true + }, + "status": "closed", + "tags": [], + "title": "test mttr", + "updated_at": "2022-04-29T13:24:44.448Z", + "updated_by": { + "email": null, + "full_name": null, + "username": "elastic" + } + }, + "coreMigrationVersion": "8.3.0", + "id": "cf948570-c7bf-11ec-9771-d5eef9232089", + "migrationVersion": { + "cases": "8.3.0" + }, + "references": [], + "type": "cases", + "updated_at": "2022-04-29T13:24:44.449Z", + "version": "WzE0NjgsMV0=" +} + +{ + "attributes": { + "closed_at": null, + "closed_by": null, + "connector": { + "fields": null, + "name": "none", + "type": ".none" + }, + "created_at": "2022-03-20T10:16:56.252Z", + "created_by": { + "email": "", + "full_name": "", + "username": "elastic" + }, + "description": "test 2", + "external_service": null, + "owner": "securitySolutionFixture", + "settings": { + "syncAlerts": false + }, + "status": "open", + "tags": [], + "title": "stack", + "updated_at": "2022-03-29T10:33:09.754Z", + "updated_by": { + "email": "", + "full_name": "", + "username": "elastic" + } + }, + "coreMigrationVersion": "8.3.0", + "id": "df948570-c7bf-11ec-9771-d5eef9232089", + "migrationVersion": { + "cases": "8.3.0" + }, + "references": [], + "type": "cases", + "updated_at": "2022-04-29T13:24:44.449Z", + "version": "WzE0NjgsMV0=" +}