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 56194e6d7ba65..9f73dcd620d30 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 eventAnnotation: 19334 screenshotting: 22870 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/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 1984619c12074..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', @@ -74,6 +78,12 @@ export interface PanelState extends PersistableStateDefinition

{ + id: string; +} + export type EmbeddablePersistableStateService = PersistableStateService; export interface CommonEmbeddableStartContract { 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/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_item.tsx b/src/plugins/unified_search/public/filter_bar/filter_item.tsx index 136931b6e463a..16234a2953dc7 100644 --- a/src/plugins/unified_search/public/filter_bar/filter_item.tsx +++ b/src/plugins/unified_search/public/filter_bar/filter_item.tsx @@ -399,6 +399,6 @@ export function FilterItem(props: FilterItemProps) { ); } - +// 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 1200234a793a4..93805c6cfec1c 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'; @@ -28,7 +29,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 93f1aaf19fae8..5ba2474066275 100755 --- a/src/plugins/unified_search/public/plugin.ts +++ b/src/plugins/unified_search/public/plugin.ts @@ -14,7 +14,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/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/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/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/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/navigation/constants.ts b/x-pack/plugins/cloud_security_posture/public/common/navigation/constants.ts index 82f6161da6851..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 @@ -28,4 +28,5 @@ export const allNavigationItems: Record = { 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/latest_findings/latest_findings_container.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/latest_findings/latest_findings_container.tsx index 0a831a3f88a8d..78a1fd758b6ee 100644 --- 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 @@ -8,6 +8,7 @@ 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'; @@ -18,7 +19,7 @@ import type { FindingsBaseURLQuery } from '../types'; import { useFindingsCounter } from '../use_findings_count'; import { FindingsDistributionBar } from '../layout/findings_distribution_bar'; import { getBaseQuery } from '../utils'; -import { PageWrapper } from '../layout/findings_layout'; +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'; @@ -57,6 +58,13 @@ export const LatestFindingsContainer = ({ dataView }: { dataView: DataView }) => loading={findingsGroupByNone.isLoading} /> + + + } + /> + ({ query: { language: 'kuery', query: '' }, filters: [], }); -export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => { +export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => ( + + } + /> + } + /> + +); + +const LatestFindingsByResourceContainer = ({ dataView }: { dataView: DataView }) => { useCspBreadcrumbs([findingsNavigation.findings_by_resource]); const { urlQuery, setUrlQuery } = useUrlQuery(getDefaultQuery); const findingsGroupByResource = useFindingsByResource( @@ -40,6 +57,16 @@ export const FindingsByResourceContainer = ({ dataView }: { dataView: DataView } loading={findingsGroupByResource.isLoading} /> + + + } + /> + 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/layout/findings_layout.tsx b/x-pack/plugins/cloud_security_posture/public/pages/findings/layout/findings_layout.tsx index 2db5e366b5e05..337e1237d9287 100644 --- 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 @@ -7,16 +7,8 @@ import React from 'react'; import { EuiSpacer, EuiTitle, useEuiTheme } from '@elastic/eui'; import { css } from '@emotion/react'; -import { FormattedMessage } from '@kbn/i18n-react'; -interface Props { - title?: string; -} - -export const PageWrapper: React.FC = ({ - children, - title = , -}) => { +export const PageWrapper: React.FC = ({ children }) => { const { euiTheme } = useEuiTheme(); return (
= ({ padding: ${euiTheme.size.l}; `} > - -

{title}

-
- {children}
); }; + +export const PageTitle: React.FC = ({ children }) => ( + +
+ {children} + +
+
+); + +export const PageTitleText = ({ title }: { title: React.ReactNode }) =>

{title}

; diff --git a/x-pack/plugins/infra/server/lib/metrics/make_get_metric_indices.test.ts b/x-pack/plugins/infra/server/lib/metrics/make_get_metric_indices.test.ts new file mode 100644 index 0000000000000..9dedf9b5afaa0 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/make_get_metric_indices.test.ts @@ -0,0 +1,30 @@ +/* + * 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 } from '@kbn/core/server/mocks'; +import { defaultSourceConfiguration, InfraSource } from '../sources'; +import { createInfraSourcesMock } from '../sources/mocks'; +import { makeGetMetricIndices } from './make_get_metric_indices'; + +describe('getMetricIndices', () => { + it('should return the indices from a resolved configuration', async () => { + const sourceConfiguration: InfraSource = { + id: 'default', + origin: 'stored', + configuration: defaultSourceConfiguration, + }; + const infraSourcesMock = createInfraSourcesMock(); + infraSourcesMock.getSourceConfiguration.mockResolvedValueOnce(sourceConfiguration); + + const getMetricIndices = makeGetMetricIndices(infraSourcesMock); + + const savedObjectsClient = savedObjectsClientMock.create(); + const metricIndices = await getMetricIndices(savedObjectsClient); + + expect(metricIndices).toEqual(defaultSourceConfiguration.metricAlias); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/make_get_metric_indices.ts b/x-pack/plugins/infra/server/lib/metrics/make_get_metric_indices.ts new file mode 100644 index 0000000000000..a6ff7e96a55a0 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/make_get_metric_indices.ts @@ -0,0 +1,16 @@ +/* + * 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 type { SavedObjectsClientContract } from '@kbn/core/server'; +import type { IInfraSources } from '../sources'; + +export function makeGetMetricIndices(metricSources: IInfraSources) { + return async (savedObjectsClient: SavedObjectsClientContract, sourceId: string = 'default') => { + const source = await metricSources.getSourceConfiguration(savedObjectsClient, sourceId); + return source.configuration.metricAlias; + }; +} diff --git a/x-pack/plugins/infra/server/plugin.ts b/x-pack/plugins/infra/server/plugin.ts index 8eb2bac57dc56..bfd7113ec4dc0 100644 --- a/x-pack/plugins/infra/server/plugin.ts +++ b/x-pack/plugins/infra/server/plugin.ts @@ -7,8 +7,6 @@ import { Server } from '@hapi/hapi'; import { schema } from '@kbn/config-schema'; -import { i18n } from '@kbn/i18n'; -import { Logger } from '@kbn/logging'; import { CoreStart, Plugin, @@ -16,6 +14,8 @@ import { PluginInitializerContext, } from '@kbn/core/server'; import { handleEsError } from '@kbn/es-ui-shared-plugin/server'; +import { i18n } from '@kbn/i18n'; +import { Logger } from '@kbn/logging'; import { LOGS_FEATURE_ID, METRICS_FEATURE_ID } from '../common/constants'; import { defaultLogViewsStaticConfig } from '../common/log_views'; import { publicConfigKeys } from '../common/plugin_config_types'; @@ -35,6 +35,7 @@ import { InfraFieldsDomain } from './lib/domains/fields_domain'; import { InfraLogEntriesDomain } from './lib/domains/log_entries_domain'; import { InfraMetricsDomain } from './lib/domains/metrics_domain'; import { InfraBackendLibs, InfraDomainLibs } from './lib/infra_types'; +import { makeGetMetricIndices } from './lib/metrics/make_get_metric_indices'; import { infraSourceConfigurationSavedObjectType, InfraSources } from './lib/sources'; import { InfraSourceStatus } from './lib/source_status'; import { logViewSavedObjectType } from './saved_objects'; @@ -236,6 +237,7 @@ export class InfraServerPlugin return { logViews, + getMetricIndices: makeGetMetricIndices(this.libs.sources), }; } diff --git a/x-pack/plugins/infra/server/types.ts b/x-pack/plugins/infra/server/types.ts index e3792c8977cfe..108575c0f8324 100644 --- a/x-pack/plugins/infra/server/types.ts +++ b/x-pack/plugins/infra/server/types.ts @@ -5,7 +5,11 @@ * 2.0. */ -import type { CoreSetup, CustomRequestHandlerContext } from '@kbn/core/server'; +import type { + CoreSetup, + CustomRequestHandlerContext, + SavedObjectsClientContract, +} from '@kbn/core/server'; import type { SearchRequestHandlerContext } from '@kbn/data-plugin/server'; import type { MlPluginSetup } from '@kbn/ml-plugin/server'; import type { InfraStaticSourceConfiguration } from '../common/source_configuration/source_configuration'; @@ -27,6 +31,10 @@ export interface InfraPluginSetup { export interface InfraPluginStart { logViews: LogViewsServiceStart; + getMetricIndices: ( + savedObjectsClient: SavedObjectsClientContract, + sourceId?: string + ) => Promise; } export type MlSystem = ReturnType; 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/server/migrations/saved_object_migrations.ts b/x-pack/plugins/lens/server/migrations/saved_object_migrations.ts index 6f9cd588d4ce3..00ec6c29154e3 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 53804a6bbcfe0..6b38bb4b4f631 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, ValueLabelConfig } 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/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/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=" +}