Title in ReactNode
@@ -1224,7 +1293,7 @@ export const WithUIComponents = (args: NavigationServices) => {
export const MinimalUI = (args: NavigationServices) => {
const services = storybookMock.getServices({
...args,
- navLinks$: of([...navLinksMock, ...deepLinks]),
+ deepLinks$,
onProjectNavigationChange: (updated) => {
action('Update chrome navigation')(JSON.stringify(updated, null, 2));
},
@@ -1272,230 +1341,6 @@ export const MinimalUI = (args: NavigationServices) => {
);
};
-export const CreativeUI = (args: NavigationServices) => {
- const services = storybookMock.getServices({
- ...args,
- navLinks$: of([...navLinksMock, ...deepLinks]),
- onProjectNavigationChange: (updated) => {
- action('Update chrome navigation')(JSON.stringify(updated, null, 2));
- },
- recentlyAccessed$: of([
- { label: 'This is an example', link: '/app/example/39859', id: '39850' },
- { label: 'Another example', link: '/app/example/5235', id: '5235' },
- ]),
- });
-
- return (
-
-
-
-
-
-
-
-
- Hello!
-
-
-
-
-
-
- As you can see there is really no limit in what UI you can create!
-
-
-
- Have fun!
-
-
-
-
-
-
-
-
-
-
- );
-};
-
-export const UpdatingState = (args: NavigationServices) => {
- const simpleGroupDef: GroupDefinition = {
- type: 'navGroup',
- id: 'observability_project_nav',
- title: 'Observability',
- icon: 'logoObservability',
- children: [
- {
- id: 'aiops',
- title: 'AIOps',
- icon: 'branch',
- children: [
- {
- title: 'Anomaly detection',
- id: 'ml:anomalyDetection',
- link: 'ml:anomalyDetection',
- },
- {
- title: 'Log Rate Analysis',
- id: 'ml:logRateAnalysis',
- link: 'ml:logRateAnalysis',
- },
- {
- title: 'Change Point Detections',
- link: 'ml:changePointDetections',
- id: 'ml:changePointDetections',
- },
- {
- title: 'Job Notifications',
- link: 'ml:notifications',
- id: 'ml:notifications',
- },
- ],
- },
- {
- id: 'project_settings_project_nav',
- title: 'Project settings',
- icon: 'gear',
- children: [
- { id: 'management', link: 'management' },
- { id: 'integrations', link: 'integrations' },
- { id: 'fleet', link: 'fleet' },
- ],
- },
- ],
- };
- const firstSection = simpleGroupDef.children![0];
- const firstSectionFirstChild = firstSection.children![0];
- const secondSection = simpleGroupDef.children![1];
- const secondSectionFirstChild = secondSection.children![0];
-
- const activeNodeSets: ChromeProjectNavigationNode[][][] = [
- [
- [
- {
- ...simpleGroupDef,
- path: [simpleGroupDef.id],
- } as unknown as ChromeProjectNavigationNode,
- {
- ...firstSection,
- path: [simpleGroupDef.id, firstSection.id],
- } as unknown as ChromeProjectNavigationNode,
- {
- ...firstSectionFirstChild,
- path: [simpleGroupDef.id, firstSection.id, firstSectionFirstChild.id],
- } as unknown as ChromeProjectNavigationNode,
- ],
- ],
- [
- [
- {
- ...simpleGroupDef,
- path: [simpleGroupDef.id],
- } as unknown as ChromeProjectNavigationNode,
- {
- ...secondSection,
- path: [simpleGroupDef.id, secondSection.id],
- } as unknown as ChromeProjectNavigationNode,
- {
- ...secondSectionFirstChild,
- path: [simpleGroupDef.id, secondSection.id, secondSectionFirstChild.id],
- } as unknown as ChromeProjectNavigationNode,
- ],
- ],
- ];
-
- // use state to track which element of activeNodeSets is active
- const [activeNodeIndex, setActiveNodeIndex] = useStateStorybook
(0);
- const changeActiveNode = () => {
- const value = (activeNodeIndex + 1) % 2; // toggle between 0 and 1
- setActiveNodeIndex(value);
- };
-
- const activeNodes$ = new BehaviorSubject([]);
- activeNodes$.next(activeNodeSets[activeNodeIndex]);
-
- const services = storybookMock.getServices({
- ...args,
- activeNodes$,
- navLinks$: of([...navLinksMock, ...deepLinks]),
- onProjectNavigationChange: (updated) => {
- action('Update chrome navigation')(JSON.stringify(updated, null, 2));
- },
- });
-
- return (
-
-
-
-
-
- );
-};
-
export default {
title: 'Chrome/Navigation',
description: 'Navigation container to render items for cross-app linking',
diff --git a/packages/shared-ux/chrome/navigation/src/ui/types.ts b/packages/shared-ux/chrome/navigation/src/ui/types.ts
index 9051cc3747bf0..768155ced3050 100644
--- a/packages/shared-ux/chrome/navigation/src/ui/types.ts
+++ b/packages/shared-ux/chrome/navigation/src/ui/types.ts
@@ -30,26 +30,15 @@ export interface NodeProps<
* Children of the node. For Navigation.Item (only) it allows a function to be set.
* This function will receive the ChromeProjectNavigationNode object
*/
- children?: ((navNode: ChromeProjectNavigationNode) => ReactNode) | ReactNode;
-}
-
-/**
- * @internal
- *
- * Internally we enhance the Props passed to the Navigation.Item component.
- */
-export interface NodePropsEnhanced<
- LinkId extends AppDeepLinkId = AppDeepLinkId,
- Id extends string = string,
- ChildrenId extends string = Id
-> extends NodeProps {
- /**
- * Forces the node to be active. This is used to force a collapisble nav group to be open
- * even if the URL does not match any of the nodes in the group.
- */
- isActive?: boolean;
- /** Flag to indicate if the navigation node is a group or not */
- isGroup: boolean;
+ children?: ReactNode;
+ /** @internal - Prop internally controlled, don't use it. */
+ parentNodePath?: string;
+ /** @internal - Prop internally controlled, don't use it. */
+ rootIndex?: number;
+ /** @internal - Prop internally controlled, don't use it. */
+ treeDepth?: number;
+ /** @internal - Prop internally controlled, don't use it. */
+ index?: number;
}
/** The preset that can be pass to the NavigationBucket component */
@@ -181,16 +170,14 @@ export interface ProjectNavigationDefinition<
*
* Function to unregister a navigation node from its parent.
*/
-export type UnRegisterFunction = (id: string) => void;
+export type UnRegisterFunction = () => void;
/**
* @internal
*
* A function to register a navigation node on its parent.
*/
-export type RegisterFunction = (navNode: ChromeProjectNavigationNode) => {
- /** The function to unregister the node. */
- unregister: UnRegisterFunction;
- /** The full path of the node in the navigation tree. */
- path: string[];
-};
+export type RegisterFunction = (
+ navNode: ChromeProjectNavigationNode,
+ order?: number
+) => UnRegisterFunction;
diff --git a/packages/shared-ux/chrome/navigation/src/utils.ts b/packages/shared-ux/chrome/navigation/src/utils.ts
index 8322fe797590b..efbf3c0e2a42e 100644
--- a/packages/shared-ux/chrome/navigation/src/utils.ts
+++ b/packages/shared-ux/chrome/navigation/src/utils.ts
@@ -6,11 +6,16 @@
* Side Public License, v 1.
*/
+import React, { type ReactNode } from 'react';
import type { ChromeProjectNavigationNode, NodeDefinition } from '@kbn/core-chrome-browser';
+import { NavigationFooter } from './ui/components/navigation_footer';
+import { NavigationGroup } from './ui/components/navigation_group';
+import { NavigationItem } from './ui/components/navigation_item';
+import { RecentlyAccessed } from './ui/components/recently_accessed';
let uniqueId = 0;
-function generateUniqueNodeId() {
+export function generateUniqueNodeId() {
const id = `node${uniqueId++}`;
return id;
}
@@ -19,15 +24,6 @@ export function isAbsoluteLink(link: string) {
return link.startsWith('http://') || link.startsWith('https://');
}
-export function nodePathToString(
- node?: T
-): T extends { path?: string[]; id: string } ? string : undefined {
- if (!node) return undefined as T extends { path?: string[]; id: string } ? string : undefined;
- return (node.path ? node.path.join('.') : node.id) as T extends { path?: string[]; id: string }
- ? string
- : undefined;
-}
-
export function isGroupNode({ children }: Pick) {
return children !== undefined;
}
@@ -50,3 +46,37 @@ export function getNavigationNodeHref({
}: Pick): string | undefined {
return deepLink?.url ?? href;
}
+
+function isSamePath(pathA: string | null, pathB: string | null) {
+ if (pathA === null || pathB === null) {
+ return false;
+ }
+ return pathA === pathB;
+}
+
+export function isActiveFromUrl(nodePath: string, activeNodes: ChromeProjectNavigationNode[][]) {
+ return activeNodes.reduce((acc, nodesBranch) => {
+ return acc === true ? acc : nodesBranch.some((branch) => isSamePath(branch.path, nodePath));
+ }, false);
+}
+
+type ChildType = 'item' | 'group' | 'recentlyAccessed' | 'footer' | 'unknown';
+
+export const getChildType = (child: ReactNode): ChildType => {
+ if (!React.isValidElement(child)) {
+ return 'unknown';
+ }
+
+ switch (child.type) {
+ case NavigationItem:
+ return 'item';
+ case NavigationGroup:
+ return 'group';
+ case RecentlyAccessed:
+ return 'recentlyAccessed';
+ case NavigationFooter:
+ return 'footer';
+ default:
+ return 'unknown';
+ }
+};
diff --git a/packages/shared-ux/chrome/navigation/types/index.ts b/packages/shared-ux/chrome/navigation/types/index.ts
index e162e395362a9..43ff4083ba414 100644
--- a/packages/shared-ux/chrome/navigation/types/index.ts
+++ b/packages/shared-ux/chrome/navigation/types/index.ts
@@ -24,7 +24,7 @@ import type { CloudLinks } from '../src/cloud_links';
export interface NavigationServices {
basePath: BasePathService;
recentlyAccessed$: Observable;
- navLinks$: Observable>;
+ deepLinks$: Observable>>;
navIsOpen: boolean;
navigateToUrl: NavigateToUrlFn;
onProjectNavigationChange: (chromeProjectNavigation: ChromeProjectNavigation) => void;
diff --git a/x-pack/plugins/cases/public/components/all_cases/columns_popover.tsx b/x-pack/plugins/cases/public/components/all_cases/columns_popover.tsx
index e95afcc159e98..d16b1f20059a9 100644
--- a/x-pack/plugins/cases/public/components/all_cases/columns_popover.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/columns_popover.tsx
@@ -7,6 +7,7 @@
import type { ChangeEvent } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
+import { css } from '@emotion/react';
import type { DropResult } from '@elastic/eui';
@@ -128,7 +129,11 @@ export const ColumnsPopover: React.FC = ({
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx
index ecc9233fc327c..dcec2558aad46 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.test.tsx
@@ -43,6 +43,7 @@ describe('useActions', () => {
"align": "right",
"name": "Actions",
"render": [Function],
+ "width": "100px",
},
}
`);
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx
index ea43f79b4954e..70a163bcd69a0 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_actions.tsx
@@ -244,6 +244,7 @@ export const useActions = ({ disableActions }: UseBulkActionsProps): UseBulkActi
);
},
+ width: '100px',
}
: null,
};
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx
index b61e7548b089e..0577dcabeb67d 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.test.tsx
@@ -90,13 +90,12 @@ describe('useCasesColumns ', () => {
"field": "assignees",
"name": "Assignees",
"render": [Function],
- "width": "180px",
},
Object {
"field": "tags",
"name": "Tags",
"render": [Function],
- "width": "15%",
+ "width": "12%",
},
Object {
"align": "right",
@@ -110,13 +109,14 @@ describe('useCasesColumns ', () => {
"field": "totalComment",
"name": "Comments",
"render": [Function],
+ "width": "90px",
},
Object {
"field": "category",
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -139,13 +139,13 @@ describe('useCasesColumns ', () => {
Object {
"name": "External incident",
"render": [Function],
- "width": undefined,
},
Object {
"field": "status",
"name": "Status",
"render": [Function],
"sortable": true,
+ "width": "110px",
},
Object {
"field": "severity",
@@ -158,6 +158,7 @@ describe('useCasesColumns ', () => {
"align": "right",
"name": "Actions",
"render": [Function],
+ "width": "100px",
},
],
"isLoadingColumns": false,
@@ -190,13 +191,12 @@ describe('useCasesColumns ', () => {
"field": "assignees",
"name": "Assignees",
"render": [Function],
- "width": "180px",
},
Object {
"field": "tags",
"name": "Tags",
"render": [Function],
- "width": "15%",
+ "width": "12%",
},
Object {
"align": "right",
@@ -210,13 +210,14 @@ describe('useCasesColumns ', () => {
"field": "totalComment",
"name": "Comments",
"render": [Function],
+ "width": "90px",
},
Object {
"field": "category",
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -233,13 +234,13 @@ describe('useCasesColumns ', () => {
Object {
"name": "External incident",
"render": [Function],
- "width": undefined,
},
Object {
"field": "status",
"name": "Status",
"render": [Function],
"sortable": true,
+ "width": "110px",
},
Object {
"field": "severity",
@@ -252,6 +253,7 @@ describe('useCasesColumns ', () => {
"align": "right",
"name": "Actions",
"render": [Function],
+ "width": "100px",
},
],
"isLoadingColumns": false,
@@ -288,7 +290,7 @@ describe('useCasesColumns ', () => {
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -336,7 +338,7 @@ describe('useCasesColumns ', () => {
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -384,7 +386,7 @@ describe('useCasesColumns ', () => {
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -430,7 +432,7 @@ describe('useCasesColumns ', () => {
"field": "tags",
"name": "Tags",
"render": [Function],
- "width": "15%",
+ "width": "12%",
},
Object {
"align": "right",
@@ -444,13 +446,14 @@ describe('useCasesColumns ', () => {
"field": "totalComment",
"name": "Comments",
"render": [Function],
+ "width": "90px",
},
Object {
"field": "category",
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -467,13 +470,13 @@ describe('useCasesColumns ', () => {
Object {
"name": "External incident",
"render": [Function],
- "width": undefined,
},
Object {
"field": "status",
"name": "Status",
"render": [Function],
"sortable": true,
+ "width": "110px",
},
Object {
"field": "severity",
@@ -536,7 +539,7 @@ describe('useCasesColumns ', () => {
"field": "tags",
"name": "Tags",
"render": [Function],
- "width": "15%",
+ "width": "12%",
},
Object {
"align": "right",
@@ -550,13 +553,14 @@ describe('useCasesColumns ', () => {
"field": "totalComment",
"name": "Comments",
"render": [Function],
+ "width": "90px",
},
Object {
"field": "category",
"name": "Category",
"render": [Function],
"sortable": true,
- "width": "100px",
+ "width": "120px",
},
Object {
"field": "createdAt",
@@ -573,13 +577,13 @@ describe('useCasesColumns ', () => {
Object {
"name": "External incident",
"render": [Function],
- "width": undefined,
},
Object {
"field": "status",
"name": "Status",
"render": [Function],
"sortable": true,
+ "width": "110px",
},
Object {
"field": "severity",
diff --git a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx
index 6ef5dcae9fe6b..de053bfb27fab 100644
--- a/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx
+++ b/x-pack/plugins/cases/public/components/all_cases/use_cases_columns.tsx
@@ -137,7 +137,6 @@ export const useCasesColumns = ({
render: (assignees: CaseUI['assignees']) => (
),
- width: '180px',
},
tags: {
field: casesColumnsConfig.tags.field,
@@ -184,7 +183,7 @@ export const useCasesColumns = ({
}
return getEmptyCellValue();
},
- width: '15%',
+ width: '12%',
},
totalAlerts: {
field: casesColumnsConfig.totalAlerts.field,
@@ -204,6 +203,7 @@ export const useCasesColumns = ({
totalComment != null
? renderStringField(`${totalComment}`, `case-table-column-commentCount`)
: getEmptyCellValue(),
+ width: '90px',
},
category: {
field: casesColumnsConfig.category.field,
@@ -217,7 +217,7 @@ export const useCasesColumns = ({
}
return getEmptyCellValue();
},
- width: '100px',
+ width: '120px',
},
closedAt: {
field: casesColumnsConfig.closedAt.field,
@@ -273,7 +273,6 @@ export const useCasesColumns = ({
}
return getEmptyCellValue();
},
- width: isSelectorView ? '80px' : undefined,
},
status: {
field: casesColumnsConfig.status.field,
@@ -286,6 +285,7 @@ export const useCasesColumns = ({
return getEmptyCellValue();
},
+ width: '110px',
},
severity: {
field: casesColumnsConfig.severity.field,
diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx
index f9b913af4d429..b7c87f3356d38 100644
--- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx
+++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.test.tsx
@@ -37,11 +37,21 @@ describe('CustomFieldsList', () => {
it('shows CustomFieldsList correctly', async () => {
appMockRender.render();
- expect(screen.getByTestId('custom-fields-list')).toBeInTheDocument();
+ expect(await screen.findByTestId('custom-fields-list')).toBeInTheDocument();
- for (const field of customFieldsConfigurationMock) {
- expect(screen.getByTestId(`custom-field-${field.key}-${field.type}`)).toBeInTheDocument();
- }
+ expect(
+ await screen.findByTestId(
+ `custom-field-${customFieldsConfigurationMock[0].key}-${customFieldsConfigurationMock[0].type}`
+ )
+ ).toBeInTheDocument();
+ expect(await screen.findByText('Text')).toBeInTheDocument();
+ expect(await screen.findByText('Required')).toBeInTheDocument();
+ expect(
+ await screen.findByTestId(
+ `custom-field-${customFieldsConfigurationMock[1].key}-${customFieldsConfigurationMock[1].type}`
+ )
+ ).toBeInTheDocument();
+ expect(await screen.findByText('Toggle')).toBeInTheDocument();
});
it('shows single CustomFieldsList correctly', async () => {
@@ -49,16 +59,21 @@ describe('CustomFieldsList', () => {
);
- const list = screen.getByTestId('custom-fields-list');
+ const list = await screen.findByTestId('custom-fields-list');
expect(list).toBeInTheDocument();
expect(
- screen.getByTestId(
+ await screen.findByTestId(
`custom-field-${customFieldsConfigurationMock[0].key}-${customFieldsConfigurationMock[0].type}`
)
).toBeInTheDocument();
+ expect(await screen.findByText('Text')).toBeInTheDocument();
+ expect(await screen.findByText('Required')).toBeInTheDocument();
+ expect(
+ await within(list).findByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`)
+ ).toBeInTheDocument();
expect(
- within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
+ await within(list).findByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
).toBeInTheDocument();
});
@@ -76,10 +91,12 @@ describe('CustomFieldsList', () => {
it('shows confirmation modal when deleting a field ', async () => {
appMockRender.render();
- const list = screen.getByTestId('custom-fields-list');
+ const list = await screen.findByTestId('custom-fields-list');
userEvent.click(
- within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
+ await within(list).findByTestId(
+ `${customFieldsConfigurationMock[0].key}-custom-field-delete`
+ )
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
@@ -88,15 +105,17 @@ describe('CustomFieldsList', () => {
it('calls onDeleteCustomField when confirm', async () => {
appMockRender.render();
- const list = screen.getByTestId('custom-fields-list');
+ const list = await screen.findByTestId('custom-fields-list');
userEvent.click(
- within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
+ await within(list).findByTestId(
+ `${customFieldsConfigurationMock[0].key}-custom-field-delete`
+ )
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
- userEvent.click(screen.getByText('Delete'));
+ userEvent.click(await screen.findByText('Delete'));
await waitFor(() => {
expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument();
@@ -109,15 +128,17 @@ describe('CustomFieldsList', () => {
it('does not call onDeleteCustomField when cancel', async () => {
appMockRender.render();
- const list = screen.getByTestId('custom-fields-list');
+ const list = await screen.findByTestId('custom-fields-list');
userEvent.click(
- within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-delete`)
+ await within(list).findByTestId(
+ `${customFieldsConfigurationMock[0].key}-custom-field-delete`
+ )
);
expect(await screen.findByTestId('confirm-delete-custom-field-modal')).toBeInTheDocument();
- userEvent.click(screen.getByText('Cancel'));
+ userEvent.click(await screen.findByText('Cancel'));
await waitFor(() => {
expect(screen.queryByTestId('confirm-delete-custom-field-modal')).not.toBeInTheDocument();
@@ -134,10 +155,10 @@ describe('CustomFieldsList', () => {
it('calls onEditCustomField correctly', async () => {
appMockRender.render();
- const list = screen.getByTestId('custom-fields-list');
+ const list = await screen.findByTestId('custom-fields-list');
userEvent.click(
- within(list).getByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`)
+ await within(list).findByTestId(`${customFieldsConfigurationMock[0].key}-custom-field-edit`)
);
await waitFor(() => {
diff --git a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx
index 649b0ec5d339f..cfccb53e48db3 100644
--- a/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx
+++ b/x-pack/plugins/cases/public/components/custom_fields/custom_fields_list/index.tsx
@@ -13,7 +13,10 @@ import {
EuiSpacer,
EuiText,
EuiButtonIcon,
+ useEuiTheme,
+ EuiBadge,
} from '@elastic/eui';
+import * as i18n from '../translations';
import type { CustomFieldTypes, CustomFieldsConfiguration } from '../../../../common/types/domain';
import { builderMap } from '../builder';
@@ -28,6 +31,7 @@ export interface Props {
const CustomFieldsListComponent: React.FC = (props) => {
const { customFields, onDeleteCustomField, onEditCustomField } = props;
const [selectedItem, setSelectedItem] = useState(null);
+ const { euiTheme } = useEuiTheme();
const renderTypeLabel = (type?: CustomFieldTypes) => {
const createdBuilder = type && builderMap[type];
@@ -69,7 +73,12 @@ const CustomFieldsListComponent: React.FC = (props) => {
{customField.label}
- {renderTypeLabel(customField.type)}
+
+ {renderTypeLabel(customField.type)}
+
+ {customField.required && (
+ {i18n.REQUIRED}
+ )}
diff --git a/x-pack/plugins/cases/public/components/custom_fields/translations.ts b/x-pack/plugins/cases/public/components/custom_fields/translations.ts
index ac7f99f191373..a5ac6da2fa5f3 100644
--- a/x-pack/plugins/cases/public/components/custom_fields/translations.ts
+++ b/x-pack/plugins/cases/public/components/custom_fields/translations.ts
@@ -66,6 +66,10 @@ export const FIELD_OPTION_REQUIRED = i18n.translate(
}
);
+export const REQUIRED = i18n.translate('xpack.cases.customFields.required', {
+ defaultMessage: 'Required',
+});
+
export const REQUIRED_FIELD = (fieldName: string): string =>
i18n.translate('xpack.cases.customFields.requiredField', {
values: { fieldName },
diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts
index edc34d50598ec..7bd380af258e2 100644
--- a/x-pack/plugins/fleet/server/errors/handlers.ts
+++ b/x-pack/plugins/fleet/server/errors/handlers.ts
@@ -34,6 +34,13 @@ import {
PackagePolicyNotFoundError,
FleetUnauthorizedError,
PackagePolicyNameExistsError,
+ PackageOutdatedError,
+ PackageInvalidArchiveError,
+ BundledPackageLocationNotFoundError,
+ PackageRemovalError,
+ PackageESError,
+ KibanaSOReferenceError,
+ PackageAlreadyInstalledError,
} from '.';
type IngestErrorHandler = (
@@ -47,30 +54,30 @@ interface IngestErrorHandlerParams {
}
// unsure if this is correct. would prefer to use something "official"
// this type is based on BadRequest values observed while debugging https://github.com/elastic/kibana/issues/75862
-
const getHTTPResponseCode = (error: FleetError): number => {
- if (error instanceof RegistryResponseError) {
- // 4xx/5xx's from EPR
- return 500;
+ // Bad Request
+ if (error instanceof PackageFailedVerificationError) {
+ return 400;
}
- if (error instanceof RegistryConnectionError || error instanceof RegistryError) {
- // Connection errors (ie. RegistryConnectionError) / fallback (RegistryError) from EPR
- return 502; // Bad Gateway
+ if (error instanceof PackageOutdatedError) {
+ return 400;
}
- if (error instanceof PackageNotFoundError || error instanceof PackagePolicyNotFoundError) {
- return 404; // Not Found
+ if (error instanceof PackageInvalidArchiveError) {
+ return 400;
}
- if (error instanceof AgentPolicyNameExistsError) {
- return 409; // Conflict
+ if (error instanceof PackageRemovalError) {
+ return 400;
}
- if (error instanceof PackageUnsupportedMediaTypeError) {
- return 415; // Unsupported Media Type
+ if (error instanceof KibanaSOReferenceError) {
+ return 400;
}
- if (error instanceof PackageFailedVerificationError) {
- return 400; // Bad Request
+ // Unauthorized
+ if (error instanceof FleetUnauthorizedError) {
+ return 403;
}
- if (error instanceof ConcurrentInstallOperationError) {
- return 409; // Conflict
+ // Not Found
+ if (error instanceof PackageNotFoundError || error instanceof PackagePolicyNotFoundError) {
+ return 404;
}
if (error instanceof AgentNotFoundError) {
return 404;
@@ -78,14 +85,41 @@ const getHTTPResponseCode = (error: FleetError): number => {
if (error instanceof AgentActionNotFoundError) {
return 404;
}
- if (error instanceof FleetUnauthorizedError) {
- return 403; // Unauthorized
+ // Conflict
+ if (error instanceof AgentPolicyNameExistsError) {
+ return 409;
+ }
+ if (error instanceof ConcurrentInstallOperationError) {
+ return 409;
}
if (error instanceof PackagePolicyNameExistsError) {
- return 409; // Conflict
+ return 409;
+ }
+ if (error instanceof PackageAlreadyInstalledError) {
+ return 409;
+ }
+ // Unsupported Media Type
+ if (error instanceof PackageUnsupportedMediaTypeError) {
+ return 415;
}
+ // Internal Server Error
if (error instanceof UninstallTokenError) {
- return 500; // Internal Error
+ return 500;
+ }
+ if (error instanceof BundledPackageLocationNotFoundError) {
+ return 500;
+ }
+ if (error instanceof PackageESError) {
+ return 500;
+ }
+ if (error instanceof RegistryResponseError) {
+ // 4xx/5xx's from EPR
+ return 500;
+ }
+ // Bad Gateway
+ if (error instanceof RegistryConnectionError || error instanceof RegistryError) {
+ // Connection errors (ie. RegistryConnectionError) / fallback (RegistryError) from EPR
+ return 502;
}
return 400; // Bad Request
};
@@ -115,7 +149,7 @@ export function fleetErrorToResponseOptions(error: IngestErrorHandlerParams['err
};
}
- // not sure what type of error this is. log as much as possible
+ // default response is 500
logger.error(error);
return {
statusCode: 500,
diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts
index 0b2c6b0fc5e93..7f607f4692774 100644
--- a/x-pack/plugins/fleet/server/errors/index.ts
+++ b/x-pack/plugins/fleet/server/errors/index.ts
@@ -26,8 +26,9 @@ export class RegistryResponseError extends RegistryError {
super(message);
}
}
+
+// Package errors
export class PackageNotFoundError extends FleetError {}
-export class PackageKeyInvalidError extends FleetError {}
export class PackageOutdatedError extends FleetError {}
export class PackageFailedVerificationError extends FleetError {
constructor(pkgName: string, pkgVersion: string) {
@@ -37,22 +38,25 @@ export class PackageFailedVerificationError extends FleetError {
};
}
}
+export class PackageUnsupportedMediaTypeError extends FleetError {}
+export class PackageInvalidArchiveError extends FleetError {}
+export class PackageRemovalError extends FleetError {}
+export class PackageESError extends FleetError {}
+export class ConcurrentInstallOperationError extends FleetError {}
+export class BundledPackageLocationNotFoundError extends FleetError {}
+export class KibanaSOReferenceError extends FleetError {}
+export class PackageAlreadyInstalledError extends FleetError {}
+
export class AgentPolicyError extends FleetError {}
export class AgentPolicyNotFoundError extends FleetError {}
export class AgentNotFoundError extends FleetError {}
export class AgentActionNotFoundError extends FleetError {}
export class AgentPolicyNameExistsError extends AgentPolicyError {}
-export class PackageUnsupportedMediaTypeError extends FleetError {}
-export class PackageInvalidArchiveError extends FleetError {}
-export class PackageCacheError extends FleetError {}
-export class PackageOperationNotSupportedError extends FleetError {}
-export class ConcurrentInstallOperationError extends FleetError {}
export class AgentReassignmentError extends FleetError {}
export class PackagePolicyIneligibleForUpgradeError extends FleetError {}
export class PackagePolicyValidationError extends FleetError {}
export class PackagePolicyNameExistsError extends FleetError {}
export class PackagePolicyNotFoundError extends FleetError {}
-export class BundledPackageNotFoundError extends FleetError {}
export class HostedAgentPolicyRestrictionRelatedError extends FleetError {
constructor(message = 'Cannot perform that action') {
super(
diff --git a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts
index 077aa720b96d8..0bc220a500fb1 100644
--- a/x-pack/plugins/fleet/server/services/epm/agent/agent.ts
+++ b/x-pack/plugins/fleet/server/services/epm/agent/agent.ts
@@ -10,17 +10,18 @@ import { safeLoad, safeDump } from 'js-yaml';
import type { PackagePolicyConfigRecord } from '../../../../common/types';
import { toCompiledSecretRef } from '../../secrets';
+import { PackageInvalidArchiveError } from '../../../errors';
const handlebars = Handlebars.create();
export function compileTemplate(variables: PackagePolicyConfigRecord, templateStr: string) {
- const { vars, yamlValues } = buildTemplateVariables(variables, templateStr);
+ const { vars, yamlValues } = buildTemplateVariables(variables);
let compiledTemplate: string;
try {
const template = handlebars.compile(templateStr, { noEscape: true });
compiledTemplate = template(vars);
} catch (err) {
- throw new Error(`Error while compiling agent template: ${err.message}`);
+ throw new PackageInvalidArchiveError(`Error while compiling agent template: ${err.message}`);
}
compiledTemplate = replaceRootLevelYamlVariables(yamlValues, compiledTemplate);
@@ -64,7 +65,7 @@ function replaceVariablesInYaml(yamlVariables: { [k: string]: any }, yaml: any)
return yaml;
}
-function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateStr: string) {
+function buildTemplateVariables(variables: PackagePolicyConfigRecord) {
const yamlValues: { [k: string]: any } = {};
const vars = Object.entries(variables).reduce((acc, [key, recordEntry]) => {
// support variables with . like key.patterns
@@ -72,13 +73,17 @@ function buildTemplateVariables(variables: PackagePolicyConfigRecord, templateSt
const lastKeyPart = keyParts.pop();
if (!lastKeyPart || !isValidKey(lastKeyPart)) {
- throw new Error('Invalid key');
+ throw new PackageInvalidArchiveError(
+ `Error while compiling agent template: Invalid key ${lastKeyPart}`
+ );
}
let varPart = acc;
for (const keyPart of keyParts) {
if (!isValidKey(keyPart)) {
- throw new Error('Invalid key');
+ throw new PackageInvalidArchiveError(
+ `Error while compiling agent template: Invalid key ${keyPart}`
+ );
}
if (!varPart[keyPart]) {
varPart[keyPart] = {};
diff --git a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts
index cfa110589a010..81d55c5fd3138 100644
--- a/x-pack/plugins/fleet/server/services/epm/archive/storage.ts
+++ b/x-pack/plugins/fleet/server/services/epm/archive/storage.ts
@@ -19,6 +19,7 @@ import type {
InstallSource,
PackageAssetReference,
} from '../../../../common/types';
+import { PackageInvalidArchiveError, PackageNotFoundError } from '../../../errors';
import { appContextService } from '../../app_context';
@@ -70,13 +71,13 @@ export async function archiveEntryToESDocument(opts: {
// validation: filesize? asset type? anything else
if (dataUtf8.length > currentMaxAssetBytes) {
- throw new Error(
+ throw new PackageInvalidArchiveError(
`File at ${path} is larger than maximum allowed size of ${currentMaxAssetBytes}`
);
}
if (dataBase64.length > currentMaxAssetBytes) {
- throw new Error(
+ throw new PackageInvalidArchiveError(
`After base64 encoding file at ${path} is larger than maximum allowed size of ${currentMaxAssetBytes}`
);
}
@@ -113,7 +114,7 @@ export async function saveArchiveEntries(opts: {
const bulkBody = await Promise.all(
paths.map((path) => {
const buffer = getArchiveEntry(path);
- if (!buffer) throw new Error(`Could not find ArchiveEntry at ${path}`);
+ if (!buffer) throw new PackageNotFoundError(`Could not find ArchiveEntry at ${path}`);
const { name, version } = packageInfo;
return archiveEntryToBulkCreateObject({ path, buffer, name, version, installSource });
})
diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts
index 3aa86b526addd..61a75d28b7999 100644
--- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts
+++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts
@@ -14,6 +14,7 @@ import { getAsset, getPathParts } from '../../archive';
import { updateEsAssetReferences } from '../../packages/install';
import { getESAssetMetadata } from '../meta';
import { retryTransientEsErrors } from '../retry';
+import { PackageInvalidArchiveError } from '../../../../errors';
export async function installILMPolicy(
packageInfo: InstallablePackage,
@@ -57,7 +58,7 @@ export async function installILMPolicy(
{ logger }
);
} catch (err) {
- throw new Error(err.message);
+ throw new PackageInvalidArchiveError(`Couldn't install ilm policies: ${err.message}`);
}
})
);
diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
index eb0ea4f62c73e..a26aa2c8e0114 100644
--- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
+++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts
@@ -33,6 +33,7 @@ import {
} from '../../../../constants';
import { getESAssetMetadata } from '../meta';
import { retryTransientEsErrors } from '../retry';
+import { PackageESError, PackageInvalidArchiveError } from '../../../../errors';
import { getDefaultProperties, histogram, keyword, scaledFloat } from './mappings';
@@ -102,7 +103,9 @@ export function getTemplate({
isIndexModeTimeSeries,
});
if (template.template.settings.index.final_pipeline) {
- throw new Error(`Error template for ${templateIndexPattern} contains a final_pipeline`);
+ throw new PackageInvalidArchiveError(
+ `Error template for ${templateIndexPattern} contains a final_pipeline`
+ );
}
const esBaseComponents = getBaseEsComponents(type, !!isIndexModeTimeSeries);
@@ -427,8 +430,8 @@ function _generateMappings(
matchingType = field.object_type_mapping_type ?? 'object';
break;
default:
- throw new Error(
- `no dynamic mapping generated for field ${path} of type ${field.object_type}`
+ throw new PackageInvalidArchiveError(
+ `No dynamic mapping generated for field ${path} of type ${field.object_type}`
);
}
@@ -908,7 +911,9 @@ const rolloverDataStream = (dataStreamName: string, esClient: ElasticsearchClien
alias: dataStreamName,
});
} catch (error) {
- throw new Error(`cannot rollover data stream [${dataStreamName}] due to error: ${error}`);
+ throw new PackageESError(
+ `Cannot rollover data stream [${dataStreamName}] due to error: ${error}`
+ );
}
};
@@ -1055,7 +1060,11 @@ const updateExistingDataStream = async ({
{ logger }
);
} catch (err) {
- throw new Error(`could not update lifecycle settings for ${dataStreamName}: ${err.message}`);
+ // Check if this error can happen because of invalid settings;
+ // We are returning a 500 but in that case it should be a 400 instead
+ throw new PackageESError(
+ `Could not update lifecycle settings for ${dataStreamName}: ${err.message}`
+ );
}
}
@@ -1078,6 +1087,8 @@ const updateExistingDataStream = async ({
{ logger }
);
} catch (err) {
- throw new Error(`could not update index template settings for ${dataStreamName}`);
+ // Same as above - Check if this error can happen because of invalid settings;
+ // We are returning a 500 but in that case it should be a 400 instead
+ throw new PackageESError(`Could not update index template settings for ${dataStreamName}`);
}
};
diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
index 20a0484c77a4a..23327a2253f86 100644
--- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
+++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts
@@ -35,6 +35,7 @@ import { savedObjectTypes } from '../../packages';
import { indexPatternTypes, getIndexPatternSavedObjects } from '../index_pattern/install';
import { saveKibanaAssetsRefs } from '../../packages/install';
import { deleteKibanaSavedObjectsAssets } from '../../packages/remove';
+import { KibanaSOReferenceError } from '../../../../errors';
import { withPackageSpan } from '../../packages/utils';
@@ -340,7 +341,7 @@ export async function installKibanaSavedObjects({
);
if (otherErrors?.length) {
- throw new Error(
+ throw new KibanaSOReferenceError(
`Encountered ${
otherErrors.length
} errors creating saved objects: ${formatImportErrorsForLog(otherErrors)}`
@@ -383,7 +384,7 @@ export async function installKibanaSavedObjects({
});
if (resolveErrors?.length) {
- throw new Error(
+ throw new KibanaSOReferenceError(
`Encountered ${
resolveErrors.length
} errors resolving reference errors: ${formatImportErrorsForLog(resolveErrors)}`
diff --git a/x-pack/plugins/fleet/server/services/epm/package_service.ts b/x-pack/plugins/fleet/server/services/epm/package_service.ts
index 39ca950af93db..e6f71cb7cb96c 100644
--- a/x-pack/plugins/fleet/server/services/epm/package_service.ts
+++ b/x-pack/plugins/fleet/server/services/epm/package_service.ts
@@ -29,7 +29,7 @@ import type {
} from '../../types';
import type { FleetAuthzRouteConfig } from '../security/types';
import { checkSuperuser, getAuthzFromRequest, doesNotHaveRequiredFleetAuthz } from '../security';
-import { FleetUnauthorizedError } from '../../errors';
+import { FleetUnauthorizedError, FleetError } from '../../errors';
import { INSTALL_PACKAGES_AUTHZ, READ_PACKAGE_INFO_AUTHZ } from '../../routes/epm';
import { installTransforms, isTransform } from './elasticsearch/transform/install';
@@ -208,7 +208,7 @@ class PackageClientImpl implements PackageClient {
const transformPaths = assetPaths.filter(isTransform);
if (transformPaths.length !== assetPaths.length) {
- throw new Error('reinstallEsAssets is currently only implemented for transform assets');
+ throw new FleetError('reinstallEsAssets is currently only implemented for transform assets');
}
if (transformPaths.length) {
diff --git a/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts b/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts
index 7078761a4a583..92f9674656103 100644
--- a/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts
+++ b/x-pack/plugins/fleet/server/services/epm/packages/bundled_packages.ts
@@ -9,7 +9,7 @@ import fs from 'fs/promises';
import path from 'path';
import type { BundledPackage, Installation } from '../../../types';
-import { FleetError } from '../../../errors';
+import { BundledPackageLocationNotFoundError } from '../../../errors';
import { appContextService } from '../../app_context';
import { splitPkgKey, pkgToPkgKey } from '../registry';
@@ -19,7 +19,9 @@ export async function getBundledPackages(): Promise {
const bundledPackageLocation = config?.developer?.bundledPackageLocation;
if (!bundledPackageLocation) {
- throw new FleetError('xpack.fleet.developer.bundledPackageLocation is not configured');
+ throw new BundledPackageLocationNotFoundError(
+ 'xpack.fleet.developer.bundledPackageLocation is not configured'
+ );
}
// If the bundled package directory is missing, we log a warning during setup,
@@ -51,7 +53,7 @@ export async function getBundledPackages(): Promise {
return result;
} catch (err) {
const logger = appContextService.getLogger();
- logger.debug(`Unable to read bundled packages from ${bundledPackageLocation}`);
+ logger.warn(`Unable to read bundled packages from ${bundledPackageLocation}`);
return [];
}
diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts
index 68a884fd5f198..a3ed5f62d7f1e 100644
--- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts
+++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts
@@ -44,7 +44,6 @@ import type {
} from '../../../../common/types';
import type { Installation, PackageInfo, PackagePolicySOAttributes } from '../../../types';
import {
- FleetError,
PackageFailedVerificationError,
PackageNotFoundError,
RegistryResponseError,
@@ -575,6 +574,7 @@ export async function getPackageFromSource(options: {
logger.debug(`retrieved installed package ${pkgName}-${pkgVersion}`);
} catch (error) {
if (error instanceof PackageFailedVerificationError) {
+ logger.error(`package ${pkgName}-${pkgVersion} failed verification`);
throw error;
}
// treating this is a 404 as no status code returned
@@ -600,7 +600,7 @@ export async function getPackageFromSource(options: {
}
}
if (!res) {
- throw new FleetError(`package info for ${pkgName}-${pkgVersion} does not exist`);
+ throw new PackageNotFoundError(`Package info for ${pkgName}-${pkgVersion} does not exist`);
}
return {
paths: res.paths,
diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts
index a3919bbdb20e7..40db0c76dd66a 100644
--- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts
+++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts
@@ -57,11 +57,13 @@ import {
DATASET_VAR_NAME,
} from '../../../../common/constants';
import {
- type FleetError,
+ FleetError,
PackageOutdatedError,
PackagePolicyValidationError,
ConcurrentInstallOperationError,
FleetUnauthorizedError,
+ PackageInvalidArchiveError,
+ PackageNotFoundError,
} from '../../../errors';
import { PACKAGES_SAVED_OBJECT_TYPE, MAX_TIME_COMPLETE_INSTALL } from '../../../constants';
import { dataStreamService, licenseService } from '../..';
@@ -202,7 +204,7 @@ export async function ensureInstalledPackage(options: {
}
const installation = await getInstallation({ savedObjectsClient, pkgName });
- if (!installation) throw new Error(`could not get installation ${pkgName}`);
+ if (!installation) throw new FleetError(`Could not get installation for ${pkgName}`);
return installation;
}
@@ -714,7 +716,7 @@ export type InstallPackageParams = {
export async function installPackage(args: InstallPackageParams): Promise {
if (!('installSource' in args)) {
- throw new Error('installSource is required');
+ throw new FleetError('installSource is required');
}
const logger = appContextService.getLogger();
@@ -805,7 +807,7 @@ export async function installPackage(args: InstallPackageParams): Promise {
getLogger: jest.fn().mockReturnValue({
info: jest.fn(),
error: jest.fn(),
+ warn: jest.fn(),
}),
},
packagePolicyService: {
@@ -78,7 +79,7 @@ describe('removeInstallation', () => {
force: false,
})
).rejects.toThrowError(
- `unable to remove package with existing package policy(s) in use by agent(s)`
+ `Unable to remove package with existing package policy(s) in use by agent(s)`
);
});
diff --git a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts
index c65a4d165cf7f..ba9bace6a0dee 100644
--- a/x-pack/plugins/fleet/server/services/epm/packages/remove.ts
+++ b/x-pack/plugins/fleet/server/services/epm/packages/remove.ts
@@ -7,8 +7,6 @@
import type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server';
-import Boom from '@hapi/boom';
-
import type { SavedObject } from '@kbn/core/server';
import { SavedObjectsClient } from '@kbn/core/server';
@@ -42,6 +40,7 @@ import { deleteIlms } from '../elasticsearch/datastream_ilm/remove';
import { removeArchiveEntries } from '../archive/storage';
import { auditLoggingService } from '../../audit_logging';
+import { FleetError, PackageRemovalError } from '../../../errors';
import { populatePackagePolicyAssignedAgentsCount } from '../../package_policies/populate_package_policy_assigned_agents_count';
@@ -56,7 +55,7 @@ export async function removeInstallation(options: {
}): Promise {
const { savedObjectsClient, pkgName, pkgVersion, esClient } = options;
const installation = await getInstallation({ savedObjectsClient, pkgName });
- if (!installation) throw Boom.badRequest(`${pkgName} is not installed`);
+ if (!installation) throw new PackageRemovalError(`${pkgName} is not installed`);
const { total, items } = await packagePolicyService.list(savedObjectsClient, {
kuery: `${PACKAGE_POLICY_SAVED_OBJECT_TYPE}.package.name:${pkgName}`,
@@ -72,17 +71,12 @@ export async function removeInstallation(options: {
if (options.force || items.every((item) => (item.agents ?? 0) === 0)) {
// delete package policies
const ids = items.map((item) => item.id);
- appContextService
- .getLogger()
- .info(
- `deleting package policies of ${pkgName} package because not used by agents or force flag was enabled: ${ids}`
- );
await packagePolicyService.delete(savedObjectsClient, esClient, ids, {
force: options.force,
});
} else {
- throw Boom.badRequest(
- `unable to remove package with existing package policy(s) in use by agent(s)`
+ throw new PackageRemovalError(
+ `Unable to remove package with existing package policy(s) in use by agent(s)`
);
}
}
@@ -242,7 +236,7 @@ async function deleteIndexTemplate(esClient: ElasticsearchClient, name: string):
try {
await esClient.indices.deleteIndexTemplate({ name }, { ignore: [404] });
} catch {
- throw new Error(`error deleting index template ${name}`);
+ throw new FleetError(`Error deleting index template ${name}`);
}
}
}
@@ -253,7 +247,7 @@ async function deleteComponentTemplate(esClient: ElasticsearchClient, name: stri
try {
await esClient.cluster.deleteComponentTemplate({ name }, { ignore: [404] });
} catch (error) {
- throw new Error(`error deleting component template ${name}`);
+ throw new FleetError(`Error deleting component template ${name}`);
}
}
}
diff --git a/x-pack/plugins/fleet/server/services/epm/packages/update.ts b/x-pack/plugins/fleet/server/services/epm/packages/update.ts
index 3072dfed86636..72c43b6dc688a 100644
--- a/x-pack/plugins/fleet/server/services/epm/packages/update.ts
+++ b/x-pack/plugins/fleet/server/services/epm/packages/update.ts
@@ -12,7 +12,7 @@ import type { ExperimentalIndexingFeature } from '../../../../common/types';
import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../constants';
import type { Installation, UpdatePackageRequestSchema } from '../../../types';
-import { FleetError } from '../../../errors';
+import { PackageNotFoundError } from '../../../errors';
import { auditLoggingService } from '../../audit_logging';
@@ -29,7 +29,7 @@ export async function updatePackage(
const installedPackage = await getInstallationObject({ savedObjectsClient, pkgName });
if (!installedPackage) {
- throw new FleetError(`package ${pkgName} is not installed`);
+ throw new PackageNotFoundError(`Error while updating package: ${pkgName} is not installed`);
}
auditLoggingService.writeCustomSoAuditLog({
diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts
index 487faf55730bd..24c81dd023244 100644
--- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts
+++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts
@@ -43,6 +43,7 @@ import {
PackageNotFoundError,
RegistryResponseError,
PackageFailedVerificationError,
+ PackageUnsupportedMediaTypeError,
} from '../../../errors';
import { getBundledPackageByName } from '../packages/bundled_packages';
@@ -364,8 +365,11 @@ export async function getPackage(
function ensureContentType(archivePath: string) {
const contentType = mime.lookup(archivePath);
+
if (!contentType) {
- throw new Error(`Unknown compression format for '${archivePath}'. Please use .zip or .gz`);
+ throw new PackageUnsupportedMediaTypeError(
+ `Unknown compression format for '${archivePath}'. Please use .zip or .gz`
+ );
}
return contentType;
}
diff --git a/x-pack/plugins/ml/common/types/ml_server_info.ts b/x-pack/plugins/ml/common/types/ml_server_info.ts
index e5141d6f2e78f..215f46c26bef8 100644
--- a/x-pack/plugins/ml/common/types/ml_server_info.ts
+++ b/x-pack/plugins/ml/common/types/ml_server_info.ts
@@ -20,6 +20,8 @@ export interface MlServerDefaults {
export interface MlServerLimits {
max_model_memory_limit?: string;
effective_max_model_memory_limit?: string;
+ max_single_ml_node_processors?: number;
+ total_ml_processors?: number;
}
export interface MlInfoResponse {
diff --git a/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx b/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx
index 3ac6960af55ca..102af34d3e95d 100644
--- a/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx
+++ b/x-pack/plugins/ml/public/application/model_management/deployment_setup.tsx
@@ -31,7 +31,7 @@ import type { I18nStart, OverlayStart, ThemeServiceStart } from '@kbn/core/publi
import { css } from '@emotion/react';
import { numberValidator } from '@kbn/ml-agg-utils';
import { toMountPoint } from '@kbn/react-kibana-mount';
-import { isCloudTrial } from '../services/ml_server_info';
+import { getNewJobLimits, isCloudTrial } from '../services/ml_server_info';
import {
composeValidators,
dictionaryValidator,
@@ -42,7 +42,7 @@ import { ModelItem } from './models_list';
interface DeploymentSetupProps {
config: ThreadingParams;
onConfigChange: (config: ThreadingParams) => void;
- errors: Partial>;
+ errors: Partial>>;
isUpdate?: boolean;
deploymentsParams?: Record;
}
@@ -66,6 +66,11 @@ export const DeploymentSetup: FC = ({
isUpdate,
deploymentsParams,
}) => {
+ const {
+ total_ml_processors: totalMlProcessors,
+ max_single_ml_node_processors: maxSingleMlNodeProcessors,
+ } = getNewJobLimits();
+
const numOfAllocation = config.numOfAllocations;
const threadsPerAllocations = config.threadsPerAllocations;
@@ -76,17 +81,20 @@ export const DeploymentSetup: FC = ({
const threadsPerAllocationsOptions = useMemo(
() =>
- new Array(THREADS_MAX_EXPONENT).fill(null).map((v, i) => {
- const value = Math.pow(2, i);
- const id = value.toString();
-
- return {
- id,
- label: id,
- value,
- };
- }),
- []
+ new Array(THREADS_MAX_EXPONENT)
+ .fill(null)
+ .map((v, i) => Math.pow(2, i))
+ .filter(maxSingleMlNodeProcessors ? (v) => v <= maxSingleMlNodeProcessors : (v) => true)
+ .map((value) => {
+ const id = value.toString();
+
+ return {
+ id,
+ label: id,
+ value,
+ };
+ }),
+ [maxSingleMlNodeProcessors]
);
const disableThreadingControls = config.priority === 'low';
@@ -251,11 +259,28 @@ export const DeploymentSetup: FC = ({
}
hasChildLabel={false}
isDisabled={disableThreadingControls}
+ isInvalid={!!errors.numOfAllocations}
+ error={
+ errors?.numOfAllocations?.min ? (
+
+ ) : errors?.numOfAllocations?.max ? (
+
+ ) : null
+ }
>
= ({
}) => {
const isUpdate = !!initialParams;
+ const { total_ml_processors: totalMlProcessors } = getNewJobLimits();
+
const [config, setConfig] = useState(
initialParams ?? {
numOfAllocations: 1,
@@ -373,7 +400,7 @@ export const StartUpdateDeploymentModal: FC = ({
const numOfAllocationsValidator = composeValidators(
requiredValidator(),
- numberValidator({ min: 1, integerOnly: true })
+ numberValidator({ min: 1, max: totalMlProcessors, integerOnly: true })
);
const numOfAllocationsErrors = numOfAllocationsValidator(config.numOfAllocations);
diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.test.tsx b/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.test.tsx
index 3adc5975e91a8..bc478e54135ba 100644
--- a/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.test.tsx
+++ b/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.test.tsx
@@ -13,7 +13,7 @@ import { Comparator, Aggregators } from '../../../../../common/custom_threshold_
import { useKibana } from '../../../../utils/kibana_react';
import { kibanaStartMock } from '../../../../utils/kibana_react.mock';
import { MetricExpression } from '../../types';
-import { PreviewChart } from './preview_chart';
+import { getBufferThreshold, PreviewChart } from './preview_chart';
jest.mock('../../../../utils/kibana_react');
@@ -70,3 +70,18 @@ describe('Preview chart', () => {
expect(wrapper.find('[data-test-subj="thresholdRuleNoChartData"]').exists()).toBeTruthy();
});
});
+
+describe('getBufferThreshold', () => {
+ const testData = [
+ { threshold: undefined, buffer: '0.00' },
+ { threshold: 0.1, buffer: '0.12' },
+ { threshold: 0.01, buffer: '0.02' },
+ { threshold: 0.001, buffer: '0.01' },
+ { threshold: 0.00098, buffer: '0.01' },
+ { threshold: 130, buffer: '143.00' },
+ ];
+
+ it.each(testData)('getBufferThreshold($threshold) = $buffer', ({ threshold, buffer }) => {
+ expect(getBufferThreshold(threshold)).toBe(buffer);
+ });
+});
diff --git a/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.tsx b/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.tsx
index 23d27a554a77c..1c25d22b3a595 100644
--- a/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.tsx
+++ b/x-pack/plugins/observability/public/components/custom_threshold/components/preview_chart/preview_chart.tsx
@@ -44,6 +44,9 @@ const getOperationTypeFromRuleAggType = (aggType: AggType): OperationType => {
return aggType;
};
+export const getBufferThreshold = (threshold?: number): string =>
+ (Math.ceil((threshold || 0) * 1.1 * 100) / 100).toFixed(2).toString();
+
export function PreviewChart({
metricExpression,
dataView,
@@ -147,7 +150,7 @@ export function PreviewChart({
const bufferRefLine = new XYReferenceLinesLayer({
data: [
{
- value: Math.round((threshold[0] || 0) * 1.1).toString(),
+ value: getBufferThreshold(threshold[0]),
color: 'transparent',
fill,
format,
diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts
index 5b3502225e769..74979b0549b91 100644
--- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts
+++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.test.ts
@@ -119,7 +119,7 @@ describe('formatChromeProjectNavNodes', () => {
{
id: chromeNavLink1.id,
title: link1.title,
- path: [chromeNavLink1.id],
+ path: chromeNavLink1.id,
deepLink: chromeNavLink1,
},
]);
@@ -132,7 +132,7 @@ describe('formatChromeProjectNavNodes', () => {
{
id: chromeNavLink3.id,
title: chromeNavLink3.title,
- path: [chromeNavLink3.id],
+ path: chromeNavLink3.id,
deepLink: chromeNavLink3,
},
]);
@@ -145,13 +145,13 @@ describe('formatChromeProjectNavNodes', () => {
{
id: chromeNavLink1.id,
title: link1.title,
- path: [chromeNavLink1.id],
+ path: chromeNavLink1.id,
deepLink: chromeNavLink1,
children: [
{
id: chromeNavLink2.id,
title: link2.title,
- path: [chromeNavLink1.id, chromeNavLink2.id],
+ path: [chromeNavLink1.id, chromeNavLink2.id].join('.'),
deepLink: chromeNavLink2,
},
],
@@ -173,24 +173,24 @@ describe('formatChromeProjectNavNodes', () => {
{
id: chromeNavLinkTest.id,
title: link1.title,
- path: [chromeNavLinkTest.id],
+ path: chromeNavLinkTest.id,
deepLink: chromeNavLinkTest,
children: [
{
id: chromeNavLinkMl1.id,
title: chromeNavLinkMl1.title,
- path: [chromeNavLinkTest.id, chromeNavLinkMl1.id],
+ path: [chromeNavLinkTest.id, chromeNavLinkMl1.id].join('.'),
deepLink: chromeNavLinkMl1,
},
{
id: defaultNavCategory1.id,
title: defaultNavCategory1.title,
- path: [chromeNavLinkTest.id, defaultNavCategory1.id],
+ path: [chromeNavLinkTest.id, defaultNavCategory1.id].join('.'),
children: [
{
id: chromeNavLinkMl2.id,
title: 'Overridden ML SubLink 2',
- path: [chromeNavLinkTest.id, defaultNavCategory1.id, chromeNavLinkMl2.id],
+ path: [chromeNavLinkTest.id, defaultNavCategory1.id, chromeNavLinkMl2.id].join('.'),
deepLink: chromeNavLinkMl2,
},
],
@@ -208,7 +208,7 @@ describe('formatChromeProjectNavNodes', () => {
{
id: chromeNavLink2.id,
title: link2.title,
- path: [chromeNavLink2.id],
+ path: chromeNavLink2.id,
deepLink: chromeNavLink2,
},
]);
@@ -230,14 +230,14 @@ describe('formatChromeProjectNavNodes', () => {
{
id: chromeNavLinkTest.id,
title: link1.title,
- path: [chromeNavLinkTest.id],
+ path: chromeNavLinkTest.id,
deepLink: chromeNavLinkTest,
breadcrumbStatus: 'hidden',
},
{
id: chromeNavLink2.id,
title: link2.title,
- path: [chromeNavLink2.id],
+ path: chromeNavLink2.id,
deepLink: chromeNavLink2,
},
]);
diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts
index 0dc8a1140aaab..b787aeb927361 100644
--- a/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts
+++ b/x-pack/plugins/security_solution_serverless/public/navigation/navigation_tree/chrome_navigation_tree.ts
@@ -19,7 +19,7 @@ import { isBreadcrumbHidden } from './utils';
export const getFormatChromeProjectNavNodes = (services: Services) => {
const formatChromeProjectNavNodes = (
projectNavLinks: ProjectNavigationLink[],
- path: string[] = []
+ path?: string
): ChromeProjectNavigationNode[] => {
const { chrome } = services;
@@ -31,7 +31,7 @@ export const getFormatChromeProjectNavNodes = (services: Services) => {
const link: ChromeProjectNavigationNode = {
id: navLinkId,
title,
- path: [...path, navLinkId],
+ path: path ? [path, navLinkId].join('.') : navLinkId,
deepLink: chrome.navLinks.get(navLinkId),
...(isBreadcrumbHidden(id) && { breadcrumbStatus: 'hidden' }),
};
@@ -63,7 +63,7 @@ export const getFormatChromeProjectNavNodes = (services: Services) => {
const processDefaultNav = (
children: NodeDefinition[],
- path: string[]
+ path: string
): ChromeProjectNavigationNode[] => {
const { chrome } = services;
return children.reduce((navNodes, node) => {
@@ -80,7 +80,7 @@ export const getFormatChromeProjectNavNodes = (services: Services) => {
const navNode: ChromeProjectNavigationNode = {
id,
title: node.title || '',
- path: [...path, id],
+ path: [path, id].join('.'),
breadcrumbStatus: node.breadcrumbStatus,
getIsActive: node.getIsActive,
};
diff --git a/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx b/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx
index b26700eb8e4b3..2532589d1c994 100644
--- a/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx
+++ b/x-pack/plugins/security_solution_serverless/public/navigation/project_navigation/project_navigation.tsx
@@ -13,7 +13,7 @@ import type {
import { SolutionSideNavPanelContent } from '@kbn/security-solution-side-nav/panel';
import useObservable from 'react-use/lib/useObservable';
import { useKibana } from '../../common/services';
-import type { ProjectNavigationLink, ProjectPageName } from '../links/types';
+import type { ProjectNavigationLink } from '../links/types';
import { useFormattedSideNavItems } from '../side_navigation/use_side_nav_items';
import { CATEGORIES, FOOTER_CATEGORIES } from '../categories';
import { formatNavigationTree } from '../navigation_tree/navigation_tree';
@@ -21,8 +21,7 @@ import { formatNavigationTree } from '../navigation_tree/navigation_tree';
const getPanelContentProvider = (
projectNavLinks: ProjectNavigationLink[]
): React.FC =>
- React.memo(function PanelContentProvider({ selectedNode: { path }, closePanel }) {
- const linkId = path[path.length - 1] as ProjectPageName;
+ React.memo(function PanelContentProvider({ selectedNode: { id: linkId }, closePanel }) {
const currentPanelItem = projectNavLinks.find((item) => item.id === linkId);
const { title = '', links = [], categories } = currentPanelItem ?? {};
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/links_list.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/links_list.test.tsx
new file mode 100644
index 0000000000000..10c78d4029d9a
--- /dev/null
+++ b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/links_list.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 { screen, render } from '@testing-library/react';
+import { LinksList } from './links_list';
+import userEvent from '@testing-library/user-event';
+
+describe('LinksList', () => {
+ const editAction = jest.fn();
+
+ const options = {
+ index: 0,
+ errors: {
+ links: [],
+ },
+ editAction,
+ links: [],
+ };
+
+ beforeEach(() => jest.clearAllMocks());
+
+ it('the list is empty by default', () => {
+ render();
+
+ expect(screen.queryByTestId('linksListItemRow')).not.toBeInTheDocument();
+ });
+
+ it('clicking add button calls editAction with correct params', async () => {
+ render();
+
+ userEvent.click(await screen.findByTestId('pagerDutyAddLinkButton'));
+
+ expect(editAction).toHaveBeenCalledWith('links', [{ href: '', text: '' }], 0);
+ });
+
+ it('clicking remove link button calls editAction with correct params', async () => {
+ render(
+
+ );
+
+ expect(await screen.findAllByTestId('linksListItemRow', { exact: false })).toHaveLength(3);
+
+ userEvent.click((await screen.findAllByTestId('pagerDutyRemoveLinkButton'))[1]);
+
+ expect(editAction).toHaveBeenCalledWith(
+ 'links',
+ [
+ { href: '1', text: 'foobar' },
+ { href: '3', text: 'foobar' },
+ ],
+ 0
+ );
+ });
+
+ it('editing a link href field calls editAction with correct params', async () => {
+ render();
+
+ expect(await screen.findByTestId('linksListItemRow', { exact: false })).toBeInTheDocument();
+
+ userEvent.paste(await screen.findByTestId('linksHrefInput'), 'newHref');
+
+ expect(editAction).toHaveBeenCalledWith('links', [{ href: 'newHref', text: 'foobar' }], 0);
+ });
+
+ it('editing a link text field calls editAction with correct params', async () => {
+ render();
+
+ expect(await screen.findByTestId('linksListItemRow', { exact: false })).toBeInTheDocument();
+
+ userEvent.paste(await screen.findByTestId('linksTextInput'), 'newText');
+
+ expect(editAction).toHaveBeenCalledWith('links', [{ href: 'foobar', text: 'newText' }], 0);
+ });
+
+ it('correctly displays error messages', async () => {
+ render();
+
+ expect(await screen.findByText('FoobarError'));
+ });
+});
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/links_list.tsx b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/links_list.tsx
new file mode 100644
index 0000000000000..70477fc03683a
--- /dev/null
+++ b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/links_list.tsx
@@ -0,0 +1,138 @@
+/*
+ * 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 {
+ EuiButton,
+ EuiButtonIcon,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSpacer,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import {
+ ActionParamsProps,
+ TextFieldWithMessageVariables,
+} from '@kbn/triggers-actions-ui-plugin/public';
+import { PagerDutyActionParams } from '../types';
+
+type LinksListProps = Pick<
+ ActionParamsProps,
+ 'index' | 'editAction' | 'errors' | 'messageVariables'
+> &
+ Pick;
+
+export const LinksList: React.FC = ({
+ editAction,
+ errors,
+ index,
+ links,
+ messageVariables,
+}) => {
+ const areLinksInvalid = Array.isArray(errors.links) && errors.links.length > 0;
+
+ return (
+
+ );
+};
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.test.tsx
index 311d4bfac8679..4df68dee15880 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.test.tsx
+++ b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.test.tsx
@@ -43,6 +43,8 @@ describe('pagerduty action params validation', () => {
component: 'test',
group: 'group',
class: 'test class',
+ customDetails: '{}',
+ links: [],
};
expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
@@ -50,6 +52,8 @@ describe('pagerduty action params validation', () => {
dedupKey: [],
summary: [],
timestamp: [],
+ links: [],
+ customDetails: [],
},
});
});
@@ -74,6 +78,142 @@ describe('pagerduty action params validation', () => {
dedupKey: [],
summary: [],
timestamp: expect.arrayContaining(expected),
+ links: [],
+ customDetails: [],
+ },
+ });
+ });
+
+ test('action params validation fails when customDetails are not valid JSON', async () => {
+ const actionParams = {
+ eventAction: 'trigger',
+ dedupKey: 'test',
+ summary: '2323',
+ source: 'source',
+ severity: 'critical',
+ timestamp: new Date().toISOString(),
+ component: 'test',
+ group: 'group',
+ class: 'test class',
+ customDetails: '{foo:bar}',
+ links: [],
+ };
+
+ expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
+ errors: {
+ dedupKey: [],
+ summary: [],
+ timestamp: [],
+ links: [],
+ customDetails: ['Custom details must be a valid JSON.'],
+ },
+ });
+ });
+
+ test('action params validation does not fail when customDetails are not JSON but have mustache templates inside', async () => {
+ const actionParams = {
+ eventAction: 'trigger',
+ dedupKey: 'test',
+ summary: '2323',
+ source: 'source',
+ severity: 'critical',
+ timestamp: new Date().toISOString(),
+ component: 'test',
+ group: 'group',
+ class: 'test class',
+ customDetails: '{"details": {{alert.flapping}}}',
+ links: [],
+ };
+
+ expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
+ errors: {
+ dedupKey: [],
+ summary: [],
+ timestamp: [],
+ links: [],
+ customDetails: [],
+ },
+ });
+ });
+
+ test('action params validation fails when a link is missing the href field', async () => {
+ const actionParams = {
+ eventAction: 'trigger',
+ dedupKey: 'test',
+ summary: '2323',
+ source: 'source',
+ severity: 'critical',
+ timestamp: new Date().toISOString(),
+ component: 'test',
+ group: 'group',
+ class: 'test class',
+ customDetails: '{}',
+ links: [{ href: '', text: 'foobar' }],
+ };
+
+ expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
+ errors: {
+ dedupKey: [],
+ summary: [],
+ timestamp: [],
+ links: ['Link properties cannot be empty.'],
+ customDetails: [],
+ },
+ });
+ });
+
+ test('action params validation fails when a link is missing the text field', async () => {
+ const actionParams = {
+ eventAction: 'trigger',
+ dedupKey: 'test',
+ summary: '2323',
+ source: 'source',
+ severity: 'critical',
+ timestamp: new Date().toISOString(),
+ component: 'test',
+ group: 'group',
+ class: 'test class',
+ customDetails: '{}',
+ links: [{ href: 'foobar', text: '' }],
+ };
+
+ expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
+ errors: {
+ dedupKey: [],
+ summary: [],
+ timestamp: [],
+ links: ['Link properties cannot be empty.'],
+ customDetails: [],
+ },
+ });
+ });
+
+ test('action params validation does not throw the same error multiple times for links', async () => {
+ const actionParams = {
+ eventAction: 'trigger',
+ dedupKey: 'test',
+ summary: '2323',
+ source: 'source',
+ severity: 'critical',
+ timestamp: new Date().toISOString(),
+ component: 'test',
+ group: 'group',
+ class: 'test class',
+ customDetails: '{}',
+ links: [
+ { href: 'foobar', text: '' },
+ { href: '', text: 'foobar' },
+ { href: '', text: '' },
+ ],
+ };
+
+ expect(await connectorTypeModel.validateParams(actionParams)).toEqual({
+ errors: {
+ dedupKey: [],
+ summary: [],
+ timestamp: [],
+ links: ['Link properties cannot be empty.'],
+ customDetails: [],
},
});
});
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.tsx b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.tsx
index 0dd1513ac55ac..b02665eec66be 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.tsx
+++ b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty.tsx
@@ -50,6 +50,8 @@ export function getConnectorType(): ConnectorTypeModel<
summary: new Array(),
timestamp: new Array(),
dedupKey: new Array(),
+ links: new Array(),
+ customDetails: new Array(),
};
const validationResult = { errors };
if (
@@ -79,6 +81,31 @@ export function getConnectorType(): ConnectorTypeModel<
);
}
}
+ if (Array.isArray(actionParams.links)) {
+ actionParams.links.forEach(({ href, text }) => {
+ if ((!href || !text) && errors.links.length === 0) {
+ errors.links.push(
+ i18n.translate('xpack.stackConnectors.components.pagerDuty.error.invalidLink', {
+ defaultMessage: 'Link properties cannot be empty.',
+ })
+ );
+ }
+ });
+ }
+ if (actionParams.customDetails?.length && !hasMustacheTokens(actionParams.customDetails)) {
+ try {
+ JSON.parse(actionParams.customDetails);
+ } catch {
+ errors.customDetails.push(
+ i18n.translate(
+ 'xpack.stackConnectors.components.pagerDuty.error.invalidCustomDetails',
+ {
+ defaultMessage: 'Custom details must be a valid JSON.',
+ }
+ )
+ );
+ }
+ }
return validationResult;
},
actionConnectorFields: lazy(() => import('./pagerduty_connectors')),
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.test.tsx
index 19f47166b2726..aa1e6be9bebe0 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.test.tsx
+++ b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.test.tsx
@@ -11,7 +11,7 @@ import { EventActionOptions, SeverityActionOptions } from '../types';
import PagerDutyParamsFields from './pagerduty_params';
describe('PagerDutyParamsFields renders', () => {
- test('all params fields is rendered', () => {
+ test('all params fields are rendered', () => {
const actionParams = {
eventAction: EventActionOptions.TRIGGER,
dedupKey: 'test',
@@ -22,6 +22,11 @@ describe('PagerDutyParamsFields renders', () => {
component: 'test',
group: 'group',
class: 'test class',
+ customDetails: '{"foo":"bar"}',
+ links: [
+ { href: 'foo', text: 'bar' },
+ { href: 'foo', text: 'bar' },
+ ],
};
const wrapper = mountWithIntl(
@@ -55,6 +60,9 @@ describe('PagerDutyParamsFields renders', () => {
expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="summaryInput"]').length > 0).toBeTruthy();
expect(wrapper.find('[data-test-subj="dedupKeyAddVariableButton"]').length > 0).toBeTruthy();
+ expect(wrapper.find('[data-test-subj="customDetailsJsonEditor"]').length > 0).toBeTruthy();
+ expect(wrapper.find('[data-test-subj="linksList"]').length > 0).toBeTruthy();
+ expect(wrapper.find('[data-test-subj="pagerDutyAddLinkButton"]').length > 0).toBeTruthy();
});
test('params select fields do not auto set values eventActionSelect', () => {
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.tsx
index 970ef6ae1ecbf..8505adfee17f8 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.tsx
+++ b/x-pack/plugins/stack_connectors/public/connector_types/pagerduty/pagerduty_params.tsx
@@ -6,12 +6,23 @@
*/
import React from 'react';
-import { EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect, EuiSpacer } from '@elastic/eui';
+import {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSelect,
+ EuiSpacer,
+ useEuiTheme,
+} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isUndefined } from 'lodash';
-import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public';
+import {
+ ActionParamsProps,
+ JsonEditorWithMessageVariables,
+} from '@kbn/triggers-actions-ui-plugin/public';
import { TextFieldWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public';
import { PagerDutyActionParams } from '../types';
+import { LinksList } from './links_list';
const PagerDutyParamsFields: React.FunctionComponent> = ({
actionParams,
@@ -20,8 +31,20 @@ const PagerDutyParamsFields: React.FunctionComponent {
- const { eventAction, dedupKey, summary, source, severity, timestamp, component, group } =
- actionParams;
+ const { euiTheme } = useEuiTheme();
+
+ const {
+ eventAction,
+ dedupKey,
+ summary,
+ source,
+ severity,
+ timestamp,
+ component,
+ group,
+ customDetails,
+ links,
+ } = actionParams;
const severityOptions = [
{
value: 'critical',
@@ -125,7 +148,7 @@ const PagerDutyParamsFields: React.FunctionComponent
-
+
- {isTriggerPagerDutyEvent ? (
+ {isTriggerPagerDutyEvent && (
<>
+
+
>
- ) : null}
+ )}
>
);
};
diff --git a/x-pack/plugins/stack_connectors/public/connector_types/types.ts b/x-pack/plugins/stack_connectors/public/connector_types/types.ts
index 72319df375e1f..eb0cb9927dce1 100644
--- a/x-pack/plugins/stack_connectors/public/connector_types/types.ts
+++ b/x-pack/plugins/stack_connectors/public/connector_types/types.ts
@@ -39,6 +39,8 @@ export interface PagerDutyActionParams {
component?: string;
group?: string;
class?: string;
+ customDetails?: string;
+ links?: Array<{ href: string; text: string }>;
}
export interface IndexActionParams {
diff --git a/x-pack/test_serverless/functional/services/ml/observability_navigation.ts b/x-pack/test_serverless/functional/services/ml/observability_navigation.ts
index 3dd18587a9140..91c149dab37ac 100644
--- a/x-pack/test_serverless/functional/services/ml/observability_navigation.ts
+++ b/x-pack/test_serverless/functional/services/ml/observability_navigation.ts
@@ -16,10 +16,10 @@ export function MachineLearningNavigationProviderObservability({
async function navigateToArea(id: string) {
await svlCommonNavigation.sidenav.openSection('observability_project_nav.aiops');
- await testSubjects.existOrFail(`~nav-item-id-observability_project_nav.aiops.ml:${id}`, {
+ await testSubjects.existOrFail(`~nav-item-observability_project_nav.aiops.ml:${id}`, {
timeout: 60 * 1000,
});
- await testSubjects.click(`~nav-item-id-observability_project_nav.aiops.ml:${id}`);
+ await testSubjects.click(`~nav-item-observability_project_nav.aiops.ml:${id}`);
}
return {