diff --git a/.eslintignore b/.eslintignore index fbdd70703f3c40..9de2cc28729602 100644 --- a/.eslintignore +++ b/.eslintignore @@ -33,6 +33,7 @@ target /x-pack/plugins/canvas/shareable_runtime/build /x-pack/plugins/canvas/storybook /x-pack/plugins/monitoring/public/lib/jquery_flot +/x-pack/plugins/reporting/server/export_types/printable_pdf/server/lib/pdf/assets/** /x-pack/legacy/plugins/infra/common/graphql/types.ts /x-pack/legacy/plugins/infra/public/graphql/types.ts /x-pack/legacy/plugins/infra/server/graphql/types.ts diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 3c559a6cde211d..b52bf5bf02b7ba 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -60,6 +60,7 @@ import { ViewMode, SavedObjectEmbeddableInput, ContainerOutput, + EmbeddableInput, } from '../../../embeddable/public'; import { NavAction, SavedDashboardPanel } from '../types'; @@ -430,9 +431,16 @@ export class DashboardAppController { .getStateTransfer(scopedHistory()) .getIncomingEmbeddablePackage(); if (incomingState) { - container.addNewEmbeddable(incomingState.type, { - savedObjectId: incomingState.id, - }); + if ('id' in incomingState) { + container.addNewEmbeddable(incomingState.type, { + savedObjectId: incomingState.id, + }); + } else if ('input' in incomingState) { + container.addNewEmbeddable( + incomingState.type, + incomingState.input + ); + } } } diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 9f0ccbe2b00ef2..f19974942c43d1 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -70,7 +70,7 @@ export { isRangeSelectTriggerContext, isValueClickTriggerContext, EmbeddableStateTransfer, - EmbeddableOriginatingAppState, + EmbeddableEditorState, EmbeddablePackageState, EmbeddableRenderer, EmbeddableRendererProps, diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx index 4b602efb027177..594a7ad73c3965 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.test.tsx @@ -59,7 +59,7 @@ test('redirects to app using state transfer', async () => { const embeddable = new EditableEmbeddable({ id: '123', viewMode: ViewMode.EDIT }, true); embeddable.getOutput = jest.fn(() => ({ editApp: 'ultraVisualize', editPath: '/123' })); await action.execute({ embeddable }); - expect(stateTransferMock.navigateToWithOriginatingApp).toHaveBeenCalledWith('ultraVisualize', { + expect(stateTransferMock.navigateToEditor).toHaveBeenCalledWith('ultraVisualize', { path: '/123', state: { originatingApp: 'superCoolCurrentApp' }, }); diff --git a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts index d983dc9f418535..9177a77d547b0b 100644 --- a/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts +++ b/src/plugins/embeddable/public/lib/actions/edit_panel_action.ts @@ -24,7 +24,7 @@ import { take } from 'rxjs/operators'; import { ViewMode } from '../types'; import { EmbeddableFactoryNotFoundError } from '../errors'; import { EmbeddableStart } from '../../plugin'; -import { IEmbeddable, EmbeddableOriginatingAppState, EmbeddableStateTransfer } from '../..'; +import { IEmbeddable, EmbeddableEditorState, EmbeddableStateTransfer } from '../..'; export const ACTION_EDIT_PANEL = 'editPanel'; @@ -35,7 +35,7 @@ interface ActionContext { interface NavigationContext { app: string; path: string; - state?: EmbeddableOriginatingAppState; + state?: EmbeddableEditorState; } export class EditPanelAction implements Action { @@ -88,7 +88,7 @@ export class EditPanelAction implements Action { const appTarget = this.getAppTarget(context); if (appTarget) { if (this.stateTransfer && appTarget.state) { - await this.stateTransfer.navigateToWithOriginatingApp(appTarget.app, { + await this.stateTransfer.navigateToEditor(appTarget.app, { path: appTarget.path, state: appTarget.state, }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index 0d5ae6be68185b..b7dd95ccba32ca 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -38,7 +38,7 @@ describe('embeddable state transfer', () => { }); it('can send an outgoing originating app state', async () => { - await stateTransfer.navigateToWithOriginatingApp(destinationApp, { state: { originatingApp } }); + await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp } }); expect(application.navigateToApp).toHaveBeenCalledWith('superUltraVisualize', { state: { originatingApp: 'superUltraTestDashboard' }, }); @@ -50,7 +50,7 @@ describe('embeddable state transfer', () => { application.navigateToApp, (historyMock as unknown) as ScopedHistory ); - await stateTransfer.navigateToWithOriginatingApp(destinationApp, { + await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp }, appendToExistingState: true, }); @@ -94,7 +94,7 @@ describe('embeddable state transfer', () => { application.navigateToApp, (historyMock as unknown) as ScopedHistory ); - const fetchedState = stateTransfer.getIncomingOriginatingApp(); + const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' }); }); @@ -104,7 +104,7 @@ describe('embeddable state transfer', () => { application.navigateToApp, (historyMock as unknown) as ScopedHistory ); - const fetchedState = stateTransfer.getIncomingOriginatingApp(); + const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toBeUndefined(); }); diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts index 57b425d2df45c2..8f70e5a66c478f 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.ts @@ -20,8 +20,8 @@ import { cloneDeep } from 'lodash'; import { ScopedHistory, ApplicationStart } from '../../../../../core/public'; import { - EmbeddableOriginatingAppState, - isEmbeddableOriginatingAppState, + EmbeddableEditorState, + isEmbeddableEditorState, EmbeddablePackageState, isEmbeddablePackageState, } from './types'; @@ -39,16 +39,16 @@ export class EmbeddableStateTransfer { ) {} /** - * Fetches an {@link EmbeddableOriginatingAppState | originating app} argument from the scoped + * Fetches an {@link EmbeddableEditorState | originating app} argument from the scoped * history's location state. * * @param history - the scoped history to fetch from * @param options.keysToRemoveAfterFetch - an array of keys to be removed from the state after they are retrieved */ - public getIncomingOriginatingApp(options?: { + public getIncomingEditorState(options?: { keysToRemoveAfterFetch?: string[]; - }): EmbeddableOriginatingAppState | undefined { - return this.getIncomingState(isEmbeddableOriginatingAppState, { + }): EmbeddableEditorState | undefined { + return this.getIncomingState(isEmbeddableEditorState, { keysToRemoveAfterFetch: options?.keysToRemoveAfterFetch, }); } @@ -70,17 +70,17 @@ export class EmbeddableStateTransfer { /** * A wrapper around the {@link ApplicationStart.navigateToApp} method which navigates to the specified appId - * with {@link EmbeddableOriginatingAppState | originating app state} + * with {@link EmbeddableEditorState | embeddable editor state} */ - public async navigateToWithOriginatingApp( + public async navigateToEditor( appId: string, options?: { path?: string; - state: EmbeddableOriginatingAppState; + state: EmbeddableEditorState; appendToExistingState?: boolean; } ): Promise { - await this.navigateToWithState(appId, options); + await this.navigateToWithState(appId, options); } /** diff --git a/src/plugins/embeddable/public/lib/state_transfer/index.ts b/src/plugins/embeddable/public/lib/state_transfer/index.ts index e51efc5dcca26b..7daa7a0ea81d64 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/index.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/index.ts @@ -18,4 +18,4 @@ */ export { EmbeddableStateTransfer } from './embeddable_state_transfer'; -export { EmbeddableOriginatingAppState, EmbeddablePackageState } from './types'; +export { EmbeddableEditorState, EmbeddablePackageState } from './types'; diff --git a/src/plugins/embeddable/public/lib/state_transfer/types.ts b/src/plugins/embeddable/public/lib/state_transfer/types.ts index 8eae441d1be23c..a6721784302ac7 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/types.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/types.ts @@ -17,33 +17,49 @@ * under the License. */ +import { EmbeddableInput } from '..'; + /** * Represents a state package that contains the last active app id. * @public */ -export interface EmbeddableOriginatingAppState { +export interface EmbeddableEditorState { originatingApp: string; + byValueMode?: boolean; + valueInput?: EmbeddableInput; } -export function isEmbeddableOriginatingAppState( - state: unknown -): state is EmbeddableOriginatingAppState { +export function isEmbeddableEditorState(state: unknown): state is EmbeddableEditorState { return ensureFieldOfTypeExists('originatingApp', state, 'string'); } /** - * Represents a state package that contains all fields necessary to create an embeddable in a container. + * Represents a state package that contains all fields necessary to create an embeddable by reference in a container. * @public */ -export interface EmbeddablePackageState { +export interface EmbeddablePackageByReferenceState { type: string; id: string; } +/** + * Represents a state package that contains all fields necessary to create an embeddable by value in a container. + * @public + */ +export interface EmbeddablePackageByValueState { + type: string; + input: EmbeddableInput; +} + +export type EmbeddablePackageState = + | EmbeddablePackageByReferenceState + | EmbeddablePackageByValueState; + export function isEmbeddablePackageState(state: unknown): state is EmbeddablePackageState { return ( - ensureFieldOfTypeExists('type', state, 'string') && - ensureFieldOfTypeExists('id', state, 'string') + (ensureFieldOfTypeExists('type', state, 'string') && + ensureFieldOfTypeExists('id', state, 'string')) || + ensureFieldOfTypeExists('input', state, 'object') ); } diff --git a/src/plugins/embeddable/public/mocks.tsx b/src/plugins/embeddable/public/mocks.tsx index c98416cb3e8c7e..efd0ccdc4553d7 100644 --- a/src/plugins/embeddable/public/mocks.tsx +++ b/src/plugins/embeddable/public/mocks.tsx @@ -79,9 +79,9 @@ export const createEmbeddablePanelMock = ({ export const createEmbeddableStateTransferMock = (): Partial => { return { - getIncomingOriginatingApp: jest.fn(), + getIncomingEditorState: jest.fn(), getIncomingEmbeddablePackage: jest.fn(), - navigateToWithOriginatingApp: jest.fn(), + navigateToEditor: jest.fn(), navigateToWithEmbeddablePackage: jest.fn(), }; }; diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx index dd89e98fb8fe53..f48febfef5b437 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.test.tsx @@ -165,7 +165,7 @@ describe('NewVisModal', () => { ); const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]'); visButton.simulate('click'); - expect(stateTransfer.navigateToWithOriginatingApp).toBeCalledWith('otherApp', { + expect(stateTransfer.navigateToEditor).toBeCalledWith('otherApp', { path: '#/aliasUrl', state: { originatingApp: 'coolJestTestApp' }, }); diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index 84a5bca0ed0edd..1d01900ceffc28 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -172,7 +172,7 @@ class NewVisModal extends React.Component originatingApp; const visStateToEditorState = () => { diff --git a/x-pack/mocks.ts b/x-pack/mocks.ts index 28c589bee4baa2..777c8d0a081318 100644 --- a/x-pack/mocks.ts +++ b/x-pack/mocks.ts @@ -9,7 +9,7 @@ import { licensingMock } from './plugins/licensing/server/mocks'; function createCoreRequestHandlerContextMock() { return { core: coreMock.createRequestHandlerContext(), - licensing: { license: licensingMock.createLicense() }, + licensing: licensingMock.createRequestHandlerContext(), }; } diff --git a/x-pack/plugins/features/server/routes/index.test.ts b/x-pack/plugins/features/server/routes/index.test.ts index c2e8cd6129d80f..3d1efc8a479b26 100644 --- a/x-pack/plugins/features/server/routes/index.test.ts +++ b/x-pack/plugins/features/server/routes/index.test.ts @@ -16,9 +16,7 @@ import { FeatureConfig } from '../../common'; function createContextMock(licenseType: LicenseType = 'gold') { return { core: coreMock.createRequestHandlerContext(), - licensing: { - license: licensingMock.createLicense({ license: { type: licenseType } }), - }, + licensing: licensingMock.createRequestHandlerContext({ license: { type: licenseType } }), }; } diff --git a/x-pack/plugins/lens/public/app_plugin/mounter.tsx b/x-pack/plugins/lens/public/app_plugin/mounter.tsx index 7a33241792a58f..1ee618a31a6986 100644 --- a/x-pack/plugins/lens/public/app_plugin/mounter.tsx +++ b/x-pack/plugins/lens/public/app_plugin/mounter.tsx @@ -38,7 +38,7 @@ export async function mountApp( const stateTransfer = embeddable?.getStateTransfer(params.history); const { originatingApp } = - stateTransfer?.getIncomingOriginatingApp({ keysToRemoveAfterFetch: ['originatingApp'] }) || {}; + stateTransfer?.getIncomingEditorState({ keysToRemoveAfterFetch: ['originatingApp'] }) || {}; const instance = await createEditorFrame(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index 9c4f6c9b590ce5..a98f63cf9b3606 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -125,6 +125,7 @@ export function getIndexPatternDatasource({ state, savedObjectsClient: await savedObjectsClient, defaultIndexPatternId: core.uiSettings.get('defaultIndex'), + storage, }); }, @@ -207,6 +208,7 @@ export function getIndexPatternDatasource({ setState, savedObjectsClient, onError: onIndexPatternLoadError, + storage, }); }} data={data} @@ -290,6 +292,7 @@ export function getIndexPatternDatasource({ layerId: props.layerId, onError: onIndexPatternLoadError, replaceIfPossible: true, + storage, }); }} {...props} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts index b54ad3651471d7..55fd8a6d936d35 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.test.ts @@ -18,6 +18,15 @@ import { documentField } from './document_field'; jest.mock('./operations'); +const createMockStorage = (lastData?: Record) => { + return { + get: jest.fn().mockImplementation(() => lastData), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + }; +}; + const sampleIndexPatterns = { a: { id: 'a', @@ -269,8 +278,10 @@ describe('loader', () => { describe('loadInitialState', () => { it('should load a default state', async () => { + const storage = createMockStorage(); const state = await loadInitialState({ savedObjectsClient: mockClient(), + storage, }); expect(state).toMatchObject({ @@ -285,12 +296,61 @@ describe('loader', () => { layers: {}, showEmptyFields: false, }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: 'a', + }); + }); + + it('should load a default state when lastUsedIndexPatternId is not found in indexPatternRefs', async () => { + const storage = createMockStorage({ indexPatternId: 'c' }); + const state = await loadInitialState({ + savedObjectsClient: mockClient(), + storage, + }); + + expect(state).toMatchObject({ + currentIndexPatternId: 'a', + indexPatternRefs: [ + { id: 'a', title: sampleIndexPatterns.a.title }, + { id: 'b', title: sampleIndexPatterns.b.title }, + ], + indexPatterns: { + a: sampleIndexPatterns.a, + }, + layers: {}, + showEmptyFields: false, + }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: 'a', + }); + }); + + it('should load lastUsedIndexPatternId if in localStorage', async () => { + const state = await loadInitialState({ + savedObjectsClient: mockClient(), + storage: createMockStorage({ indexPatternId: 'b' }), + }); + + expect(state).toMatchObject({ + currentIndexPatternId: 'b', + indexPatternRefs: [ + { id: 'a', title: sampleIndexPatterns.a.title }, + { id: 'b', title: sampleIndexPatterns.b.title }, + ], + indexPatterns: { + b: sampleIndexPatterns.b, + }, + layers: {}, + showEmptyFields: false, + }); }); it('should use the default index pattern id, if provided', async () => { + const storage = createMockStorage(); const state = await loadInitialState({ defaultIndexPatternId: 'b', savedObjectsClient: mockClient(), + storage, }); expect(state).toMatchObject({ @@ -305,6 +365,9 @@ describe('loader', () => { layers: {}, showEmptyFields: false, }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: 'b', + }); }); it('should initialize from saved state', async () => { @@ -336,9 +399,11 @@ describe('loader', () => { }, }, }; + const storage = createMockStorage({ indexPatternId: 'a' }); const state = await loadInitialState({ state: savedState, savedObjectsClient: mockClient(), + storage, }); expect(state).toMatchObject({ @@ -353,6 +418,10 @@ describe('loader', () => { layers: savedState.layers, showEmptyFields: false, }); + + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: 'b', + }); }); }); @@ -367,6 +436,7 @@ describe('loader', () => { layers: {}, showEmptyFields: true, }; + const storage = createMockStorage({ indexPatternId: 'b' }); await changeIndexPattern({ state, @@ -374,6 +444,7 @@ describe('loader', () => { id: 'a', savedObjectsClient: mockClient(), onError: jest.fn(), + storage, }); expect(setState).toHaveBeenCalledTimes(1); @@ -383,6 +454,9 @@ describe('loader', () => { a: sampleIndexPatterns.a, }, }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: 'a', + }); }); it('handles errors', async () => { @@ -398,6 +472,8 @@ describe('loader', () => { showEmptyFields: true, }; + const storage = createMockStorage({ indexPatternId: 'b' }); + await changeIndexPattern({ state, setState, @@ -409,9 +485,11 @@ describe('loader', () => { }), }, onError, + storage, }); expect(setState).not.toHaveBeenCalled(); + expect(storage.set).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith(err); }); }); @@ -452,6 +530,8 @@ describe('loader', () => { showEmptyFields: true, }; + const storage = createMockStorage({ indexPatternId: 'a' }); + await changeLayerIndexPattern({ state, setState, @@ -459,6 +539,7 @@ describe('loader', () => { layerId: 'l1', savedObjectsClient: mockClient(), onError: jest.fn(), + storage, }); expect(setState).toHaveBeenCalledTimes(1); @@ -492,6 +573,9 @@ describe('loader', () => { }, }, }); + expect(storage.set).toHaveBeenCalledWith('lens-settings', { + indexPatternId: 'b', + }); }); it('handles errors', async () => { @@ -515,6 +599,8 @@ describe('loader', () => { showEmptyFields: true, }; + const storage = createMockStorage({ indexPatternId: 'b' }); + await changeLayerIndexPattern({ state, setState, @@ -527,9 +613,11 @@ describe('loader', () => { }), }, onError, + storage, }); expect(setState).not.toHaveBeenCalled(); + expect(storage.set).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith(err); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts index c34f4c1d231483..ca52ffe73a871f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/loader.ts @@ -5,6 +5,7 @@ */ import _ from 'lodash'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { SavedObjectsClientContract, SavedObjectAttributes, HttpSetup } from 'kibana/public'; import { SimpleSavedObject } from 'kibana/public'; import { StateSetter } from '../types'; @@ -24,6 +25,7 @@ import { IFieldType, IndexPatternTypeMeta, } from '../../../../../src/plugins/data/public'; +import { readFromStorage, writeToStorage } from '../settings_storage'; interface SavedIndexPatternAttributes extends SavedObjectAttributes { title: string; @@ -68,31 +70,48 @@ export async function loadIndexPatterns({ ); } +const getLastUsedIndexPatternId = ( + storage: IStorageWrapper, + indexPatternRefs: IndexPatternRef[] +) => { + const indexPattern = readFromStorage(storage, 'indexPatternId'); + return indexPattern && indexPatternRefs.find((i) => i.id === indexPattern)?.id; +}; + +const setLastUsedIndexPatternId = (storage: IStorageWrapper, value: string) => { + writeToStorage(storage, 'indexPatternId', value); +}; + export async function loadInitialState({ state, savedObjectsClient, defaultIndexPatternId, + storage, }: { state?: IndexPatternPersistedState; savedObjectsClient: SavedObjectsClient; defaultIndexPatternId?: string; + storage: IStorageWrapper; }): Promise { const indexPatternRefs = await loadIndexPatternRefs(savedObjectsClient); + const lastUsedIndexPatternId = getLastUsedIndexPatternId(storage, indexPatternRefs); + const requiredPatterns = _.unique( state ? Object.values(state.layers) .map((l) => l.indexPatternId) .concat(state.currentIndexPatternId) - : [defaultIndexPatternId || indexPatternRefs[0].id] + : [lastUsedIndexPatternId || defaultIndexPatternId || indexPatternRefs[0].id] ); const currentIndexPatternId = requiredPatterns[0]; + setLastUsedIndexPatternId(storage, currentIndexPatternId); + const indexPatterns = await loadIndexPatterns({ savedObjectsClient, cache: {}, patterns: requiredPatterns, }); - if (state) { return { ...state, @@ -120,12 +139,14 @@ export async function changeIndexPattern({ state, setState, onError, + storage, }: { id: string; savedObjectsClient: SavedObjectsClient; state: IndexPatternPrivateState; setState: SetState; onError: ErrorHandler; + storage: IStorageWrapper; }) { try { const indexPatterns = await loadIndexPatterns({ @@ -145,6 +166,7 @@ export async function changeIndexPattern({ }, currentIndexPatternId: id, })); + setLastUsedIndexPatternId(storage, id); } catch (err) { onError(err); } @@ -158,6 +180,7 @@ export async function changeLayerIndexPattern({ setState, onError, replaceIfPossible, + storage, }: { indexPatternId: string; layerId: string; @@ -166,6 +189,7 @@ export async function changeLayerIndexPattern({ setState: SetState; onError: ErrorHandler; replaceIfPossible?: boolean; + storage: IStorageWrapper; }) { try { const indexPatterns = await loadIndexPatterns({ @@ -186,6 +210,7 @@ export async function changeLayerIndexPattern({ }, currentIndexPatternId: replaceIfPossible ? indexPatternId : s.currentIndexPatternId, })); + setLastUsedIndexPatternId(storage, indexPatternId); } catch (err) { onError(err); } diff --git a/x-pack/plugins/lens/public/settings_storage.tsx b/x-pack/plugins/lens/public/settings_storage.tsx new file mode 100644 index 00000000000000..58e014512edab2 --- /dev/null +++ b/x-pack/plugins/lens/public/settings_storage.tsx @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; + +const STORAGE_KEY = 'lens-settings'; + +export const readFromStorage = (storage: IStorageWrapper, key: string) => { + const data = storage.get(STORAGE_KEY); + return data && data[key]; +}; +export const writeToStorage = (storage: IStorageWrapper, key: string, value: string) => { + storage.set(STORAGE_KEY, { [key]: value }); +}; diff --git a/x-pack/plugins/license_management/public/application/app_context.tsx b/x-pack/plugins/license_management/public/application/app_context.tsx index 39e7ef5f16e79a..62f019682fba99 100644 --- a/x-pack/plugins/license_management/public/application/app_context.tsx +++ b/x-pack/plugins/license_management/public/application/app_context.tsx @@ -9,7 +9,7 @@ import { ScopedHistory } from 'kibana/public'; import { CoreStart } from '../../../../../src/core/public'; import { LicensingPluginSetup, ILicense } from '../../../licensing/public'; -import { TelemetryPluginSetup } from '../../../../../src/plugins/telemetry/public'; +import { TelemetryPluginStart } from '../../../../../src/plugins/telemetry/public'; import { ClientConfigType } from '../types'; import { BreadcrumbService } from './breadcrumbs'; @@ -23,7 +23,7 @@ export interface AppDependencies { }; plugins: { licensing: LicensingPluginSetup; - telemetry?: TelemetryPluginSetup; + telemetry?: TelemetryPluginStart; }; docLinks: { security: string; diff --git a/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/opt_in_example_flyout.tsx b/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/opt_in_example_flyout.tsx new file mode 100644 index 00000000000000..a13443ad8a0d79 --- /dev/null +++ b/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/opt_in_example_flyout.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { OptInExampleFlyout } from '../../../../../../../src/plugins/telemetry_management_section/public'; + +// required for lazy loading +// eslint-disable-next-line import/no-default-export +export default OptInExampleFlyout; diff --git a/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/telemetry_opt_in.tsx b/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/telemetry_opt_in.tsx index eff5c6cc21c439..92e241a375cea5 100644 --- a/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/telemetry_opt_in.tsx +++ b/x-pack/plugins/license_management/public/application/components/telemetry_opt_in/telemetry_opt_in.tsx @@ -5,13 +5,19 @@ */ import React, { Fragment } from 'react'; -import { EuiLink, EuiCheckbox, EuiSpacer, EuiText, EuiTitle, EuiPopover } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; import { - OptInExampleFlyout, - PRIVACY_STATEMENT_URL, - TelemetryPluginSetup, -} from '../../lib/telemetry'; + EuiLink, + EuiCheckbox, + EuiSpacer, + EuiText, + EuiTitle, + EuiPopover, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { TelemetryPluginStart } from '../../lib/telemetry'; + +const OptInExampleFlyout = React.lazy(() => import('./opt_in_example_flyout')); interface State { showMoreTelemetryInfo: boolean; @@ -22,7 +28,7 @@ interface Props { onOptInChange: (isOptingInToTelemetry: boolean) => void; isOptingInToTelemetry: boolean; isStartTrial: boolean; - telemetry: TelemetryPluginSetup; + telemetry: TelemetryPluginStart; } export class TelemetryOptIn extends React.Component { @@ -54,11 +60,15 @@ export class TelemetryOptIn extends React.Component { let example = null; if (showExample) { + // Using React.Suspense and lazy loading here to avoid crashing the plugin when importing + // OptInExampleFlyout but telemetryManagementSection is disabled example = ( - this.setState({ showExample: false })} - fetchExample={telemetry.telemetryService.fetchExample} - /> + }> + this.setState({ showExample: false })} + fetchExample={telemetry.telemetryService.fetchExample} + /> + ); } @@ -116,7 +126,10 @@ export class TelemetryOptIn extends React.Component { ), telemetryPrivacyStatementLink: ( - + void; startLicenseTrial: () => void; - telemetry?: TelemetryPluginSetup; + telemetry?: TelemetryPluginStart; shouldShowStartTrial: boolean; } diff --git a/x-pack/plugins/license_management/public/plugin.ts b/x-pack/plugins/license_management/public/plugin.ts index e2e6437d12d2ab..2511337793fead 100644 --- a/x-pack/plugins/license_management/public/plugin.ts +++ b/x-pack/plugins/license_management/public/plugin.ts @@ -6,7 +6,7 @@ import { first } from 'rxjs/operators'; import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; -import { TelemetryPluginSetup } from '../../../../src/plugins/telemetry/public'; +import { TelemetryPluginStart } from '../../../../src/plugins/telemetry/public'; import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; import { LicensingPluginSetup } from '../../../plugins/licensing/public'; import { PLUGIN } from '../common/constants'; @@ -14,10 +14,13 @@ import { ClientConfigType } from './types'; import { AppDependencies } from './application'; import { BreadcrumbService } from './application/breadcrumbs'; -interface PluginsDependencies { +interface PluginsDependenciesSetup { management: ManagementSetup; licensing: LicensingPluginSetup; - telemetry?: TelemetryPluginSetup; +} + +interface PluginsDependenciesStart { + telemetry?: TelemetryPluginStart; } export interface LicenseManagementUIPluginSetup { @@ -31,7 +34,10 @@ export class LicenseManagementUIPlugin constructor(private readonly initializerContext: PluginInitializerContext) {} - setup(coreSetup: CoreSetup, plugins: PluginsDependencies): LicenseManagementUIPluginSetup { + setup( + coreSetup: CoreSetup, + plugins: PluginsDependenciesSetup + ): LicenseManagementUIPluginSetup { const config = this.initializerContext.config.get(); if (!config.ui.enabled) { @@ -42,14 +48,14 @@ export class LicenseManagementUIPlugin } const { getStartServices } = coreSetup; - const { management, telemetry, licensing } = plugins; + const { management, licensing } = plugins; management.sections.getSection(ManagementSectionId.Stack).registerApp({ id: PLUGIN.id, title: PLUGIN.title, order: 0, mount: async ({ element, setBreadcrumbs, history }) => { - const [core] = await getStartServices(); + const [core, { telemetry }] = await getStartServices(); const initialLicense = await plugins.licensing.license$.pipe(first()).toPromise(); // Setup documentation links diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts index 29bff402939581..4942d21f64ee22 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.test.ts @@ -5,9 +5,19 @@ */ import { BehaviorSubject } from 'rxjs'; -import { licenseMock } from '../common/licensing.mock'; +import { licenseMock } from '../common/licensing.mock'; import { createRouteHandlerContext } from './licensing_route_handler_context'; +import { featureUsageMock } from './services/feature_usage_service.mock'; +import { FeatureUsageServiceStart } from './services'; +import { StartServicesAccessor } from 'src/core/server'; +import { LicensingPluginStart } from './types'; + +const createStartServices = ( + featureUsage: FeatureUsageServiceStart = featureUsageMock.createStart() +): StartServicesAccessor<{}, LicensingPluginStart> => { + return async () => [{} as any, {}, { featureUsage } as LicensingPluginStart]; +}; describe('createRouteHandlerContext', () => { it('returns a function providing the last license value', async () => { @@ -15,7 +25,7 @@ describe('createRouteHandlerContext', () => { const secondLicense = licenseMock.createLicense(); const license$ = new BehaviorSubject(firstLicense); - const routeHandler = createRouteHandlerContext(license$); + const routeHandler = createRouteHandlerContext(license$, createStartServices()); const firstCtx = await routeHandler({} as any, {} as any, {} as any); license$.next(secondLicense); @@ -24,4 +34,14 @@ describe('createRouteHandlerContext', () => { expect(firstCtx.license).toBe(firstLicense); expect(secondCtx.license).toBe(secondLicense); }); + + it('returns a the feature usage API', async () => { + const license$ = new BehaviorSubject(licenseMock.createLicense()); + const featureUsage = featureUsageMock.createStart(); + + const routeHandler = createRouteHandlerContext(license$, createStartServices(featureUsage)); + const ctx = await routeHandler({} as any, {} as any, {} as any); + + expect(ctx.featureUsage).toBe(featureUsage); + }); }); diff --git a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts index 42cb0959fc373a..736a2151a3dbd9 100644 --- a/x-pack/plugins/licensing/server/licensing_route_handler_context.ts +++ b/x-pack/plugins/licensing/server/licensing_route_handler_context.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IContextProvider, RequestHandler } from 'src/core/server'; +import { IContextProvider, RequestHandler, StartServicesAccessor } from 'src/core/server'; import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { ILicense } from '../common/types'; +import { LicensingPluginStart } from './types'; /** * Create a route handler context for access to Kibana license information. @@ -16,9 +17,16 @@ import { ILicense } from '../common/types'; * @public */ export function createRouteHandlerContext( - license$: Observable + license$: Observable, + getStartServices: StartServicesAccessor<{}, LicensingPluginStart> ): IContextProvider, 'licensing'> { return async function licensingRouteHandlerContext() { - return { license: await license$.pipe(take(1)).toPromise() }; + const [, , { featureUsage }] = await getStartServices(); + const license = await license$.pipe(take(1)).toPromise(); + + return { + featureUsage, + license, + }; }; } diff --git a/x-pack/plugins/licensing/server/mocks.ts b/x-pack/plugins/licensing/server/mocks.ts index 0d154f76d51345..1a2b543b47df54 100644 --- a/x-pack/plugins/licensing/server/mocks.ts +++ b/x-pack/plugins/licensing/server/mocks.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ import { BehaviorSubject } from 'rxjs'; -import { LicensingPluginSetup, LicensingPluginStart } from './types'; +import { + LicensingPluginSetup, + LicensingPluginStart, + LicensingRequestHandlerContext, +} from './types'; import { licenseMock } from '../common/licensing.mock'; import { featureUsageMock } from './services/feature_usage_service.mock'; @@ -43,8 +47,20 @@ const createStartMock = (): jest.Mocked => { return mock; }; +const createRequestHandlerContextMock = ( + ...options: Parameters +): jest.Mocked => { + const mock: jest.Mocked = { + license: licenseMock.createLicense(...options), + featureUsage: featureUsageMock.createStart(), + }; + + return mock; +}; + export const licensingMock = { createSetup: createSetupMock, createStart: createStartMock, + createRequestHandlerContext: createRequestHandlerContextMock, ...licenseMock, }; diff --git a/x-pack/plugins/licensing/server/plugin.ts b/x-pack/plugins/licensing/server/plugin.ts index e1aa4a1b32517e..0a6964b1b829d1 100644 --- a/x-pack/plugins/licensing/server/plugin.ts +++ b/x-pack/plugins/licensing/server/plugin.ts @@ -128,7 +128,10 @@ export class LicensingPlugin implements Plugin { expect(message.schema).toEqual(payload); }); - test('it should not accept an undefined for "description"', () => { + test('it should not validate an undefined for "description"', () => { const payload = getCreateExceptionListItemSchemaMock(); delete payload.description; const decoded = createExceptionListItemSchema.decode(payload); @@ -38,7 +41,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should not accept an undefined for "name"', () => { + test('it should not validate an undefined for "name"', () => { const payload = getCreateExceptionListItemSchemaMock(); delete payload.name; const decoded = createExceptionListItemSchema.decode(payload); @@ -50,7 +53,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should not accept an undefined for "type"', () => { + test('it should not validate an undefined for "type"', () => { const payload = getCreateExceptionListItemSchemaMock(); delete payload.type; const decoded = createExceptionListItemSchema.decode(payload); @@ -62,7 +65,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should not accept an undefined for "list_id"', () => { + test('it should not validate an undefined for "list_id"', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.list_id; const decoded = createExceptionListItemSchema.decode(inputPayload); @@ -74,7 +77,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual({}); }); - test('it should accept an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "meta" but strip it out and generate a correct body not counting the auto generated uuid', () => { const payload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete payload.meta; @@ -87,7 +90,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "comments" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.comments; @@ -100,7 +103,34 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "entries" but return an array', () => { + test('it should validate "comments" array', () => { + const inputPayload = { + ...getCreateExceptionListItemSchemaMock(), + comments: getCreateCommentsArrayMock(), + }; + const decoded = createExceptionListItemSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + delete (message.schema as CreateExceptionListItemSchema).item_id; + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(inputPayload); + }); + + test('it should NOT validate "comments" with "created_at" or "created_by" values', () => { + const inputPayload: Omit & { + comments?: CommentsArray; + } = { + ...getCreateExceptionListItemSchemaMock(), + comments: [getCommentsMock()], + }; + const decoded = createExceptionListItemSchema.decode(inputPayload); + const checked = exactCheck(inputPayload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual(['invalid keys "created_at,created_by"']); + expect(message.schema).toEqual({}); + }); + + test('it should validate an undefined for "entries" but return an array', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.entries; @@ -113,7 +143,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "namespace_type" but return enum "single" and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.namespace_type; @@ -126,7 +156,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "tags" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.tags; @@ -139,7 +169,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { + test('it should validate an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); const outputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload._tags; @@ -152,7 +182,7 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "item_id" and auto generate a uuid', () => { + test('it should validate an undefined for "item_id" and auto generate a uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.item_id; const decoded = createExceptionListItemSchema.decode(inputPayload); @@ -164,7 +194,7 @@ describe('create_exception_list_item_schema', () => { ); }); - test('it should accept an undefined for "item_id" and generate a correct body not counting the uuid', () => { + test('it should validate an undefined for "item_id" and generate a correct body not counting the uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.item_id; const decoded = createExceptionListItemSchema.decode(inputPayload); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index f593b5d1640355..fb452ac89576d9 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -23,7 +23,7 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; -import { CommentsPartialArray, DefaultCommentsPartialArray, DefaultEntryArray } from '../types'; +import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; @@ -39,7 +39,7 @@ export const createExceptionListItemSchema = t.intersection([ t.exact( t.partial({ _tags, // defaults to empty array if not set during decode - comments: DefaultCommentsPartialArray, // defaults to empty array if not set during decode + comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode entries: DefaultEntryArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode @@ -63,7 +63,7 @@ export type CreateExceptionListItemSchemaDecoded = Identity< '_tags' | 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' > & { _tags: _Tags; - comments: CommentsPartialArray; + comments: CreateCommentsArray; tags: Tags; item_id: ItemId; entries: EntriesArray; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index c32b15fecb571e..582fabdc160f9e 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -23,10 +23,10 @@ import { } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; import { - CommentsPartialArray, - DefaultCommentsPartialArray, DefaultEntryArray, + DefaultUpdateCommentsArray, EntriesArray, + UpdateCommentsArray, } from '../types'; export const updateExceptionListItemSchema = t.intersection([ @@ -40,7 +40,7 @@ export const updateExceptionListItemSchema = t.intersection([ t.exact( t.partial({ _tags, // defaults to empty array if not set during decode - comments: DefaultCommentsPartialArray, // defaults to empty array if not set during decode + comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode entries: DefaultEntryArray, // defaults to empty array if not set during decode id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), @@ -65,7 +65,7 @@ export type UpdateExceptionListItemSchemaDecoded = Identity< '_tags' | 'tags' | 'entries' | 'namespace_type' | 'comments' > & { _tags: _Tags; - comments: CommentsPartialArray; + comments: UpdateCommentsArray; tags: Tags; entries: EntriesArray; namespace_type: NamespaceType; diff --git a/x-pack/plugins/lists/common/schemas/types/comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/comments.mock.ts index ee58fafe074c7b..9e56ac292f8b56 100644 --- a/x-pack/plugins/lists/common/schemas/types/comments.mock.ts +++ b/x-pack/plugins/lists/common/schemas/types/comments.mock.ts @@ -6,17 +6,12 @@ import { DATE_NOW, USER } from '../../constants.mock'; -import { CommentsArray } from './comments'; +import { Comments, CommentsArray } from './comments'; -export const getCommentsMock = (): CommentsArray => [ - { - comment: 'some comment', - created_at: DATE_NOW, - created_by: USER, - }, - { - comment: 'some other comment', - created_at: DATE_NOW, - created_by: 'lily', - }, -]; +export const getCommentsMock = (): Comments => ({ + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, +}); + +export const getCommentsArrayMock = (): CommentsArray => [getCommentsMock(), getCommentsMock()]; diff --git a/x-pack/plugins/lists/common/schemas/types/comments.test.ts b/x-pack/plugins/lists/common/schemas/types/comments.test.ts new file mode 100644 index 00000000000000..29bfde03abcc8d --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/comments.test.ts @@ -0,0 +1,217 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { DATE_NOW } from '../../constants.mock'; +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getCommentsArrayMock, getCommentsMock } from './comments.mock'; +import { + Comments, + CommentsArray, + CommentsArrayOrUndefined, + comments, + commentsArray, + commentsArrayOrUndefined, +} from './comments'; + +describe('Comments', () => { + describe('comments', () => { + test('it should validate a comments', () => { + const payload = getCommentsMock(); + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate with "updated_at" and "updated_by"', () => { + const payload = getCommentsMock(); + payload.updated_at = DATE_NOW; + payload.updated_by = 'someone'; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)"', + 'Invalid value "undefined" supplied to "({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "comment" is not a string', () => { + const payload: Omit & { comment: string[] } = { + ...getCommentsMock(), + comment: ['some value'], + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "comment"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "created_at" is not a string', () => { + const payload: Omit & { created_at: number } = { + ...getCommentsMock(), + created_at: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "created_at"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "created_by" is not a string', () => { + const payload: Omit & { created_by: number } = { + ...getCommentsMock(), + created_by: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "created_by"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "updated_at" is not a string', () => { + const payload: Omit & { updated_at: number } = { + ...getCommentsMock(), + updated_at: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "updated_at"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "updated_by" is not a string', () => { + const payload: Omit & { updated_by: number } = { + ...getCommentsMock(), + updated_by: 1, + }; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "updated_by"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: Comments & { + extraKey?: string; + } = getCommentsMock(); + payload.extraKey = 'some value'; + const decoded = comments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getCommentsMock()); + }); + }); + + describe('commentsArray', () => { + test('it should validate an array of comments', () => { + const payload = getCommentsArrayMock(); + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when a comments includes "updated_at" and "updated_by"', () => { + const commentsPayload = getCommentsMock(); + commentsPayload.updated_at = DATE_NOW; + commentsPayload.updated_by = 'someone'; + const payload = [{ ...commentsPayload }, ...getCommentsArrayMock()]; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CommentsArray; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('commentsArrayOrUndefined', () => { + test('it should validate an array of comments', () => { + const payload = getCommentsArrayMock(); + const decoded = commentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = commentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CommentsArrayOrUndefined; + const decoded = commentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/comments.ts b/x-pack/plugins/lists/common/schemas/types/comments.ts index d61608c3508f49..0ee3b05c8102f1 100644 --- a/x-pack/plugins/lists/common/schemas/types/comments.ts +++ b/x-pack/plugins/lists/common/schemas/types/comments.ts @@ -5,36 +5,24 @@ */ import * as t from 'io-ts'; -export const comment = t.exact( - t.type({ - comment: t.string, - created_at: t.string, // TODO: Make this into an ISO Date string check, - created_by: t.string, - }) -); - -export const commentsArray = t.array(comment); -export type CommentsArray = t.TypeOf; -export type Comment = t.TypeOf; -export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]); -export type CommentsArrayOrUndefined = t.TypeOf; - -export const commentPartial = t.intersection([ +export const comments = t.intersection([ t.exact( t.type({ comment: t.string, + created_at: t.string, // TODO: Make this into an ISO Date string check, + created_by: t.string, }) ), t.exact( t.partial({ - created_at: t.string, // TODO: Make this into an ISO Date string check, - created_by: t.string, + updated_at: t.string, + updated_by: t.string, }) ), ]); -export const commentsPartialArray = t.array(commentPartial); -export type CommentsPartialArray = t.TypeOf; -export type CommentPartial = t.TypeOf; -export const commentsPartialArrayOrUndefined = t.union([commentsPartialArray, t.undefined]); -export type CommentsPartialArrayOrUndefined = t.TypeOf; +export const commentsArray = t.array(comments); +export type CommentsArray = t.TypeOf; +export type Comments = t.TypeOf; +export const commentsArrayOrUndefined = t.union([commentsArray, t.undefined]); +export type CommentsArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts new file mode 100644 index 00000000000000..60a59432275ca2 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/create_comments.mock.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { CreateComments, CreateCommentsArray } from './create_comments'; + +export const getCreateCommentsMock = (): CreateComments => ({ + comment: 'some comments', +}); + +export const getCreateCommentsArrayMock = (): CreateCommentsArray => [getCreateCommentsMock()]; diff --git a/x-pack/plugins/lists/common/schemas/types/create_comments.test.ts b/x-pack/plugins/lists/common/schemas/types/create_comments.test.ts new file mode 100644 index 00000000000000..d2680750e05e4e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/create_comments.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getCreateCommentsArrayMock, getCreateCommentsMock } from './create_comments.mock'; +import { + CreateComments, + CreateCommentsArray, + CreateCommentsArrayOrUndefined, + createComments, + createCommentsArray, + createCommentsArrayOrUndefined, +} from './create_comments'; + +describe('CreateComments', () => { + describe('createComments', () => { + test('it should validate a comments', () => { + const payload = getCreateCommentsMock(); + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "{| comment: string |}"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when "comment" is not a string', () => { + const payload: Omit & { comment: string[] } = { + ...getCreateCommentsMock(), + comment: ['some value'], + }; + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "["some value"]" supplied to "comment"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should strip out extra keys', () => { + const payload: CreateComments & { + extraKey?: string; + } = getCreateCommentsMock(); + payload.extraKey = 'some value'; + const decoded = createComments.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(getCreateCommentsMock()); + }); + }); + + describe('createCommentsArray', () => { + test('it should validate an array of comments', () => { + const payload = getCreateCommentsArrayMock(); + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CreateCommentsArray; + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('createCommentsArrayOrUndefined', () => { + test('it should validate an array of comments', () => { + const payload = getCreateCommentsArrayMock(); + const decoded = createCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = createCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as CreateCommentsArrayOrUndefined; + const decoded = createCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/create_comments.ts b/x-pack/plugins/lists/common/schemas/types/create_comments.ts new file mode 100644 index 00000000000000..c34419298ef935 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/create_comments.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; + +export const createComments = t.exact( + t.type({ + comment: t.string, + }) +); + +export const createCommentsArray = t.array(createComments); +export type CreateCommentsArray = t.TypeOf; +export type CreateComments = t.TypeOf; +export const createCommentsArrayOrUndefined = t.union([createCommentsArray, t.undefined]); +export type CreateCommentsArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts new file mode 100644 index 00000000000000..3a4241aaec82d3 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_comments_array.test.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultCommentsArray } from './default_comments_array'; +import { CommentsArray } from './comments'; +import { getCommentsArrayMock } from './comments.mock'; + +describe('default_comments_array', () => { + test('it should validate an empty array', () => { + const payload: CommentsArray = []; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of comments', () => { + const payload: CommentsArray = getCommentsArrayMock(); + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "1" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + 'Invalid value "some string" supplied to "Array<({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>)>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts index e824d481b36188..e8be299246ab82 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_comments_array.ts @@ -7,14 +7,9 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; -import { CommentsArray, CommentsPartialArray, comment, commentPartial } from './comments'; +import { CommentsArray, comments } from './comments'; export type DefaultCommentsArrayC = t.Type; -export type DefaultCommentsPartialArrayC = t.Type< - CommentsPartialArray, - CommentsPartialArray, - unknown ->; /** * Types the DefaultCommentsArray as: @@ -26,24 +21,8 @@ export const DefaultCommentsArray: DefaultCommentsArrayC = new t.Type< unknown >( 'DefaultCommentsArray', - t.array(comment).is, - (input, context): Either => - input == null ? t.success([]) : t.array(comment).validate(input, context), - t.identity -); - -/** - * Types the DefaultCommentsPartialArray as: - * - If null or undefined, then a default array of type entry will be set - */ -export const DefaultCommentsPartialArray: DefaultCommentsPartialArrayC = new t.Type< - CommentsPartialArray, - CommentsPartialArray, - unknown ->( - 'DefaultCommentsPartialArray', - t.array(commentPartial).is, - (input, context): Either => - input == null ? t.success([]) : t.array(commentPartial).validate(input, context), + t.array(comments).is, + (input): Either => + input == null ? t.success([]) : t.array(comments).decode(input), t.identity ); diff --git a/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts new file mode 100644 index 00000000000000..f5ef7d0ad96bd0 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultCreateCommentsArray } from './default_create_comments_array'; +import { CreateCommentsArray } from './create_comments'; +import { getCreateCommentsArrayMock } from './create_comments.mock'; + +describe('default_create_comments_array', () => { + test('it should validate an empty array', () => { + const payload: CreateCommentsArray = []; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of comments', () => { + const payload: CreateCommentsArray = getCreateCommentsArrayMock(); + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "Array<{| comment: string |}>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultCreateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.ts new file mode 100644 index 00000000000000..51431b9c39850b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_create_comments_array.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { CreateCommentsArray, createComments } from './create_comments'; + +export type DefaultCreateCommentsArrayC = t.Type; + +/** + * Types the DefaultCreateComments as: + * - If null or undefined, then a default array of type entry will be set + */ +export const DefaultCreateCommentsArray: DefaultCreateCommentsArrayC = new t.Type< + CreateCommentsArray, + CreateCommentsArray, + unknown +>( + 'DefaultCreateComments', + t.array(createComments).is, + (input): Either => + input == null ? t.success([]) : t.array(createComments).decode(input), + t.identity +); diff --git a/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts new file mode 100644 index 00000000000000..b023e73cb9328b --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultUpdateCommentsArray } from './default_update_comments_array'; +import { UpdateCommentsArray } from './update_comments'; +import { getUpdateCommentsArrayMock } from './update_comments.mock'; + +describe('default_update_comments_array', () => { + test('it should validate an empty array', () => { + const payload: UpdateCommentsArray = []; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of comments', () => { + const payload: UpdateCommentsArray = getUpdateCommentsArrayMock(); + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should NOT validate an array of numbers', () => { + const payload = [1]; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + // TODO: Known weird error formatting that is on our list to address + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate an array of strings', () => { + const payload = ['some string']; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "some string" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = DefaultUpdateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.ts b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.ts new file mode 100644 index 00000000000000..c2593826a6358e --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_update_comments_array.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { UpdateCommentsArray, updateCommentsArray } from './update_comments'; + +export type DefaultUpdateCommentsArrayC = t.Type; + +/** + * Types the DefaultCommentsUpdate as: + * - If null or undefined, then a default array of type entry will be set + */ +export const DefaultUpdateCommentsArray: DefaultUpdateCommentsArrayC = new t.Type< + UpdateCommentsArray, + UpdateCommentsArray, + unknown +>( + 'DefaultCreateComments', + updateCommentsArray.is, + (input): Either => + input == null ? t.success([]) : updateCommentsArray.decode(input), + t.identity +); diff --git a/x-pack/plugins/lists/common/schemas/types/index.ts b/x-pack/plugins/lists/common/schemas/types/index.ts index 97f2b0f59a5fdb..16433e00f2b163 100644 --- a/x-pack/plugins/lists/common/schemas/types/index.ts +++ b/x-pack/plugins/lists/common/schemas/types/index.ts @@ -3,8 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ +export * from './comments'; +export * from './create_comments'; +export * from './update_comments'; export * from './default_comments_array'; -export * from './default_entries_array'; +export * from './default_create_comments_array'; +export * from './default_update_comments_array'; export * from './default_namespace'; -export * from './comments'; +export * from './default_entries_array'; export * from './entries'; diff --git a/x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts b/x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts new file mode 100644 index 00000000000000..3e963c2607dc53 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/update_comments.mock.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getCommentsMock } from './comments.mock'; +import { getCreateCommentsMock } from './create_comments.mock'; +import { UpdateCommentsArray } from './update_comments'; + +export const getUpdateCommentsArrayMock = (): UpdateCommentsArray => [ + getCommentsMock(), + getCreateCommentsMock(), +]; diff --git a/x-pack/plugins/lists/common/schemas/types/update_comments.test.ts b/x-pack/plugins/lists/common/schemas/types/update_comments.test.ts new file mode 100644 index 00000000000000..7668504b031b5a --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/update_comments.test.ts @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { getUpdateCommentsArrayMock } from './update_comments.mock'; +import { + UpdateCommentsArray, + UpdateCommentsArrayOrUndefined, + updateCommentsArray, + updateCommentsArrayOrUndefined, +} from './update_comments'; +import { getCommentsMock } from './comments.mock'; +import { getCreateCommentsMock } from './create_comments.mock'; + +describe('CommentsUpdate', () => { + describe('updateCommentsArray', () => { + test('it should validate an array of comments', () => { + const payload = getUpdateCommentsArrayMock(); + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of existing comments', () => { + const payload = [getCommentsMock()]; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of new comments', () => { + const payload = [getCreateCommentsMock()]; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when undefined', () => { + const payload = undefined; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as UpdateCommentsArray; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + }); + + describe('updateCommentsArrayOrUndefined', () => { + test('it should validate an array of comments', () => { + const payload = getUpdateCommentsArrayMock(); + const decoded = updateCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate when undefined', () => { + const payload = undefined; + const decoded = updateCommentsArrayOrUndefined.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate when array includes non comments types', () => { + const payload = ([1] as unknown) as UpdateCommentsArrayOrUndefined; + const decoded = updateCommentsArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + 'Invalid value "1" supplied to "Array<(({| comment: string, created_at: string, created_by: string |} & Partial<{| updated_at: string, updated_by: string |}>) | {| comment: string |})>"', + ]); + expect(message.schema).toEqual({}); + }); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/update_comments.ts b/x-pack/plugins/lists/common/schemas/types/update_comments.ts new file mode 100644 index 00000000000000..4a21bfa363d450 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/update_comments.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as t from 'io-ts'; + +import { comments } from './comments'; +import { createComments } from './create_comments'; + +export const updateCommentsArray = t.array(t.union([comments, createComments])); +export type UpdateCommentsArray = t.TypeOf; +export const updateCommentsArrayOrUndefined = t.union([updateCommentsArray, t.undefined]); +export type UpdateCommentsArrayOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index 72a689650ea2d3..975641b9bebe26 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -250,7 +250,7 @@ describe('Exceptions Lists API', () => { }); // TODO Would like to just use getExceptionListSchemaMock() here, but // validation returns object in different order, making the strings not match - expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists', { + expect(fetchMock).toHaveBeenCalledWith('/api/exception_lists/items', { body: JSON.stringify(payload), method: 'PUT', signal: abortCtrl.signal, diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 2ab7695d8c17c0..a581cfd08ecc19 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -176,7 +176,7 @@ export const updateExceptionListItem = async ({ if (validatedRequest != null) { try { - const response = await http.fetch(EXCEPTION_LIST_URL, { + const response = await http.fetch(EXCEPTION_LIST_ITEM_URL, { body: JSON.stringify(listItem), method: 'PUT', signal, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index 57bc63e6f7e358..fc04c5e278d64a 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -77,6 +77,12 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = { created_by: { type: 'keyword', }, + updated_at: { + type: 'keyword', + }, + updated_by: { + type: 'keyword', + }, }, }, entries: { diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json index 33c9303c7b5231..08bd95b7d124c8 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json @@ -5,14 +5,7 @@ "type": "simple", "description": "This is a sample change here this list", "name": "Sample Endpoint Exception List update change", - "comments": [ - { - "comment": "this was an old comment.", - "created_by": "lily", - "created_at": "2020-04-20T15:25:31.830Z" - }, - { "comment": "this is a newly added comment" } - ], + "comments": [{ "comment": "this is a newly added comment" }], "entries": [ { "field": "event.category", diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index 22a9fbcfb53aff..a84283aeabbba5 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -8,7 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import uuid from 'uuid'; import { - CommentsPartialArray, + CreateCommentsArray, Description, EntriesArray, ExceptionListItemSchema, @@ -25,13 +25,13 @@ import { import { getSavedObjectType, - transformComments, + transformCreateCommentsToComments, transformSavedObjectToExceptionListItem, } from './utils'; interface CreateExceptionListItemOptions { _tags: _Tags; - comments: CommentsPartialArray; + comments: CreateCommentsArray; listId: ListId; itemId: ItemId; savedObjectsClient: SavedObjectsClientContract; @@ -64,9 +64,10 @@ export const createExceptionListItem = async ({ }: CreateExceptionListItemOptions): Promise => { const savedObjectType = getSavedObjectType({ namespaceType }); const dateNow = new Date().toISOString(); + const transformedComments = transformCreateCommentsToComments({ comments, user }); const savedObject = await savedObjectsClient.create(savedObjectType, { _tags, - comments: transformComments({ comments, user }), + comments: transformedComments, created_at: dateNow, created_by: user, description, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 03f5de516561b5..203d32911a6df0 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - CommentsPartialArray, + CreateCommentsArray, Description, DescriptionOrUndefined, EntriesArray, @@ -30,6 +30,7 @@ import { SortOrderOrUndefined, Tags, TagsOrUndefined, + UpdateCommentsArray, _Tags, _TagsOrUndefined, } from '../../../common/schemas'; @@ -88,7 +89,7 @@ export interface GetExceptionListItemOptions { export interface CreateExceptionListItemOptions { _tags: _Tags; - comments: CommentsPartialArray; + comments: CreateCommentsArray; entries: EntriesArray; itemId: ItemId; listId: ListId; @@ -102,7 +103,7 @@ export interface CreateExceptionListItemOptions { export interface UpdateExceptionListItemOptions { _tags: _TagsOrUndefined; - comments: CommentsPartialArray; + comments: UpdateCommentsArray; entries: EntriesArrayOrUndefined; id: IdOrUndefined; itemId: ItemIdOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts index 7ca9bfd83ab646..5578063fd9b6ca 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts @@ -7,7 +7,6 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - CommentsPartialArray, DescriptionOrUndefined, EntriesArrayOrUndefined, ExceptionListItemSchema, @@ -19,19 +18,20 @@ import { NameOrUndefined, NamespaceType, TagsOrUndefined, + UpdateCommentsArrayOrUndefined, _TagsOrUndefined, } from '../../../common/schemas'; import { getSavedObjectType, - transformComments, transformSavedObjectUpdateToExceptionListItem, + transformUpdateCommentsToComments, } from './utils'; import { getExceptionListItem } from './get_exception_list_item'; interface UpdateExceptionListItemOptions { id: IdOrUndefined; - comments: CommentsPartialArray; + comments: UpdateCommentsArrayOrUndefined; _tags: _TagsOrUndefined; name: NameOrUndefined; description: DescriptionOrUndefined; @@ -71,12 +71,17 @@ export const updateExceptionListItem = async ({ if (exceptionListItem == null) { return null; } else { + const transformedComments = transformUpdateCommentsToComments({ + comments, + existingComments: exceptionListItem.comments, + user, + }); const savedObject = await savedObjectsClient.update( savedObjectType, exceptionListItem.id, { _tags, - comments: transformComments({ comments, user }), + comments: transformedComments, description, entries, meta, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts new file mode 100644 index 00000000000000..9cc2aacd884584 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.test.ts @@ -0,0 +1,437 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import sinon from 'sinon'; +import moment from 'moment'; + +import { DATE_NOW, USER } from '../../../common/constants.mock'; + +import { + isCommentEqual, + transformCreateCommentsToComments, + transformUpdateComments, + transformUpdateCommentsToComments, +} from './utils'; + +describe('utils', () => { + const anchor = '2020-06-17T20:34:51.337Z'; + const unix = moment(anchor).valueOf(); + let clock: sinon.SinonFakeTimers; + + beforeEach(() => { + clock = sinon.useFakeTimers(unix); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('#transformUpdateCommentsToComments', () => { + test('it returns empty array if "comments" is undefined and no comments exist', () => { + const comments = transformUpdateCommentsToComments({ + comments: undefined, + existingComments: [], + user: 'lily', + }); + + expect(comments).toEqual([]); + }); + + test('it formats newly added comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im a new comment' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im a new comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it formats multiple newly added comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im a new comment' }, + { comment: 'Im another new comment' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im a new comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im another new comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it should not throw if comments match existing comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it does not throw if user tries to update one of their own existing comments', () => { + const comments = transformUpdateCommentsToComments({ + comments: [ + { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + updated_at: anchor, + updated_by: 'lily', + }, + ]); + }); + + test('it throws an error if user tries to update their comment, without passing in the "created_at" and "created_by" properties', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { + comment: 'Im an old comment that is trying to be updated', + }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"When trying to update a comment, \\"created_at\\" and \\"created_by\\" must be present"` + ); + }); + + test('it throws an error if user tries to delete comments', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"Comments cannot be deleted, only new comments may be added"` + ); + }); + + test('it throws if user tries to update existing comment timestamp', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update existing comment author', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'me!' }, + ], + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update an existing comment that is not their own', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update order of comments', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im a new comment' }, + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot( + `"When trying to update a comment, \\"created_at\\" and \\"created_by\\" must be present"` + ); + }); + + test('it throws an error if user tries to add comment formatted as existing comment when none yet exist', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im a new comment' }, + ], + existingComments: [], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Only new comments may be added"`); + }); + + test('it throws if empty comment exists', () => { + expect(() => + transformUpdateCommentsToComments({ + comments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: ' ' }, + ], + existingComments: [ + { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + ], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Empty comments not allowed"`); + }); + }); + + describe('#transformCreateCommentsToComments', () => { + test('it returns "undefined" if "comments" is "undefined"', () => { + const comments = transformCreateCommentsToComments({ + comments: undefined, + user: 'lily', + }); + + expect(comments).toBeUndefined(); + }); + + test('it formats newly added comments', () => { + const comments = transformCreateCommentsToComments({ + comments: [{ comment: 'Im a new comment' }, { comment: 'Im another new comment' }], + user: 'lily', + }); + + expect(comments).toEqual([ + { + comment: 'Im a new comment', + created_at: anchor, + created_by: 'lily', + }, + { + comment: 'Im another new comment', + created_at: anchor, + created_by: 'lily', + }, + ]); + }); + + test('it throws an error if user tries to add an empty comment', () => { + expect(() => + transformCreateCommentsToComments({ + comments: [{ comment: ' ' }], + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Empty comments not allowed"`); + }); + }); + + describe('#transformUpdateComments', () => { + test('it updates comment and adds "updated_at" and "updated_by"', () => { + const comments = transformUpdateComments({ + comment: { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: DATE_NOW, + created_by: 'lily', + }, + user: 'lily', + }); + + expect(comments).toEqual({ + comment: 'Im an old comment that is trying to be updated', + created_at: '2020-04-20T15:25:31.830Z', + created_by: 'lily', + updated_at: anchor, + updated_by: 'lily', + }); + }); + + test('it throws if user tries to update an existing comment that is not their own', () => { + expect(() => + transformUpdateComments({ + comment: { + comment: 'Im an old comment that is trying to be updated', + created_at: DATE_NOW, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: DATE_NOW, + created_by: 'lily', + }, + user: 'bane', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Not authorized to edit others comments"`); + }); + + test('it throws if user tries to update an existing comments timestamp', () => { + expect(() => + transformUpdateComments({ + comment: { + comment: 'Im an old comment that is trying to be updated', + created_at: anchor, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: DATE_NOW, + created_by: 'lily', + }, + user: 'lily', + }) + ).toThrowErrorMatchingInlineSnapshot(`"Unable to update comment"`); + }); + }); + + describe('#isCommentEqual', () => { + test('it returns false if "comment" values differ', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some older comment', + created_at: DATE_NOW, + created_by: USER, + } + ); + + expect(result).toBeFalsy(); + }); + + test('it returns false if "created_at" values differ', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some old comment', + created_at: anchor, + created_by: USER, + } + ); + + expect(result).toBeFalsy(); + }); + + test('it returns false if "created_by" values differ', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: 'lily', + } + ); + + expect(result).toBeFalsy(); + }); + + test('it returns true if comment values are equivalent', () => { + const result = isCommentEqual( + { + comment: 'some old comment', + created_at: DATE_NOW, + created_by: USER, + }, + { + created_at: DATE_NOW, + created_by: USER, + // Disabling to assure that order doesn't matter + // eslint-disable-next-line sort-keys + comment: 'some old comment', + } + ); + + expect(result).toBeTruthy(); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index 5690a42bed87ed..14b5309f67dc92 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -6,15 +6,21 @@ import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { ErrorWithStatusCode } from '../../error_with_status_code'; import { + Comments, + CommentsArray, CommentsArrayOrUndefined, - CommentsPartialArrayOrUndefined, + CreateComments, + CreateCommentsArrayOrUndefined, ExceptionListItemSchema, ExceptionListSchema, ExceptionListSoSchema, FoundExceptionListItemSchema, FoundExceptionListSchema, NamespaceType, + UpdateCommentsArrayOrUndefined, + comments as commentsSchema, } from '../../../common/schemas'; import { SavedObjectType, @@ -251,21 +257,103 @@ export const transformSavedObjectsToFoundExceptionList = ({ }; }; -export const transformComments = ({ +/* + * Determines whether two comments are equal, this is a very + * naive implementation, not meant to be used for deep equality of complex objects + */ +export const isCommentEqual = (commentA: Comments, commentB: Comments): boolean => { + const a = Object.values(commentA).sort().join(); + const b = Object.values(commentB).sort().join(); + + return a === b; +}; + +export const transformUpdateCommentsToComments = ({ + comments, + existingComments, + user, +}: { + comments: UpdateCommentsArrayOrUndefined; + existingComments: CommentsArray; + user: string; +}): CommentsArray => { + const newComments = comments ?? []; + + if (newComments.length < existingComments.length) { + throw new ErrorWithStatusCode( + 'Comments cannot be deleted, only new comments may be added', + 403 + ); + } else { + return newComments.flatMap((c, index) => { + const existingComment = existingComments[index]; + + if (commentsSchema.is(existingComment) && !commentsSchema.is(c)) { + throw new ErrorWithStatusCode( + 'When trying to update a comment, "created_at" and "created_by" must be present', + 403 + ); + } else if (commentsSchema.is(c) && existingComment == null) { + throw new ErrorWithStatusCode('Only new comments may be added', 403); + } else if ( + commentsSchema.is(c) && + existingComment != null && + !isCommentEqual(c, existingComment) + ) { + return transformUpdateComments({ comment: c, existingComment, user }); + } else { + return transformCreateCommentsToComments({ comments: [c], user }) ?? []; + } + }); + } +}; + +export const transformUpdateComments = ({ + comment, + existingComment, + user, +}: { + comment: Comments; + existingComment: Comments; + user: string; +}): Comments => { + if (comment.created_by !== user) { + // existing comment is being edited, can only be edited by author + throw new ErrorWithStatusCode('Not authorized to edit others comments', 401); + } else if (existingComment.created_at !== comment.created_at) { + throw new ErrorWithStatusCode('Unable to update comment', 403); + } else if (comment.comment.trim().length === 0) { + throw new ErrorWithStatusCode('Empty comments not allowed', 403); + } else { + const dateNow = new Date().toISOString(); + + return { + ...comment, + updated_at: dateNow, + updated_by: user, + }; + } +}; + +export const transformCreateCommentsToComments = ({ comments, user, }: { - comments: CommentsPartialArrayOrUndefined; + comments: CreateCommentsArrayOrUndefined; user: string; }): CommentsArrayOrUndefined => { const dateNow = new Date().toISOString(); if (comments != null) { - return comments.map((comment) => { - return { - comment: comment.comment, - created_at: comment.created_at ?? dateNow, - created_by: comment.created_by ?? user, - }; + return comments.map((c: CreateComments) => { + if (c.comment.trim().length === 0) { + throw new ErrorWithStatusCode('Empty comments not allowed', 403); + } else { + return { + comment: c.comment, + created_at: dateNow, + created_by: user, + }; + } }); } else { return comments; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 244819080c93de..b936aea0476906 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -37,7 +37,7 @@ import { getEntryMatchAnyMock, getEntriesArrayMock, } from '../../../../../lists/common/schemas/types/entries.mock'; -import { getCommentsMock } from '../../../../../lists/common/schemas/types/comments.mock'; +import { getCommentsArrayMock } from '../../../../../lists/common/schemas/types/comments.mock'; describe('Exception helpers', () => { beforeEach(() => { @@ -382,7 +382,7 @@ describe('Exception helpers', () => { describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { - const payload = getCommentsMock(); + const payload = getCommentsArrayMock(); const result = getFormattedComments(payload); expect(result[0].username).toEqual('some user'); @@ -390,7 +390,7 @@ describe('Exception helpers', () => { }); test('it returns formatted timeline icon with comment users initial', () => { - const payload = getCommentsMock(); + const payload = getCommentsArrayMock(); const result = getFormattedComments(payload); const wrapper = mount(result[0].timelineIcon as React.ReactElement); @@ -399,12 +399,12 @@ describe('Exception helpers', () => { }); test('it returns comment text', () => { - const payload = getCommentsMock(); + const payload = getCommentsArrayMock(); const result = getFormattedComments(payload); const wrapper = mount(result[0].children as React.ReactElement); - expect(wrapper.text()).toEqual('some comment'); + expect(wrapper.text()).toEqual('some old comment'); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 164940db619f9b..ae4131f9f62c22 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -10,9 +10,10 @@ import { capitalize } from 'lodash'; import moment from 'moment'; import * as i18n from './translations'; -import { FormattedEntry, OperatorOption, DescriptionListItem, Comment } from './types'; +import { FormattedEntry, OperatorOption, DescriptionListItem } from './types'; import { EXCEPTION_OPERATORS, isOperator } from './operators'; import { + CommentsArray, Entry, EntriesArray, ExceptionListItemSchema, @@ -183,7 +184,7 @@ export const getDescriptionListContent = ( * * @param comments ExceptionItem.comments */ -export const getFormattedComments = (comments: Comment[]): EuiCommentProps[] => +export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] => comments.map((comment) => ({ username: comment.created_by, timestamp: moment(comment.created_at).format('on MMM Do YYYY @ HH:mm:ss'), diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts index 24c328462ce2fe..ed2be64b4430fd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts @@ -26,12 +26,6 @@ export interface DescriptionListItem { description: NonNullable; } -export interface Comment { - created_by: string; - created_at: string; - comment: string; -} - export enum ExceptionListType { DETECTION_ENGINE = 'detection', ENDPOINT = 'endpoint', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx index 3ea8507d82a157..f5b34b7838d25d 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/exception_details.test.tsx @@ -12,7 +12,7 @@ import moment from 'moment-timezone'; import { ExceptionDetails } from './exception_details'; import { getExceptionListItemSchemaMock } from '../../../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; -import { getCommentsMock } from '../../../../../../../lists/common/schemas/types/comments.mock'; +import { getCommentsArrayMock } from '../../../../../../../lists/common/schemas/types/comments.mock'; describe('ExceptionDetails', () => { beforeEach(() => { @@ -42,7 +42,7 @@ describe('ExceptionDetails', () => { test('it renders comments button if comments exist', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders correct number of comments', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = [getCommentsMock()[0]]; + exceptionItem.comments = [getCommentsArrayMock()[0]]; const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders comments plural if more than one', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders comments show text if "showComments" is false', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it renders comments hide text if "showComments" is true', () => { const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { test('it invokes "onCommentsClick" when comments button clicked', () => { const mockOnCommentsClick = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> ( ({ eui: euiLightVars, darkMode: false })}>{storyFn()} @@ -68,7 +68,7 @@ storiesOf('Components|ExceptionItem', module) const payload = getExceptionListItemSchemaMock(); payload._tags = []; payload.description = ''; - payload.comments = getCommentsMock(); + payload.comments = getCommentsArrayMock(); payload.entries = [ { field: 'actingProcess.file.signer', @@ -106,7 +106,7 @@ storiesOf('Components|ExceptionItem', module) }) .add('with everything', () => { const payload = getExceptionListItemSchemaMock(); - payload.comments = getCommentsMock(); + payload.comments = getCommentsArrayMock(); return ( { it('it renders ExceptionDetails and ExceptionEntries', () => { @@ -83,7 +83,7 @@ describe('ExceptionItem', () => { it('it renders comment accordion closed to begin with', () => { const mockOnDeleteException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> { it('it renders comment accordion open when showComments is true', () => { const mockOnDeleteException = jest.fn(); const exceptionItem = getExceptionListItemSchemaMock(); - exceptionItem.comments = getCommentsMock(); + exceptionItem.comments = getCommentsArrayMock(); const wrapper = mount( ({ eui: euiLightVars, darkMode: false })}> ({ })); describe('Index Fields & Browser Fields', () => { + test('At initialization the value of indicesExists should be true', async () => { + const { result, waitForNextUpdate } = renderHook(() => useWithSource()); + const initialResult = result.current; + + await waitForNextUpdate(); + + return expect(initialResult).toEqual({ + browserFields: {}, + errorMessage: null, + indexPattern: { + fields: [], + title: 'apm-*-transaction*,auditbeat-*,endgame-*,filebeat-*,packetbeat-*,winlogbeat-*', + }, + indicesExist: true, + loading: true, + }); + }); + test('returns memoized value', async () => { const { result, waitForNextUpdate, rerender } = renderHook(() => useWithSource()); await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 34ac5f8f5d94fa..5e80953914c970 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -102,7 +102,7 @@ export const useWithSource = (sourceId = 'default', indexToAdd?: string[] | null browserFields: EMPTY_BROWSER_FIELDS, errorMessage: null, indexPattern: getIndexFields(defaultIndex.join(), []), - indicesExist: undefined, + indicesExist: indicesExistOrDataTemporarilyUnavailable(undefined), loading: false, }); diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index b5a400d187f822..677543ec0dba68 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -7,12 +7,12 @@ import { i18n } from '@kbn/i18n'; export const EMPTY_TITLE = i18n.translate('xpack.securitySolution.pages.common.emptyTitle', { - defaultMessage: 'Welcome to SIEM. Let’s get you started.', + defaultMessage: 'Welcome to Security Solution. Let’s get you started.', }); export const EMPTY_MESSAGE = i18n.translate('xpack.securitySolution.pages.common.emptyMessage', { defaultMessage: - 'To begin using security information and event management (SIEM), you’ll need to add SIEM-related data, in Elastic Common Schema (ECS) format, to the Elastic Stack. An easy way to get started is by installing and configuring our data shippers, called Beats. Let’s do that now!', + 'To begin using security information and event management (Security Solution), you’ll need to add security solution related data, in Elastic Common Schema (ECS) format, to the Elastic Stack. An easy way to get started is by installing and configuring our data shippers, called Beats. Let’s do that now!', }); export const EMPTY_ACTION_PRIMARY = i18n.translate( diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index 575ff26330a460..f3a724a755a48e 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -15,6 +15,7 @@ export { UseExceptionListSuccess, } from '../../lists/public'; export { + CommentsArray, ExceptionListSchema, ExceptionListItemSchema, Entry, diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 360c81abadc810..b247170a4a5db7 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -16,6 +16,7 @@ import { PluginInitializerContext, Plugin as IPlugin, DEFAULT_APP_CATEGORIES, + AppNavLinkStatus, } from '../../../../src/core/public'; import { Storage } from '../../../../src/plugins/kibana_utils/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; @@ -35,6 +36,7 @@ import { APP_CASES_PATH, SHOW_ENDPOINT_ALERTS_NAV, APP_ENDPOINT_ALERTS_PATH, + APP_PATH, } from '../common/constants'; import { ConfigureEndpointDatasource } from './management/pages/policy/view/ingest_manager_integration/configure_datasource'; @@ -86,18 +88,18 @@ export class Plugin implements IPlugin { - // const [{ application }] = await core.getStartServices(); - // application.navigateToApp(`${APP_ID}:${SecurityPageName.overview}`, { replace: true }); - // return () => true; - // }, - // }); + core.application.register({ + exactRoute: true, + id: APP_ID, + title: 'Security', + appRoute: APP_PATH, + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + const [{ application }] = await core.getStartServices(); + application.navigateToApp(`${APP_ID}:${SecurityPageName.overview}`, { replace: true }); + return () => true; + }, + }); core.application.register({ id: `${APP_ID}:${SecurityPageName.overview}`, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8c9110e4e3b488..61f6c05d6d1817 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -16568,7 +16568,6 @@ "xpack.uptime.locationMap.locations.missing.message": "重要な位置情報構成がありません。{codeBlock}フィールドを使用して、アップタイムチェック用に一意の地域を作成できます。", "xpack.uptime.locationMap.locations.missing.message1": "詳細については、ドキュメンテーションを参照してください。", "xpack.uptime.locationMap.locations.missing.title": "地理情報の欠測", - "xpack.uptime.locationMap.locations.tags.others": "{otherLoc}その他 ...", "xpack.uptime.locationName.helpLinkAnnotation": "場所を追加", "xpack.uptime.ml.durationChart.exploreInMlApp": "ML アプリで探索", "xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle": "異常検知", @@ -16652,12 +16651,7 @@ "xpack.uptime.monitorStatusBar.locations.oneLocStatus": "{loc}場所での{status}", "xpack.uptime.monitorStatusBar.locations.upStatus": "{loc}場所での{status}", "xpack.uptime.monitorStatusBar.monitorUrlLinkAriaLabel": "監視 URL リンク", - "xpack.uptime.monitorStatusBar.sslCertificate.overview": "証明書概要", "xpack.uptime.monitorStatusBar.sslCertificate.title": "証明書", - "xpack.uptime.monitorStatusBar.sslCertificateExpired.badgeContent": "{emphasizedText}が期限切れになりました", - "xpack.uptime.monitorStatusBar.sslCertificateExpired.label.ariaLabel": "{validityDate}に期限切れになりました", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.badgeContent": "{emphasizedText}が期限切れになります", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.label.ariaLabel": "{validityDate}に期限切れになります", "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "最終確認からの経過時間", "xpack.uptime.navigateToAlertingButton.content": "アラートを管理", "xpack.uptime.navigateToAlertingUi": "Uptime を離れてアラート管理ページに移動します", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 94be00dd2a6da4..653edcdec9caea 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -16574,7 +16574,6 @@ "xpack.uptime.locationMap.locations.missing.message": "重要的地理位置配置缺失。您可以使用 {codeBlock} 字段为您的运行时间检查创建独特的地理区域。", "xpack.uptime.locationMap.locations.missing.message1": "在我们的文档中获取更多的信息。", "xpack.uptime.locationMap.locations.missing.title": "地理信息缺失", - "xpack.uptime.locationMap.locations.tags.others": "{otherLoc} 其他......", "xpack.uptime.locationName.helpLinkAnnotation": "添加位置", "xpack.uptime.ml.durationChart.exploreInMlApp": "在 ML 应用中浏览", "xpack.uptime.ml.enableAnomalyDetectionPanel.anomalyDetectionTitle": "异常检测", @@ -16658,12 +16657,7 @@ "xpack.uptime.monitorStatusBar.locations.oneLocStatus": "在 {loc} 位置{status}", "xpack.uptime.monitorStatusBar.locations.upStatus": "在 {loc} 位置{status}", "xpack.uptime.monitorStatusBar.monitorUrlLinkAriaLabel": "监测 URL 链接", - "xpack.uptime.monitorStatusBar.sslCertificate.overview": "证书概览", "xpack.uptime.monitorStatusBar.sslCertificate.title": "证书", - "xpack.uptime.monitorStatusBar.sslCertificateExpired.badgeContent": "{emphasizedText}过期", - "xpack.uptime.monitorStatusBar.sslCertificateExpired.label.ariaLabel": "已于 {validityDate}过期", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.badgeContent": "{emphasizedText}过期", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.label.ariaLabel": "将于 {validityDate}过期", "xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel": "自上次检查以来经过的时间", "xpack.uptime.navigateToAlertingButton.content": "管理告警", "xpack.uptime.navigateToAlertingUi": "离开 Uptime 并前往“Alerting 管理”页面", diff --git a/x-pack/plugins/uptime/common/runtime_types/common.ts b/x-pack/plugins/uptime/common/runtime_types/common.ts index e07c46fa01cfe9..603d242d4dcd04 100644 --- a/x-pack/plugins/uptime/common/runtime_types/common.ts +++ b/x-pack/plugins/uptime/common/runtime_types/common.ts @@ -6,15 +6,19 @@ import * as t from 'io-ts'; -export const LocationType = t.partial({ +export const LocationType = t.type({ lat: t.string, lon: t.string, }); -export const CheckGeoType = t.partial({ - name: t.string, - location: LocationType, -}); +export const CheckGeoType = t.intersection([ + t.type({ + name: t.string, + }), + t.partial({ + location: LocationType, + }), +]); export const SummaryType = t.partial({ up: t.number, @@ -34,5 +38,6 @@ export const DateRangeType = t.type({ export type Summary = t.TypeOf; export type Location = t.TypeOf; +export type GeoPoint = t.TypeOf; export type StatesIndexStatus = t.TypeOf; export type DateRange = t.TypeOf; diff --git a/x-pack/plugins/uptime/common/runtime_types/monitor/locations.ts b/x-pack/plugins/uptime/common/runtime_types/monitor/locations.ts index ea3cfe677ca991..00ed1dc407e98f 100644 --- a/x-pack/plugins/uptime/common/runtime_types/monitor/locations.ts +++ b/x-pack/plugins/uptime/common/runtime_types/monitor/locations.ts @@ -7,17 +7,23 @@ import * as t from 'io-ts'; import { CheckGeoType, SummaryType } from '../common'; // IO type for validation -export const MonitorLocationType = t.partial({ +export const MonitorLocationType = t.type({ + up_history: t.number, + down_history: t.number, + timestamp: t.string, summary: SummaryType, geo: CheckGeoType, - timestamp: t.string, }); // Typescript type for type checking export type MonitorLocation = t.TypeOf; export const MonitorLocationsType = t.intersection([ - t.type({ monitorId: t.string }), + t.type({ + monitorId: t.string, + up_history: t.number, + down_history: t.number, + }), t.partial({ locations: t.array(MonitorLocationType) }), ]); export type MonitorLocations = t.TypeOf; diff --git a/x-pack/plugins/uptime/public/components/certificates/translations.ts b/x-pack/plugins/uptime/public/components/certificates/translations.ts index 518eddf1211a4f..176625d647ca0e 100644 --- a/x-pack/plugins/uptime/public/components/certificates/translations.ts +++ b/x-pack/plugins/uptime/public/components/certificates/translations.ts @@ -18,6 +18,10 @@ export const EXPIRES_SOON = i18n.translate('xpack.uptime.certs.expireSoon', { defaultMessage: 'Expires soon', }); +export const EXPIRES = i18n.translate('xpack.uptime.certs.expires', { + defaultMessage: 'Expires', +}); + export const SEARCH_CERTS = i18n.translate('xpack.uptime.certs.searchCerts', { defaultMessage: 'Search certificates', }); diff --git a/x-pack/plugins/uptime/public/components/common/translations.ts b/x-pack/plugins/uptime/public/components/common/translations.ts new file mode 100644 index 00000000000000..d2c466ddf0c838 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/common/translations.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const URL_LABEL = i18n.translate('xpack.uptime.monitorList.table.url.name', { + defaultMessage: 'Url', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/index.ts b/x-pack/plugins/uptime/public/components/monitor/index.ts index cb7b27afded023..fd9a9a2c897d74 100644 --- a/x-pack/plugins/uptime/public/components/monitor/index.ts +++ b/x-pack/plugins/uptime/public/components/monitor/index.ts @@ -6,7 +6,7 @@ export * from './ml'; export * from './ping_list'; -export * from './location_map'; -export * from './monitor_status_details'; +export * from './status_details/location_map'; +export * from './status_details'; export * from './ping_histogram'; export * from './monitor_charts'; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_map.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_map.test.tsx.snap deleted file mode 100644 index 7b847782fe1ac8..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_map.test.tsx.snap +++ /dev/null @@ -1,282 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LocationMap component doesnt shows warning if geo is provided 1`] = ` - - - - - - - - - - - - - - -`; - -exports[`LocationMap component renders correctly against snapshot 1`] = ` - - - - - - - - - - - - - - - -`; - -exports[`LocationMap component renders named locations that have missing geo data 1`] = ` - - - - - - - - - - - - - - - -`; - -exports[`LocationMap component shows warning if geo information is missing 1`] = ` - - - - - - - - - - - - - - - -`; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap deleted file mode 100644 index f2a390792918ae..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap +++ /dev/null @@ -1,682 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`LocationStatusTags component renders properly against props 1`] = ` - - - - - - - - Berlin - - - - - - 1 Mon ago - - - - - - - - - - Berlin - - - - - - 1 Mon ago - - - - - - - - Islamabad - - - - - - 1 Mon ago - - - - - - -`; - -exports[`LocationStatusTags component renders when all locations are down 1`] = ` -.c3 { - display: inline-block; - margin-left: 4px; -} - -.c2 { - font-weight: 600; -} - -.c1 { - margin-bottom: 5px; - white-space: nowrap; -} - -.c0 { - max-height: 229px; - overflow: hidden; - margin-top: auto; -} - -@media (max-width:1042px) { - .c1 { - display: inline-block; - margin-right: 16px; - } -} - -
- -
- - - -
-
- Islamabad -
-
-
-
-
- -
-
- 5s ago -
-
-
-
-
- - - -
-
- Berlin -
-
-
-
-
- -
-
- 5m ago -
-
-
-
-
- -
-`; - -exports[`LocationStatusTags component renders when all locations are up 1`] = ` -.c3 { - display: inline-block; - margin-left: 4px; -} - -.c2 { - font-weight: 600; -} - -.c1 { - margin-bottom: 5px; - white-space: nowrap; -} - -.c0 { - max-height: 229px; - overflow: hidden; - margin-top: auto; -} - -@media (max-width:1042px) { - .c1 { - display: inline-block; - margin-right: 16px; - } -} - -
- - -
- - - -
-
- Berlin -
-
-
-
-
- -
-
- 5d ago -
-
-
-
-
- - - -
-
- Islamabad -
-
-
-
-
- -
-
- 5s ago -
-
-
-
-
-
-`; - -exports[`LocationStatusTags component renders when there are many location 1`] = ` -Array [ - .c3 { - display: inline-block; - margin-left: 4px; -} - -.c2 { - font-weight: 600; -} - -.c1 { - margin-bottom: 5px; - white-space: nowrap; -} - -.c0 { - max-height: 229px; - overflow: hidden; - margin-top: auto; -} - -@media (max-width:1042px) { - .c1 { - display: inline-block; - margin-right: 16px; - } -} - -
- -
- - - -
-
- Islamabad -
-
-
-
-
- -
-
- 5s ago -
-
-
-
-
- - - -
-
- Berlin -
-
-
-
-
- -
-
- 5m ago -
-
-
-
-
- - - -
-
- st-paul -
-
-
-
-
- -
-
- 5h ago -
-
-
-
-
- - - -
-
- Tokyo -
-
-
-
-
- -
-
- 5d ago -
-
-
-
-
- - - -
-
- New York -
-
-
-
-
- -
-
- 1 Mon ago -
-
-
-
-
- - - -
-
- Toronto -
-
-
-
-
- -
-
- 5 Mon ago -
-
-
-
-
- - - -
-
- Sydney -
-
-
-
-
- -
-
- 5 Yr ago -
-
-
-
-
- - - -
-
- Paris -
-
-
-
-
- -
-
- 5 Yr ago -
-
-
-
-
- -
, - .c0 { - padding-left: 18px; -} - -@media (max-width:1042px) { - -} - -
-
-
-

- 1 Others ... -

-
-
-
, -] -`; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/location_map.tsx b/x-pack/plugins/uptime/public/components/monitor/location_map/location_map.tsx deleted file mode 100644 index 916f1cbb63e534..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/location_map.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import styled from 'styled-components'; -import { EuiFlexGroup, EuiFlexItem, EuiErrorBoundary, EuiHideFor } from '@elastic/eui'; -import { LocationStatusTags } from './location_status_tags'; -import { EmbeddedMap, LocationPoint } from './embeddables/embedded_map'; -import { MonitorLocations, MonitorLocation } from '../../../../common/runtime_types'; -import { UNNAMED_LOCATION } from '../../../../common/constants'; -import { LocationMissingWarning } from './location_missing'; - -// These height/width values are used to make sure map is in center of panel -// And to make sure, it doesn't take too much space -const MapPanel = styled.div` - height: 240px; - width: 520px; - @media (min-width: 1300px) { - margin-right: 20px; - } - @media (max-width: 574px) { - height: 250px; - width: 100%; - margin-right: 0; - } -`; - -const EuiFlexItemTags = styled(EuiFlexItem)` - padding-top: 5px; - @media (max-width: 1042px) { - flex-basis: 80% !important; - flex-grow: 0 !important; - order: 1; - } -`; - -const FlexGroup = styled(EuiFlexGroup)` - @media (max-width: 850px) { - justify-content: center; - } -`; - -interface LocationMapProps { - monitorLocations: MonitorLocations; -} - -export const LocationMap = ({ monitorLocations }: LocationMapProps) => { - const upPoints: LocationPoint[] = []; - const downPoints: LocationPoint[] = []; - - let isGeoInfoMissing = false; - - if (monitorLocations?.locations) { - monitorLocations.locations.forEach((item: MonitorLocation) => { - if (item.geo?.name === UNNAMED_LOCATION || !item.geo?.location) { - isGeoInfoMissing = true; - } else if ( - item.geo?.name !== UNNAMED_LOCATION && - !!item.geo.location.lat && - !!item.geo.location.lon - ) { - // TypeScript doesn't infer that the above checks in this block's condition - // ensure that lat and lon are defined when we try to pass the location object directly, - // but if we destructure the values it does. Improvement to this block is welcome. - const { lat, lon } = item.geo.location; - if (item?.summary?.down === 0) { - upPoints.push({ lat, lon }); - } else { - downPoints.push({ lat, lon }); - } - } - }); - } - - return ( - - - - - - - - {isGeoInfoMissing && } - - - - - - - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/location_status_tags.tsx b/x-pack/plugins/uptime/public/components/monitor/location_map/location_status_tags.tsx deleted file mode 100644 index db84fd5e2ca425..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/location_status_tags.tsx +++ /dev/null @@ -1,130 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useContext } from 'react'; -import moment from 'moment'; -import styled from 'styled-components'; -import { EuiBadge, EuiText } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { UptimeThemeContext } from '../../../contexts'; -import { MonitorLocation } from '../../../../common/runtime_types'; -import { SHORT_TIMESPAN_LOCALE, SHORT_TS_LOCALE } from '../../../../common/constants'; - -const TimeStampSpan = styled.span` - display: inline-block; - margin-left: 4px; -`; - -const TextStyle = styled.div` - font-weight: 600; -`; - -const BadgeItem = styled.div` - margin-bottom: 5px; - white-space: nowrap; - @media (max-width: 1042px) { - display: inline-block; - margin-right: 16px; - } -`; - -// Set height so that it remains within panel, enough height to display 7 locations tags -const TagContainer = styled.div` - max-height: 229px; - overflow: hidden; - margin-top: auto; -`; - -const OtherLocationsDiv = styled.div` - padding-left: 18px; -`; - -interface Props { - locations: MonitorLocation[]; -} - -interface StatusTag { - label: string; - timestamp: number; -} - -export const LocationStatusTags = ({ locations }: Props) => { - const { - colors: { gray, danger }, - } = useContext(UptimeThemeContext); - - const upLocations: StatusTag[] = []; - const downLocations: StatusTag[] = []; - - locations.forEach((item: any) => { - if (item.summary.down === 0) { - upLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); - } else { - downLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); - } - }); - - // Sort lexicographically by label - upLocations.sort((a, b) => { - return a.label > b.label ? 1 : b.label > a.label ? -1 : 0; - }); - - const tagLabel = (item: StatusTag, ind: number, color: string) => { - return ( - - - - {item.label} - - - - {moment(item.timestamp).fromNow()} - - - ); - }; - - const prevLocal: string = moment.locale() ?? 'en'; - - const renderTags = () => { - const shortLocale = moment.locale(SHORT_TS_LOCALE) === SHORT_TS_LOCALE; - if (!shortLocale) { - moment.defineLocale(SHORT_TS_LOCALE, SHORT_TIMESPAN_LOCALE); - } - - const tags = ( - - {downLocations.map((item, ind) => tagLabel(item, ind, danger))} - {upLocations.map((item, ind) => tagLabel(item, ind, gray))} - - ); - - // Need to reset locale so it doesn't effect other parts of the app - moment.locale(prevLocal); - return tags; - }; - - return ( - <> - {renderTags()} - {locations.length > 7 && ( - - -

- -

-
-
- )} - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap deleted file mode 100644 index ff63b3695fb8df..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap +++ /dev/null @@ -1,55 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MonitorStatusBar component renders duration in ms, not us 1`] = ` -
-
-
-

- Up in 2 Locations -

-
-
- -
- -

- id1 -

-
-
-
-
-
-`; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/ssl_certificate.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/ssl_certificate.tsx deleted file mode 100644 index 73b58e8a33f6ba..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/ssl_certificate.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import moment from 'moment'; -import { i18n } from '@kbn/i18n'; -import { Link } from 'react-router-dom'; -import { EuiSpacer, EuiText, EuiBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { Tls } from '../../../../../common/runtime_types'; -import { useCertStatus } from '../../../../hooks'; -import { CERT_STATUS, CERTIFICATES_ROUTE } from '../../../../../common/constants'; - -interface Props { - /** - * TLS information coming from monitor in ES heartbeat index - */ - tls: Tls | null | undefined; -} - -export const MonitorSSLCertificate = ({ tls }: Props) => { - const certStatus = useCertStatus(tls?.not_after); - - const isExpiringSoon = certStatus === CERT_STATUS.EXPIRING_SOON; - - const isExpired = certStatus === CERT_STATUS.EXPIRED; - - const relativeDate = moment(tls?.not_after).fromNow(); - - return certStatus ? ( - <> - - {i18n.translate('xpack.uptime.monitorStatusBar.sslCertificate.title', { - defaultMessage: 'Certificate:', - })} - - - - - - {isExpired ? ( - {relativeDate}, - }} - /> - ) : ( - - {relativeDate} - - ), - }} - /> - )} - - - - - - {i18n.translate('xpack.uptime.monitorStatusBar.sslCertificate.overview', { - defaultMessage: 'Certificate overview', - })} - - - - - - ) : null; -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_bar.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_bar.tsx deleted file mode 100644 index 36159dc29eccde..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_bar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - EuiLink, - EuiTitle, - EuiTextColor, - EuiSpacer, - EuiText, - EuiFlexGroup, - EuiFlexItem, -} from '@elastic/eui'; -import { MonitorSSLCertificate } from './ssl_certificate'; -import * as labels from './translations'; -import { StatusByLocations } from './status_by_location'; -import { Ping } from '../../../../../common/runtime_types'; -import { MonitorLocations } from '../../../../../common/runtime_types'; - -interface MonitorStatusBarProps { - monitorId: string; - monitorStatus: Ping | null; - monitorLocations: MonitorLocations; -} - -export const MonitorStatusBarComponent: React.FC = ({ - monitorId, - monitorStatus, - monitorLocations, -}) => { - const full = monitorStatus?.url?.full ?? ''; - - return ( - - - - - - - - {full} - - - - - - -

{monitorId}

-
-
-
- - - - -
- ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/translations.ts b/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/translations.ts deleted file mode 100644 index f60a1ceeaafb84..00000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/translations.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; - -export const healthStatusMessageAriaLabel = i18n.translate( - 'xpack.uptime.monitorStatusBar.healthStatusMessageAriaLabel', - { - defaultMessage: 'Monitor status', - } -); - -export const upLabel = i18n.translate('xpack.uptime.monitorStatusBar.healthStatusMessage.upLabel', { - defaultMessage: 'Up', -}); - -export const downLabel = i18n.translate( - 'xpack.uptime.monitorStatusBar.healthStatusMessage.downLabel', - { - defaultMessage: 'Down', - } -); - -export const monitorUrlLinkAriaLabel = i18n.translate( - 'xpack.uptime.monitorStatusBar.monitorUrlLinkAriaLabel', - { - defaultMessage: 'Monitor URL link', - } -); - -export const durationTextAriaLabel = i18n.translate( - 'xpack.uptime.monitorStatusBar.durationTextAriaLabel', - { - defaultMessage: 'Monitor duration in milliseconds', - } -); - -export const timestampFromNowTextAriaLabel = i18n.translate( - 'xpack.uptime.monitorStatusBar.timestampFromNowTextAriaLabel', - { - defaultMessage: 'Time since last check', - } -); - -export const loadingMessage = i18n.translate('xpack.uptime.monitorStatusBar.loadingMessage', { - defaultMessage: 'Loading…', -}); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap new file mode 100644 index 00000000000000..d53f338b60aedb --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/monitor_status.bar.test.tsx.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorStatusBar component renders 1`] = ` +Array [ +
+
+

+ Up in 2 Locations +

+
+
, +
, + .c0.c0.c0 { + width: 35%; +} + +.c1.c1.c1 { + width: 65%; + overflow-wrap: anywhere; +} + +
+
+ Overall availability +
+
+ 0.00 % +
+
+ Url +
+
+ + +
+ +
+
+ Monitor ID +
+
+
, +] +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/__snapshots__/ssl_certificate.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/ssl_certificate.test.tsx.snap similarity index 61% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/__snapshots__/ssl_certificate.test.tsx.snap rename to x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/ssl_certificate.test.tsx.snap index 628e1d576181eb..5b63a09d4f7c4b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/__snapshots__/ssl_certificate.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/__snapshots__/ssl_certificate.test.tsx.snap @@ -2,61 +2,91 @@ exports[`SSL Certificate component renders 1`] = ` Array [ -
- Certificate: -
, + TLS Certificate + ,
, -
-
-
- Expires - - - - in 2 months - - - -
-
- , + + + , ] `; -exports[`SSL Certificate component renders null if invalid date 1`] = `null`; +exports[`SSL Certificate component renders null if invalid date 1`] = ` +Array [ + .c0.c0.c0 { + width: 35%; +} + +
+ TLS Certificate +
, +
, + .c0.c0.c0 { + width: 65%; + overflow-wrap: anywhere; +} + +
+ + + -- + + +
, +] +`; exports[`SSL Certificate component shallow renders 1`] = ` { let monitorStatus: Ping; @@ -49,18 +50,21 @@ describe('MonitorStatusBar component', () => { const spy = jest.spyOn(redux, 'useDispatch'); spy.mockReturnValue(jest.fn()); - const spy1 = jest.spyOn(redux, 'useSelector'); - spy1.mockReturnValue(true); + jest.spyOn(redux, 'useSelector').mockImplementation((fn, d) => { + if (fn.name === ' monitorStatusSelector') { + return monitorStatus; + } else { + return monitorLocations; + } + }); }); - it('renders duration in ms, not us', () => { - const component = renderWithIntl( - - ); + it('renders', () => { + const history = createMemoryHistory({ + initialEntries: ['/aWQx/'], + }); + history.location.key = 'test'; + const component = renderWithRouter(, history); expect(component).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/ssl_certificate.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/ssl_certificate.test.tsx similarity index 75% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/ssl_certificate.test.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/__test__/ssl_certificate.test.tsx index e8ffc3bf26c425..a4b360ea690d09 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/ssl_certificate.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/ssl_certificate.test.tsx @@ -6,9 +6,9 @@ import React from 'react'; import moment from 'moment'; -import { EuiBadge } from '@elastic/eui'; +import { EuiIcon } from '@elastic/eui'; import { Tls } from '../../../../../common/runtime_types'; -import { MonitorSSLCertificate } from '../monitor_status_bar'; +import { MonitorSSLCertificate } from '../status_bar'; import * as redux from 'react-redux'; import { mountWithRouter, renderWithRouter, shallowWithRouter } from '../../../../lib'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../../common/constants'; @@ -58,14 +58,12 @@ describe('SSL Certificate component', () => { }; const component = mountWithRouter(); - const badgeComponent = component.find(EuiBadge); + const lockIcon = component.find(EuiIcon); - expect(badgeComponent.props().color).toBe('warning'); + expect(lockIcon.props().color).toBe('warning'); - const badgeComponentText = component.find('.euiBadge__text'); - expect(badgeComponentText.text()).toBe(moment(dateIn5Days).fromNow()); - - expect(badgeComponent.find('span.euiBadge--warning')).toBeTruthy(); + const componentText = component.find('h4'); + expect(componentText.text()).toBe('Expires soon ' + moment(dateIn5Days).fromNow()); }); it('does not render the expiration date with a warning state if expiry date is greater than a month', () => { @@ -75,12 +73,10 @@ describe('SSL Certificate component', () => { }; const component = mountWithRouter(); - const badgeComponent = component.find(EuiBadge); - expect(badgeComponent.props().color).toBe('default'); - - const badgeComponentText = component.find('.euiBadge__text'); - expect(badgeComponentText.text()).toBe(moment(dateIn40Days).fromNow()); + const lockIcon = component.find(EuiIcon); + expect(lockIcon.props().color).toBe('success'); - expect(badgeComponent.find('span.euiBadge--warning')).toHaveLength(0); + const componentText = component.find('h4'); + expect(componentText.text()).toBe('Expires ' + moment(dateIn40Days).fromNow()); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/status_by_location.test.tsx similarity index 77% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/status_by_location.test.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/__test__/status_by_location.test.tsx index b2619825311d70..b171a8bedb8a7f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/__test__/status_by_location.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/__test__/status_by_location.test.tsx @@ -17,10 +17,16 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, { summary: { up: 4, down: 0 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, ]; const component = shallowWithIntl(); @@ -32,10 +38,16 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, { summary: { up: 4, down: 0 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, ]; const component = renderWithIntl(); @@ -47,6 +59,9 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, ]; const component = renderWithIntl(); @@ -58,6 +73,9 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, ]; const component = renderWithIntl(); @@ -69,10 +87,16 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, { summary: { up: 0, down: 4 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, ]; const component = renderWithIntl(); @@ -84,10 +108,16 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, { summary: { up: 4, down: 0 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + up_history: 4, + down_history: 0, + timestamp: '2020-01-13T22:50:06.536Z', }, ]; const component = renderWithIntl(); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap new file mode 100644 index 00000000000000..9496274a691711 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/availability_reporting.test.tsx.snap @@ -0,0 +1,381 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvailabilityReporting component renders correctly against snapshot 1`] = ` +Array [ + @media (max-width:1042px) { + +} + +
, + .c0 { + white-space: nowrap; + display: inline-block; +} + +@media (max-width:1042px) { + .c0 { + display: inline-block; + margin-right: 16px; + } +} + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Location + +
+
+
+ + Availability + +
+
+
+ + Last check + +
+
+
+ Location +
+
+
+ + + +
+

+ au-heartbeat +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 36m ago + +
+
+
+ Location +
+
+
+ + + +
+

+ nyc-heartbeat +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 36m ago + +
+
+
+ Location +
+
+
+ + + +
+

+ spa-heartbeat +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 36m ago + +
+
+
+
, +] +`; + +exports[`AvailabilityReporting component shallow renders correctly against snapshot 1`] = ` + + + + +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap new file mode 100644 index 00000000000000..05e0b50a86f359 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/location_status_tags.test.tsx.snap @@ -0,0 +1,1085 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LocationStatusTags component renders properly against props 1`] = ` + + + + + +`; + +exports[`LocationStatusTags component renders when all locations are down 1`] = ` +.c1 { + white-space: nowrap; + display: inline-block; +} + +.c0 { + max-height: 246px; + overflow: hidden; +} + +@media (max-width:1042px) { + .c1 { + display: inline-block; + margin-right: 16px; + } +} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + Location + +
+
+
+ + Availability + +
+
+
+ + Last check + +
+
+
+ Location +
+
+
+ + + +
+

+ Berlin +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5m ago + +
+
+
+ Location +
+
+
+ + + +
+

+ Islamabad +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5s ago + +
+
+
+
+
+`; + +exports[`LocationStatusTags component renders when all locations are up 1`] = ` +.c1 { + white-space: nowrap; + display: inline-block; +} + +.c0 { + max-height: 246px; + overflow: hidden; +} + +@media (max-width:1042px) { + .c1 { + display: inline-block; + margin-right: 16px; + } +} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ + Location + +
+
+
+ + Availability + +
+
+
+ + Last check + +
+
+
+ Location +
+
+
+ + + +
+

+ Berlin +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5d ago + +
+
+
+ Location +
+
+
+ + + +
+

+ Islamabad +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5s ago + +
+
+
+
+
+`; + +exports[`LocationStatusTags component renders when there are many location 1`] = ` +.c1 { + white-space: nowrap; + display: inline-block; +} + +.c0 { + max-height: 246px; + overflow: hidden; +} + +@media (max-width:1042px) { + .c1 { + display: inline-block; + margin-right: 16px; + } +} + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Location + +
+
+
+ + Availability + +
+
+
+ + Last check + +
+
+
+ Location +
+
+
+ + + +
+

+ Berlin +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5m ago + +
+
+
+ Location +
+
+
+ + + +
+

+ Islamabad +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5s ago + +
+
+
+ Location +
+
+
+ + + +
+

+ New York +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 1 Mon ago + +
+
+
+ Location +
+
+
+ + + +
+

+ Paris +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5 Yr ago + +
+
+
+ Location +
+
+
+ + + +
+

+ Sydney +

+
+
+
+
+
+
+
+
+ Availability +
+
+ + 100.00 % + +
+
+
+ Last check +
+
+ + 5 Yr ago + +
+
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap new file mode 100644 index 00000000000000..3381efa62286b3 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/__snapshots__/tag_label.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TagLabel component renders correctly against snapshot 1`] = ` +.c0 { + white-space: nowrap; + display: inline-block; +} + +@media (max-width:1042px) { + .c0 { + display: inline-block; + margin-right: 16px; + } +} + +
+ + + +
+

+ US-East +

+
+
+
+
+
+`; + +exports[`TagLabel component shallow render correctly against snapshot 1`] = ` + + + +

+ US-East +

+
+
+
+`; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/availability_reporting.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/availability_reporting.test.tsx new file mode 100644 index 00000000000000..de9f6b0d3b30f7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/availability_reporting.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { AvailabilityReporting } from '../availability_reporting'; +import { StatusTag } from '../location_status_tags'; + +describe('AvailabilityReporting component', () => { + let allLocations: StatusTag[]; + + beforeEach(() => { + allLocations = [ + { + label: 'au-heartbeat', + timestamp: '36m ago', + color: '#d3dae6', + availability: 100, + }, + { + label: 'nyc-heartbeat', + timestamp: '36m ago', + color: '#d3dae6', + availability: 100, + }, + { label: 'spa-heartbeat', timestamp: '36m ago', color: '#d3dae6', availability: 100 }, + ]; + }); + + it('shallow renders correctly against snapshot', () => { + const component = shallowWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders correctly against snapshot', () => { + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_status_tags.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx similarity index 84% rename from x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_status_tags.test.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx index 28b44824017935..bfeaa6085e9985 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_status_tags.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/location_status_tags.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import moment from 'moment'; import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { MonitorLocation } from '../../../../../common/runtime_types/monitor'; +import { MonitorLocation } from '../../../../../../common/runtime_types/monitor'; import { LocationStatusTags } from '../index'; describe('LocationStatusTags component', () => { @@ -19,16 +19,22 @@ describe('LocationStatusTags component', () => { summary: { up: 4, down: 0 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'w').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'w').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 2 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'w').toISOString(), + up_history: 4, + down_history: 0, }, ]; const component = shallowWithIntl(); @@ -41,41 +47,57 @@ describe('LocationStatusTags component', () => { summary: { up: 0, down: 1 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 's').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'm').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'h').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'd').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'New York', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'w').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Toronto', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'M').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Sydney', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'y').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 1 }, geo: { name: 'Paris', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'y').toISOString(), + up_history: 4, + down_history: 0, }, ]; const component = renderWithIntl(); @@ -88,11 +110,15 @@ describe('LocationStatusTags component', () => { summary: { up: 4, down: 0 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 's').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'd').toISOString(), + up_history: 4, + down_history: 0, }, ]; const component = renderWithIntl(); @@ -105,11 +131,15 @@ describe('LocationStatusTags component', () => { summary: { up: 0, down: 2 }, geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 's').toISOString(), + up_history: 4, + down_history: 0, }, { summary: { up: 0, down: 2 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: moment().subtract('5', 'm').toISOString(), + up_history: 4, + down_history: 0, }, ]; const component = renderWithIntl(); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/tag_label.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/tag_label.test.tsx new file mode 100644 index 00000000000000..3560784122298c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/__tests__/tag_label.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { TagLabel } from '../tag_label'; + +describe('TagLabel component', () => { + it('shallow render correctly against snapshot', () => { + const component = shallowWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders correctly against snapshot', () => { + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/availability_reporting.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/availability_reporting.tsx new file mode 100644 index 00000000000000..8fed5db5e0271b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/availability_reporting.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiBasicTable, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Pagination } from '@elastic/eui/src/components/basic_table/pagination_bar'; +import { StatusTag } from './location_status_tags'; +import { TagLabel } from './tag_label'; +import { AvailabilityLabel, LastCheckLabel, LocationLabel } from '../translations'; + +interface Props { + allLocations: StatusTag[]; +} + +export const formatAvailabilityValue = (val: number) => { + const result = Math.round(val * 100) / 100; + return result.toFixed(2); +}; + +export const AvailabilityReporting: React.FC = ({ allLocations }) => { + const [pageIndex, setPageIndex] = useState(0); + + const cols = [ + { + field: 'label', + name: LocationLabel, + truncateText: true, + render: (val: string, item: StatusTag) => { + return ; + }, + }, + { + field: 'availability', + name: AvailabilityLabel, + align: 'right' as const, + render: (val: number) => { + return ( + + + + ); + }, + }, + { + name: LastCheckLabel, + field: 'timestamp', + align: 'right' as const, + }, + ]; + const pageSize = 5; + + const pagination: Pagination = { + pageIndex, + pageSize, + totalItemCount: allLocations.length, + hidePerPageOptions: true, + }; + + const onTableChange = ({ page }: any) => { + setPageIndex(page.index); + }; + + const paginationProps = allLocations.length > pageSize ? { pagination } : {}; + + return ( + <> + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/index.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/index.ts new file mode 100644 index 00000000000000..fb42a162522a83 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AvailabilityReporting } from './availability_reporting'; +export { LocationStatusTags } from './location_status_tags'; +export { TagLabel } from './tag_label'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx new file mode 100644 index 00000000000000..6096499213a10e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/location_status_tags.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext } from 'react'; +import moment from 'moment'; +import styled from 'styled-components'; +import { UptimeThemeContext } from '../../../../contexts'; +import { MonitorLocation } from '../../../../../common/runtime_types'; +import { SHORT_TIMESPAN_LOCALE, SHORT_TS_LOCALE } from '../../../../../common/constants'; +import { AvailabilityReporting } from '../index'; + +// Set height so that it remains within panel, enough height to display 7 locations tags +const TagContainer = styled.div` + max-height: 246px; + overflow: hidden; +`; + +interface Props { + locations: MonitorLocation[]; +} + +export interface StatusTag { + label: string; + timestamp: string; + color: string; + availability: number; +} + +export const LocationStatusTags = ({ locations }: Props) => { + const { + colors: { gray, danger }, + } = useContext(UptimeThemeContext); + + const allLocations: StatusTag[] = []; + const prevLocal: string = moment.locale() ?? 'en'; + + const shortLocale = moment.locale(SHORT_TS_LOCALE) === SHORT_TS_LOCALE; + if (!shortLocale) { + moment.defineLocale(SHORT_TS_LOCALE, SHORT_TIMESPAN_LOCALE); + } + + locations.forEach((item: MonitorLocation) => { + allLocations.push({ + label: item.geo.name!, + timestamp: moment(new Date(item.timestamp).valueOf()).fromNow(), + color: item.summary.down === 0 ? gray : danger, + availability: (item.up_history / (item.up_history + item.down_history)) * 100, + }); + }); + + // Need to reset locale so it doesn't effect other parts of the app + moment.locale(prevLocal); + + // Sort lexicographically by label + allLocations.sort((a, b) => { + return a.label > b.label ? 1 : b.label > a.label ? -1 : 0; + }); + + if (allLocations.length === 0) { + return null; + } + + return ( + <> + + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx new file mode 100644 index 00000000000000..dbd73fc7d440b7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/availability_reporting/tag_label.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EuiBadge, EuiText } from '@elastic/eui'; + +const BadgeItem = styled.div` + white-space: nowrap; + display: inline-block; + @media (max-width: 1042px) { + display: inline-block; + margin-right: 16px; + } +`; + +interface Props { + color: string; + label: string; +} + +export const TagLabel: React.FC = ({ color, label }) => { + return ( + + + +

{label}

+
+
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/index.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/index.ts similarity index 63% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/index.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/index.ts index e95f14472e9e84..ae3a07d231da3f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/index.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/index.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export { MonitorStatusBarComponent } from './monitor_status_bar'; export { MonitorStatusDetailsComponent } from './status_details'; -export { StatusByLocations } from './monitor_status_bar/status_by_location'; +export { StatusByLocations } from './status_bar/status_by_location'; export { MonitorStatusDetails } from './status_details_container'; -export { MonitorStatusBar } from './monitor_status_bar/status_bar_container'; +export { MonitorStatusBar } from './status_bar/status_bar'; +export { AvailabilityReporting } from './availability_reporting/availability_reporting'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__tests__/__snapshots__/location_availability.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__tests__/__snapshots__/location_availability.test.tsx.snap new file mode 100644 index 00000000000000..517dd45cdfdc6f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__tests__/__snapshots__/location_availability.test.tsx.snap @@ -0,0 +1,280 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LocationAvailability component doesnt shows warning if geo is provided 1`] = ` + + + + +

+ Monitoring from +

+
+
+ + + +
+ + + + + +
+`; + +exports[`LocationAvailability component renders correctly against snapshot 1`] = ` + + + + +

+ Monitoring from +

+
+
+ + + +
+ + + + + +
+`; + +exports[`LocationAvailability component renders named locations that have missing geo data 1`] = ` + + + + +

+ Monitoring from +

+
+
+ + + +
+ + + + + +
+`; + +exports[`LocationAvailability component shows warning if geo information is missing 1`] = ` + + + + + + + + + + + + + + + +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_map.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__tests__/location_availability.test.tsx similarity index 57% rename from x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_map.test.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__tests__/location_availability.test.tsx index 1913e677dd674d..52a5dd10664cfa 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_map.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/__tests__/location_availability.test.tsx @@ -6,59 +6,105 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { LocationMap } from '../location_map'; -import { MonitorLocations } from '../../../../../common/runtime_types'; -import { LocationMissingWarning } from '../location_missing'; +import { LocationAvailability } from '../location_availability'; +import { MonitorLocations } from '../../../../../../common/runtime_types'; +import { LocationMissingWarning } from '../../location_map/location_missing'; + +class LocalStorageMock { + store: Record; + constructor() { + this.store = { 'xpack.uptime.detailPage.selectedView': 'list' }; + } + + clear() { + this.store = {}; + } + + getItem(key: string) { + return this.store[key] || null; + } + + setItem(key: string, value: string) { + this.store[key] = value.toString(); + } + + removeItem(key: string) { + delete this.store[key]; + } +} // Note For shallow test, we need absolute time strings -describe('LocationMap component', () => { +describe('LocationAvailability component', () => { let monitorLocations: MonitorLocations; beforeEach(() => { + // @ts-ignore replacing a call to localStorage we use for monitor list size + global.localStorage = new LocalStorageMock(); + + // @ts-ignore replacing a call to localStorage we use for monitor list size + global.localStorage.setItem('xpack.uptime.detailPage.selectedView', 'list'); + monitorLocations = { monitorId: 'wapo', + up_history: 12, + down_history: 0, locations: [ { summary: { up: 4, down: 0 }, geo: { name: 'New York', location: { lat: '40.730610', lon: ' -73.935242' } }, timestamp: '2020-01-13T22:50:06.536Z', + up_history: 4, + down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: '2020-01-13T22:50:04.354Z', + up_history: 4, + down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Unnamed-location' }, timestamp: '2020-01-13T22:50:02.753Z', + up_history: 4, + down_history: 0, }, ], }; }); it('renders correctly against snapshot', () => { - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); it('shows warning if geo information is missing', () => { + // @ts-ignore replacing a call to localStorage we use for monitor list size + global.localStorage.setItem('xpack.uptime.detailPage.selectedView', 'map'); + monitorLocations = { monitorId: 'wapo', + up_history: 8, + down_history: 0, locations: [ { summary: { up: 4, down: 0 }, geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: '2020-01-13T22:50:04.354Z', + up_history: 4, + down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Unnamed-location' }, timestamp: '2020-01-13T22:50:02.753Z', + up_history: 4, + down_history: 0, }, ], }; - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); const warningComponent = component.find(LocationMissingWarning); @@ -68,20 +114,26 @@ describe('LocationMap component', () => { it('doesnt shows warning if geo is provided', () => { monitorLocations = { monitorId: 'wapo', + up_history: 8, + down_history: 0, locations: [ { summary: { up: 4, down: 0 }, geo: { name: 'New York', location: { lat: '40.730610', lon: ' -73.935242' } }, timestamp: '2020-01-13T22:50:06.536Z', + up_history: 4, + down_history: 0, }, { summary: { up: 4, down: 0 }, geo: { name: 'Tokyo', location: { lat: '52.487448', lon: ' 13.394798' } }, timestamp: '2020-01-13T22:50:04.354Z', + up_history: 4, + down_history: 0, }, ], }; - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); const warningComponent = component.find(LocationMissingWarning); @@ -91,16 +143,20 @@ describe('LocationMap component', () => { it('renders named locations that have missing geo data', () => { monitorLocations = { monitorId: 'wapo', + up_history: 4, + down_history: 0, locations: [ { summary: { up: 4, down: 0 }, geo: { name: 'New York', location: undefined }, timestamp: '2020-01-13T22:50:06.536Z', + up_history: 4, + down_history: 0, }, ], }; - const component = shallowWithIntl(); + const component = shallowWithIntl(); expect(component).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx new file mode 100644 index 00000000000000..89b969fdcf691f --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/location_availability.tsx @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { EuiFlexGroup, EuiFlexItem, EuiErrorBoundary, EuiTitle } from '@elastic/eui'; +import { LocationStatusTags } from '../availability_reporting'; +import { LocationPoint } from '../location_map/embeddables/embedded_map'; +import { MonitorLocations, MonitorLocation } from '../../../../../common/runtime_types'; +import { UNNAMED_LOCATION } from '../../../../../common/constants'; +import { LocationMissingWarning } from '../location_map/location_missing'; +import { useSelectedView } from './use_selected_view'; +import { LocationMap } from '../location_map'; +import { MonitoringFrom } from '../translations'; +import { ToggleViewBtn } from './toggle_view_btn'; + +const EuiFlexItemTags = styled(EuiFlexItem)` + width: 350px; + @media (max-width: 1042px) { + width: 100%; + } +`; + +interface LocationMapProps { + monitorLocations: MonitorLocations; +} + +export const LocationAvailability = ({ monitorLocations }: LocationMapProps) => { + const upPoints: LocationPoint[] = []; + const downPoints: LocationPoint[] = []; + + let isAnyGeoInfoMissing = false; + + if (monitorLocations?.locations) { + monitorLocations.locations.forEach(({ geo, summary }: MonitorLocation) => { + if (geo?.name === UNNAMED_LOCATION || !geo?.location) { + isAnyGeoInfoMissing = true; + } else if (!!geo.location.lat && !!geo.location.lon) { + if (summary?.down === 0) { + upPoints.push(geo as LocationPoint); + } else { + downPoints.push(geo as LocationPoint); + } + } + }); + } + const { selectedView: initialView } = useSelectedView(); + + const [selectedView, setSelectedView] = useState(initialView); + + return ( + + + {selectedView === 'list' && ( + + +

{MonitoringFrom}

+
+
+ )} + {selectedView === 'map' && ( + {isAnyGeoInfoMissing && } + )} + + { + setSelectedView(val); + }} + /> + +
+ + + {selectedView === 'list' && ( + + + + )} + {selectedView === 'map' && ( + + + + )} + +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/toggle_view_btn.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/toggle_view_btn.tsx new file mode 100644 index 00000000000000..dfce583945cce4 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/toggle_view_btn.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import styled from 'styled-components'; +import { EuiButtonGroup } from '@elastic/eui'; +import { useSelectedView } from './use_selected_view'; +import { ChangeToListView, ChangeToMapView } from '../translations'; + +const ToggleViewButtons = styled.span` + margin-left: auto; +`; + +interface Props { + onChange: (val: string) => void; +} + +export const ToggleViewBtn = ({ onChange }: Props) => { + const toggleButtons = [ + { + id: `listBtn`, + label: ChangeToMapView, + name: 'listView', + iconType: 'list', + 'data-test-subj': 'uptimeMonitorToggleListBtn', + 'aria-label': ChangeToMapView, + }, + { + id: `mapBtn`, + label: ChangeToListView, + name: 'mapView', + iconType: 'mapMarker', + 'data-test-subj': 'uptimeMonitorToggleMapBtn', + 'aria-label': ChangeToListView, + }, + ]; + + const { selectedView, setSelectedView } = useSelectedView(); + + const onChangeView = (optionId: string) => { + const currView = optionId === 'listBtn' ? 'list' : 'map'; + setSelectedView(currView); + onChange(currView); + }; + + return ( + + onChangeView(id)} + type="multi" + isIconOnly + style={{ marginLeft: 'auto' }} + /> + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/use_selected_view.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/use_selected_view.ts new file mode 100644 index 00000000000000..f132f502666f0a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_availability/use_selected_view.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState } from 'react'; + +const localKey = 'xpack.uptime.detailPage.selectedView'; + +interface Props { + selectedView: string; + setSelectedView: (val: string) => void; +} + +export const useSelectedView = (): Props => { + const getSelectedView = localStorage.getItem(localKey) ?? 'list'; + + const [selectedView, setSelectedView] = useState(getSelectedView); + + useEffect(() => { + localStorage.setItem(localKey, selectedView); + }, [selectedView]); + + return { selectedView, setSelectedView }; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/__snapshots__/location_map.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/__snapshots__/location_map.test.tsx.snap new file mode 100644 index 00000000000000..6b3d157c23fee7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/__snapshots__/location_map.test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LocationMap component renders correctly against snapshot 1`] = ` + + + +`; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_missing.test.tsx.snap b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/__snapshots__/location_missing.test.tsx.snap similarity index 99% rename from x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_missing.test.tsx.snap rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/__snapshots__/location_missing.test.tsx.snap index 150c4581dfd13a..cd282f916f46cd 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/__snapshots__/location_missing.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/__snapshots__/location_missing.test.tsx.snap @@ -4,6 +4,7 @@ exports[`LocationMissingWarning component renders correctly against snapshot 1`] .c0 { margin-left: auto; margin-bottom: 3px; + margin-right: 5px; }
{ + let upPoints: LocationPoint[]; + + beforeEach(() => { + upPoints = [ + { + name: 'New York', + location: { lat: '40.730610', lon: ' -73.935242' }, + }, + { + name: 'Tokyo', + location: { lat: '52.487448', lon: ' 13.394798' }, + }, + ]; + }); + + it('renders correctly against snapshot', () => { + const component = shallowWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_missing.test.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/location_missing.test.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/location_map/__tests__/location_missing.test.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/__tests__/location_missing.test.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/__tests__/__mocks__/mock.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts similarity index 96% rename from x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/__tests__/__mocks__/mock.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts index 291ab555fbdc67..626011e3d09c9a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/__tests__/__mocks__/mock.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/__mocks__/mock.ts @@ -14,6 +14,7 @@ export const mockDownPointsLayer = { __featureCollection: { features: [ { + id: 'Asia', type: 'feature', geometry: { type: 'Point', @@ -21,6 +22,7 @@ export const mockDownPointsLayer = { }, }, { + id: 'APJ', type: 'feature', geometry: { type: 'Point', @@ -28,6 +30,7 @@ export const mockDownPointsLayer = { }, }, { + id: 'Canada', type: 'feature', geometry: { type: 'Point', @@ -79,6 +82,7 @@ export const mockUpPointsLayer = { __featureCollection: { features: [ { + id: 'US-EAST', type: 'feature', geometry: { type: 'Point', @@ -86,6 +90,7 @@ export const mockUpPointsLayer = { }, }, { + id: 'US-WEST', type: 'feature', geometry: { type: 'Point', @@ -93,6 +98,7 @@ export const mockUpPointsLayer = { }, }, { + id: 'Europe', type: 'feature', geometry: { type: 'Point', diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/__tests__/map_config.test.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts similarity index 65% rename from x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/__tests__/map_config.test.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts index 7d53d784ff3387..09a41bd9eb4b9f 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/__tests__/map_config.test.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/__tests__/map_config.test.ts @@ -7,7 +7,7 @@ import { getLayerList } from '../map_config'; import { mockLayerList } from './__mocks__/mock'; import { LocationPoint } from '../embedded_map'; -import { UptimeAppColors } from '../../../../../uptime_app'; +import { UptimeAppColors } from '../../../../../../uptime_app'; jest.mock('uuid', () => { return { @@ -22,14 +22,14 @@ describe('map_config', () => { beforeEach(() => { upPoints = [ - { lat: '52.487239', lon: '13.399262' }, - { lat: '55.487239', lon: '13.399262' }, - { lat: '54.487239', lon: '14.399262' }, + { name: 'US-EAST', location: { lat: '52.487239', lon: '13.399262' } }, + { location: { lat: '55.487239', lon: '13.399262' }, name: 'US-WEST' }, + { location: { lat: '54.487239', lon: '14.399262' }, name: 'Europe' }, ]; downPoints = [ - { lat: '52.487239', lon: '13.399262' }, - { lat: '55.487239', lon: '13.399262' }, - { lat: '54.487239', lon: '14.399262' }, + { location: { lat: '52.487239', lon: '13.399262' }, name: 'Asia' }, + { location: { lat: '55.487239', lon: '13.399262' }, name: 'APJ' }, + { location: { lat: '54.487239', lon: '14.399262' }, name: 'Canada' }, ]; colors = { danger: '#BC261E', diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/embedded_map.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/embedded_map.tsx similarity index 68% rename from x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/embedded_map.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/embedded_map.tsx index 8c971ba8c20f2f..33b0a9b8c2cbc2 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/embedded_map.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/embedded_map.tsx @@ -7,28 +7,35 @@ import React, { useEffect, useState, useContext, useRef } from 'react'; import uuid from 'uuid'; import styled from 'styled-components'; +import { createPortalNode, InPortal, OutPortal } from 'react-reverse-portal'; import { MapEmbeddable, MapEmbeddableInput, // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../../maps/public/embeddable'; +} from '../../../../../../../maps/public/embeddable'; import * as i18n from './translations'; -import { Location } from '../../../../../common/runtime_types'; +import { GeoPoint } from '../../../../../../common/runtime_types'; import { getLayerList } from './map_config'; -import { UptimeThemeContext, UptimeStartupPluginsContext } from '../../../../contexts'; +import { UptimeThemeContext, UptimeStartupPluginsContext } from '../../../../../contexts'; + +import { MAP_SAVED_OBJECT_TYPE } from '../../../../../../../maps/public'; +import { MapToolTipComponent } from './map_tool_tip'; import { isErrorEmbeddable, ViewMode, ErrorEmbeddable, -} from '../../../../../../../../src/plugins/embeddable/public'; -import { MAP_SAVED_OBJECT_TYPE } from '../../../../../../maps/public'; +} from '../../../../../../../../../src/plugins/embeddable/public'; +import { + RenderTooltipContentParams, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../maps/public/classes/tooltips/tooltip_property'; export interface EmbeddedMapProps { upPoints: LocationPoint[]; downPoints: LocationPoint[]; } -export type LocationPoint = Required; +export type LocationPoint = Required; const EmbeddedPanel = styled.div` z-index: auto; @@ -58,6 +65,8 @@ export const EmbeddedMap = React.memo(({ upPoints, downPoints }: EmbeddedMapProp } const factory: any = embeddablePlugin.getEmbeddableFactory(MAP_SAVED_OBJECT_TYPE); + const portalNode = React.useMemo(() => createPortalNode(), []); + const input: MapEmbeddableInput = { id: uuid.v4(), filters: [], @@ -77,12 +86,38 @@ export const EmbeddedMap = React.memo(({ upPoints, downPoints }: EmbeddedMapProp zoom: 0, }, disableInteractive: true, - disableTooltipControl: true, hideToolbarOverlay: true, hideLayerControl: true, hideViewControl: true, }; + const renderTooltipContent = ({ + addFilters, + closeTooltip, + features, + isLocked, + getLayerName, + loadFeatureProperties, + loadFeatureGeometry, + }: RenderTooltipContentParams) => { + const props = { + addFilters, + closeTooltip, + isLocked, + getLayerName, + loadFeatureProperties, + loadFeatureGeometry, + }; + const relevantFeatures = features.filter( + (item: any) => item.layerId === 'up_points' || item.layerId === 'down_points' + ); + if (relevantFeatures.length > 0) { + return ; + } + closeTooltip(); + return null; + }; + useEffect(() => { async function setupEmbeddable() { if (!factory) { @@ -94,11 +129,13 @@ export const EmbeddedMap = React.memo(({ upPoints, downPoints }: EmbeddedMapProp }); if (embeddableObject && !isErrorEmbeddable(embeddableObject)) { - embeddableObject.setLayerList(getLayerList(upPoints, downPoints, colors)); + embeddableObject.setRenderTooltipContent(renderTooltipContent); + await embeddableObject.setLayerList(getLayerList(upPoints, downPoints, colors)); } setEmbeddable(embeddableObject); } + setupEmbeddable(); // we want this effect to execute exactly once after the component mounts @@ -126,6 +163,9 @@ export const EmbeddedMap = React.memo(({ upPoints, downPoints }: EmbeddedMapProp className="embPanel__content" ref={embeddableRoot} /> + + + ); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/low_poly_layer.json b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/low_poly_layer.json similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/low_poly_layer.json rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/low_poly_layer.json diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/map_config.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.ts similarity index 94% rename from x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/map_config.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.ts index f1b530c767f1fc..e766641102a249 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/map_config.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_config.ts @@ -6,7 +6,7 @@ import lowPolyLayerFeatures from './low_poly_layer.json'; import { LocationPoint } from './embedded_map'; -import { UptimeAppColors } from '../../../../uptime_app'; +import { UptimeAppColors } from '../../../../../uptime_app'; /** * Returns `Source/Destination Point-to-point` Map LayerList configuration, with a source, @@ -70,9 +70,10 @@ export const getLowPolyLayer = () => { export const getDownPointsLayer = (downPoints: LocationPoint[], dangerColor: string) => { const features = downPoints?.map((point) => ({ type: 'feature', + id: point.name, geometry: { type: 'Point', - coordinates: [+point.lon, +point.lat], + coordinates: [+point.location.lon, +point.location.lat], }, })); return { @@ -122,9 +123,10 @@ export const getDownPointsLayer = (downPoints: LocationPoint[], dangerColor: str export const getUpPointsLayer = (upPoints: LocationPoint[]) => { const features = upPoints?.map((point) => ({ type: 'feature', + id: point.name, geometry: { type: 'Point', - coordinates: [+point.lon, +point.lat], + coordinates: [+point.location.lon, +point.location.lat], }, })); return { diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_tool_tip.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_tool_tip.tsx new file mode 100644 index 00000000000000..e1b3713fb8cbed --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/map_tool_tip.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { i18n } from '@kbn/i18n'; +import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; +import { + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiOutsideClickDetector, + EuiPopoverTitle, +} from '@elastic/eui'; +import { TagLabel } from '../../availability_reporting'; +import { UptimeThemeContext } from '../../../../../contexts'; +import { AppState } from '../../../../../state'; +import { monitorLocationsSelector } from '../../../../../state/selectors'; +import { useMonitorId } from '../../../../../hooks'; +import { MonitorLocation } from '../../../../../../common/runtime_types/monitor'; +import { formatAvailabilityValue } from '../../availability_reporting/availability_reporting'; +import { LastCheckLabel } from '../../translations'; +import { + RenderTooltipContentParams, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../../../maps/public/classes/tooltips/tooltip_property'; + +type MapToolTipProps = Partial; + +export const MapToolTipComponent = ({ closeTooltip, features = [] }: MapToolTipProps) => { + const { id: featureId, layerId } = features[0] ?? {}; + const locationName = featureId?.toString(); + const { + colors: { gray, danger }, + } = useContext(UptimeThemeContext); + + const monitorId = useMonitorId(); + + const monitorLocations = useSelector((state: AppState) => + monitorLocationsSelector(state, monitorId) + ); + if (!locationName || !monitorLocations?.locations) { + return null; + } + const { + timestamp, + up_history: ups, + down_history: downs, + }: MonitorLocation = monitorLocations.locations!.find( + ({ geo }: MonitorLocation) => geo.name === locationName + )!; + + const availability = (ups / (ups + downs)) * 100; + + return ( + { + if (closeTooltip != null) { + closeTooltip(); + } + }} + > + <> + + {layerId === 'up_points' ? ( + + ) : ( + + )} + + + Availability + + {i18n.translate('xpack.uptime.mapToolTip.AvailabilityStat.title', { + defaultMessage: '{value} %', + values: { value: formatAvailabilityValue(availability) }, + description: 'A percentage value like 23.5%', + })} + + {LastCheckLabel} + + {moment(timestamp).fromNow()} + + + + + ); +}; + +export const MapToolTip = React.memo(MapToolTipComponent); diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/translations.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/translations.ts similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/location_map/embeddables/translations.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/embeddables/translations.ts diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/index.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts similarity index 81% rename from x-pack/plugins/uptime/public/components/monitor/location_map/index.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts index 140d33bbeef66f..4261400bebc6fc 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/index.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/index.ts @@ -5,4 +5,4 @@ */ export * from './location_map'; -export * from './location_status_tags'; +export * from '../availability_reporting/location_status_tags'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.tsx new file mode 100644 index 00000000000000..3d0a097d20d70e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_map.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { EmbeddedMap, LocationPoint } from './embeddables/embedded_map'; + +// These height/width values are used to make sure map is in center of panel +// And to make sure, it doesn't take too much space +const MapPanel = styled.div` + height: 240px; + width: 520px; + margin-right: 65px; + @media (max-width: 574px) { + height: 250px; + width: 100%; + } +`; + +interface Props { + upPoints: LocationPoint[]; + downPoints: LocationPoint[]; +} + +export const LocationMap = ({ upPoints, downPoints }: Props) => { + return ( + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/location_map/location_missing.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.tsx similarity index 96% rename from x-pack/plugins/uptime/public/components/monitor/location_map/location_missing.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.tsx index 6ce31e4cc8243f..e364b6b8940b32 100644 --- a/x-pack/plugins/uptime/public/components/monitor/location_map/location_missing.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/location_map/location_missing.tsx @@ -16,11 +16,12 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; -import { LocationLink } from '../../common/location_link'; +import { LocationLink } from '../../../common/location_link'; const EuiPopoverRight = styled(EuiFlexItem)` margin-left: auto; margin-bottom: 3px; + margin-right: 5px; `; export const LocationMissingWarning = () => { diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/index.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/index.ts similarity index 72% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/index.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/index.ts index 3c861412a39e91..22a059d6037781 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/index.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/index.ts @@ -5,5 +5,4 @@ */ export { MonitorSSLCertificate } from './ssl_certificate'; -export { MonitorStatusBarComponent } from './status_bar'; -export { MonitorStatusBar } from './status_bar_container'; +export { MonitorStatusBar } from './status_bar'; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/ssl_certificate.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/ssl_certificate.tsx new file mode 100644 index 00000000000000..93720ab313ee3a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/ssl_certificate.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Tls } from '../../../../../common/runtime_types'; +import { CERTIFICATES_ROUTE } from '../../../../../common/constants'; +import { MonListDescription, MonListTitle } from './status_bar'; +import { CertStatusColumn } from '../../../overview/monitor_list/cert_status_column'; + +interface Props { + /** + * TLS information coming from monitor in ES heartbeat index + */ + tls: Tls | undefined; +} + +export const MonitorSSLCertificate = ({ tls }: Props) => { + return tls?.not_after ? ( + <> + + + + + + + + + + + + ) : null; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx new file mode 100644 index 00000000000000..afcc8fae7a8ac0 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_bar.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; +import { + EuiLink, + EuiIcon, + EuiSpacer, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { MonitorSSLCertificate } from './ssl_certificate'; +import * as labels from '../translations'; +import { StatusByLocations } from './status_by_location'; +import { useStatusBar } from './use_status_bar'; +import { MonitorIDLabel, OverallAvailability } from '../translations'; +import { URL_LABEL } from '../../../common/translations'; +import { MonitorLocations } from '../../../../../common/runtime_types/monitor'; +import { formatAvailabilityValue } from '../availability_reporting/availability_reporting'; + +export const MonListTitle = styled(EuiDescriptionListTitle)` + &&& { + width: 35%; + } +`; + +export const MonListDescription = styled(EuiDescriptionListDescription)` + &&& { + width: 65%; + overflow-wrap: anywhere; + } +`; + +export const MonitorStatusBar: React.FC = () => { + const { monitorId, monitorStatus, monitorLocations = {} } = useStatusBar(); + + const { locations, up_history: ups, down_history: downs } = monitorLocations as MonitorLocations; + + const full = monitorStatus?.url?.full ?? ''; + + const availability = (ups === 0 && downs === 0) || !ups ? 0 : (ups / (ups + downs)) * 100; + + return ( + <> +
+ +
+ + + {OverallAvailability} + + + + {URL_LABEL} + + + {full} + + + {MonitorIDLabel} + {monitorId} + + + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_by_location.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_by_location.tsx similarity index 100% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_by_location.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/status_by_location.tsx diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_bar_container.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/use_status_bar.ts similarity index 66% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_bar_container.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/use_status_bar.ts index 95622954375151..27e953aab1b710 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/status_bar_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_bar/use_status_bar.ts @@ -4,24 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useContext, useEffect } from 'react'; +import { useContext, useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { monitorLocationsSelector, monitorStatusSelector } from '../../../../state/selectors'; -import { MonitorStatusBarComponent } from './index'; -import { getMonitorStatusAction } from '../../../../state/actions'; -import { useGetUrlParams } from '../../../../hooks'; import { UptimeRefreshContext } from '../../../../contexts'; -import { MonitorIdParam } from '../../../../../common/types'; +import { useGetUrlParams, useMonitorId } from '../../../../hooks'; +import { monitorLocationsSelector, monitorStatusSelector } from '../../../../state/selectors'; import { AppState } from '../../../../state'; +import { getMonitorStatusAction } from '../../../../state/actions'; +import { Ping } from '../../../../../common/runtime_types/ping'; +import { MonitorLocations } from '../../../../../common/runtime_types/monitor'; -export const MonitorStatusBar: React.FC = ({ monitorId }) => { +interface MonitorStatusBarProps { + monitorId: string; + monitorStatus: Ping | null; + monitorLocations?: MonitorLocations; +} + +export const useStatusBar = (): MonitorStatusBarProps => { const { lastRefresh } = useContext(UptimeRefreshContext); const { dateRangeStart: dateStart, dateRangeEnd: dateEnd } = useGetUrlParams(); const dispatch = useDispatch(); + const monitorId = useMonitorId(); + const monitorStatus = useSelector(monitorStatusSelector); + const monitorLocations = useSelector((state: AppState) => monitorLocationsSelector(state, monitorId) ); @@ -30,11 +39,5 @@ export const MonitorStatusBar: React.FC = ({ monitorId }) => { dispatch(getMonitorStatusAction({ dateStart, dateEnd, monitorId })); }, [monitorId, dateStart, dateEnd, lastRefresh, dispatch]); - return ( - - ); + return { monitorStatus, monitorLocations, monitorId }; }; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/status_details.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_details.tsx similarity index 72% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/status_details.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/status_details.tsx index 3766ac86785f71..dea39494646a62 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/status_details.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_details.tsx @@ -7,31 +7,24 @@ import React, { useContext, useEffect, useState } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; import styled from 'styled-components'; -import { LocationMap } from '../location_map'; +import { LocationAvailability } from './location_availability/location_availability'; import { UptimeRefreshContext } from '../../../contexts'; import { MonitorLocations } from '../../../../common/runtime_types'; -import { MonitorStatusBar } from './monitor_status_bar'; +import { MonitorStatusBar } from './status_bar'; interface MonitorStatusDetailsProps { - monitorId: string; monitorLocations: MonitorLocations; } const WrapFlexItem = styled(EuiFlexItem)` &&& { - @media (max-width: 768px) { - width: 100%; - } - @media (max-width: 1042px) { - flex-basis: 520px; + @media (max-width: 800px) { + flex-basis: 100%; } } `; -export const MonitorStatusDetailsComponent = ({ - monitorId, - monitorLocations, -}: MonitorStatusDetailsProps) => { +export const MonitorStatusDetailsComponent = ({ monitorLocations }: MonitorStatusDetailsProps) => { const { refreshApp } = useContext(UptimeRefreshContext); const [isTabActive] = useState(document.visibilityState); @@ -56,12 +49,12 @@ export const MonitorStatusDetailsComponent = ({ return ( - - - + + + - - + + diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/status_details_container.tsx b/x-pack/plugins/uptime/public/components/monitor/status_details/status_details_container.tsx similarity index 92% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/status_details_container.tsx rename to x-pack/plugins/uptime/public/components/monitor/status_details/status_details_container.tsx index 251f3562f9d1a3..92e144bd1b9cc4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/status_details_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/status_details_container.tsx @@ -28,7 +28,5 @@ export const MonitorStatusDetails: React.FC = ({ monitorId }) => dispatch(getMonitorLocationsAction({ dateStart, dateEnd, monitorId })); }, [monitorId, dateStart, dateEnd, lastRefresh, dispatch]); - return ( - - ); + return ; }; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/translations.ts b/x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts similarity index 50% rename from x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/translations.ts rename to x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts index f60a1ceeaafb84..f593525fa09424 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_status_details/monitor_status_bar/translations.ts +++ b/x-pack/plugins/uptime/public/components/monitor/status_details/translations.ts @@ -48,3 +48,56 @@ export const timestampFromNowTextAriaLabel = i18n.translate( export const loadingMessage = i18n.translate('xpack.uptime.monitorStatusBar.loadingMessage', { defaultMessage: 'Loading…', }); + +export const MonitorIDLabel = i18n.translate('xpack.uptime.monitorStatusBar.monitor.id', { + defaultMessage: 'Monitor ID', +}); + +export const OverallAvailability = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.availability', + { + defaultMessage: 'Overall availability', + } +); + +export const MonitoringFrom = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.monitoringFrom', + { + defaultMessage: 'Monitoring from', + } +); + +export const ChangeToMapView = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.monitoringFrom.listToMap', + { + defaultMessage: 'Change to map view to check availability by location.', + } +); + +export const ChangeToListView = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.monitoringFrom.MapToList', + { + defaultMessage: 'Change to list view to check availability by location.', + } +); + +export const LocationLabel = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.availabilityReport.location', + { + defaultMessage: 'Location', + } +); + +export const AvailabilityLabel = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.availabilityReport.availability', + { + defaultMessage: 'Availability', + } +); + +export const LastCheckLabel = i18n.translate( + 'xpack.uptime.monitorStatusBar.monitor.availabilityReport.lastCheck', + { + defaultMessage: 'Last check', + } +); diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/cert_status_column.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/cert_status_column.tsx index 5ad0f4d3d1d494..b7c70198912fcd 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/cert_status_column.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/cert_status_column.tsx @@ -8,13 +8,14 @@ import React from 'react'; import moment from 'moment'; import styled from 'styled-components'; import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; -import { Cert } from '../../../../common/runtime_types'; +import { Cert, Tls } from '../../../../common/runtime_types'; import { useCertStatus } from '../../../hooks'; -import { EXPIRED, EXPIRES_SOON } from '../../certificates/translations'; +import { EXPIRED, EXPIRES, EXPIRES_SOON } from '../../certificates/translations'; import { CERT_STATUS } from '../../../../common/constants'; interface Props { - cert: Cert; + cert: Cert | Tls; + boldStyle?: boolean; } const Span = styled.span` @@ -22,7 +23,15 @@ const Span = styled.span` vertical-align: middle; `; -export const CertStatusColumn: React.FC = ({ cert }) => { +const H4Text = styled.h4` + &&& { + margin: 0 0 0 4px; + display: inline-block; + color: inherit; + } +`; + +export const CertStatusColumn: React.FC = ({ cert, boldStyle = false }) => { const certStatus = useCertStatus(cert?.not_after); const relativeDate = moment(cert?.not_after).fromNow(); @@ -32,9 +41,15 @@ export const CertStatusColumn: React.FC = ({ cert }) => { - - {text} {relativeDate} - + {boldStyle ? ( + + {text} {relativeDate} + + ) : ( + + {text} {relativeDate} + + )} ); @@ -47,5 +62,5 @@ export const CertStatusColumn: React.FC = ({ cert }) => { return ; } - return certStatus ? : --; + return certStatus ? : --; }; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx index 1526838460957c..75d587579f66fa 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list.tsx @@ -30,6 +30,7 @@ import { MonitorListProps } from './monitor_list_container'; import { MonitorList } from '../../../state/reducers/monitor_list'; import { CertStatusColumn } from './cert_status_column'; import { MonitorListHeader } from './monitor_list_header'; +import { URL_LABEL } from '../../common/translations'; interface Props extends MonitorListProps { pageSize: number; @@ -106,7 +107,7 @@ export const MonitorListComponent: React.FC = ({ { align: 'left' as const, field: 'state.url.full', - name: labels.URL, + name: URL_LABEL, render: (url: string, summary: MonitorSummary) => ( {url} diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts b/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts index e70eef1d911617..ee922a9ef803f3 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/translations.ts @@ -62,10 +62,6 @@ export const NO_DATA_MESSAGE = i18n.translate('xpack.uptime.monitorList.noItemMe description: 'This message is shown if the monitors table is rendered but has no items.', }); -export const URL = i18n.translate('xpack.uptime.monitorList.table.url.name', { - defaultMessage: 'Url', -}); - export const UP = i18n.translate('xpack.uptime.monitorList.statusColumn.upLabel', { defaultMessage: 'Up', }); diff --git a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap index f9927752f520c9..fcf68ad97c8cec 100644 --- a/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap +++ b/x-pack/plugins/uptime/public/pages/__tests__/__snapshots__/page_header.test.tsx.snap @@ -2,21 +2,25 @@ exports[`PageHeader shallow renders extra links: page_header_with_extra_links 1`] = ` Array [ - @media only screen and (max-width:1024px) and (min-width:868px) { - .c0.c0.c0 .euiSuperDatePicker__flexWrapper { + .c0 { + white-space: nowrap; +} + +@media only screen and (max-width:1024px) and (min-width:868px) { + .c1.c1.c1 .euiSuperDatePicker__flexWrapper { width: 500px; } } @media only screen and (max-width:880px) { - .c0.c0.c0 { + .c1.c1.c1 { -webkit-box-flex: 1; -webkit-flex-grow: 1; -ms-flex-positive: 1; flex-grow: 1; } - .c0.c0.c0 .euiSuperDatePicker__flexWrapper { + .c1.c1.c1 .euiSuperDatePicker__flexWrapper { width: calc(100% + 8px); } } @@ -28,7 +32,7 @@ Array [ class="euiFlexItem" >

TestingHeading

@@ -126,7 +130,7 @@ Array [

TestingHeading

@@ -273,7 +281,7 @@ Array [ class="euiFlexItem euiFlexItem--flexGrowZero" />

TestingHeading

diff --git a/x-pack/plugins/uptime/public/pages/page_header.tsx b/x-pack/plugins/uptime/public/pages/page_header.tsx index 2e84f59f0b552e..421e0e3a4ebdef 100644 --- a/x-pack/plugins/uptime/public/pages/page_header.tsx +++ b/x-pack/plugins/uptime/public/pages/page_header.tsx @@ -19,6 +19,7 @@ interface PageHeaderProps { extraLinks?: boolean; datePicker?: boolean; } + const SETTINGS_LINK_TEXT = i18n.translate('xpack.uptime.page_header.settingsLink', { defaultMessage: 'Settings', }); @@ -43,6 +44,10 @@ const StyledPicker = styled(EuiFlexItem)` } `; +const H1Text = styled.h1` + white-space: nowrap; +`; + export const PageHeader = React.memo( ({ headingText, extraLinks = false, datePicker = true }: PageHeaderProps) => { const DatePickerComponent = () => @@ -89,7 +94,7 @@ export const PageHeader = React.memo( > -

{headingText}

+ {headingText}
{extraLinkComponents} diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts index c8d3ca043edc50..17d79002e6f7d4 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_locations.ts @@ -32,7 +32,7 @@ export const getMonitorLocations: UMElasticsearchQueryFn< bool: { filter: [ { - match: { + term: { 'monitor.id': monitorId, }, }, @@ -70,6 +70,18 @@ export const getMonitorLocations: UMElasticsearchQueryFn< _source: ['monitor', 'summary', 'observer', '@timestamp'], }, }, + down_history: { + sum: { + field: 'summary.down', + missing: 0, + }, + }, + up_history: { + sum: { + field: 'summary.up', + missing: 0, + }, + }, }, }, }, @@ -99,10 +111,17 @@ export const getMonitorLocations: UMElasticsearchQueryFn< } }; + let totalUps = 0; + let totalDowns = 0; + const monLocs: MonitorLocation[] = []; - locations.forEach((loc: any) => { - const mostRecentLocation = loc.most_recent.hits.hits[0]._source; + locations.forEach(({ most_recent: mostRecent, up_history, down_history }: any) => { + const mostRecentLocation = mostRecent.hits.hits[0]._source; + totalUps += up_history.value; + totalDowns += down_history.value; const location: MonitorLocation = { + up_history: up_history.value ?? 0, + down_history: down_history.value ?? 0, summary: mostRecentLocation?.summary, geo: getGeo(mostRecentLocation?.observer?.geo), timestamp: mostRecentLocation['@timestamp'], @@ -113,5 +132,7 @@ export const getMonitorLocations: UMElasticsearchQueryFn< return { monitorId, locations: monLocs, + up_history: totalUps, + down_history: totalDowns, }; }; diff --git a/x-pack/test/accessibility/apps/uptime.ts b/x-pack/test/accessibility/apps/uptime.ts index dccce4ba1b0b19..ebd120fa0feea4 100644 --- a/x-pack/test/accessibility/apps/uptime.ts +++ b/x-pack/test/accessibility/apps/uptime.ts @@ -65,7 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { it('detail page', async () => { await uptimeService.navigation.goToMonitor(A11Y_TEST_MONITOR_ID); - await uptimeService.monitor.locationMapIsRendered(); + await uptimeService.monitor.displayOverallAvailability('0.00 %'); await a11y.testAppSnapshot(); }); diff --git a/x-pack/test/functional/apps/rollup_job/rollup_jobs.js b/x-pack/test/functional/apps/rollup_job/rollup_jobs.js index 28f5e2ae00f09e..5b6484d7184f30 100644 --- a/x-pack/test/functional/apps/rollup_job/rollup_jobs.js +++ b/x-pack/test/functional/apps/rollup_job/rollup_jobs.js @@ -31,9 +31,9 @@ export default function ({ getService, getPageObjects }) { it('create new rollup job', async () => { const interval = '1000ms'; - pastDates.map(async (day) => { + for (const day of pastDates) { await es.index(mockIndices(day, rollupSourceDataPrepend)); - }); + } await PageObjects.common.navigateToApp('rollupJob'); await PageObjects.rollup.createNewRollUpJob( diff --git a/x-pack/test/functional/apps/uptime/locations.ts b/x-pack/test/functional/apps/uptime/locations.ts index 27c42a365ec74d..8aefca6a70195d 100644 --- a/x-pack/test/functional/apps/uptime/locations.ts +++ b/x-pack/test/functional/apps/uptime/locations.ts @@ -11,41 +11,58 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default ({ getPageObjects, getService }: FtrProviderContext) => { const { uptime: uptimePage } = getPageObjects(['uptime']); const uptime = getService('uptime'); + const es = getService('legacyEs'); const monitor = () => uptime.monitor; + const MONITOR_ID = 'location-testing-id'; + + const LessAvailMonitor = 'less-availability-monitor'; + + const addMonitorWithNoLocation = async () => { + /** + * This mogrify function will strip the documents of their location + * data (but preserve their location name), which is necessary for + * this test to work as desired. + * @param d current document + */ + const mogrifyNoLocation = (d: any) => { + if (d.observer?.geo?.location) { + d.observer.geo.location = undefined; + } + return d; + }; + await makeChecksWithStatus(es, MONITOR_ID, 5, 2, 10000, {}, 'up', mogrifyNoLocation); + }; + + const addLessAvailMonitor = async () => { + await makeChecksWithStatus(es, LessAvailMonitor, 5, 2, 10000, {}, 'up'); + await makeChecksWithStatus(es, LessAvailMonitor, 5, 2, 10000, {}, 'down'); + }; describe('Observer location', () => { const start = moment().subtract('15', 'm').toISOString(); const end = moment().toISOString(); - const MONITOR_ID = 'location-testing-id'; + before(async () => { + await addMonitorWithNoLocation(); + await addLessAvailMonitor(); + await uptime.navigation.goToUptime(); + await uptimePage.goToRoot(true); + }); beforeEach(async () => { - /** - * This mogrify function will strip the documents of their location - * data (but preserve their location name), which is necessary for - * this test to work as desired. - * @param d current document - */ - const mogrifyNoLocation = (d: any) => { - if (d.observer?.geo?.location) { - d.observer.geo.location = undefined; - } - return d; - }; - await makeChecksWithStatus( - getService('legacyEs'), - MONITOR_ID, - 5, - 2, - 10000, - {}, - 'up', - mogrifyNoLocation - ); - await uptime.navigation.goToUptime(); + await addMonitorWithNoLocation(); + await addLessAvailMonitor(); + if (!(await uptime.navigation.isOnDetailsPage())) + await uptimePage.loadDataAndGoToMonitorPage(start, end, MONITOR_ID); + }); + + it('displays the overall availability', async () => { + await monitor().displayOverallAvailability('100.00 %'); + }); - await uptimePage.loadDataAndGoToMonitorPage(start, end, MONITOR_ID); + it('can change the view to map', async () => { + await monitor().toggleToMapView(); }); it('renders the location panel and canvas', async () => { @@ -55,5 +72,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('renders the location missing popover when monitor has location name, but no geo data', async () => { await monitor().locationMissingExists(); }); + + it('displays less monitor availability', async () => { + await uptime.navigation.goToHomeViaBreadCrumb(); + await uptimePage.loadDataAndGoToMonitorPage(start, end, LessAvailMonitor); + await monitor().displayOverallAvailability('50.00 %'); + }); }); }; diff --git a/x-pack/test/functional/services/uptime/common.ts b/x-pack/test/functional/services/uptime/common.ts index b5be1e29a0e8cb..5f544b5e460106 100644 --- a/x-pack/test/functional/services/uptime/common.ts +++ b/x-pack/test/functional/services/uptime/common.ts @@ -58,13 +58,13 @@ export function UptimeCommonProvider({ getService }: FtrProviderContext) { '[data-test-subj="xpack.uptime.filterBar.filterStatusUp"]' ); if (await upFilter.elementHasClass('euiFilterButton-hasActiveFilters')) { - this.setStatusFilterUp(); + await this.setStatusFilterUp(); } const downFilter = await find.byCssSelector( '[data-test-subj="xpack.uptime.filterBar.filterStatusDown"]' ); if (await downFilter.elementHasClass('euiFilterButton-hasActiveFilters')) { - this.setStatusFilterDown(); + await this.setStatusFilterDown(); } }, async selectFilterItem(filterType: string, option: string) { diff --git a/x-pack/test/functional/services/uptime/monitor.ts b/x-pack/test/functional/services/uptime/monitor.ts index 3137711698ba48..593950fbb7619c 100644 --- a/x-pack/test/functional/services/uptime/monitor.ts +++ b/x-pack/test/functional/services/uptime/monitor.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import expect from '@kbn/expect/expect.js'; import { FtrProviderContext } from '../../ftr_provider_context'; export function UptimeMonitorProvider({ getService }: FtrProviderContext) { @@ -17,6 +18,13 @@ export function UptimeMonitorProvider({ getService }: FtrProviderContext) { timeout: 3000, }); }, + async displayOverallAvailability(availabilityVal: string) { + return retry.tryForTime(60 * 1000, async () => { + await testSubjects.existOrFail('uptimeOverallAvailability'); + const availability = await testSubjects.getVisibleText('uptimeOverallAvailability'); + expect(availability).to.be(availabilityVal); + }); + }, async locationMapIsRendered() { return retry.tryForTime(15000, async () => { await testSubjects.existOrFail('xpack.uptime.locationMap.embeddedPanel', { @@ -45,5 +53,8 @@ export function UptimeMonitorProvider({ getService }: FtrProviderContext) { ); }); }, + async toggleToMapView() { + await testSubjects.click('uptimeMonitorToggleMapBtn'); + }, }; } diff --git a/x-pack/test/functional/services/uptime/navigation.ts b/x-pack/test/functional/services/uptime/navigation.ts index 7c5a4632f86278..f8e0c0cff41f4d 100644 --- a/x-pack/test/functional/services/uptime/navigation.ts +++ b/x-pack/test/functional/services/uptime/navigation.ts @@ -56,6 +56,7 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv }, goToMonitor: async (monitorId: string) => { + // only go to monitor page if not already there if (!(await testSubjects.exists('uptimeMonitorPage', { timeout: 0 }))) { await testSubjects.click(`monitor-page-link-${monitorId}`); await testSubjects.existOrFail('uptimeMonitorPage', { @@ -80,5 +81,13 @@ export function UptimeNavigationProvider({ getService, getPageObjects }: FtrProv await PageObjects.timePicker.setAbsoluteRange(dateStart, dateEnd); await this.goToMonitor(monitorId); }, + + async isOnDetailsPage() { + return await testSubjects.exists('uptimeMonitorPage', { timeout: 0 }); + }, + + async goToHomeViaBreadCrumb() { + await testSubjects.click('breadcrumb first'); + }, }; } diff --git a/yarn.lock b/yarn.lock index e87e7b77f09793..6e307e8f1cc0d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13033,9 +13033,9 @@ element-resize-detector@^1.1.15: batch-processor "^1.0.0" elliptic@^6.0.0: - version "6.5.2" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" - integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== + version "6.5.3" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" + integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== dependencies: bn.js "^4.4.0" brorand "^1.0.1"