diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3d8862beb67d9..b1100fca6d466 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -873,6 +873,7 @@ x-pack/platform/packages/shared/kbn-inference-tracing-config @elastic/appex-ai-i x-pack/platform/packages/shared/kbn-key-value-metadata-table @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team x-pack/platform/packages/shared/kbn-kibana-api-cli @elastic/appex-ai-infra x-pack/platform/packages/shared/kbn-langchain @elastic/security-generative-ai +x-pack/platform/packages/shared/kbn-page-attachment-schema @elastic/obs-ux-management-team x-pack/platform/packages/shared/kbn-profiler-cli @elastic/obs-knowledge-team x-pack/platform/packages/shared/kbn-sample-parser @elastic/streams-program-team x-pack/platform/packages/shared/kbn-search-index-documents @elastic/search-kibana diff --git a/package.json b/package.json index 15989e01f31a4..b93f7b0520064 100644 --- a/package.json +++ b/package.json @@ -759,6 +759,7 @@ "@kbn/osquery-io-ts-types": "link:src/platform/packages/shared/kbn-osquery-io-ts-types", "@kbn/osquery-plugin": "link:x-pack/platform/plugins/shared/osquery", "@kbn/paertial-results-example-plugin": "link:examples/partial_results_example", + "@kbn/page-attachment-schema": "link:x-pack/platform/packages/shared/kbn-page-attachment-schema", "@kbn/painless-lab-plugin": "link:x-pack/platform/plugins/private/painless_lab", "@kbn/palettes": "link:src/platform/packages/shared/kbn-palettes", "@kbn/panel-loader": "link:src/platform/packages/private/kbn-panel-loader", diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index aea43395f775c..f65dc3ccc5fd7 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -105,7 +105,7 @@ pageLoadAssetSize: navigation: 13598 newsfeed: 12371 noDataPage: 1749 - observability: 107869 + observability: 160191 observabilityAIAssistant: 46941 observabilityAIAssistantApp: 18553 observabilityAiAssistantManagement: 6438 diff --git a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts index e0f37849e1c49..0c2d11f440435 100644 --- a/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/src/platform/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -384,6 +384,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.observabilityAiAssistantManagement.logSourcesEnabled (boolean?)', 'xpack.observabilityAiAssistantManagement.spacesEnabled (boolean?)', 'xpack.observabilityAiAssistantManagement.visibilityEnabled (boolean?)', + 'xpack.observabilityShared.unsafe.investigativeExperienceEnabled (boolean?)', 'share.new_version.enabled (boolean?)', 'aiAssistantManagementSelection.preferredAIAssistantType (default?|never?|observability?|security?)', /** diff --git a/tsconfig.base.json b/tsconfig.base.json index 5debdc334f8bb..468a2c6e13bdd 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1462,6 +1462,8 @@ "@kbn/osquery-plugin/*": ["x-pack/platform/plugins/shared/osquery/*"], "@kbn/paertial-results-example-plugin": ["examples/partial_results_example"], "@kbn/paertial-results-example-plugin/*": ["examples/partial_results_example/*"], + "@kbn/page-attachment-schema": ["x-pack/platform/packages/shared/kbn-page-attachment-schema"], + "@kbn/page-attachment-schema/*": ["x-pack/platform/packages/shared/kbn-page-attachment-schema/*"], "@kbn/painless-lab-plugin": ["x-pack/platform/plugins/private/painless_lab"], "@kbn/painless-lab-plugin/*": ["x-pack/platform/plugins/private/painless_lab/*"], "@kbn/palettes": ["src/platform/packages/shared/kbn-palettes"], diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/README.md b/x-pack/platform/packages/shared/kbn-page-attachment-schema/README.md new file mode 100644 index 0000000000000..718324f6acd12 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/README.md @@ -0,0 +1,3 @@ +# @kbn/page-attachment-schema + +This shared package contains the zod schema definition for persisted state of the page attachment type used in cases. diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/index.ts b/x-pack/platform/packages/shared/kbn-page-attachment-schema/index.ts new file mode 100644 index 0000000000000..eb0e4bc8c2df3 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './src/latest'; +export * from './src/constants'; diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/jest.config.js b/x-pack/platform/packages/shared/kbn-page-attachment-schema/jest.config.js new file mode 100644 index 0000000000000..b4b24dbed57bd --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/jest.config.js @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../../../..', + roots: ['/x-pack/platform/packages/shared/kbn-page-attachment-schema'], +}; diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/kibana.jsonc b/x-pack/platform/packages/shared/kbn-page-attachment-schema/kibana.jsonc new file mode 100644 index 0000000000000..ed1c254bbf588 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/kibana.jsonc @@ -0,0 +1,9 @@ +{ + "type": "shared-common", + "id": "@kbn/page-attachment-schema", + "owner": [ + "@elastic/obs-ux-management-team" + ], + "group": "platform", + "visibility": "shared" +} \ No newline at end of file diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/package.json b/x-pack/platform/packages/shared/kbn-page-attachment-schema/package.json new file mode 100644 index 0000000000000..124d6d7dac197 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/package.json @@ -0,0 +1,7 @@ +{ + "name": "@kbn/page-attachment-schema", + "description": "Page attachment zod schema definition and constants.", + "private": true, + "version": "1.0.0", + "license": "Elastic License 2.0" +} diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/constants.ts b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/constants.ts new file mode 100644 index 0000000000000..6a14dc5355d9e --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const PAGE_ATTACHMENT_TYPE = '.page'; diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/latest.ts b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/latest.ts new file mode 100644 index 0000000000000..905ddd3da4178 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/latest.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { + type PageAttachmentPersistedState, + pageAttachmentPersistedStateSchema, +} from './schema/latest'; diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/schema/latest.ts b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/schema/latest.ts new file mode 100644 index 0000000000000..79317a159bd28 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/schema/latest.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { type PageAttachmentPersistedState, pageAttachmentPersistedStateSchema } from './v1'; diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/schema/v1.ts b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/schema/v1.ts new file mode 100644 index 0000000000000..1ae46596eda17 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/src/schema/v1.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { z } from '@kbn/zod'; + +export const pageAttachmentPersistedStateSchema = z.object({ + /** + * The type of page or page asset, e.g., 'dashboard', 'synthetics-test-run', 'slo-history', etc + */ + type: z.string(), + url: z.object({ + /** + * The URL to the page or page asset, excluding the base path. + */ + pathAndQuery: z.string(), + /** + * The label to render in the "Go to" action, example "View in Dashboard" for the asset + */ + actionLabel: z.string(), + /** + * The label to render in the attachment comment for the page or page asset + */ + label: z.string(), + /** + * The icon, rendered via {@link EuiIcon}, representing the page type, displayed in the comment and action. + */ + iconType: z.string().optional(), + }), + /** + * Optional screen context for the page. A plain text description that + * can be provided to an LLM to generate a summary or perform analysis + */ + screenContext: z.array(z.object({ screenDescription: z.string() })).optional(), +}); + +export type PageAttachmentPersistedState = z.infer; diff --git a/x-pack/platform/packages/shared/kbn-page-attachment-schema/tsconfig.json b/x-pack/platform/packages/shared/kbn-page-attachment-schema/tsconfig.json new file mode 100644 index 0000000000000..183ef8c332581 --- /dev/null +++ b/x-pack/platform/packages/shared/kbn-page-attachment-schema/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "target/types", + "types": [ + "jest", + "node" + ] + }, + "include": [ + "**/*.ts" + ], + "kbn_references": [ + "@kbn/zod", + ], + "exclude": [ + "target/**/*", + ] +} diff --git a/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/v1.ts b/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/v1.ts index e1e01052d4a89..ff8b835f63f06 100644 --- a/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/v1.ts +++ b/x-pack/platform/plugins/shared/cases/common/types/domain/attachment/v1.ts @@ -262,12 +262,13 @@ export type ExternalReferenceAttachment = rt.TypeOf; +export type PersistableStateAttachmentState = rt.TypeOf; export type PersistableStateAttachment = rt.TypeOf; export type PersistableStateAttachmentAttributes = rt.TypeOf< typeof PersistableStateAttachmentAttributesRt diff --git a/x-pack/platform/plugins/shared/cases/kibana.jsonc b/x-pack/platform/plugins/shared/cases/kibana.jsonc index 2b61adb925671..c22100a2348b5 100644 --- a/x-pack/platform/plugins/shared/cases/kibana.jsonc +++ b/x-pack/platform/plugins/shared/cases/kibana.jsonc @@ -48,7 +48,8 @@ "savedObjectsFinder" ], "extraPublicDirs": [ - "common" + "common", + "public/client/attachment_framework/types" ] } } diff --git a/x-pack/platform/plugins/shared/cases/public/index.tsx b/x-pack/platform/plugins/shared/cases/public/index.tsx index af6e71580f944..b7f953dbfdef0 100644 --- a/x-pack/platform/plugins/shared/cases/public/index.tsx +++ b/x-pack/platform/plugins/shared/cases/public/index.tsx @@ -23,3 +23,5 @@ export type { CaseAttachments, CaseAttachmentsWithoutOwner } from './types'; export type { ICasesDeepLinkId } from './common/navigation'; export { getCasesDeepLinks, CasesDeepLinkId, generateCaseViewPath } from './common/navigation'; + +export { AttachmentActionType } from './client/attachment_framework/types'; diff --git a/x-pack/platform/plugins/shared/cases/server/internal_attachments/index.ts b/x-pack/platform/plugins/shared/cases/server/internal_attachments/index.ts index a6fb48d79a72e..35b06455cd3dc 100644 --- a/x-pack/platform/plugins/shared/cases/server/internal_attachments/index.ts +++ b/x-pack/platform/plugins/shared/cases/server/internal_attachments/index.ts @@ -7,8 +7,7 @@ import { badRequest } from '@hapi/boom'; import { FileAttachmentMetadataRt } from '../../common/types/domain'; -import { LENS_ATTACHMENT_TYPE } from '../../common/constants/visualizations'; -import { FILE_ATTACHMENT_TYPE } from '../../common/constants'; +import { FILE_ATTACHMENT_TYPE, LENS_ATTACHMENT_TYPE } from '../../common/constants'; import { decodeWithExcessOrThrow } from '../common/runtime_types'; import type { ExternalReferenceAttachmentTypeRegistry } from '../attachment_framework/external_reference_registry'; diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_create_attachments.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_create_attachments.ts index d585eee080162..71db38cd5d0e5 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_create_attachments.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/internal/bulk_create_attachments.ts @@ -9,7 +9,7 @@ import { schema } from '@kbn/config-schema'; import { INTERNAL_BULK_CREATE_ATTACHMENTS_URL } from '../../../../common/constants'; import { createCaseError } from '../../../common/error'; import { createCasesRoute } from '../create_cases_route'; -import { escapeHatch } from '../utils'; +import { validAttachment } from '../utils'; import type { attachmentApiV1 } from '../../../../common/types/api'; import type { caseDomainV1 } from '../../../../common/types/domain'; import { DEFAULT_CASES_ROUTE_SECURITY } from '../constants'; @@ -22,7 +22,7 @@ export const bulkCreateAttachmentsRoute = createCasesRoute({ params: schema.object({ case_id: schema.string(), }), - body: schema.arrayOf(escapeHatch), + body: schema.arrayOf(validAttachment), }, routerOptions: { access: 'internal', diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/utils.test.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/utils.test.ts index 68a6088309f7a..d03f8226062df 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/utils.test.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/utils.test.ts @@ -7,8 +7,15 @@ import { isBoom, boomify } from '@hapi/boom'; import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { PAGE_ATTACHMENT_TYPE } from '@kbn/page-attachment-schema'; import type { HTTPError } from '../../common/error'; -import { extractWarningValueFromWarningHeader, logDeprecatedEndpoint, wrapError } from './utils'; +import { + extractWarningValueFromWarningHeader, + logDeprecatedEndpoint, + wrapError, + validAttachment, + isPersistableStatePageAttachment, +} from './utils'; describe('Utils', () => { describe('wrapError', () => { @@ -83,4 +90,57 @@ describe('Utils', () => { ); }); }); + + describe('validAttachment', () => { + it('validates a valid attachment', () => { + const attachment = { + persistableStateAttachmentTypeId: PAGE_ATTACHMENT_TYPE, + persistableStateAttachmentState: { + url: { pathAndQuery: '/internal/safe-url' }, + }, + }; + + expect(validAttachment.validate(attachment)).toEqual(attachment); + }); + + it('throws an error for an external URL', () => { + const invalidAttachment = { + persistableStateAttachmentTypeId: PAGE_ATTACHMENT_TYPE, + persistableStateAttachmentState: { + url: { pathAndQuery: 'http://external-url.com' }, + }, + }; + + expect(() => validAttachment.validate(invalidAttachment)).toThrow( + 'External urls are not supported for page attachments. The provided url is: http://external-url.com' + ); + }); + }); + + describe('isPersistableStatePageAttachment', () => { + it('returns true for a valid persistable state page attachment', () => { + const attachment = { + persistableStateAttachmentTypeId: PAGE_ATTACHMENT_TYPE, + persistableStateAttachmentState: {}, + }; + + expect(isPersistableStatePageAttachment(attachment)).toBe(true); + }); + + it('returns false for an invalid attachment', () => { + const invalidAttachment = { + someOtherProperty: 'value', + }; + + expect(isPersistableStatePageAttachment(invalidAttachment)).toBe(false); + }); + + it('returns false for a null value', () => { + expect(isPersistableStatePageAttachment(null)).toBe(false); + }); + + it('returns false for a non-object value', () => { + expect(isPersistableStatePageAttachment('string')).toBe(false); + }); + }); }); diff --git a/x-pack/platform/plugins/shared/cases/server/routes/api/utils.ts b/x-pack/platform/plugins/shared/cases/server/routes/api/utils.ts index e363d37482a04..1260515d54dba 100644 --- a/x-pack/platform/plugins/shared/cases/server/routes/api/utils.ts +++ b/x-pack/platform/plugins/shared/cases/server/routes/api/utils.ts @@ -9,6 +9,12 @@ import type { Boom } from '@hapi/boom'; import { boomify, isBoom } from '@hapi/boom'; import { schema } from '@kbn/config-schema'; import type { CustomHttpResponseOptions, ResponseError, Headers, Logger } from '@kbn/core/server'; +import { isInternalURL } from '@kbn/std'; +import { + type PageAttachmentPersistedState, + PAGE_ATTACHMENT_TYPE, +} from '@kbn/page-attachment-schema'; +import type { AttachmentRequestAttributes } from '../../common/types/attachments'; import type { CaseError, HTTPError } from '../../common/error'; import { isCaseError, isHTTPError } from '../../common/error'; @@ -35,7 +41,45 @@ export function wrapError( }; } -export const escapeHatch = schema.object({}, { unknowns: 'allow' }); +export const escapeHatch = schema.object( + {}, + { + unknowns: 'allow', + } +); + +export const validAttachment = schema.object( + {}, + { + unknowns: 'allow', + validate: (value) => { + if ( + isPersistableStatePageAttachment(value) && + value.persistableStateAttachmentTypeId === PAGE_ATTACHMENT_TYPE + ) { + const persistedState = + value.persistableStateAttachmentState as PageAttachmentPersistedState; + const url = persistedState?.url?.pathAndQuery; + if (url && !isInternalURL(url)) { + return `External urls are not supported for page attachments. The provided url is: ${url}`; + } + } + }, + } +); + +/** + * Checks if the given value is a persistable state page attachment type. + */ +export const isPersistableStatePageAttachment = ( + value: unknown +): value is AttachmentRequestAttributes => { + return ( + typeof value === 'object' && + value !== null && + Boolean((value as AttachmentRequestAttributes).persistableStateAttachmentTypeId) + ); +}; /** * Creates a warning header with a message formatted according to RFC7234. diff --git a/x-pack/platform/plugins/shared/cases/tsconfig.json b/x-pack/platform/plugins/shared/cases/tsconfig.json index e596496134c54..a6d61fd96e735 100644 --- a/x-pack/platform/plugins/shared/cases/tsconfig.json +++ b/x-pack/platform/plugins/shared/cases/tsconfig.json @@ -93,7 +93,8 @@ "@kbn/elastic-assistant-common", "@kbn/test", "@kbn/dev-utils", - "@kbn/tooling-log" + "@kbn/tooling-log", + "@kbn/page-attachment-schema" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc b/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc index 040ff249ffcd5..28393f1debc6a 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/kibana.jsonc @@ -26,7 +26,6 @@ "dataViewEditor", "alerting", "actions", - "dashboard", "licensing", "expressions", "lens", diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx index a34cf51a9d39a..fd7a6775db091 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/connectors_app.tsx @@ -24,7 +24,6 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { Storage } from '@kbn/kibana-utils-plugin/public'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; -import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { SharePluginStart } from '@kbn/share-plugin/public'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { ActionTypeRegistryContract, RuleTypeRegistryContract } from '../types'; @@ -44,7 +43,6 @@ export interface TriggersAndActionsUiServices extends CoreStart { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; - dashboard: DashboardStart; charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx index abfdf0fe9a3dc..51fea5429b1fc 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/application/rules_app.tsx @@ -33,7 +33,6 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public'; import { ruleDetailsRoute, createRuleRoute, editRuleRoute } from '@kbn/rule-data-utils'; import { QueryClientProvider } from '@tanstack/react-query'; -import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { ExpressionsStart } from '@kbn/expressions-plugin/public'; import { CloudSetup } from '@kbn/cloud-plugin/public'; import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; @@ -66,7 +65,6 @@ export interface TriggersAndActionsUiServices extends CoreStart { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; - dashboard: DashboardStart; charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts index 5c5507ed6b3f9..4b196a73cbcf0 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/common/lib/kibana/kibana_react.mock.ts @@ -10,7 +10,6 @@ import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks'; -import { dashboardPluginMock } from '@kbn/dashboard-plugin/public/mocks'; import { fieldFormatsServiceMock } from '@kbn/field-formats-plugin/public/mocks'; import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; import { licensingMock } from '@kbn/licensing-plugin/public/mocks'; @@ -44,7 +43,6 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => { }, history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), - dashboard: dashboardPluginMock.createStartContract(), data: dataPluginMock.createStartContract(), dataViews: dataViewPluginMocks.createStartContract(), dataViewEditor: { diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts b/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts index 6d2c99e445807..140f29376313c 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/public/plugin.ts @@ -25,7 +25,6 @@ import { Storage } from '@kbn/kibana-utils-plugin/public'; import type { SpacesPluginStart } from '@kbn/spaces-plugin/public'; import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/public'; import { triggersActionsRoute } from '@kbn/rule-data-utils'; -import type { DashboardStart } from '@kbn/dashboard-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { ServerlessPluginStart } from '@kbn/serverless/public'; @@ -160,7 +159,6 @@ interface PluginsStart { data: DataPublicPluginStart; dataViews: DataViewsPublicPluginStart; dataViewEditor: DataViewEditorStart; - dashboard: DashboardStart; charts: ChartsPluginStart; alerting?: AlertingStart; spaces?: SpacesPluginStart; @@ -295,7 +293,6 @@ export class Plugin return renderApp({ ...coreStart, actions: plugins.actions, - dashboard: pluginsStart.dashboard, cloud: plugins.cloud, data: pluginsStart.data, dataViews: pluginsStart.dataViews, @@ -352,7 +349,6 @@ export class Plugin return renderApp({ ...coreStart, actions: plugins.actions, - dashboard: pluginsStart.dashboard, data: pluginsStart.data, dataViews: pluginsStart.dataViews, dataViewEditor: pluginsStart.dataViewEditor, @@ -398,7 +394,6 @@ export class Plugin return renderApp({ ...coreStart, actions: plugins.actions, - dashboard: pluginsStart.dashboard, data: pluginsStart.data, dataViews: pluginsStart.dataViews, dataViewEditor: pluginsStart.dataViewEditor, diff --git a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json index 9690f26871a8b..3205f99049f1b 100644 --- a/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json +++ b/x-pack/platform/plugins/shared/triggers_actions_ui/tsconfig.json @@ -48,7 +48,6 @@ "@kbn/safer-lodash-set", "@kbn/field-types", "@kbn/core-ui-settings-common", - "@kbn/dashboard-plugin", "@kbn/licensing-plugin", "@kbn/expressions-plugin", "@kbn/core-saved-objects-api-server", diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment.test.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment.test.tsx new file mode 100644 index 0000000000000..8d0d2874f8c33 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment.test.tsx @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import { screen, waitFor, render } from '@testing-library/react'; +import { + type PageAttachmentPersistedState, + PAGE_ATTACHMENT_TYPE, +} from '@kbn/page-attachment-schema'; +import type { PersistableStateAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types'; +import { getPageAttachmentType } from './attachment'; + +jest.mock('../../utils/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + navigateToUrl: jest.fn(), + }, + http: { + externalUrl: { isInternalUrl: jest.fn().mockReturnValue(true) }, + }, + notifications: { toasts: { addDanger: jest.fn() } }, + }, + }), +})); + +describe('getPageAttachmentType', () => { + const state: PageAttachmentPersistedState = { + type: 'exampleType', + url: { + pathAndQuery: '/test/path?query=1', + iconType: 'link', + actionLabel: 'View in Dashboards', + label: 'Test Link', + }, + }; + const attachmentViewProps: PersistableStateAttachmentViewProps = { + persistableStateAttachmentTypeId: PAGE_ATTACHMENT_TYPE, + persistableStateAttachmentState: state, + attachmentId: 'test', + caseData: { title: 'Test Case', id: 'case-id' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates the attachment type correctly', () => { + const linkType = getPageAttachmentType(); + + expect(linkType).toStrictEqual({ + id: PAGE_ATTACHMENT_TYPE, + icon: 'link', + displayName: 'Page', + getAttachmentViewObject: expect.any(Function), + getAttachmentRemovalObject: expect.any(Function), + }); + }); + + describe('getAttachmentViewObject', () => { + it('renders the event correctly', () => { + const linkType = getPageAttachmentType(); + const event = linkType.getAttachmentViewObject(attachmentViewProps).event; + + expect(event).toBe('added a page'); + }); + + it('renders the timelineAvatar correctly', () => { + const linkType = getPageAttachmentType(); + const avatar = linkType.getAttachmentViewObject(attachmentViewProps).timelineAvatar; + + expect(avatar).toBe('link'); + }); + + it('does not hide the default actions', () => { + const linkType = getPageAttachmentType(); + const hideDefaultActions = + linkType.getAttachmentViewObject(attachmentViewProps).hideDefaultActions; + + expect(hideDefaultActions).toBe(false); + }); + + it('renders the children correctly', async () => { + const linkType = getPageAttachmentType(); + const Component = linkType.getAttachmentViewObject(attachmentViewProps).children!; + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + }); + }); + + describe('getAttachmentRemovalObject', () => { + it('renders the removal event correctly', () => { + const linkType = getPageAttachmentType(); + const event = linkType.getAttachmentRemovalObject?.(attachmentViewProps); + + expect(event).toEqual({ event: 'removed page' }); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment.tsx new file mode 100644 index 0000000000000..1667f4d4797df --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment.tsx @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import { + type PageAttachmentPersistedState, + PAGE_ATTACHMENT_TYPE, +} from '@kbn/page-attachment-schema'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { + type PersistableStateAttachmentType, + type PersistableStateAttachmentViewProps, + type AttachmentViewObject, + type AttachmentAction, +} from '@kbn/cases-plugin/public/client/attachment_framework/types'; +import { AttachmentActionType } from '@kbn/cases-plugin/public'; +import * as i18n from './translations'; + +const AttachmentChildrenLazy = React.lazy(() => import('./attachment_children')); +const GoToAction = React.lazy(() => import('./go_to_action')); + +const getPageAttachmentViewObject = + (): AttachmentViewObject => { + return { + event: i18n.ADDED_PAGE, + timelineAvatar: 'link', + hideDefaultActions: false, + children: AttachmentChildrenLazy, + getActions: (props: PersistableStateAttachmentViewProps) => + getPageAttachmentActions( + props.persistableStateAttachmentState as PageAttachmentPersistedState + ), + }; + }; + +export const getPageAttachmentType = (): PersistableStateAttachmentType => ({ + id: PAGE_ATTACHMENT_TYPE, + icon: 'link', + displayName: i18n.PAGE_LABEL, + getAttachmentViewObject: getPageAttachmentViewObject, + getAttachmentRemovalObject: () => ({ event: i18n.REMOVED_PAGE }), +}); + +const getPageAttachmentActions = (state: PageAttachmentPersistedState): AttachmentAction[] => [ + { + type: AttachmentActionType.CUSTOM as const, + render: () => getGoToAction({ state }), + isPrimary: true, + }, + { + type: AttachmentActionType.CUSTOM as const, + render: () => getGoToAction({ state }), + isPrimary: false, + }, +]; + +function getGoToAction({ state }: { state: PageAttachmentPersistedState }) { + return ( + }> + + + ); +} diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment_children.test.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment_children.test.tsx new file mode 100644 index 0000000000000..a7ad1c903f1fd --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment_children.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { PageAttachmentPersistedState } from '@kbn/page-attachment-schema'; +import { render, screen } from '@testing-library/react'; +import { PageAttachmentChildren } from './attachment_children'; +import { useKibana } from '../../utils/kibana_react'; + +jest.mock('../../utils/kibana_react', () => ({ + useKibana: jest.fn().mockReturnValue({ + services: { + application: { + navigateToUrl: jest.fn(), + }, + http: { + externalUrl: { isInternalUrl: jest.fn().mockReturnValue(true) }, + }, + notifications: { toasts: { addDanger: jest.fn() } }, + }, + }), +})); + +describe('PageAttachmentChildren', () => { + const mockNavigateToUrl = jest.fn(); + const mockPersistableState: PageAttachmentPersistedState = { + type: 'mockType', + url: { + actionLabel: 'View in Dashboards', + pathAndQuery: '/test/path?query=1', + label: 'Sample Dashboard', + iconType: 'link', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + navigateToUrl: mockNavigateToUrl, + }, + http: { + externalUrl: { isInternalUrl: jest.fn().mockReturnValue(true) }, + }, + notifications: { toasts: { addDanger: jest.fn() } }, + }, + }); + }); + + it('renders the link with correct label and href', () => { + render(); + + const link = screen.getByRole('button', { name: 'Sample Dashboard' }); + link.click(); + expect(mockNavigateToUrl).toHaveBeenCalledWith('http://localhost/test/path?query=1'); + }); + + it('renders a callout when an external URL is detected', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + application: { + navigateToUrl: mockNavigateToUrl, + }, + http: { + externalUrl: { isInternalUrl: jest.fn().mockReturnValue(false) }, + }, + notifications: { toasts: { addDanger: jest.fn() } }, + }, + }); + const label = 'External Dashboard'; + const externalUrlState: PageAttachmentPersistedState = { + type: 'mockType', + url: { + actionLabel: 'View in Dashboards', + pathAndQuery: 'https://external.com/test/path?query=1', + label, + iconType: 'link', + }, + }; + + render(); + + const callout = screen.getByText('External URL detected'); + expect(callout).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: label })).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment_children.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment_children.tsx new file mode 100644 index 0000000000000..704b0a09ac82f --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/attachment_children.tsx @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiLink, EuiCallOut, EuiText, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import { type PersistableStateAttachmentViewProps } from '@kbn/cases-plugin/public/client/attachment_framework/types'; +import type { PageAttachmentPersistedState } from '@kbn/page-attachment-schema'; +import { useKibana } from '../../utils/kibana_react'; + +interface AttachmentChildrenProps { + persistableStateAttachmentState: PersistableStateAttachmentViewProps['persistableStateAttachmentState']; +} + +export function PageAttachmentChildren({ + persistableStateAttachmentState, +}: AttachmentChildrenProps) { + const pageState = persistableStateAttachmentState as PageAttachmentPersistedState; + const { + services: { + application, + http: { + externalUrl: { isInternalUrl }, + }, + notifications: { toasts }, + }, + } = useKibana(); + const { url } = pageState; + const label = url?.label; + + const href = useMemo(() => { + try { + const parsedUrl = new URL(url.pathAndQuery, window.location.origin); + return parsedUrl.href; + } catch (e) { + return ''; + } + }, [url?.pathAndQuery]); + + const onClick = useCallback(() => { + if (!isInternalUrl(href)) { + toasts.addDanger({ + title: i18n.translate('xpack.observability.caseView.pageAttachment.externalUrlWarning', { + defaultMessage: 'External URL detected', + }), + text: i18n.translate('xpack.observability.caseView.pageAttachment.externalUrlWarningText', { + defaultMessage: 'External URLs are not supported. Please check the URL and try again.', + }), + }); + return; + } + + if (href) { + application.navigateToUrl(href); + } + }, [application, href, isInternalUrl, toasts]); + + if (!isInternalUrl(href)) { + return ( + + +

+ {i18n.translate('xpack.observability.caseView.pageAttachment.externalUrlWarningText', { + defaultMessage: + 'External URLs are not supported. Please check the URL and try again.', + })} +

+
+
+ ); + } + + if (!url || !href || !label) { + return ( + + +

+ {i18n.translate('xpack.observability.caseView.pageAttachment.noUrlProvided', { + defaultMessage: 'This page attachment does not contain a valid URL.', + })} +

+
+
+ ); + } + + return ( + <> + + {url.iconType && ( + + + + )} + + + {label} + + + + + ); +} + +PageAttachmentChildren.displayName = 'PageAttachmentChildren'; + +// Note: This is for lazy loading +// eslint-disable-next-line import/no-default-export +export default PageAttachmentChildren; diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/go_to_action.test.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/go_to_action.test.tsx new file mode 100644 index 0000000000000..a439c4fe3236a --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/go_to_action.test.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import GoToAction from './go_to_action'; + +describe('GoToAction', () => { + it('renders the button with correct props when state is valid', () => { + const state = { + type: 'discoverSession', + url: { + pathAndQuery: '/app/discover#/', + label: 'Discover session', + actionLabel: 'Go to Discover', + iconType: 'discoverApp', + }, + }; + + render(); + const button = screen.getByTestId('cases-go-to-action'); + + expect(button).toBeInTheDocument(); + expect(within(button).getByText('Go to Discover')).toBeInTheDocument(); + expect(button).toHaveAttribute('href', '/app/discover#/'); + expect(button).toHaveAttribute('aria-label', 'Go to Discover'); + expect(button).toHaveAttribute('data-test-subj', 'cases-go-to-action'); + }); + + it('returns null when href or actionLabel is missing', () => { + const state = { + url: { + pathAndQuery: null, + label: 'Discover session', + actionLabel: 'actionLabel', + }, + iconType: 'discoverApp', + type: 'discoverSession', + screenContext: null, + }; + + // @ts-ignore next line + render(); + expect(screen.queryByText('actionLabel')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/go_to_action.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/go_to_action.tsx new file mode 100644 index 0000000000000..14de00a07eb8d --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/go_to_action.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiButtonEmpty, type IconType } from '@elastic/eui'; +import type { PageAttachmentPersistedState } from '@kbn/page-attachment-schema'; + +export interface GoToActionProps { + state: PageAttachmentPersistedState; +} + +function GoToAction({ state }: GoToActionProps) { + const url = state?.url; + const href = url?.pathAndQuery; + + const actionLabel = url?.actionLabel; + const iconType = url?.iconType as IconType; + if (!href || !actionLabel) { + return null; + } + const buttonProps = { + iconType, + 'aria-label': actionLabel, + href, + 'data-test-subj': 'cases-go-to-action', + }; + + return {actionLabel}; +} + +GoToAction.displayName = 'PageAttachmentGoToAction'; + +// Note: This is for lazy loading +// eslint-disable-next-line import/no-default-export +export default GoToAction; diff --git a/x-pack/solutions/observability/plugins/observability/public/attachments/page/translations.tsx b/x-pack/solutions/observability/plugins/observability/public/attachments/page/translations.tsx new file mode 100644 index 0000000000000..b0a3b277ddefe --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability/public/attachments/page/translations.tsx @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const ADDED_PAGE = i18n.translate('xpack.observability.caseView.page.addedPage', { + defaultMessage: 'added a page', +}); +export const REMOVED_PAGE = i18n.translate('xpack.observability.caseView.page.removedPage', { + defaultMessage: 'removed page', +}); +export const PAGE_LABEL = i18n.translate('xpack.observability.caseView.page.pageLabel', { + defaultMessage: 'Page', +}); diff --git a/x-pack/solutions/observability/plugins/observability/public/plugin.ts b/x-pack/solutions/observability/plugins/observability/public/plugin.ts index aaf3cffed3f77..7ecee5acd71e9 100644 --- a/x-pack/solutions/observability/plugins/observability/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/observability/public/plugin.ts @@ -99,6 +99,7 @@ import { CaseDetailsLocatorDefinition, CasesOverviewLocatorDefinition, } from '../common/locators/cases'; +import { getPageAttachmentType } from './attachments/page/attachment'; import { TelemetryService } from './services/telemetry/telemetry_service'; export interface ConfigSchema { @@ -269,6 +270,13 @@ export class Plugin const logsLocator = pluginsSetup.share.url.locators.get(DISCOVER_APP_LOCATOR); + if ( + pluginsSetup.cases && + pluginsSetup.observabilityShared.config.unsafe?.investigativeExperienceEnabled + ) { + pluginsSetup.cases.attachmentFramework.registerPersistableState(getPageAttachmentType()); + } + const mount = async (params: AppMountParameters) => { // Load application bundle const { renderApp } = await import('./application'); diff --git a/x-pack/solutions/observability/plugins/observability/server/plugin.ts b/x-pack/solutions/observability/plugins/observability/server/plugin.ts index 751f47d590954..3fca997f65a91 100644 --- a/x-pack/solutions/observability/plugins/observability/server/plugin.ts +++ b/x-pack/solutions/observability/plugins/observability/server/plugin.ts @@ -6,6 +6,7 @@ */ import type { AlertingServerSetup, AlertingServerStart } from '@kbn/alerting-plugin/server'; +import type { CasesServerSetup } from '@kbn/cases-plugin/server'; import type { ContentManagementServerSetup } from '@kbn/content-management-plugin/server'; import type { DashboardPluginStart } from '@kbn/dashboard-plugin/server'; import { @@ -16,6 +17,10 @@ import { CloudSetup } from '@kbn/cloud-plugin/server'; import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from '@kbn/core/server'; import { DISCOVER_APP_LOCATOR, type DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; import { FeaturesPluginSetup } from '@kbn/features-plugin/server'; +import type { + ObservabilitySharedPluginSetup, + ObservabilitySharedPluginStart, +} from '@kbn/observability-shared-plugin/server'; import { RuleRegistryPluginSetupContract, RuleRegistryPluginStartContract, @@ -25,6 +30,7 @@ import { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server' import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; import { DataViewsServerPluginStart } from '@kbn/data-views-plugin/server'; import { PluginSetup as ESQLSetup } from '@kbn/esql/server'; +import { PAGE_ATTACHMENT_TYPE } from '@kbn/page-attachment-schema'; import { getLogsFeature } from './features/logs_feature'; import { ObservabilityConfig } from '.'; import { OBSERVABILITY_TIERED_FEATURES, observabilityFeatureId } from '../common'; @@ -49,6 +55,7 @@ export type ObservabilityPluginSetup = ReturnType; interface PluginSetup { alerting: AlertingServerSetup; + cases?: CasesServerSetup; features: FeaturesPluginSetup; ruleRegistry: RuleRegistryPluginSetupContract; share: SharePluginSetup; @@ -57,6 +64,7 @@ interface PluginSetup { cloud?: CloudSetup; contentManagement: ContentManagementServerSetup; esql: ESQLSetup; + observabilityShared: ObservabilitySharedPluginSetup; } interface PluginStart { @@ -65,6 +73,7 @@ interface PluginStart { dataViews: DataViewsServerPluginStart; ruleRegistry: RuleRegistryPluginStartContract; dashboard: DashboardPluginStart; + observabilityShared: ObservabilitySharedPluginStart; } export class ObservabilityPlugin implements Plugin @@ -93,6 +102,14 @@ export class ObservabilityPlugin plugins.features.registerKibanaFeature(getCasesFeatureV2(casesCapabilities, casesApiTags)); plugins.features.registerKibanaFeature(getCasesFeatureV3(casesCapabilities, casesApiTags)); + if ( + plugins.cases && + plugins.observabilityShared.config.unsafe?.investigativeExperienceEnabled + ) { + plugins.cases.attachmentFramework.registerPersistableState({ + id: PAGE_ATTACHMENT_TYPE, + }); + } plugins.features.registerKibanaFeature(getLogsFeature()); let annotationsApiPromise: Promise | undefined; diff --git a/x-pack/solutions/observability/plugins/observability/tsconfig.json b/x-pack/solutions/observability/plugins/observability/tsconfig.json index cb5df0333c845..01d6874b8043a 100644 --- a/x-pack/solutions/observability/plugins/observability/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability/tsconfig.json @@ -128,6 +128,7 @@ "@kbn/esql", "@kbn/apm-synthtrace-client", "@kbn/scout-oblt", + "@kbn/page-attachment-schema" ], "exclude": ["target/**/*"] } diff --git a/x-pack/solutions/observability/plugins/observability_shared/common/config.ts b/x-pack/solutions/observability/plugins/observability_shared/common/config.ts new file mode 100644 index 0000000000000..823ab705452ec --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_shared/common/config.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const observabilitySharedConfigSchema = schema.object({ + unsafe: schema.maybe( + schema.object({ + investigativeExperienceEnabled: schema.maybe(schema.boolean({ defaultValue: false })), + }) + ), +}); + +export type ObservabilitySharedConfig = TypeOf; +export type ObservabilitySharedBrowserConfig = Pick< + TypeOf, + 'unsafe' +>; diff --git a/x-pack/solutions/observability/plugins/observability_shared/kibana.jsonc b/x-pack/solutions/observability/plugins/observability_shared/kibana.jsonc index aa6160837df27..5b45bfc1d534f 100644 --- a/x-pack/solutions/observability/plugins/observability_shared/kibana.jsonc +++ b/x-pack/solutions/observability/plugins/observability_shared/kibana.jsonc @@ -9,10 +9,10 @@ "plugin": { "id": "observabilityShared", "browser": true, - "server": false, + "server": true, "configPath": [ "xpack", - "observability_shared" + "observabilityShared" ], "requiredPlugins": [ "uiActions", diff --git a/x-pack/solutions/observability/plugins/observability_shared/public/index.ts b/x-pack/solutions/observability/plugins/observability_shared/public/index.ts index 48ccdb516f02e..a631980ec9761 100644 --- a/x-pack/solutions/observability/plugins/observability_shared/public/index.ts +++ b/x-pack/solutions/observability/plugins/observability_shared/public/index.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { PluginInitializerContext } from '@kbn/core/public'; import { ObservabilitySharedPlugin } from './plugin'; export type { ObservabilitySharedPlugin, @@ -12,8 +12,8 @@ export type { ObservabilitySharedPluginStart, ProfilingLocators, } from './plugin'; -export const plugin = () => { - return new ObservabilitySharedPlugin(); +export const plugin = (initializerContext: PluginInitializerContext) => { + return new ObservabilitySharedPlugin(initializerContext); }; export type { diff --git a/x-pack/solutions/observability/plugins/observability_shared/public/plugin.ts b/x-pack/solutions/observability/plugins/observability_shared/public/plugin.ts index f8189de70138e..1205a1f29effc 100644 --- a/x-pack/solutions/observability/plugins/observability_shared/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/observability_shared/public/plugin.ts @@ -7,6 +7,7 @@ import type { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; +import type { PluginInitializerContext } from '@kbn/core/public'; import type { BrowserUrlService, SharePluginSetup, @@ -42,6 +43,7 @@ import { type MetricsExplorerLocator, type TransactionDetailsByTraceIdLocator, } from '../common'; +import { ObservabilitySharedBrowserConfig } from '../common/config'; import { updateGlobalNavigation } from './services/update_global_navigation'; import { DependencyOverviewLocator, @@ -85,9 +87,11 @@ interface ObservabilitySharedLocators { export class ObservabilitySharedPlugin implements Plugin { private readonly navigationRegistry = createNavigationRegistry(); private isSidebarEnabled$: BehaviorSubject; + private config: ObservabilitySharedBrowserConfig; - constructor() { + constructor(private readonly initializerContext: PluginInitializerContext) { this.isSidebarEnabled$ = new BehaviorSubject(true); + this.config = this.initializerContext.config.get(); } public setup(coreSetup: CoreSetup, pluginsSetup: ObservabilitySharedSetup) { @@ -103,6 +107,7 @@ export class ObservabilitySharedPlugin implements Plugin { navigation: { registerSections: this.navigationRegistry.registerSections, }, + config: this.config, }; } @@ -125,6 +130,7 @@ export class ObservabilitySharedPlugin implements Plugin { registerSections: this.navigationRegistry.registerSections, }, updateGlobalNavigation, + config: this.config, }; } diff --git a/x-pack/solutions/observability/plugins/observability_shared/server/config.ts b/x-pack/solutions/observability/plugins/observability_shared/server/config.ts new file mode 100644 index 0000000000000..7ec3d55e97729 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_shared/server/config.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PluginConfigDescriptor } from '@kbn/core/server'; +import { observabilitySharedConfigSchema } from '../common/config'; + +export const config: PluginConfigDescriptor = { + exposeToBrowser: { + unsafe: true, + }, + schema: observabilitySharedConfigSchema, +}; diff --git a/x-pack/solutions/observability/plugins/observability_shared/server/index.ts b/x-pack/solutions/observability/plugins/observability_shared/server/index.ts new file mode 100644 index 0000000000000..29c54d9572306 --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_shared/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '@kbn/core/server'; + +export { config } from './config'; + +export type { + ObservabilitySharedPlugin, + ObservabilitySharedPluginSetup, + ObservabilitySharedPluginStart, +} from './plugin'; + +export async function plugin(initializerContext: PluginInitializerContext) { + const { ObservabilitySharedPlugin } = await import('./plugin'); + return new ObservabilitySharedPlugin(initializerContext); +} diff --git a/x-pack/solutions/observability/plugins/observability_shared/server/plugin.ts b/x-pack/solutions/observability/plugins/observability_shared/server/plugin.ts new file mode 100644 index 0000000000000..db6ff1861adec --- /dev/null +++ b/x-pack/solutions/observability/plugins/observability_shared/server/plugin.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { PluginInitializerContext, Plugin as PluginType } from '@kbn/core/server'; + +import { ObservabilitySharedConfig } from '../common/config'; + +export type ObservabilitySharedPluginSetup = ReturnType; +export type ObservabilitySharedPluginStart = ReturnType; +export class ObservabilitySharedPlugin implements PluginType { + private config: ObservabilitySharedConfig; + + constructor(private readonly initContext: PluginInitializerContext) { + this.config = this.initContext.config.get(); + } + + public setup() { + return { + config: this.config, + }; + } + + public start() { + return { + config: this.config, + }; + } + + public stop() {} +} diff --git a/x-pack/solutions/observability/plugins/observability_shared/tsconfig.json b/x-pack/solutions/observability/plugins/observability_shared/tsconfig.json index aa319ede40589..8cd380fb2e007 100644 --- a/x-pack/solutions/observability/plugins/observability_shared/tsconfig.json +++ b/x-pack/solutions/observability/plugins/observability_shared/tsconfig.json @@ -48,6 +48,7 @@ "@kbn/charts-theme", "@kbn/deeplinks-observability", "@kbn/home-sample-data-tab", + "@kbn/config-schema", ], "exclude": ["target/**/*", ".storybook/**/*.js"] } diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.test.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.test.ts new file mode 100644 index 0000000000000..bdbb4a8244d49 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { monitorDetailNavigatorParams } from './monitor_detail'; + +describe('monitorDetailNavigatorParams', () => { + const { getLocation } = monitorDetailNavigatorParams; + + it('generates the correct path with only configId', async () => { + const result = await getLocation({ configId: 'test-config-id' }); + expect(result).toEqual({ + app: 'synthetics', + path: '/monitor/test-config-id', + state: {}, + }); + }); + + it('includes locationId in the query parameters', async () => { + const result = await getLocation({ + configId: 'test-config-id', + locationId: 'test-location-id', + }); + expect(result).toEqual({ + app: 'synthetics', + path: '/monitor/test-config-id?locationId=test-location-id', + state: {}, + }); + }); + + it('includes spaceId in the query parameters', async () => { + const result = await getLocation({ configId: 'test-config-id', spaceId: 'test-space-id' }); + expect(result).toEqual({ + app: 'synthetics', + path: '/monitor/test-config-id?spaceId=test-space-id', + state: {}, + }); + }); + + it('includes timeRange in the query parameters', async () => { + const result = await getLocation({ + configId: 'test-config-id', + timeRange: { from: 'now-15m', to: 'now' }, + }); + expect(result).toEqual({ + app: 'synthetics', + path: '/monitor/test-config-id?dateRangeStart=now-15m&dateRangeEnd=now', + state: {}, + }); + }); + + it('includes tabId in the path', async () => { + const result = await getLocation({ configId: 'test-config-id', tabId: 'test-tab-id' }); + expect(result).toEqual({ + app: 'synthetics', + path: '/monitor/test-config-id/test-tab-id', + state: {}, + }); + }); + + it('combines all parameters correctly', async () => { + const result = await getLocation({ + configId: 'test-config-id', + locationId: 'test-location-id', + spaceId: 'test-space-id', + timeRange: { from: 'now-15m', to: 'now' }, + tabId: 'test-tab-id', + }); + expect(result).toEqual({ + app: 'synthetics', + path: '/monitor/test-config-id/test-tab-id?locationId=test-location-id&spaceId=test-space-id&dateRangeStart=now-15m&dateRangeEnd=now', + state: {}, + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.ts index d79cce62fedf9..a5749f09e9b65 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/locators/monitor_detail.ts @@ -6,25 +6,37 @@ */ import { syntheticsMonitorDetailLocatorID } from '@kbn/observability-plugin/common'; +import { TimeRange } from '@kbn/es-query'; async function navigate({ configId, locationId, spaceId, + timeRange, + tabId, }: { configId: string; locationId?: string; spaceId?: string; + timeRange?: TimeRange; + tabId?: string; }) { let queryParam = locationId ? `?locationId=${locationId}` : ''; + const tab = `${tabId ? `/${tabId}` : ''}`; if (spaceId) { queryParam += queryParam ? `&spaceId=${spaceId}` : `?spaceId=${spaceId}`; } + if (timeRange) { + queryParam += queryParam + ? `&dateRangeStart=${timeRange.from}&dateRangeEnd=${timeRange.to}` + : `?dateRangeStart=${timeRange.from}&dateRangeEnd=${timeRange.to}`; + } + return { app: 'synthetics', - path: `/monitor/${configId}${queryParam}`, + path: `/monitor/${configId}${tab}${queryParam}`, state: {}, }; } diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/components/refresh_button.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/components/refresh_button.tsx index aa9c1f57328f3..45a4fdcdb4e49 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/components/refresh_button.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/common/components/refresh_button.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiContextMenuItem } from '@elastic/eui'; import { useSyntheticsRefreshContext } from '../../../contexts'; export function RefreshButton() { @@ -23,6 +23,19 @@ export function RefreshButton() { ); } +export function RefreshContextItem() { + const { refreshApp } = useSyntheticsRefreshContext(); + return ( + refreshApp()} + > + {REFRESH_LABEL} + + ); +} + export const REFRESH_LABEL = i18n.translate('xpack.synthetics.overview.refresh', { defaultMessage: 'Refresh', }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/actions.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/actions.test.tsx new file mode 100644 index 0000000000000..61eba82c7b962 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/actions.test.tsx @@ -0,0 +1,185 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Actions } from './actions'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useParams, useLocation } from 'react-router-dom'; +import { useDispatch, useSelector } from 'react-redux'; + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useKibana: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), + useLocation: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +describe('Actions Component', () => { + let mockDispatch: jest.Mock; + + beforeEach(() => { + mockDispatch = jest.fn(); + + (useDispatch as jest.Mock).mockReturnValue(mockDispatch); + (useSelector as jest.Mock).mockReturnValue([]); + (useParams as jest.Mock).mockReturnValue({ monitorId: 'test-monitor-id' }); + (useLocation as jest.Mock).mockReturnValue({ search: '?test=true' }); + }); + + it('renders all default action items', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + notifications: { + toasts: { + addDanger: jest.fn(), + }, + }, + observabilityShared: { + config: { + unsafe: { + investigativeExperienceEnabled: false, + }, + }, + }, + cases: { + ui: { + getCasesContext: jest.fn(() => ({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + }, + helpers: { + canUseCases: jest.fn(() => ({ + read: true, + update: true, + push: true, + })), + }, + hooks: { + useCasesAddToExistingCaseModal: jest.fn(() => ({ + open: jest.fn(), + })), + }, + }, + }, + }); + render(); + + // Open the popover + fireEvent.click(screen.getByTestId('monitorDetailsHeaderControlActionsButton')); + + expect(screen.getByText('Edit monitor')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + expect(screen.getByText('Run test manually')).toBeInTheDocument(); + expect(screen.queryByText('Add to Case')).not.toBeInTheDocument(); + }); + + it('renders Add to Case item when investigativeExperienceEnabled is true and has cases permissions', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + notifications: { + toasts: { + addDanger: jest.fn(), + }, + }, + observabilityShared: { + config: { + unsafe: { + investigativeExperienceEnabled: true, + }, + }, + }, + cases: { + ui: { + getCasesContext: jest.fn(() => ({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + }, + helpers: { + canUseCases: jest.fn(() => ({ + read: true, + update: true, + push: true, + })), + }, + hooks: { + useCasesAddToExistingCaseModal: jest.fn(() => ({ + open: jest.fn(), + })), + }, + }, + }, + }); + + render(); + + // Open the popover + fireEvent.click(screen.getByTestId('monitorDetailsHeaderControlActionsButton')); + + expect(screen.getByText('Edit monitor')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + expect(screen.getByText('Run test manually')).toBeInTheDocument(); + expect(screen.getByText('Add to case')).toBeInTheDocument(); + }); + + it('does not render Add to case item when permissions are not sufficient', () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + notifications: { + toasts: { + addDanger: jest.fn(), + }, + }, + observabilityShared: { + config: { + unsafe: { + investigativeExperienceEnabled: true, + }, + }, + }, + cases: { + ui: { + getCasesContext: jest.fn(() => ({ children }: { children: React.ReactNode }) => ( +
{children}
+ )), + }, + helpers: { + canUseCases: jest.fn(() => ({ + read: false, + update: false, + push: false, + })), + }, + hooks: { + useCasesAddToExistingCaseModal: jest.fn(() => ({ + open: jest.fn(), + })), + }, + }, + }, + }); + + render(); + + // Open the popover + fireEvent.click(screen.getByTestId('monitorDetailsHeaderControlActionsButton')); + + expect(screen.getByText('Edit monitor')).toBeInTheDocument(); + expect(screen.getByText('Refresh')).toBeInTheDocument(); + expect(screen.getByText('Run test manually')).toBeInTheDocument(); + expect(screen.queryByText('Add to case')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/actions.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/actions.tsx new file mode 100644 index 0000000000000..4ef6d15c36149 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/actions.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButton, EuiPopover, EuiContextMenuPanel } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { RunTestManuallyContextItem } from './run_test_manually'; +import { EditMonitorContextItem } from './monitor_summary/edit_monitor_link'; +import { RefreshContextItem } from '../common/components/refresh_button'; +import { AddToCaseContextItem } from './add_to_case_action'; +import { ClientPluginsStart } from '../../../../plugin'; + +export function Actions() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const handleActionsClick = () => setIsPopoverOpen((value) => !value); + const { + services: { observabilityShared }, + } = useKibana(); + const isAddToCaseEnabled = + observabilityShared.config?.unsafe?.investigativeExperienceEnabled || false; + + const closePopover = () => setIsPopoverOpen(false); + return ( + + {i18n.translate('xpack.synthetics.monitorDetails.headerControl.actions', { + defaultMessage: 'Actions', + })} + + } + isOpen={isPopoverOpen} + closePopover={closePopover} + > + , + , + , + ].concat(isAddToCaseEnabled ? [] : [])} + /> + + ); +} diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/add_to_case_action.test.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/add_to_case_action.test.tsx new file mode 100644 index 0000000000000..0ef12eeb994af --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/add_to_case_action.test.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AddToCaseContextItem } from './add_to_case_action'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useSelectedMonitor } from './hooks/use_selected_monitor'; +import { useGetUrlParams } from '../../hooks'; + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useKibana: jest.fn(), +})); + +jest.mock('./hooks/use_selected_monitor', () => ({ + useSelectedMonitor: jest.fn(), +})); + +jest.mock('../../hooks', () => ({ + useGetUrlParams: jest.fn(), + useMonitorDetailLocator: jest + .fn() + .mockImplementation( + ({ timeRange, locationId, configId, tabId }) => + `/${tabId}?${locationId ? `locationId=${locationId}&` : ''}dateRangeStart=${ + timeRange.from + }&dateRangeEnd=${timeRange.to}&configId=${configId}` + ), +})); + +describe('AddToCaseContextItem', () => { + const mockUseKibana = useKibana as jest.Mock; + const mockUseSelectedMonitor = useSelectedMonitor as jest.Mock; + const mockUseGetUrlParams = useGetUrlParams as jest.Mock; + const mockAddDanger = jest.fn(); + const mockOpen = jest.fn(); + + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-06-26T12:57:38.568Z')); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseKibana.mockReturnValue({ + services: { + cases: { + ui: { + getCasesContext: jest.fn(() => ({ children }: any) => <>{children}), + }, + helpers: { + canUseCases: jest.fn(() => ({ + read: true, + update: true, + push: true, + })), + }, + hooks: { + useCasesAddToExistingCaseModal: jest.fn(() => ({ + open: mockOpen, + })), + }, + }, + notifications: { + toasts: { + addDanger: mockAddDanger, + }, + }, + }, + }); + + mockUseSelectedMonitor.mockReturnValue({ + monitor: { config_id: 'test-config-id', name: 'Test Monitor' }, + }); + + mockUseGetUrlParams.mockReturnValue({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + locationId: 'test-location-id', + }); + }); + + it('renders the AddToCaseContextItem component', () => { + render(); + expect(screen.getByText('Add to case')).toBeInTheDocument(); + }); + + it('opens the case modal when clicked', () => { + mockUseKibana.mockReturnValueOnce({ + services: { + cases: { + ui: { + getCasesContext: jest.fn(() => ({ children }: any) => <>{children}), + }, + helpers: { + canUseCases: jest.fn(() => ({ + read: true, + update: true, + push: true, + })), + }, + hooks: { + useCasesAddToExistingCaseModal: jest.fn().mockReturnValue({ + open: mockOpen, + }), + }, + }, + notifications: { + toasts: { + addDanger: jest.fn(), + }, + }, + }, + }); + + render(); + fireEvent.click(screen.getByText('Add to case')); + + expect(mockOpen).toHaveBeenCalledWith({ + getAttachments: expect.any(Function), + }); + }); + + it('shows an error toast if monitor or URL is missing', () => { + mockUseSelectedMonitor.mockReturnValueOnce({ monitor: null }); + + render(); + fireEvent.click(screen.getByText('Add to case')); + + expect(mockAddDanger).toHaveBeenCalledWith({ + title: 'Error adding monitor to case', + 'data-test-subj': 'monitorAddToCaseError', + }); + }); + + it('defines persistableStateAttachmentState correctly when case modal is opened', () => { + render(); + fireEvent.click(screen.getByText('Add to case')); + + const getAttachments = mockOpen.mock.calls[0][0].getAttachments; + const attachments = getAttachments(); + + expect(attachments).toHaveLength(1); + expect(attachments[0]).toEqual({ + persistableStateAttachmentState: { + type: 'synthetics_monitor', + url: { + pathAndQuery: + '/history?locationId=test-location-id&dateRangeStart=2025-06-26T12:42:38.568Z&dateRangeEnd=2025-06-26T12:57:38.568Z&configId=test-config-id', + label: 'Test Monitor', + actionLabel: 'Go to Monitor History', + iconType: 'uptimeApp', + }, + }, + persistableStateAttachmentTypeId: '.page', + type: 'persistableState', + }); + }); + + it('converts relative date ranges to absolute date ranges via the component', () => { + mockUseGetUrlParams.mockReturnValue({ + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + locationId: 'test-location-id', + }); + + render(); + fireEvent.click(screen.getByText('Add to case')); + + const getAttachments = mockOpen.mock.calls[0][0].getAttachments; + const attachments = getAttachments(); + + expect(attachments[0].persistableStateAttachmentState.url.pathAndQuery).toEqual( + '/history?locationId=test-location-id&dateRangeStart=2025-06-26T12:42:38.568Z&dateRangeEnd=2025-06-26T12:57:38.568Z&configId=test-config-id' + ); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/add_to_case_action.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/add_to_case_action.tsx new file mode 100644 index 0000000000000..4371e658971a0 --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/add_to_case_action.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useMemo, useCallback } from 'react'; +import { EuiContextMenuItem } from '@elastic/eui'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { type TimeRange, getAbsoluteTimeRange } from '@kbn/data-plugin/common'; +import { i18n } from '@kbn/i18n'; +import type { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public'; +import type { PageAttachmentPersistedState } from '@kbn/page-attachment-schema'; +import { type CasesPermissions } from '@kbn/cases-plugin/common'; +import { ClientPluginsStart } from '../../../../plugin'; +import { useSelectedMonitor } from './hooks/use_selected_monitor'; +import { useGetUrlParams, useMonitorDetailLocator } from '../../hooks'; + +export function AddToCaseContextItem() { + const { + services: { cases }, + } = useKibana(); + const getCasesContext = cases?.ui?.getCasesContext; + const canUseCases = cases?.helpers?.canUseCases; + + const casesPermissions: CasesPermissions = useMemo(() => { + if (!canUseCases) { + return { + all: false, + create: false, + read: false, + update: false, + delete: false, + push: false, + connectors: false, + settings: false, + reopenCase: false, + createComment: false, + assign: false, + }; + } + return canUseCases(); + }, [canUseCases]); + const hasCasesPermissions = + casesPermissions.read && casesPermissions.update && casesPermissions.push; + const CasesContext = useMemo(() => { + if (!getCasesContext) { + return React.Fragment; + } + return getCasesContext(); + }, [getCasesContext]); + + if (!cases) { + return null; + } + + return hasCasesPermissions ? ( + + + + ) : null; +} +function AddToCaseButtonContent() { + const { monitor } = useSelectedMonitor(); + const { dateRangeEnd, dateRangeStart, locationId } = useGetUrlParams(); + const services = useKibana().services; + const notifications = services.notifications; + // type checked in wrapper component + const useCasesAddToExistingCaseModal = services.cases?.hooks?.useCasesAddToExistingCaseModal!; + const casesModal = useCasesAddToExistingCaseModal(); + const timeRange: TimeRange = { + from: dateRangeStart, + to: dateRangeEnd, + }; + + const redirectUrl = useMonitorDetailLocator({ + configId: monitor?.config_id ?? '', + timeRange: convertToAbsoluteTimeRange(timeRange), + locationId, + tabId: 'history', + }); + + const onClick = useCallback(() => { + if (!redirectUrl || !monitor?.name) { + notifications.toasts.addDanger({ + title: i18n.translate('xpack.synthetics.cases.addToCaseModal.error.noMonitorLocator', { + defaultMessage: 'Error adding monitor to case', + }), + 'data-test-subj': 'monitorAddToCaseError', + }); + return; + } + + casesModal.open({ + getAttachments: () => { + const persistableStateAttachmentState: PageAttachmentPersistedState = { + type: 'synthetics_monitor', + url: { + pathAndQuery: redirectUrl, + label: monitor.name, + actionLabel: i18n.translate( + 'xpack.synthetics.cases.addToCaseModal.goToMonitorHistoryActionLabel', + { + defaultMessage: 'Go to Monitor History', + } + ), + iconType: 'uptimeApp', + }, + }; + return [ + { + persistableStateAttachmentState, + persistableStateAttachmentTypeId: '.page', + type: 'persistableState', + }, + ] as CaseAttachmentsWithoutOwner; + }, + }); + }, [casesModal, notifications.toasts, monitor?.name, redirectUrl]); + + return ( + + {i18n.translate('xpack.synthetics.cases.addToCaseModal.buttonLabel', { + defaultMessage: 'Add to case', + })} + + ); +} + +export const convertToAbsoluteTimeRange = (timeRange?: TimeRange): TimeRange | undefined => { + if (!timeRange) { + return; + } + + const absRange = getAbsoluteTimeRange( + { + from: timeRange.from, + to: timeRange.to, + }, + { forceNow: new Date() } + ); + + return { + from: absRange.from, + to: absRange.to, + }; +}; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx index a633a1760be99..fc3a71a4523cf 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/hooks/use_selected_monitor.tsx @@ -55,7 +55,7 @@ export const useSelectedMonitor = ({ : null; const isMonitorMissing = - error?.body.statusCode === 404 && + error?.body?.statusCode === 404 && (error.getPayload as { monitorId: string })?.monitorId === monitorId; useEffect(() => { diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/edit_monitor_link.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/edit_monitor_link.tsx index aba79c6621da0..3c9cc876eb24b 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/edit_monitor_link.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/monitor_summary/edit_monitor_link.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; -import { EuiButton } from '@elastic/eui'; +import { EuiButton, EuiContextMenuItem } from '@elastic/eui'; import { useCanEditSynthetics } from '../../../../../hooks/use_capabilities'; import { useSyntheticsSettingsContext } from '../../../contexts'; @@ -45,6 +45,32 @@ export const EditMonitorLink = () => { ); }; +export const EditMonitorContextItem = () => { + const { basePath } = useSyntheticsSettingsContext(); + const { monitorId } = useParams<{ monitorId: string }>(); + const { spaceId } = useGetUrlParams(); + const canEditSynthetics = useCanEditSynthetics(); + const isLinkDisabled = !canEditSynthetics; + const linkProps = isLinkDisabled + ? { disabled: true } + : { + href: + `${basePath}/app/synthetics/edit-monitor/${monitorId}` + + (spaceId ? `?spaceId=${spaceId}` : ''), + }; + + return ( + + {EDIT_MONITOR} + + ); +}; + const EDIT_MONITOR = i18n.translate('xpack.synthetics.monitorSummary.editMonitor', { defaultMessage: 'Edit monitor', }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/route_config.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/route_config.tsx index b8d0db0f5a774..5c82bc164d0f5 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/route_config.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/route_config.tsx @@ -12,10 +12,8 @@ import { EuiIcon, EuiPageHeaderProps } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { MonitorDetailsAlerts } from './monitor_alerts/monitor_detail_alerts'; import { MonitorAlertsIcon } from './monitor_alerts/alerts_icon'; -import { RefreshButton } from '../common/components/refresh_button'; import { MonitorNotFoundPage } from './monitor_not_found_page'; import { MonitorDetailsPageTitle } from './monitor_details_page_title'; -import { RunTestManually } from './run_test_manually'; import { MonitorDetailsLastRun } from './monitor_details_last_run'; import { MonitorDetailsStatus } from './monitor_details_status'; import { MonitorDetailsLocation } from './monitor_details_location'; @@ -23,7 +21,7 @@ import { MonitorErrors } from './monitor_errors/monitor_errors'; import { MonitorErrorsIcon } from './monitor_errors/errors_icon'; import { MonitorHistory } from './monitor_history/monitor_history'; import { MonitorSummary } from './monitor_summary/monitor_summary'; -import { EditMonitorLink } from './monitor_summary/edit_monitor_link'; +import { Actions } from './actions'; import { MONITOR_ALERTS_ROUTE, MONITOR_ERRORS_ROUTE, @@ -126,17 +124,11 @@ const getMonitorSummaryHeader = ( const monitorId = match.params.monitorId; const rightSideItems = [ - , - , - , + , , , , ]; - if (selectedTab === 'alerts' || selectedTab === 'history' || selectedTab === 'errors') { - // remove first item refresh button - rightSideItems.shift(); - } return { pageTitle: , diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/run_test_manually.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/run_test_manually.tsx index e32918705634b..a278795611c74 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/run_test_manually.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/components/monitor_details/run_test_manually.tsx @@ -5,7 +5,15 @@ * 2.0. */ -import { EuiButton, EuiToolTip } from '@elastic/eui'; +import { + EuiButton, + EuiToolTip, + EuiContextMenuItem, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingSpinner, +} from '@elastic/eui'; import React from 'react'; import { i18n } from '@kbn/i18n'; import { useDispatch, useSelector } from 'react-redux'; @@ -63,6 +71,52 @@ export const RunTestManually = () => { ); }; +export const RunTestManuallyContextItem = () => { + const dispatch = useDispatch(); + + const { monitor } = useSelectedMonitor(); + const testInProgress = useSelector(manualTestRunInProgressSelector(monitor?.config_id)); + + const canUsePublicLocations = useCanUsePublicLocations(monitor?.[ConfigKey.LOCATIONS]); + + const { space } = useKibanaSpace(); + + const content = !canUsePublicLocations + ? CANNOT_PERFORM_ACTION_PUBLIC_LOCATIONS + : testInProgress + ? TEST_SCHEDULED_LABEL + : TEST_NOW_ARIA_LABEL; + + return ( + + { + if (monitor) { + const spaceId = 'spaceId' in monitor ? (monitor.spaceId as string) : undefined; + dispatch( + manualTestMonitorAction.get({ + configId: monitor.config_id, + name: monitor.name, + ...(spaceId && spaceId !== space?.id ? { spaceId } : {}), + }) + ); + } + }} + > + + + {testInProgress ? : } + + {{RUN_TEST_LABEL}} + + + + ); +}; + const RUN_TEST_LABEL = i18n.translate('xpack.synthetics.monitorSummary.runTestManually', { defaultMessage: 'Run test manually', }); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx index cc0284550354b..c938fc8cc0b37 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/contexts/synthetics_shared_context.tsx @@ -55,6 +55,7 @@ export const SyntheticsSharedContext: React.FC< slo: startPlugins.slo, serverless: startPlugins.serverless, charts: startPlugins.charts, + security: startPlugins.security, }} > diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/index.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/index.ts index ce0163ff74fb9..00042a442bdeb 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/index.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/index.ts @@ -18,3 +18,4 @@ export * from './use_dimensions'; export * from './use_fleet_permissions'; export * from './use_monitor_enable_handler'; export * from './use_edit_monitor_locator'; +export * from './use_monitor_detail_locator'; diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.test.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.test.ts new file mode 100644 index 0000000000000..10b924910afca --- /dev/null +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.test.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook, waitFor } from '@testing-library/react'; +import { useMonitorDetailLocator } from './use_monitor_detail_locator'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { useKibanaSpace } from '../../../hooks/use_kibana_space'; + +jest.mock('@kbn/kibana-react-plugin/public', () => ({ + useKibana: jest.fn(), +})); + +jest.mock('../../../hooks/use_kibana_space', () => ({ + useKibanaSpace: jest.fn(), +})); + +const mockLocator = { + getRedirectUrl: jest.fn(), +}; + +describe('useMonitorDetailLocator', () => { + beforeEach(() => { + jest.clearAllMocks(); + (useKibana as jest.Mock).mockReturnValue({ + services: { + share: { + url: { + locators: { + get: jest.fn().mockReturnValue(mockLocator), + }, + }, + }, + }, + }); + (useKibanaSpace as jest.Mock).mockReturnValue({ space: { id: 'default' } }); + }); + + it('should generate the correct monitor URL', async () => { + const mockUrl = 'http://example.com/monitor'; + mockLocator.getRedirectUrl.mockReturnValue(mockUrl); + + const { result } = renderHook(() => + useMonitorDetailLocator({ + configId: 'test-config', + locationId: 'test-location', + timeRange: { from: 'now-15m', to: 'now' }, + tabId: 'overview', + }) + ); + + await waitFor(() => { + expect(mockLocator.getRedirectUrl).toHaveBeenCalledWith({ + configId: 'test-config', + locationId: 'test-location', + timeRange: { from: 'now-15m', to: 'now' }, + tabId: 'overview', + }); + }); + + expect(result.current).toBe(mockUrl); + }); + + it('should return undefined if locator is not available', async () => { + (useKibana as jest.Mock).mockReturnValue({ + services: { + share: { + url: { + locators: { + get: jest.fn().mockReturnValue(undefined), + }, + }, + }, + }, + }); + + const { result } = renderHook(() => + useMonitorDetailLocator({ + configId: 'test-config', + }) + ); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.ts b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.ts index 6758e14ce6d20..8f77b7e935ff1 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/apps/synthetics/hooks/use_monitor_detail_locator.ts @@ -8,19 +8,26 @@ import { useEffect, useState } from 'react'; import { syntheticsMonitorDetailLocatorID } from '@kbn/observability-plugin/common'; import { useKibana } from '@kbn/kibana-react-plugin/public'; +import { TimeRange } from '@kbn/es-query'; import { getMonitorSpaceToAppend } from './use_edit_monitor_locator'; import { useKibanaSpace } from '../../../hooks/use_kibana_space'; import { ClientPluginsStart } from '../../../plugin'; +export interface MonitorDetailLocatorParams { + configId: string; + locationId?: string; + timeRange?: TimeRange; + spaces?: string[]; + tabId?: string; +} + export function useMonitorDetailLocator({ configId, locationId, spaces, -}: { - configId: string; - locationId?: string; - spaces?: string[]; -}) { + timeRange, + tabId, +}: MonitorDetailLocatorParams) { const { space } = useKibanaSpace(); const [monitorUrl, setMonitorUrl] = useState(undefined); const locator = useKibana().services?.share?.url.locators.get( @@ -29,15 +36,17 @@ export function useMonitorDetailLocator({ useEffect(() => { async function generateUrl() { - const url = await locator?.getUrl({ + const url = locator?.getRedirectUrl({ configId, locationId, + timeRange, + tabId, ...getMonitorSpaceToAppend(space, spaces), }); setMonitorUrl(url); } generateUrl(); - }, [locator, configId, locationId, spaces, space?.id, space]); + }, [locator, configId, locationId, spaces, space?.id, space, timeRange, tabId]); return monitorUrl; } diff --git a/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts b/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts index 9c3ed82493e0f..b0f59299b98c3 100644 --- a/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts +++ b/x-pack/solutions/observability/plugins/synthetics/public/plugin.ts @@ -63,6 +63,7 @@ import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { SLOPublicStart } from '@kbn/slo-plugin/public'; import type { ChartsPluginStart } from '@kbn/charts-plugin/public'; import { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public'; +import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import { EmbeddableEnhancedPluginStart } from '@kbn/embeddable-enhanced-plugin/public'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; @@ -87,6 +88,7 @@ export interface ClientPluginsSetup { observabilityShared: ObservabilitySharedPluginSetup; observabilityAIAssistant?: ObservabilityAIAssistantPublicSetup; share: SharePluginSetup; + security: SecurityPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; cloud?: CloudSetup; embeddable: EmbeddableSetup; @@ -107,6 +109,7 @@ export interface ClientPluginsStart { observabilityShared: ObservabilitySharedPluginStart; observabilityAIAssistant?: ObservabilityAIAssistantPublicStart; share: SharePluginStart; + security: SecurityPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginStart; cases?: CasesPublicStart; dataViews: DataViewsPublicPluginStart; diff --git a/x-pack/solutions/observability/plugins/synthetics/tsconfig.json b/x-pack/solutions/observability/plugins/synthetics/tsconfig.json index 3b3b86496dac1..b779724acbe84 100644 --- a/x-pack/solutions/observability/plugins/synthetics/tsconfig.json +++ b/x-pack/solutions/observability/plugins/synthetics/tsconfig.json @@ -117,7 +117,8 @@ "@kbn/core-lifecycle-server", "@kbn/shared-ux-error-boundary", "@kbn/field-formats-plugin", - "@kbn/core-ui-settings-browser" + "@kbn/core-ui-settings-browser", + "@kbn/page-attachment-schema" ], "exclude": ["target/**/*"] } diff --git a/yarn.lock b/yarn.lock index 5ceab573a10d6..7ca236cc30159 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6782,6 +6782,10 @@ version "0.0.0" uid "" +"@kbn/page-attachment-schema@link:x-pack/platform/packages/shared/kbn-page-attachment-schema": + version "0.0.0" + uid "" + "@kbn/painless-lab-plugin@link:x-pack/platform/plugins/private/painless_lab": version "0.0.0" uid ""