mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Observability] [Cases] Cases in the observability app (#101487)
This commit is contained in:
parent
b242765654
commit
de07e98663
82 changed files with 2949 additions and 206 deletions
|
@ -148,6 +148,7 @@ export const applicationUsageSchema = {
|
|||
maps: commonSchema,
|
||||
ml: commonSchema,
|
||||
monitoring: commonSchema,
|
||||
observabilityCases: commonSchema,
|
||||
'observability-overview': commonSchema,
|
||||
osquery: commonSchema,
|
||||
security_account: commonSchema,
|
||||
|
|
|
@ -3970,6 +3970,137 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"observabilityCases": {
|
||||
"properties": {
|
||||
"appId": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The application being tracked"
|
||||
}
|
||||
},
|
||||
"viewId": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "Always `main`"
|
||||
}
|
||||
},
|
||||
"clicks_total": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the application since we started counting them"
|
||||
}
|
||||
},
|
||||
"clicks_7_days": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the application over the last 7 days"
|
||||
}
|
||||
},
|
||||
"clicks_30_days": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the application over the last 30 days"
|
||||
}
|
||||
},
|
||||
"clicks_90_days": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the application over the last 90 days"
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_total": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen since we started counting them."
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_7_days": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen over the last 7 days"
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_30_days": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen over the last 30 days"
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_90_days": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen over the last 90 days"
|
||||
}
|
||||
},
|
||||
"views": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"properties": {
|
||||
"appId": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The application being tracked"
|
||||
}
|
||||
},
|
||||
"viewId": {
|
||||
"type": "keyword",
|
||||
"_meta": {
|
||||
"description": "The application view being tracked"
|
||||
}
|
||||
},
|
||||
"clicks_total": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the application sub view since we started counting them"
|
||||
}
|
||||
},
|
||||
"clicks_7_days": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the active application sub view over the last 7 days"
|
||||
}
|
||||
},
|
||||
"clicks_30_days": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the active application sub view over the last 30 days"
|
||||
}
|
||||
},
|
||||
"clicks_90_days": {
|
||||
"type": "long",
|
||||
"_meta": {
|
||||
"description": "General number of clicks in the active application sub view over the last 90 days"
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_total": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application sub view is active and on-screen since we started counting them."
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_7_days": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen active application sub view over the last 7 days"
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_30_days": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen active application sub view over the last 30 days"
|
||||
}
|
||||
},
|
||||
"minutes_on_screen_90_days": {
|
||||
"type": "float",
|
||||
"_meta": {
|
||||
"description": "Minutes the application is active and on-screen active application sub view over the last 90 days"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"observability-overview": {
|
||||
"properties": {
|
||||
"appId": {
|
||||
|
|
|
@ -97,6 +97,9 @@ export default async function ({ readConfigFile }) {
|
|||
pathname: '/app/home',
|
||||
hash: '/',
|
||||
},
|
||||
observabilityCases: {
|
||||
pathname: '/app/observability/cases',
|
||||
},
|
||||
},
|
||||
junit: {
|
||||
reportName: 'Chrome UI Functional Tests',
|
||||
|
|
|
@ -62,9 +62,11 @@ interface AllCasesGenericProps {
|
|||
caseDetailsNavigation?: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; // if not passed, case name is not displayed as a link (Formerly dependant on isSelectorView)
|
||||
configureCasesNavigation?: CasesNavigation; // if not passed, header with nav is not displayed (Formerly dependant on isSelectorView)
|
||||
createCaseNavigation: CasesNavigation;
|
||||
disableAlerts?: boolean;
|
||||
hiddenStatuses?: CaseStatusWithAllStatus[];
|
||||
isSelectorView?: boolean;
|
||||
onRowClick?: (theCase?: Case | SubCase) => void;
|
||||
showTitle?: boolean;
|
||||
updateCase?: (newCase: Case) => void;
|
||||
userCanCrud: boolean;
|
||||
}
|
||||
|
@ -75,9 +77,11 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
|
|||
caseDetailsNavigation,
|
||||
configureCasesNavigation,
|
||||
createCaseNavigation,
|
||||
disableAlerts,
|
||||
hiddenStatuses = [],
|
||||
isSelectorView,
|
||||
onRowClick,
|
||||
showTitle,
|
||||
updateCase,
|
||||
userCanCrud,
|
||||
}) => {
|
||||
|
@ -190,6 +194,7 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
|
|||
|
||||
const columns = useCasesColumns({
|
||||
caseDetailsNavigation,
|
||||
disableAlerts,
|
||||
dispatchUpdateCaseProperty,
|
||||
filterStatus: filterOptions.status,
|
||||
handleIsLoading,
|
||||
|
@ -271,6 +276,7 @@ export const AllCasesGeneric = React.memo<AllCasesGenericProps>(
|
|||
createCaseNavigation={createCaseNavigation}
|
||||
configureCasesNavigation={configureCasesNavigation}
|
||||
refresh={refresh}
|
||||
showTitle={showTitle}
|
||||
userCanCrud={userCanCrud}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -55,6 +55,7 @@ const renderStringField = (field: string, dataTestSubj: string) =>
|
|||
|
||||
export interface GetCasesColumn {
|
||||
caseDetailsNavigation?: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>;
|
||||
disableAlerts?: boolean;
|
||||
dispatchUpdateCaseProperty: (u: UpdateCase) => void;
|
||||
filterStatus: string;
|
||||
handleIsLoading: (a: boolean) => void;
|
||||
|
@ -64,6 +65,7 @@ export interface GetCasesColumn {
|
|||
}
|
||||
export const useCasesColumns = ({
|
||||
caseDetailsNavigation,
|
||||
disableAlerts = false,
|
||||
dispatchUpdateCaseProperty,
|
||||
filterStatus,
|
||||
handleIsLoading,
|
||||
|
@ -203,15 +205,19 @@ export const useCasesColumns = ({
|
|||
},
|
||||
truncateText: true,
|
||||
},
|
||||
{
|
||||
align: RIGHT_ALIGNMENT,
|
||||
field: 'totalAlerts',
|
||||
name: ALERTS,
|
||||
render: (totalAlerts: Case['totalAlerts']) =>
|
||||
totalAlerts != null
|
||||
? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`)
|
||||
: getEmptyTagValue(),
|
||||
},
|
||||
...(!disableAlerts
|
||||
? [
|
||||
{
|
||||
align: RIGHT_ALIGNMENT,
|
||||
field: 'totalAlerts',
|
||||
name: ALERTS,
|
||||
render: (totalAlerts: Case['totalAlerts']) =>
|
||||
totalAlerts != null
|
||||
? renderStringField(`${totalAlerts}`, `case-table-column-alertsCount`)
|
||||
: getEmptyTagValue(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
align: RIGHT_ALIGNMENT,
|
||||
field: 'totalComment',
|
||||
|
|
|
@ -20,6 +20,7 @@ interface OwnProps {
|
|||
configureCasesNavigation: CasesNavigation;
|
||||
createCaseNavigation: CasesNavigation;
|
||||
refresh: number;
|
||||
showTitle?: boolean;
|
||||
userCanCrud: boolean;
|
||||
}
|
||||
|
||||
|
@ -40,9 +41,10 @@ export const CasesTableHeader: FunctionComponent<Props> = ({
|
|||
configureCasesNavigation,
|
||||
createCaseNavigation,
|
||||
refresh,
|
||||
showTitle = true,
|
||||
userCanCrud,
|
||||
}) => (
|
||||
<CaseHeaderPage title={i18n.PAGE_TITLE}>
|
||||
<CaseHeaderPage title={showTitle ? i18n.PAGE_TITLE : ''}>
|
||||
<EuiFlexGroup
|
||||
alignItems="center"
|
||||
gutterSize="m"
|
||||
|
|
|
@ -14,6 +14,8 @@ export interface AllCasesProps extends Owner {
|
|||
caseDetailsNavigation: CasesNavigation<CaseDetailsHrefSchema, 'configurable'>; // if not passed, case name is not displayed as a link (Formerly dependant on isSelector)
|
||||
configureCasesNavigation: CasesNavigation; // if not passed, header with nav is not displayed (Formerly dependant on isSelector)
|
||||
createCaseNavigation: CasesNavigation;
|
||||
disableAlerts?: boolean;
|
||||
showTitle?: boolean;
|
||||
userCanCrud: boolean;
|
||||
}
|
||||
|
||||
|
|
|
@ -80,7 +80,7 @@ describe('Callout', () => {
|
|||
});
|
||||
|
||||
it('dismiss the callout correctly', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click');
|
||||
wrapper.update();
|
||||
|
|
|
@ -35,11 +35,9 @@ const CallOutComponent = ({
|
|||
type,
|
||||
]);
|
||||
|
||||
return showCallOut ? (
|
||||
return showCallOut && !isEmpty(messages) ? (
|
||||
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
|
||||
{!isEmpty(messages) && (
|
||||
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
|
||||
)}
|
||||
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
|
||||
<EuiButton
|
||||
data-test-subj={`callout-dismiss-${id}`}
|
||||
color={type === 'success' ? 'secondary' : type}
|
||||
|
|
|
@ -11,7 +11,7 @@ import md5 from 'md5';
|
|||
import * as i18n from './translations';
|
||||
import { ErrorMessage } from './types';
|
||||
|
||||
export const savedObjectReadOnlyErrorMessage: ErrorMessage = {
|
||||
export const permissionsReadOnlyErrorMessage: ErrorMessage = {
|
||||
id: 'read-only-privileges-error',
|
||||
title: i18n.READ_ONLY_FEATURE_TITLE,
|
||||
description: <>{i18n.READ_ONLY_FEATURE_MSG}</>,
|
||||
|
|
|
@ -26,7 +26,14 @@ jest.mock('react-router-dom', () => {
|
|||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
allCasesNavigation: {
|
||||
href: 'all-cases-href',
|
||||
onClick: () => {},
|
||||
},
|
||||
caseData: basicCase,
|
||||
currentExternalIncident: null,
|
||||
};
|
||||
describe('CaseView actions', () => {
|
||||
const handleOnDeleteConfirm = jest.fn();
|
||||
const handleToggleModal = jest.fn();
|
||||
|
@ -49,7 +56,7 @@ describe('CaseView actions', () => {
|
|||
it('clicking trash toggles modal', () => {
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<Actions caseData={basicCase} currentExternalIncident={null} />
|
||||
<Actions {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -67,7 +74,7 @@ describe('CaseView actions', () => {
|
|||
}));
|
||||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<Actions caseData={basicCase} currentExternalIncident={null} />
|
||||
<Actions {...defaultProps} />
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
@ -82,7 +89,7 @@ describe('CaseView actions', () => {
|
|||
const wrapper = mount(
|
||||
<TestProviders>
|
||||
<Actions
|
||||
caseData={basicCase}
|
||||
{...defaultProps}
|
||||
currentExternalIncident={{
|
||||
...basicPush,
|
||||
firstPushIndex: 5,
|
||||
|
|
|
@ -7,26 +7,27 @@
|
|||
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import * as i18n from '../case_view/translations';
|
||||
import { useDeleteCases } from '../../containers/use_delete_cases';
|
||||
import { ConfirmDeleteCaseModal } from '../confirm_delete_case';
|
||||
import { PropertyActions } from '../property_actions';
|
||||
import { Case } from '../../containers/types';
|
||||
import { Case } from '../../../common';
|
||||
import { CaseService } from '../../containers/use_get_case_user_actions';
|
||||
import { CasesNavigation } from '../links';
|
||||
|
||||
interface CaseViewActions {
|
||||
allCasesNavigation: CasesNavigation;
|
||||
caseData: Case;
|
||||
currentExternalIncident: CaseService | null;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ActionsComponent: React.FC<CaseViewActions> = ({
|
||||
allCasesNavigation,
|
||||
caseData,
|
||||
currentExternalIncident,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const history = useHistory();
|
||||
// Delete case
|
||||
const {
|
||||
handleToggleModal,
|
||||
|
@ -57,7 +58,7 @@ const ActionsComponent: React.FC<CaseViewActions> = ({
|
|||
);
|
||||
|
||||
if (isDeleted) {
|
||||
history.push('/');
|
||||
allCasesNavigation.onClick(null);
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
|
|
@ -16,7 +16,12 @@ describe('CaseActionBar', () => {
|
|||
const onRefresh = jest.fn();
|
||||
const onUpdateField = jest.fn();
|
||||
const defaultProps = {
|
||||
allCasesNavigation: {
|
||||
href: 'all-cases-href',
|
||||
onClick: () => {},
|
||||
},
|
||||
caseData: basicCase,
|
||||
disableAlerting: false,
|
||||
isLoading: false,
|
||||
onRefresh,
|
||||
onUpdateField,
|
||||
|
|
|
@ -16,16 +16,16 @@ import {
|
|||
EuiFlexItem,
|
||||
EuiIconTip,
|
||||
} from '@elastic/eui';
|
||||
import { CaseStatuses, CaseType } from '../../../common';
|
||||
import { Case, CaseStatuses, CaseType } from '../../../common';
|
||||
import * as i18n from '../case_view/translations';
|
||||
import { FormattedRelativePreferenceDate } from '../formatted_date';
|
||||
import { Actions } from './actions';
|
||||
import { Case } from '../../containers/types';
|
||||
import { CaseService } from '../../containers/use_get_case_user_actions';
|
||||
import { StatusContextMenu } from './status_context_menu';
|
||||
import { getStatusDate, getStatusTitle } from './helpers';
|
||||
import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch';
|
||||
import { OnUpdateFields } from '../case_view';
|
||||
import { CasesNavigation } from '../links';
|
||||
|
||||
const MyDescriptionList = styled(EuiDescriptionList)`
|
||||
${({ theme }) => css`
|
||||
|
@ -37,17 +37,21 @@ const MyDescriptionList = styled(EuiDescriptionList)`
|
|||
`;
|
||||
|
||||
interface CaseActionBarProps {
|
||||
allCasesNavigation: CasesNavigation;
|
||||
caseData: Case;
|
||||
currentExternalIncident: CaseService | null;
|
||||
disabled?: boolean;
|
||||
disableAlerting: boolean;
|
||||
isLoading: boolean;
|
||||
onRefresh: () => void;
|
||||
onUpdateField: (args: OnUpdateFields) => void;
|
||||
}
|
||||
const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
||||
allCasesNavigation,
|
||||
caseData,
|
||||
currentExternalIncident,
|
||||
disabled = false,
|
||||
disableAlerting,
|
||||
isLoading,
|
||||
onRefresh,
|
||||
onUpdateField,
|
||||
|
@ -104,25 +108,27 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
<EuiFlexItem grow={false}>
|
||||
<EuiDescriptionList compressed>
|
||||
<EuiFlexGroup gutterSize="l" alignItems="center">
|
||||
<EuiFlexItem>
|
||||
<EuiDescriptionListTitle>
|
||||
<EuiFlexGroup component="span" alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{i18n.SYNC_ALERTS}</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip content={i18n.SYNC_ALERTS_HELP} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
<SyncAlertsSwitch
|
||||
disabled={disabled || isLoading}
|
||||
isSynced={caseData.settings.syncAlerts}
|
||||
onSwitchChange={onSyncAlertsChanged}
|
||||
/>
|
||||
</EuiDescriptionListDescription>
|
||||
</EuiFlexItem>
|
||||
{!disableAlerting && (
|
||||
<EuiFlexItem>
|
||||
<EuiDescriptionListTitle>
|
||||
<EuiFlexGroup component="span" alignItems="center" gutterSize="xs">
|
||||
<EuiFlexItem grow={false}>
|
||||
<span>{i18n.SYNC_ALERTS}</span>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiIconTip content={i18n.SYNC_ALERTS_HELP} />
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiDescriptionListTitle>
|
||||
<EuiDescriptionListDescription>
|
||||
<SyncAlertsSwitch
|
||||
disabled={disabled || isLoading}
|
||||
isSynced={caseData.settings.syncAlerts}
|
||||
onSwitchChange={onSyncAlertsChanged}
|
||||
/>
|
||||
</EuiDescriptionListDescription>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
<EuiFlexItem>
|
||||
<EuiButtonEmpty data-test-subj="case-refresh" iconType="refresh" onClick={onRefresh}>
|
||||
{i18n.CASE_REFRESH}
|
||||
|
@ -130,6 +136,7 @@ const CaseActionBarComponent: React.FC<CaseActionBarProps> = ({
|
|||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false} data-test-subj="case-view-actions">
|
||||
<Actions
|
||||
allCasesNavigation={allCasesNavigation}
|
||||
caseData={caseData}
|
||||
currentExternalIncident={currentExternalIncident}
|
||||
disabled={disabled}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* 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 { EuiEmptyPrompt, EuiButton } from '@elastic/eui';
|
||||
import * as i18n from './translations';
|
||||
import { CasesNavigation } from '../links';
|
||||
|
||||
interface Props {
|
||||
allCasesNavigation: CasesNavigation;
|
||||
caseId: string;
|
||||
}
|
||||
|
||||
export const DoesNotExist = ({ allCasesNavigation, caseId }: Props) => (
|
||||
<EuiEmptyPrompt
|
||||
iconColor="default"
|
||||
iconType="addDataApp"
|
||||
title={<h2>{i18n.DOES_NOT_EXIST_TITLE}</h2>}
|
||||
titleSize="xs"
|
||||
body={<p>{i18n.DOES_NOT_EXIST_DESCRIPTION(caseId)}</p>}
|
||||
actions={
|
||||
<EuiButton onClick={allCasesNavigation.onClick} size="s" color="primary" fill>
|
||||
{i18n.DOES_NOT_EXIST_BUTTON}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -6,7 +6,6 @@
|
|||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
|
||||
// import { useDispatch } from 'react-redux';
|
||||
import styled from 'styled-components';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import {
|
||||
|
@ -17,7 +16,7 @@ import {
|
|||
EuiHorizontalRule,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector } from '../../../common';
|
||||
import { CaseStatuses, CaseAttributes, CaseType, Case, CaseConnector, Ecs } from '../../../common';
|
||||
import { HeaderPage } from '../header_page';
|
||||
import { EditableTitle } from '../header_page/editable_title';
|
||||
import { TagList } from '../tag_list';
|
||||
|
@ -39,11 +38,11 @@ import {
|
|||
} from '../configure_cases/utils';
|
||||
import { StatusActionButton } from '../status/button';
|
||||
import * as i18n from './translations';
|
||||
import { Ecs } from '../../../common';
|
||||
import { CasesTimelineIntegration, CasesTimelineIntegrationProvider } from '../timeline_context';
|
||||
import { useTimelineContext } from '../timeline_context/use_timeline_context';
|
||||
import { CasesNavigation } from '../links';
|
||||
import { OwnerProvider } from '../owner_context';
|
||||
import { DoesNotExist } from './does_not_exist';
|
||||
|
||||
const gutterTimeline = '70px'; // seems to be a timeline reference from the original file
|
||||
export interface CaseViewComponentProps {
|
||||
|
@ -53,8 +52,8 @@ export interface CaseViewComponentProps {
|
|||
configureCasesNavigation: CasesNavigation;
|
||||
getCaseDetailHrefWithCommentId: (commentId: string) => string;
|
||||
onComponentInitialized?: () => void;
|
||||
ruleDetailsNavigation: CasesNavigation<string | null | undefined, 'configurable'>;
|
||||
showAlertDetails: (alertId: string, index: string) => void;
|
||||
ruleDetailsNavigation?: CasesNavigation<string | null | undefined, 'configurable'>;
|
||||
showAlertDetails?: (alertId: string, index: string) => void;
|
||||
subCaseId?: string;
|
||||
useFetchAlertData: (alertIds: string[]) => [boolean, Record<string, Ecs>];
|
||||
userCanCrud: boolean;
|
||||
|
@ -327,7 +326,9 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
|
||||
const onShowAlertDetails = useCallback(
|
||||
(alertId: string, index: string) => {
|
||||
showAlertDetails(alertId, index);
|
||||
if (showAlertDetails) {
|
||||
showAlertDetails(alertId, index);
|
||||
}
|
||||
},
|
||||
[showAlertDetails]
|
||||
);
|
||||
|
@ -359,9 +360,11 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
title={caseData.title}
|
||||
>
|
||||
<CaseActionBar
|
||||
currentExternalIncident={currentExternalIncident}
|
||||
allCasesNavigation={allCasesNavigation}
|
||||
caseData={caseData}
|
||||
currentExternalIncident={currentExternalIncident}
|
||||
disabled={!userCanCrud}
|
||||
disableAlerting={ruleDetailsNavigation == null}
|
||||
isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')}
|
||||
onRefresh={handleRefresh}
|
||||
onUpdateField={onUpdateField}
|
||||
|
@ -380,8 +383,8 @@ export const CaseComponent = React.memo<CaseComponentProps>(
|
|||
<>
|
||||
<UserActionTree
|
||||
getCaseDetailHrefWithCommentId={getCaseDetailHrefWithCommentId}
|
||||
getRuleDetailsHref={ruleDetailsNavigation.href}
|
||||
onRuleDetailsClick={ruleDetailsNavigation.onClick}
|
||||
getRuleDetailsHref={ruleDetailsNavigation?.href}
|
||||
onRuleDetailsClick={ruleDetailsNavigation?.onClick}
|
||||
caseServices={caseServices}
|
||||
caseUserActions={caseUserActions}
|
||||
connectors={connectors}
|
||||
|
@ -493,7 +496,7 @@ export const CaseView = React.memo(
|
|||
}: CaseViewProps) => {
|
||||
const { data, isLoading, isError, fetchCase, updateCase } = useGetCase(caseId, subCaseId);
|
||||
if (isError) {
|
||||
return null;
|
||||
return <DoesNotExist allCasesNavigation={allCasesNavigation} caseId={caseId} />;
|
||||
}
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
|
@ -128,3 +128,20 @@ export const CHANGED_CONNECTOR_FIELD = i18n.translate('xpack.cases.caseView.fiel
|
|||
export const SYNC_ALERTS = i18n.translate('xpack.cases.caseView.syncAlertsLabel', {
|
||||
defaultMessage: `Sync alerts`,
|
||||
});
|
||||
|
||||
export const DOES_NOT_EXIST_TITLE = i18n.translate('xpack.cases.caseView.doesNotExist.title', {
|
||||
defaultMessage: 'This case does not exist',
|
||||
});
|
||||
|
||||
export const DOES_NOT_EXIST_DESCRIPTION = (caseId: string) =>
|
||||
i18n.translate('xpack.cases.caseView.doesNotExist.description', {
|
||||
values: {
|
||||
caseId,
|
||||
},
|
||||
defaultMessage:
|
||||
'A case with id {caseId} could not be found. This likely means the case has been deleted, or the id is incorrect.',
|
||||
});
|
||||
|
||||
export const DOES_NOT_EXIST_BUTTON = i18n.translate('xpack.cases.caseView.doesNotExist.button', {
|
||||
defaultMessage: 'Back to Cases',
|
||||
});
|
||||
|
|
|
@ -38,6 +38,7 @@ const MySpinner = styled(EuiLoadingSpinner)`
|
|||
|
||||
interface Props {
|
||||
connectors?: ActionConnector[];
|
||||
disableAlerts?: boolean;
|
||||
hideConnectorServiceNowSir?: boolean;
|
||||
isLoadingConnectors?: boolean;
|
||||
withSteps?: boolean;
|
||||
|
@ -46,6 +47,7 @@ const empty: ActionConnector[] = [];
|
|||
export const CreateCaseForm: React.FC<Props> = React.memo(
|
||||
({
|
||||
connectors = empty,
|
||||
disableAlerts = false,
|
||||
isLoadingConnectors = false,
|
||||
hideConnectorServiceNowSir = false,
|
||||
withSteps = true,
|
||||
|
@ -99,11 +101,10 @@ export const CreateCaseForm: React.FC<Props> = React.memo(
|
|||
[connectors, hideConnectorServiceNowSir, isLoadingConnectors, isSubmitting]
|
||||
);
|
||||
|
||||
const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [
|
||||
firstStep,
|
||||
secondStep,
|
||||
thirdStep,
|
||||
]);
|
||||
const allSteps = useMemo(
|
||||
() => [firstStep, ...(!disableAlerts ? [secondStep] : []), thirdStep],
|
||||
[disableAlerts, firstStep, secondStep, thirdStep]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -117,7 +118,7 @@ export const CreateCaseForm: React.FC<Props> = React.memo(
|
|||
) : (
|
||||
<>
|
||||
{firstStep.children}
|
||||
{secondStep.children}
|
||||
{!disableAlerts && secondStep.children}
|
||||
{thirdStep.children}
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -73,7 +73,7 @@ export const FormContext: React.FC<Props> = ({
|
|||
|
||||
const submitCase = useCallback(
|
||||
async (
|
||||
{ connectorId: dataConnectorId, fields, syncAlerts, ...dataWithoutConnectorId },
|
||||
{ connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId },
|
||||
isValid
|
||||
) => {
|
||||
if (isValid) {
|
||||
|
|
|
@ -34,6 +34,7 @@ const Container = styled.div`
|
|||
export interface CreateCaseProps extends Owner {
|
||||
afterCaseCreated?: (theCase: Case, postComment: UsePostComment['postComment']) => Promise<void>;
|
||||
caseType?: CaseType;
|
||||
disableAlerts?: boolean;
|
||||
hideConnectorServiceNowSir?: boolean;
|
||||
onCancel: () => void;
|
||||
onSuccess: (theCase: Case) => Promise<void>;
|
||||
|
@ -45,6 +46,7 @@ const CreateCaseComponent = ({
|
|||
afterCaseCreated,
|
||||
caseType,
|
||||
hideConnectorServiceNowSir,
|
||||
disableAlerts,
|
||||
onCancel,
|
||||
onSuccess,
|
||||
timelineIntegration,
|
||||
|
@ -59,6 +61,7 @@ const CreateCaseComponent = ({
|
|||
>
|
||||
<CreateCaseForm
|
||||
hideConnectorServiceNowSir={hideConnectorServiceNowSir}
|
||||
disableAlerts={disableAlerts}
|
||||
withSteps={withSteps}
|
||||
/>
|
||||
<Container>
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
import React, { useCallback } from 'react';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export interface CasesNavigation<T = React.MouseEvent | MouseEvent, K = null> {
|
||||
export interface CasesNavigation<T = React.MouseEvent | MouseEvent | null, K = null> {
|
||||
href: K extends 'configurable' ? (arg: T) => string : string;
|
||||
onClick: (arg: T) => void;
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ export interface ReturnUsePushToService {
|
|||
}
|
||||
|
||||
export const usePushToService = ({
|
||||
configureCasesNavigation: { onClick, href },
|
||||
configureCasesNavigation: { href },
|
||||
connector,
|
||||
caseId,
|
||||
caseServices,
|
||||
|
@ -82,7 +82,7 @@ export const usePushToService = ({
|
|||
id="xpack.cases.caseView.pushToServiceDisableByNoConnectors"
|
||||
values={{
|
||||
link: (
|
||||
<LinkAnchor onClick={onClick} href={href} target="_blank">
|
||||
<LinkAnchor href={href} target="_blank">
|
||||
{i18n.LINK_CONNECTOR_CONFIGURE}
|
||||
</LinkAnchor>
|
||||
),
|
||||
|
|
|
@ -21,7 +21,7 @@ import { isRight } from 'fp-ts/Either';
|
|||
|
||||
import * as i18n from './translations';
|
||||
|
||||
import { Case, CaseUserActions } from '../../containers/types';
|
||||
import { Case, CaseUserActions } from '../../../common';
|
||||
import { useUpdateComment } from '../../containers/use_update_comment';
|
||||
import { useCurrentUser } from '../../common/lib/kibana';
|
||||
import { AddComment, AddCommentRefObject } from '../add_comment';
|
||||
|
@ -56,7 +56,7 @@ export interface UserActionTreeProps {
|
|||
caseUserActions: CaseUserActions[];
|
||||
connectors: ActionConnector[];
|
||||
data: Case;
|
||||
getRuleDetailsHref: (ruleId: string | null | undefined) => string;
|
||||
getRuleDetailsHref?: (ruleId: string | null | undefined) => string;
|
||||
fetchUserActions: () => void;
|
||||
isLoadingDescription: boolean;
|
||||
isLoadingUserActions: boolean;
|
||||
|
@ -397,18 +397,22 @@ export const UserActionTree = React.memo(
|
|||
|
||||
return [
|
||||
...comments,
|
||||
getAlertAttachment({
|
||||
action,
|
||||
alertId,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
getRuleDetailsHref,
|
||||
index: alertIndex,
|
||||
loadingAlertData,
|
||||
onRuleDetailsClick,
|
||||
ruleId,
|
||||
ruleName,
|
||||
onShowAlertDetails,
|
||||
}),
|
||||
...(getRuleDetailsHref != null
|
||||
? [
|
||||
getAlertAttachment({
|
||||
action,
|
||||
alertId,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
getRuleDetailsHref,
|
||||
index: alertIndex,
|
||||
loadingAlertData,
|
||||
onRuleDetailsClick,
|
||||
ruleId,
|
||||
ruleName,
|
||||
onShowAlertDetails,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
} else if (comment != null && comment.type === CommentType.generatedAlert) {
|
||||
// TODO: clean this up
|
||||
|
@ -422,16 +426,20 @@ export const UserActionTree = React.memo(
|
|||
|
||||
return [
|
||||
...comments,
|
||||
getGeneratedAlertsAttachment({
|
||||
action,
|
||||
alertIds,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
getRuleDetailsHref,
|
||||
onRuleDetailsClick,
|
||||
renderInvestigateInTimelineActionComponent,
|
||||
ruleId: comment.rule?.id ?? '',
|
||||
ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE,
|
||||
}),
|
||||
...(getRuleDetailsHref != null
|
||||
? [
|
||||
getGeneratedAlertsAttachment({
|
||||
action,
|
||||
alertIds,
|
||||
getCaseDetailHrefWithCommentId,
|
||||
getRuleDetailsHref,
|
||||
onRuleDetailsClick,
|
||||
renderInvestigateInTimelineActionComponent,
|
||||
ruleId: comment.rule?.id ?? '',
|
||||
ruleName: comment.rule?.name ?? i18n.UNKNOWN_RULE,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,7 +161,7 @@ describe('Case Configuration API', () => {
|
|||
query: {
|
||||
...DEFAULT_QUERY_PARAMS,
|
||||
reporters,
|
||||
tags: ['"coke"', '"pepsi"'],
|
||||
tags: ['coke', 'pepsi'],
|
||||
search: 'hello',
|
||||
status: CaseStatuses.open,
|
||||
owner: [SECURITY_SOLUTION_OWNER],
|
||||
|
@ -190,7 +190,7 @@ describe('Case Configuration API', () => {
|
|||
query: {
|
||||
...DEFAULT_QUERY_PARAMS,
|
||||
reporters,
|
||||
tags: ['"("', '"\\"double\\""'],
|
||||
tags: ['(', '"double"'],
|
||||
search: 'hello',
|
||||
status: CaseStatuses.open,
|
||||
owner: [SECURITY_SOLUTION_OWNER],
|
||||
|
|
|
@ -189,7 +189,7 @@ export const getCases = async ({
|
|||
}: FetchCasesProps): Promise<AllCases> => {
|
||||
const query = {
|
||||
reporters: filterOptions.reporters.map((r) => r.username ?? '').filter((r) => r !== ''),
|
||||
tags: filterOptions.tags.map((t) => `"${t.replace(/"/g, '\\"')}"`),
|
||||
tags: filterOptions.tags,
|
||||
status: filterOptions.status,
|
||||
...(filterOptions.search.length > 0 ? { search: filterOptions.search } : {}),
|
||||
...(filterOptions.onlyCollectionType ? { type: CaseType.collection } : {}),
|
||||
|
|
9
x-pack/plugins/observability/common/const.ts
Normal file
9
x-pack/plugins/observability/common/const.ts
Normal file
|
@ -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 const CASES_APP_ID = 'observabilityCases';
|
||||
export const OBSERVABILITY = 'observability';
|
|
@ -7,14 +7,16 @@
|
|||
"observability"
|
||||
],
|
||||
"optionalPlugins": [
|
||||
"licensing",
|
||||
"home",
|
||||
"usageCollection",
|
||||
"lens"
|
||||
"lens",
|
||||
"licensing",
|
||||
"usageCollection"
|
||||
],
|
||||
"requiredPlugins": [
|
||||
"data",
|
||||
"alerting",
|
||||
"cases",
|
||||
"data",
|
||||
"features",
|
||||
"ruleRegistry",
|
||||
"triggersActionsUi"
|
||||
],
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* 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 {
|
||||
getCaseDetailsUrl,
|
||||
getConfigureCasesUrl,
|
||||
getCreateCaseUrl,
|
||||
useFormatUrl,
|
||||
} from '../../../../pages/cases/links';
|
||||
import { useKibana } from '../../../../utils/kibana_react';
|
||||
import { CASES_APP_ID, CASES_OWNER } from '../constants';
|
||||
|
||||
export interface AllCasesNavProps {
|
||||
detailName: string;
|
||||
search?: string;
|
||||
subCaseId?: string;
|
||||
}
|
||||
|
||||
interface AllCasesProps {
|
||||
userCanCrud: boolean;
|
||||
}
|
||||
export const AllCases = React.memo<AllCasesProps>(({ userCanCrud }) => {
|
||||
const {
|
||||
cases: casesUi,
|
||||
application: { navigateToApp },
|
||||
} = useKibana().services;
|
||||
const { formatUrl } = useFormatUrl(CASES_APP_ID);
|
||||
|
||||
return casesUi.getAllCases({
|
||||
caseDetailsNavigation: {
|
||||
href: ({ detailName, subCaseId }: AllCasesNavProps) => {
|
||||
return formatUrl(getCaseDetailsUrl({ id: detailName, subCaseId }));
|
||||
},
|
||||
onClick: async ({ detailName, subCaseId, search }: AllCasesNavProps) =>
|
||||
navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: getCaseDetailsUrl({ id: detailName, subCaseId }),
|
||||
}),
|
||||
},
|
||||
configureCasesNavigation: {
|
||||
href: formatUrl(getConfigureCasesUrl()),
|
||||
onClick: async (ev) => {
|
||||
if (ev != null) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
return navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: getConfigureCasesUrl(),
|
||||
});
|
||||
},
|
||||
},
|
||||
createCaseNavigation: {
|
||||
href: formatUrl(getCreateCaseUrl()),
|
||||
onClick: async (ev) => {
|
||||
if (ev != null) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
return navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: getCreateCaseUrl(),
|
||||
});
|
||||
},
|
||||
},
|
||||
disableAlerts: true,
|
||||
showTitle: false,
|
||||
userCanCrud,
|
||||
owner: [CASES_OWNER],
|
||||
});
|
||||
});
|
||||
|
||||
AllCases.displayName = 'AllCases';
|
|
@ -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 React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
|
||||
import { CallOut, CallOutProps } from './callout';
|
||||
|
||||
describe('Callout', () => {
|
||||
const defaultProps: CallOutProps = {
|
||||
id: 'md5-hex',
|
||||
type: 'primary',
|
||||
title: 'a tittle',
|
||||
messages: [
|
||||
{
|
||||
id: 'generic-error',
|
||||
title: 'message-one',
|
||||
description: <p>{'error'}</p>,
|
||||
},
|
||||
],
|
||||
showCallOut: true,
|
||||
handleDismissCallout: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the callout', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeTruthy();
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeTruthy();
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('hides the callout', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} showCallOut={false} />);
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-md5-hex"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('does not show any messages when the list is empty', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-md5-hex"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - primary', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--primary')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - success', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} type={'success'} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--secondary')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - warning', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} type={'warning'} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--warning')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transform the button color correctly - danger', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} type={'danger'} />);
|
||||
const className =
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).first().prop('className') ??
|
||||
'';
|
||||
expect(className.includes('euiButton--danger')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dismiss the callout correctly', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click');
|
||||
wrapper.update();
|
||||
|
||||
expect(defaultProps.handleDismissCallout).toHaveBeenCalledWith('md5-hex', 'primary');
|
||||
});
|
||||
});
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiCallOut, EuiButton, EuiDescriptionList } from '@elastic/eui';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import React, { memo, useCallback } from 'react';
|
||||
|
||||
import { ErrorMessage } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
||||
export interface CallOutProps {
|
||||
id: string;
|
||||
type: NonNullable<ErrorMessage['errorType']>;
|
||||
title: string;
|
||||
messages: ErrorMessage[];
|
||||
showCallOut: boolean;
|
||||
handleDismissCallout: (id: string, type: NonNullable<ErrorMessage['errorType']>) => void;
|
||||
}
|
||||
|
||||
function CallOutComponent({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
messages,
|
||||
showCallOut,
|
||||
handleDismissCallout,
|
||||
}: CallOutProps) {
|
||||
const handleCallOut = useCallback(() => handleDismissCallout(id, type), [
|
||||
handleDismissCallout,
|
||||
id,
|
||||
type,
|
||||
]);
|
||||
|
||||
return showCallOut && !isEmpty(messages) ? (
|
||||
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
|
||||
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
|
||||
<EuiButton
|
||||
data-test-subj={`callout-dismiss-${id}`}
|
||||
color={type === 'success' ? 'secondary' : type}
|
||||
onClick={handleCallOut}
|
||||
>
|
||||
{i18n.DISMISS_CALLOUT}
|
||||
</EuiButton>
|
||||
</EuiCallOut>
|
||||
) : null;
|
||||
}
|
||||
|
||||
export const CallOut = memo(CallOutComponent);
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 md5 from 'md5';
|
||||
import { createCalloutId } from './helpers';
|
||||
|
||||
describe('createCalloutId', () => {
|
||||
it('creates id correctly with one id', () => {
|
||||
const digest = md5('one');
|
||||
const id = createCalloutId(['one']);
|
||||
expect(id).toBe(digest);
|
||||
});
|
||||
|
||||
it('creates id correctly with multiples ids', () => {
|
||||
const digest = md5('one|two|three');
|
||||
const id = createCalloutId(['one', 'two', 'three']);
|
||||
expect(id).toBe(digest);
|
||||
});
|
||||
|
||||
it('creates id correctly with multiples ids and delimiter', () => {
|
||||
const digest = md5('one,two,three');
|
||||
const id = createCalloutId(['one', 'two', 'three'], ',');
|
||||
expect(id).toBe(digest);
|
||||
});
|
||||
});
|
|
@ -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 React from 'react';
|
||||
import md5 from 'md5';
|
||||
|
||||
import * as i18n from './translations';
|
||||
import { ErrorMessage } from './types';
|
||||
|
||||
export const permissionsReadOnlyErrorMessage: ErrorMessage = {
|
||||
id: 'read-only-privileges-error',
|
||||
title: i18n.READ_ONLY_FEATURE_TITLE,
|
||||
description: <>{i18n.READ_ONLY_FEATURE_MSG}</>,
|
||||
errorType: 'warning',
|
||||
};
|
||||
|
||||
export const createCalloutId = (ids: string[], delimiter: string = '|'): string =>
|
||||
md5(ids.join(delimiter));
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* 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 { mount } from 'enzyme';
|
||||
|
||||
import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common';
|
||||
import { useMessagesStorage } from '../../../../hooks/use_messages_storage';
|
||||
import { createCalloutId } from './helpers';
|
||||
import { CaseCallOut, CaseCallOutProps } from '.';
|
||||
|
||||
jest.mock('../../../../hooks/use_messages_storage');
|
||||
const useSecurityLocalStorageMock = useMessagesStorage as jest.Mock;
|
||||
const securityLocalStorageMock = {
|
||||
getMessages: jest.fn(() => []),
|
||||
addMessage: jest.fn(),
|
||||
};
|
||||
|
||||
describe('CaseCallOut ', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useSecurityLocalStorageMock.mockImplementation(() => securityLocalStorageMock);
|
||||
});
|
||||
|
||||
it('renders a callout correctly', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
{ id: 'message-two', title: 'title', description: <p>{'for real'}</p> },
|
||||
],
|
||||
};
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one', 'message-two']);
|
||||
expect(wrapper.find(`[data-test-subj="callout-messages-${id}"]`).last().exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('groups the messages correctly', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'danger',
|
||||
},
|
||||
{ id: 'message-two', title: 'title two', description: <p>{'for real'}</p> },
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const idDanger = createCalloutId(['message-one']);
|
||||
const idPrimary = createCalloutId(['message-two']);
|
||||
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="case-callout-${idPrimary}"]`).last().exists()
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
wrapper.find(`[data-test-subj="case-callout-${idDanger}"]`).last().exists()
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dismisses the callout correctly', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
],
|
||||
};
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeTruthy();
|
||||
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('persist the callout of type primary when dismissed', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
expect(securityLocalStorageMock.getMessages).toHaveBeenCalledWith('observability');
|
||||
wrapper.find(`[data-test-subj="callout-dismiss-${id}"]`).last().simulate('click');
|
||||
expect(securityLocalStorageMock.addMessage).toHaveBeenCalledWith('observability', id);
|
||||
});
|
||||
|
||||
it('do not show the callout if is in the localStorage', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{ id: 'message-one', title: 'title', description: <p>{'we have two messages'}</p> },
|
||||
],
|
||||
};
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
|
||||
useSecurityLocalStorageMock.mockImplementation(() => ({
|
||||
...securityLocalStorageMock,
|
||||
getMessages: jest.fn(() => [id]),
|
||||
}));
|
||||
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj="case-callout-${id}"]`).last().exists()).toBeFalsy();
|
||||
});
|
||||
|
||||
it('do not persist a callout of type danger', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'danger',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||
wrapper.update();
|
||||
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('do not persist a callout of type warning', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'warning',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||
wrapper.update();
|
||||
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('do not persist a callout of type success', () => {
|
||||
const props: CaseCallOutProps = {
|
||||
title: 'hey title',
|
||||
messages: [
|
||||
{
|
||||
id: 'message-one',
|
||||
title: 'title one',
|
||||
description: <p>{'we have two messages'}</p>,
|
||||
errorType: 'success',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CaseCallOut {...props} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
const id = createCalloutId(['message-one']);
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-${id}"]`).simulate('click');
|
||||
wrapper.update();
|
||||
expect(securityLocalStorageMock.addMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* 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 { EuiSpacer } from '@elastic/eui';
|
||||
import React, { memo, useCallback, useState, useMemo } from 'react';
|
||||
|
||||
import { CallOut } from './callout';
|
||||
import { ErrorMessage } from './types';
|
||||
import { createCalloutId } from './helpers';
|
||||
import { useMessagesStorage } from '../../../../hooks/use_messages_storage';
|
||||
import { OBSERVABILITY } from '../../../../../common/const';
|
||||
|
||||
export * from './helpers';
|
||||
|
||||
export interface CaseCallOutProps {
|
||||
title: string;
|
||||
messages?: ErrorMessage[];
|
||||
}
|
||||
|
||||
type GroupByTypeMessages = {
|
||||
[key in NonNullable<ErrorMessage['errorType']>]: {
|
||||
messagesId: string[];
|
||||
messages: ErrorMessage[];
|
||||
};
|
||||
};
|
||||
|
||||
interface CalloutVisibility {
|
||||
[index: string]: boolean;
|
||||
}
|
||||
|
||||
function CaseCallOutComponent({ title, messages = [] }: CaseCallOutProps) {
|
||||
const { getMessages, addMessage } = useMessagesStorage();
|
||||
|
||||
const caseMessages = useMemo(() => getMessages(OBSERVABILITY), [getMessages]);
|
||||
const dismissedCallouts = useMemo(
|
||||
() =>
|
||||
caseMessages.reduce<CalloutVisibility>(
|
||||
(acc: CalloutVisibility, id) => ({
|
||||
...acc,
|
||||
[id]: false,
|
||||
}),
|
||||
{}
|
||||
),
|
||||
[caseMessages]
|
||||
);
|
||||
|
||||
const [calloutVisibility, setCalloutVisibility] = useState(dismissedCallouts);
|
||||
const handleCallOut = useCallback(
|
||||
(id, type) => {
|
||||
setCalloutVisibility((prevState) => ({ ...prevState, [id]: false }));
|
||||
if (type === 'primary') {
|
||||
addMessage(OBSERVABILITY, id);
|
||||
}
|
||||
},
|
||||
[setCalloutVisibility, addMessage]
|
||||
);
|
||||
|
||||
const groupedByTypeErrorMessages = useMemo(
|
||||
() =>
|
||||
messages.reduce<GroupByTypeMessages>(
|
||||
(acc: GroupByTypeMessages, currentMessage: ErrorMessage) => {
|
||||
const type = currentMessage.errorType == null ? 'primary' : currentMessage.errorType;
|
||||
return {
|
||||
...acc,
|
||||
[type]: {
|
||||
messagesId: [...(acc[type]?.messagesId ?? []), currentMessage.id],
|
||||
messages: [...(acc[type]?.messages ?? []), currentMessage],
|
||||
},
|
||||
};
|
||||
},
|
||||
{} as GroupByTypeMessages
|
||||
),
|
||||
[messages]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(groupedByTypeErrorMessages) as Array<keyof ErrorMessage['errorType']>).map(
|
||||
(type: NonNullable<ErrorMessage['errorType']>) => {
|
||||
const id = createCalloutId(groupedByTypeErrorMessages[type].messagesId);
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
<CallOut
|
||||
id={id}
|
||||
type={type}
|
||||
title={title}
|
||||
messages={groupedByTypeErrorMessages[type].messages}
|
||||
showCallOut={calloutVisibility[id] ?? true}
|
||||
handleDismissCallout={handleCallOut}
|
||||
/>
|
||||
<EuiSpacer />
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const CaseCallOut = memo(CaseCallOutComponent);
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const READ_ONLY_FEATURE_TITLE = i18n.translate(
|
||||
'xpack.observability.cases.readOnlyFeatureTitle',
|
||||
{
|
||||
defaultMessage: 'You cannot open new or update existing cases',
|
||||
}
|
||||
);
|
||||
|
||||
export const READ_ONLY_FEATURE_MSG = i18n.translate(
|
||||
'xpack.observability.cases.readOnlyFeatureDescription',
|
||||
{
|
||||
defaultMessage:
|
||||
'You only have privileges to view cases. If you need to open and update cases, contact your Kibana administrator.',
|
||||
}
|
||||
);
|
||||
|
||||
export const DISMISS_CALLOUT = i18n.translate(
|
||||
'xpack.observability.cases.dismissErrorsPushServiceCallOutTitle',
|
||||
{
|
||||
defaultMessage: 'Dismiss',
|
||||
}
|
||||
);
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 interface ErrorMessage {
|
||||
id: string;
|
||||
title: string;
|
||||
description: JSX.Element;
|
||||
errorType?: 'primary' | 'success' | 'warning' | 'danger';
|
||||
}
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { Ecs } from '../../../../../../cases/common';
|
||||
|
||||
// no alerts in observability so far
|
||||
// dummy hook for now as hooks cannot be called conditionally
|
||||
export const useFetchAlertData = (): [boolean, Record<string, Ecs>] => [false, {}];
|
|
@ -0,0 +1,122 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
getCaseDetailsUrl,
|
||||
getCaseDetailsUrlWithCommentId,
|
||||
getCaseUrl,
|
||||
getConfigureCasesUrl,
|
||||
useFormatUrl,
|
||||
} from '../../../../pages/cases/links';
|
||||
import { Case } from '../../../../../../cases/common';
|
||||
import { useFetchAlertData } from './helpers';
|
||||
import { useKibana } from '../../../../utils/kibana_react';
|
||||
import { CASES_APP_ID } from '../constants';
|
||||
import { casesBreadcrumbs, useBreadcrumbs } from '../../../../hooks/use_breadcrumbs';
|
||||
|
||||
interface Props {
|
||||
caseId: string;
|
||||
subCaseId?: string;
|
||||
userCanCrud: boolean;
|
||||
}
|
||||
|
||||
export interface OnUpdateFields {
|
||||
key: keyof Case;
|
||||
value: Case[keyof Case];
|
||||
onSuccess?: () => void;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
export interface CaseProps extends Props {
|
||||
fetchCase: () => void;
|
||||
caseData: Case;
|
||||
updateCase: (newCase: Case) => void;
|
||||
}
|
||||
|
||||
export const CaseView = React.memo(({ caseId, subCaseId, userCanCrud }: Props) => {
|
||||
const [caseTitle, setCaseTitle] = useState<string | null>(null);
|
||||
|
||||
const { cases: casesUi, application } = useKibana().services;
|
||||
const { navigateToApp } = application;
|
||||
const allCasesLink = getCaseUrl();
|
||||
const { formatUrl } = useFormatUrl(CASES_APP_ID);
|
||||
const href = formatUrl(allCasesLink);
|
||||
useBreadcrumbs([
|
||||
{ ...casesBreadcrumbs.cases, href },
|
||||
...(caseTitle !== null
|
||||
? [
|
||||
{
|
||||
text: caseTitle,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
|
||||
const onCaseDataSuccess = useCallback(
|
||||
(data: Case) => {
|
||||
if (caseTitle === null) {
|
||||
setCaseTitle(data.title);
|
||||
}
|
||||
},
|
||||
[caseTitle]
|
||||
);
|
||||
|
||||
const configureCasesLink = getConfigureCasesUrl();
|
||||
const allCasesHref = href;
|
||||
const configureCasesHref = formatUrl(configureCasesLink);
|
||||
const caseDetailsHref = formatUrl(getCaseDetailsUrl({ id: caseId }), { absolute: true });
|
||||
const getCaseDetailHrefWithCommentId = useCallback(
|
||||
(commentId: string) =>
|
||||
formatUrl(getCaseDetailsUrlWithCommentId({ id: caseId, commentId, subCaseId }), {
|
||||
absolute: true,
|
||||
}),
|
||||
[caseId, formatUrl, subCaseId]
|
||||
);
|
||||
|
||||
return casesUi.getCaseView({
|
||||
allCasesNavigation: {
|
||||
href: allCasesHref,
|
||||
onClick: async (ev) => {
|
||||
if (ev != null) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
return navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: allCasesLink,
|
||||
});
|
||||
},
|
||||
},
|
||||
caseDetailsNavigation: {
|
||||
href: caseDetailsHref,
|
||||
onClick: async (ev) => {
|
||||
if (ev != null) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
return navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: getCaseDetailsUrl({ id: caseId }),
|
||||
});
|
||||
},
|
||||
},
|
||||
caseId,
|
||||
configureCasesNavigation: {
|
||||
href: configureCasesHref,
|
||||
onClick: async (ev) => {
|
||||
if (ev != null) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
return navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: configureCasesLink,
|
||||
});
|
||||
},
|
||||
},
|
||||
getCaseDetailHrefWithCommentId,
|
||||
onCaseDataSuccess,
|
||||
subCaseId,
|
||||
useFetchAlertData,
|
||||
userCanCrud,
|
||||
});
|
||||
});
|
|
@ -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.
|
||||
*/
|
||||
|
||||
import { CASES_APP_ID } from '../../../../common/const';
|
||||
|
||||
export { CASES_APP_ID };
|
||||
export const CASES_OWNER = 'observability';
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { mount } from 'enzyme';
|
||||
import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common';
|
||||
|
||||
import { CreateCaseFlyout } from './flyout';
|
||||
|
||||
jest.mock('../../../../utils/kibana_react', () => ({
|
||||
useKibana: () => ({
|
||||
services: {
|
||||
cases: {
|
||||
getCreateCase: jest.fn(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
const onCloseFlyout = jest.fn();
|
||||
const onSuccess = jest.fn();
|
||||
const defaultProps = {
|
||||
onCloseFlyout,
|
||||
onSuccess,
|
||||
};
|
||||
|
||||
describe('CreateCaseFlyout', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CreateCaseFlyout {...defaultProps} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
expect(wrapper.find(`[data-test-subj='create-case-flyout']`).exists()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Closing modal calls onCloseCaseModal', () => {
|
||||
const wrapper = mount(
|
||||
<EuiThemeProvider>
|
||||
<CreateCaseFlyout {...defaultProps} />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
wrapper.find('.euiFlyout__closeButton').first().simulate('click');
|
||||
expect(onCloseFlyout).toBeCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { EuiFlyout, EuiFlyoutHeader, EuiTitle, EuiFlyoutBody } from '@elastic/eui';
|
||||
|
||||
import * as i18n from '../translations';
|
||||
import { Case } from '../../../../../../cases/common';
|
||||
import { CASES_OWNER } from '../constants';
|
||||
import { useKibana } from '../../../../utils/kibana_react';
|
||||
|
||||
export interface CreateCaseModalProps {
|
||||
afterCaseCreated?: (theCase: Case) => Promise<void>;
|
||||
onCloseFlyout: () => void;
|
||||
onSuccess: (theCase: Case) => Promise<void>;
|
||||
}
|
||||
|
||||
const StyledFlyout = styled(EuiFlyout)`
|
||||
${({ theme }) => `
|
||||
z-index: ${theme.eui.euiZModal};
|
||||
`}
|
||||
`;
|
||||
// Adding bottom padding because timeline's
|
||||
// bottom bar gonna hide the submit button.
|
||||
// might not need for obs, test this when implementing this component
|
||||
const StyledEuiFlyoutBody = styled(EuiFlyoutBody)`
|
||||
${({ theme }) => `
|
||||
&& .euiFlyoutBody__overflow {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
&& .euiFlyoutBody__overflowContent {
|
||||
display: block;
|
||||
padding: ${theme.eui.paddingSizes.l} ${theme.eui.paddingSizes.l} 70px;
|
||||
height: auto;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const FormWrapper = styled.div`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
function CreateCaseFlyoutComponent({
|
||||
afterCaseCreated,
|
||||
onCloseFlyout,
|
||||
onSuccess,
|
||||
}: CreateCaseModalProps) {
|
||||
const { cases } = useKibana().services;
|
||||
return (
|
||||
<StyledFlyout onClose={onCloseFlyout} data-test-subj="create-case-flyout">
|
||||
<EuiFlyoutHeader hasBorder>
|
||||
<EuiTitle size="m">
|
||||
<h2>{i18n.CREATE_TITLE}</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlyoutHeader>
|
||||
<StyledEuiFlyoutBody>
|
||||
<FormWrapper>
|
||||
{cases.getCreateCase({
|
||||
afterCaseCreated,
|
||||
onCancel: onCloseFlyout,
|
||||
onSuccess,
|
||||
withSteps: false,
|
||||
owner: [CASES_OWNER],
|
||||
})}
|
||||
</FormWrapper>
|
||||
</StyledEuiFlyoutBody>
|
||||
</StyledFlyout>
|
||||
);
|
||||
}
|
||||
// not yet used
|
||||
// committing for use with alerting #RAC
|
||||
export const CreateCaseFlyout = memo(CreateCaseFlyoutComponent);
|
||||
|
||||
CreateCaseFlyout.displayName = 'CreateCaseFlyout';
|
|
@ -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 React from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { EuiThemeProvider } from '../../../../../../../../src/plugins/kibana_react/common';
|
||||
import { Create } from '.';
|
||||
import { useKibana } from '../../../../utils/kibana_react';
|
||||
import { basicCase } from '../../../../../../cases/public/containers/mock';
|
||||
import { CASES_APP_ID, CASES_OWNER } from '../constants';
|
||||
import { Case } from '../../../../../../cases/common';
|
||||
import { getCaseDetailsUrl } from '../../../../pages/cases/links';
|
||||
|
||||
jest.mock('../../../../utils/kibana_react');
|
||||
|
||||
describe('Create case', () => {
|
||||
const mockCreateCase = jest.fn();
|
||||
const mockNavigateToApp = jest.fn();
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
cases: {
|
||||
getCreateCase: mockCreateCase,
|
||||
},
|
||||
application: { navigateToApp: mockNavigateToApp },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('it renders', () => {
|
||||
mount(
|
||||
<EuiThemeProvider>
|
||||
<Create />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
expect(mockCreateCase).toHaveBeenCalled();
|
||||
expect(mockCreateCase.mock.calls[0][0].owner).toEqual([CASES_OWNER]);
|
||||
});
|
||||
|
||||
it('should redirect to all cases on cancel click', async () => {
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
cases: {
|
||||
getCreateCase: ({ onCancel }: { onCancel: () => Promise<void> }) => {
|
||||
onCancel();
|
||||
},
|
||||
},
|
||||
application: { navigateToApp: mockNavigateToApp },
|
||||
},
|
||||
});
|
||||
mount(
|
||||
<EuiThemeProvider>
|
||||
<Create />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => expect(mockNavigateToApp).toHaveBeenCalledWith(`${CASES_APP_ID}`));
|
||||
});
|
||||
|
||||
it('should redirect to new case when posting the case', async () => {
|
||||
(useKibana as jest.Mock).mockReturnValue({
|
||||
services: {
|
||||
cases: {
|
||||
getCreateCase: ({ onSuccess }: { onSuccess: (theCase: Case) => Promise<void> }) => {
|
||||
onSuccess(basicCase);
|
||||
},
|
||||
},
|
||||
application: { navigateToApp: mockNavigateToApp },
|
||||
},
|
||||
});
|
||||
mount(
|
||||
<EuiThemeProvider>
|
||||
<Create />
|
||||
</EuiThemeProvider>
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockNavigateToApp).toHaveBeenNthCalledWith(1, `${CASES_APP_ID}`, {
|
||||
path: getCaseDetailsUrl({ id: basicCase.id }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import { EuiPanel } from '@elastic/eui';
|
||||
|
||||
import { useKibana } from '../../../../utils/kibana_react';
|
||||
import { getCaseDetailsUrl } from '../../../../pages/cases/links';
|
||||
import { CASES_APP_ID, CASES_OWNER } from '../constants';
|
||||
|
||||
export const Create = React.memo(() => {
|
||||
const {
|
||||
cases,
|
||||
application: { navigateToApp },
|
||||
} = useKibana().services;
|
||||
const onSuccess = useCallback(
|
||||
async ({ id }) =>
|
||||
navigateToApp(`${CASES_APP_ID}`, {
|
||||
path: getCaseDetailsUrl({ id }),
|
||||
}),
|
||||
[navigateToApp]
|
||||
);
|
||||
|
||||
const handleSetIsCancel = useCallback(() => navigateToApp(`${CASES_APP_ID}`), [navigateToApp]);
|
||||
|
||||
return (
|
||||
<EuiPanel>
|
||||
{cases.getCreateCase({
|
||||
disableAlerts: true,
|
||||
onCancel: handleSetIsCancel,
|
||||
onSuccess,
|
||||
owner: [CASES_OWNER],
|
||||
})}
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
|
||||
Create.displayName = 'Create';
|
|
@ -0,0 +1,203 @@
|
|||
/*
|
||||
* 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 CASES_FEATURE_NO_PERMISSIONS_TITLE = i18n.translate(
|
||||
'xpack.observability.cases.caseFeatureNoPermissionsTitle',
|
||||
{
|
||||
defaultMessage: 'Kibana feature privileges required',
|
||||
}
|
||||
);
|
||||
|
||||
export const CASES_FEATURE_NO_PERMISSIONS_MSG = i18n.translate(
|
||||
'xpack.observability.cases.caseFeatureNoPermissionsMessage',
|
||||
{
|
||||
defaultMessage:
|
||||
'To view cases, you must have privileges for the Cases feature in the Kibana space. For more information, contact your Kibana administrator.',
|
||||
}
|
||||
);
|
||||
export const BACK_TO_ALL = i18n.translate('xpack.observability.cases.caseView.backLabel', {
|
||||
defaultMessage: 'Back to cases',
|
||||
});
|
||||
|
||||
export const CANCEL = i18n.translate('xpack.observability.cases.caseView.cancel', {
|
||||
defaultMessage: 'Cancel',
|
||||
});
|
||||
|
||||
export const DELETE_CASE = i18n.translate(
|
||||
'xpack.observability.cases.confirmDeleteCase.deleteCase',
|
||||
{
|
||||
defaultMessage: 'Delete case',
|
||||
}
|
||||
);
|
||||
|
||||
export const DELETE_CASES = i18n.translate(
|
||||
'xpack.observability.cases.confirmDeleteCase.deleteCases',
|
||||
{
|
||||
defaultMessage: 'Delete cases',
|
||||
}
|
||||
);
|
||||
|
||||
export const NAME = i18n.translate('xpack.observability.cases.caseView.name', {
|
||||
defaultMessage: 'Name',
|
||||
});
|
||||
|
||||
export const REPORTER = i18n.translate('xpack.observability.cases.caseView.reporterLabel', {
|
||||
defaultMessage: 'Reporter',
|
||||
});
|
||||
|
||||
export const PARTICIPANTS = i18n.translate('xpack.observability.cases.caseView.particpantsLabel', {
|
||||
defaultMessage: 'Participants',
|
||||
});
|
||||
|
||||
export const CREATE_TITLE = i18n.translate('xpack.observability.cases.caseView.create', {
|
||||
defaultMessage: 'Create new case',
|
||||
});
|
||||
|
||||
export const DESCRIPTION = i18n.translate('xpack.observability.cases.caseView.description', {
|
||||
defaultMessage: 'Description',
|
||||
});
|
||||
|
||||
export const DESCRIPTION_REQUIRED = i18n.translate(
|
||||
'xpack.observability.cases.createCase.descriptionFieldRequiredError',
|
||||
{
|
||||
defaultMessage: 'A description is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const COMMENT_REQUIRED = i18n.translate(
|
||||
'xpack.observability.cases.caseView.commentFieldRequiredError',
|
||||
{
|
||||
defaultMessage: 'A comment is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const REQUIRED_FIELD = i18n.translate(
|
||||
'xpack.observability.cases.caseView.fieldRequiredError',
|
||||
{
|
||||
defaultMessage: 'Required field',
|
||||
}
|
||||
);
|
||||
|
||||
export const EDIT = i18n.translate('xpack.observability.cases.caseView.edit', {
|
||||
defaultMessage: 'Edit',
|
||||
});
|
||||
|
||||
export const OPTIONAL = i18n.translate('xpack.observability.cases.caseView.optional', {
|
||||
defaultMessage: 'Optional',
|
||||
});
|
||||
|
||||
export const PAGE_TITLE = i18n.translate('xpack.observability.cases.pageTitle', {
|
||||
defaultMessage: 'Cases',
|
||||
});
|
||||
|
||||
export const CREATE_CASE = i18n.translate('xpack.observability.cases.caseView.createCase', {
|
||||
defaultMessage: 'Create case',
|
||||
});
|
||||
|
||||
export const CLOSE_CASE = i18n.translate('xpack.observability.cases.caseView.closeCase', {
|
||||
defaultMessage: 'Close case',
|
||||
});
|
||||
|
||||
export const REOPEN_CASE = i18n.translate('xpack.observability.cases.caseView.reopenCase', {
|
||||
defaultMessage: 'Reopen case',
|
||||
});
|
||||
|
||||
export const CASE_NAME = i18n.translate('xpack.observability.cases.caseView.caseName', {
|
||||
defaultMessage: 'Case name',
|
||||
});
|
||||
|
||||
export const TO = i18n.translate('xpack.observability.cases.caseView.to', {
|
||||
defaultMessage: 'to',
|
||||
});
|
||||
|
||||
export const TAGS = i18n.translate('xpack.observability.cases.caseView.tags', {
|
||||
defaultMessage: 'Tags',
|
||||
});
|
||||
|
||||
export const ACTIONS = i18n.translate('xpack.observability.cases.allCases.actions', {
|
||||
defaultMessage: 'Actions',
|
||||
});
|
||||
|
||||
export const NO_TAGS_AVAILABLE = i18n.translate(
|
||||
'xpack.observability.cases.allCases.noTagsAvailable',
|
||||
{
|
||||
defaultMessage: 'No tags available',
|
||||
}
|
||||
);
|
||||
|
||||
export const NO_REPORTERS_AVAILABLE = i18n.translate(
|
||||
'xpack.observability.cases.caseView.noReportersAvailable',
|
||||
{
|
||||
defaultMessage: 'No reporters available.',
|
||||
}
|
||||
);
|
||||
|
||||
export const COMMENTS = i18n.translate('xpack.observability.cases.allCases.comments', {
|
||||
defaultMessage: 'Comments',
|
||||
});
|
||||
|
||||
export const TAGS_HELP = i18n.translate('xpack.observability.cases.createCase.fieldTagsHelpText', {
|
||||
defaultMessage:
|
||||
'Type one or more custom identifying tags for this case. Press enter after each tag to begin a new one.',
|
||||
});
|
||||
|
||||
export const NO_TAGS = i18n.translate('xpack.observability.cases.caseView.noTags', {
|
||||
defaultMessage: 'No tags are currently assigned to this case.',
|
||||
});
|
||||
|
||||
export const TITLE_REQUIRED = i18n.translate(
|
||||
'xpack.observability.cases.createCase.titleFieldRequiredError',
|
||||
{
|
||||
defaultMessage: 'A title is required.',
|
||||
}
|
||||
);
|
||||
|
||||
export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate(
|
||||
'xpack.observability.cases.configureCases.headerTitle',
|
||||
{
|
||||
defaultMessage: 'Configure cases',
|
||||
}
|
||||
);
|
||||
|
||||
export const CONFIGURE_CASES_BUTTON = i18n.translate(
|
||||
'xpack.observability.cases.configureCasesButton',
|
||||
{
|
||||
defaultMessage: 'Edit external connection',
|
||||
}
|
||||
);
|
||||
|
||||
export const ADD_COMMENT = i18n.translate('xpack.observability.cases.caseView.comment.addComment', {
|
||||
defaultMessage: 'Add comment',
|
||||
});
|
||||
|
||||
export const ADD_COMMENT_HELP_TEXT = i18n.translate(
|
||||
'xpack.observability.cases.caseView.comment.addCommentHelpText',
|
||||
{
|
||||
defaultMessage: 'Add a new comment...',
|
||||
}
|
||||
);
|
||||
|
||||
export const SAVE = i18n.translate('xpack.observability.cases.caseView.description.save', {
|
||||
defaultMessage: 'Save',
|
||||
});
|
||||
|
||||
export const GO_TO_DOCUMENTATION = i18n.translate(
|
||||
'xpack.observability.cases.caseView.goToDocumentationButton',
|
||||
{
|
||||
defaultMessage: 'View documentation',
|
||||
}
|
||||
);
|
||||
|
||||
export const CONNECTORS = i18n.translate('xpack.observability.cases.caseView.connectors', {
|
||||
defaultMessage: 'External Incident Management System',
|
||||
});
|
||||
|
||||
export const EDIT_CONNECTOR = i18n.translate('xpack.observability.cases.caseView.editConnector', {
|
||||
defaultMessage: 'Change external incident management system',
|
||||
});
|
|
@ -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 styled from 'styled-components';
|
||||
|
||||
export const WhitePageWrapper = styled.div`
|
||||
background-color: ${({ theme }) => theme.eui.euiColorEmptyShade};
|
||||
border-top: ${({ theme }) => theme.eui.euiBorderThin};
|
||||
flex: 1 1 auto;
|
||||
`;
|
||||
|
||||
export const SectionWrapper = styled.div`
|
||||
box-sizing: content-box;
|
||||
margin: 0 auto;
|
||||
max-width: 1175px;
|
||||
width: 100%;
|
||||
`;
|
|
@ -9,8 +9,8 @@ import { ChromeBreadcrumb } from 'kibana/public';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { MouseEvent, useEffect } from 'react';
|
||||
import { EuiBreadcrumb } from '@elastic/eui';
|
||||
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { useQueryParams } from './use_query_params';
|
||||
import { useKibana } from '../utils/kibana_react';
|
||||
|
||||
function handleBreadcrumbClick(
|
||||
breadcrumbs: ChromeBreadcrumb[],
|
||||
|
@ -39,17 +39,35 @@ export const makeBaseBreadcrumb = (href: string): EuiBreadcrumb => {
|
|||
href,
|
||||
};
|
||||
};
|
||||
|
||||
export const casesBreadcrumbs = {
|
||||
cases: {
|
||||
text: i18n.translate('xpack.observability.breadcrumbs.observability.cases', {
|
||||
defaultMessage: 'Cases',
|
||||
}),
|
||||
},
|
||||
create: {
|
||||
text: i18n.translate('xpack.observability.breadcrumbs.observability.cases.create', {
|
||||
defaultMessage: 'Create',
|
||||
}),
|
||||
},
|
||||
configure: {
|
||||
text: i18n.translate('xpack.observability.breadcrumbs.observability.cases.configure', {
|
||||
defaultMessage: 'Configure',
|
||||
}),
|
||||
},
|
||||
};
|
||||
export const useBreadcrumbs = (extraCrumbs: ChromeBreadcrumb[]) => {
|
||||
const params = useQueryParams();
|
||||
|
||||
const {
|
||||
services: { chrome, application },
|
||||
services: {
|
||||
chrome: { setBreadcrumbs },
|
||||
application: { getUrlForApp, navigateToUrl },
|
||||
},
|
||||
} = useKibana();
|
||||
|
||||
const setBreadcrumbs = chrome?.setBreadcrumbs;
|
||||
const appPath = application?.getUrlForApp('observability-overview') ?? '';
|
||||
const navigate = application?.navigateToUrl;
|
||||
const appPath = getUrlForApp('observability-overview') ?? '';
|
||||
const navigate = navigateToUrl;
|
||||
|
||||
useEffect(() => {
|
||||
if (setBreadcrumbs) {
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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 { useEffect, useState } from 'react';
|
||||
import { useKibana } from '../utils/kibana_react';
|
||||
import { CASES_APP_ID } from '../../common/const';
|
||||
|
||||
export interface UseGetUserCasesPermissions {
|
||||
crud: boolean;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export function useGetUserCasesPermissions() {
|
||||
const [casesPermissions, setCasesPermissions] = useState<UseGetUserCasesPermissions | null>(null);
|
||||
const uiCapabilities = useKibana().services.application.capabilities;
|
||||
|
||||
useEffect(() => {
|
||||
const capabilitiesCanUserCRUD: boolean =
|
||||
typeof uiCapabilities[CASES_APP_ID].crud_cases === 'boolean'
|
||||
? (uiCapabilities[CASES_APP_ID].crud_cases as boolean)
|
||||
: false;
|
||||
const capabilitiesCanUserRead: boolean =
|
||||
typeof uiCapabilities[CASES_APP_ID].read_cases === 'boolean'
|
||||
? (uiCapabilities[CASES_APP_ID].read_cases as boolean)
|
||||
: false;
|
||||
setCasesPermissions({
|
||||
crud: capabilitiesCanUserCRUD,
|
||||
read: capabilitiesCanUserRead,
|
||||
});
|
||||
}, [uiCapabilities]);
|
||||
|
||||
return casesPermissions;
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useKibana } from '../utils/kibana_react';
|
||||
|
||||
export interface UseMessagesStorage {
|
||||
getMessages: (plugin: string) => string[];
|
||||
addMessage: (plugin: string, id: string) => void;
|
||||
removeMessage: (plugin: string, id: string) => void;
|
||||
clearAllMessages: (plugin: string) => void;
|
||||
hasMessage: (plugin: string, id: string) => boolean;
|
||||
}
|
||||
|
||||
export const useMessagesStorage = (): UseMessagesStorage => {
|
||||
const { storage } = useKibana().services;
|
||||
|
||||
const getMessages = useCallback(
|
||||
(plugin: string): string[] => storage.get(`${plugin}-messages`) ?? [],
|
||||
[storage]
|
||||
);
|
||||
|
||||
const addMessage = useCallback(
|
||||
(plugin: string, id: string) => {
|
||||
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
|
||||
storage.set(`${plugin}-messages`, [...pluginStorage, id]);
|
||||
},
|
||||
[storage]
|
||||
);
|
||||
|
||||
const hasMessage = useCallback(
|
||||
(plugin: string, id: string): boolean => {
|
||||
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
|
||||
return pluginStorage.includes((val: string) => val === id);
|
||||
},
|
||||
[storage]
|
||||
);
|
||||
|
||||
const removeMessage = useCallback(
|
||||
(plugin: string, id: string) => {
|
||||
const pluginStorage = storage.get(`${plugin}-messages`) ?? [];
|
||||
storage.set(
|
||||
`${plugin}-messages`,
|
||||
pluginStorage.filter((val: string) => val !== id)
|
||||
);
|
||||
},
|
||||
[storage]
|
||||
);
|
||||
|
||||
const clearAllMessages = useCallback(
|
||||
(plugin: string): string[] => storage.remove(`${plugin}-messages`),
|
||||
[storage]
|
||||
);
|
||||
|
||||
return {
|
||||
getMessages,
|
||||
addMessage,
|
||||
clearAllMessages,
|
||||
removeMessage,
|
||||
hasMessage,
|
||||
};
|
||||
};
|
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { AllCases } from '../../components/app/cases/all_cases';
|
||||
import * as i18n from '../../components/app/cases/translations';
|
||||
|
||||
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../../components/app/cases/callout';
|
||||
import { CaseFeatureNoPermissions } from './feature_no_permissions';
|
||||
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
|
||||
export const AllCasesPage = React.memo(() => {
|
||||
const userPermissions = useGetUserCasesPermissions();
|
||||
const { ObservabilityPageTemplate } = usePluginContext();
|
||||
return userPermissions == null || userPermissions?.read ? (
|
||||
<>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: <>{i18n.PAGE_TITLE}</>,
|
||||
}}
|
||||
>
|
||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||
</ObservabilityPageTemplate>
|
||||
</>
|
||||
) : (
|
||||
<CaseFeatureNoPermissions />
|
||||
);
|
||||
});
|
||||
|
||||
AllCasesPage.displayName = 'AllCasesPage';
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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 { useParams } from 'react-router-dom';
|
||||
|
||||
import { CaseView } from '../../components/app/cases/case_view';
|
||||
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { CASES_APP_ID } from '../../components/app/cases/constants';
|
||||
import { CaseCallOut, permissionsReadOnlyErrorMessage } from '../../components/app/cases/callout';
|
||||
|
||||
export const CaseDetailsPage = React.memo(() => {
|
||||
const {
|
||||
application: { navigateToApp },
|
||||
} = useKibana().services;
|
||||
const userPermissions = useGetUserCasesPermissions();
|
||||
const { detailName: caseId, subCaseId } = useParams<{
|
||||
detailName?: string;
|
||||
subCaseId?: string;
|
||||
}>();
|
||||
|
||||
if (userPermissions != null && !userPermissions.read) {
|
||||
navigateToApp(`${CASES_APP_ID}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return caseId != null ? (
|
||||
<>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<CaseView
|
||||
caseId={caseId}
|
||||
subCaseId={subCaseId}
|
||||
userCanCrud={userPermissions?.crud ?? false}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
});
|
||||
|
||||
CaseDetailsPage.displayName = 'CaseDetailsPage';
|
|
@ -6,12 +6,11 @@
|
|||
*/
|
||||
|
||||
import React, { ComponentType } from 'react';
|
||||
import { CasesPage } from '.';
|
||||
import { RouteParams } from '../../routes';
|
||||
import { AllCasesPage } from './all_cases';
|
||||
|
||||
export default {
|
||||
title: 'app/Cases',
|
||||
component: CasesPage,
|
||||
component: AllCasesPage,
|
||||
decorators: [
|
||||
(Story: ComponentType) => {
|
||||
return <Story />;
|
||||
|
@ -20,5 +19,5 @@ export default {
|
|||
};
|
||||
|
||||
export function EmptyState() {
|
||||
return <CasesPage routeParams={{} as RouteParams<'/cases'>} />;
|
||||
return <AllCasesPage />;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* 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, { useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import * as i18n from '../../components/app/cases/translations';
|
||||
import { CASES_APP_ID, CASES_OWNER } from '../../components/app/cases/constants';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
import { casesBreadcrumbs, useBreadcrumbs } from '../../hooks/use_breadcrumbs';
|
||||
import { getCaseUrl, useFormatUrl } from './links';
|
||||
|
||||
const ButtonEmpty = styled(EuiButtonEmpty)`
|
||||
display: block;
|
||||
`;
|
||||
function ConfigureCasesPageComponent() {
|
||||
const {
|
||||
cases,
|
||||
application: { navigateToApp },
|
||||
} = useKibana().services;
|
||||
const userPermissions = useGetUserCasesPermissions();
|
||||
const { ObservabilityPageTemplate } = usePluginContext();
|
||||
const onClickGoToCases = useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
return navigateToApp(`${CASES_APP_ID}`);
|
||||
},
|
||||
[navigateToApp]
|
||||
);
|
||||
const { formatUrl } = useFormatUrl(CASES_APP_ID);
|
||||
const href = formatUrl(getCaseUrl());
|
||||
useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.configure]);
|
||||
if (userPermissions != null && !userPermissions.read) {
|
||||
navigateToApp(`${CASES_APP_ID}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: (
|
||||
<>
|
||||
<ButtonEmpty
|
||||
onClick={onClickGoToCases}
|
||||
iconType="arrowLeft"
|
||||
iconSide="left"
|
||||
flush="left"
|
||||
>
|
||||
{i18n.BACK_TO_ALL}
|
||||
</ButtonEmpty>
|
||||
{i18n.CONFIGURE_CASES_PAGE_TITLE}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{cases.getConfigureCases({
|
||||
userCanCrud: userPermissions?.crud ?? false,
|
||||
owner: [CASES_OWNER],
|
||||
})}
|
||||
</ObservabilityPageTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
export const ConfigureCasesPage = React.memo(ConfigureCasesPageComponent);
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* 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, { useCallback } from 'react';
|
||||
import { EuiButtonEmpty } from '@elastic/eui';
|
||||
import styled from 'styled-components';
|
||||
import * as i18n from '../../components/app/cases/translations';
|
||||
import { Create } from '../../components/app/cases/create';
|
||||
import { CASES_APP_ID } from '../../components/app/cases/constants';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
import { useGetUserCasesPermissions } from '../../hooks/use_get_user_cases_permissions';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
import { getCaseUrl, useFormatUrl } from './links';
|
||||
import { casesBreadcrumbs, useBreadcrumbs } from '../../hooks/use_breadcrumbs';
|
||||
|
||||
const ButtonEmpty = styled(EuiButtonEmpty)`
|
||||
display: block;
|
||||
`;
|
||||
ButtonEmpty.displayName = 'ButtonEmpty';
|
||||
export const CreateCasePage = React.memo(() => {
|
||||
const userPermissions = useGetUserCasesPermissions();
|
||||
const { ObservabilityPageTemplate } = usePluginContext();
|
||||
const {
|
||||
application: { navigateToApp },
|
||||
} = useKibana().services;
|
||||
|
||||
const goTo = useCallback(
|
||||
async (ev) => {
|
||||
ev.preventDefault();
|
||||
return navigateToApp(CASES_APP_ID);
|
||||
},
|
||||
[navigateToApp]
|
||||
);
|
||||
|
||||
const { formatUrl } = useFormatUrl(CASES_APP_ID);
|
||||
const href = formatUrl(getCaseUrl());
|
||||
useBreadcrumbs([{ ...casesBreadcrumbs.cases, href }, casesBreadcrumbs.create]);
|
||||
if (userPermissions != null && !userPermissions.crud) {
|
||||
navigateToApp(`${CASES_APP_ID}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: (
|
||||
<>
|
||||
<ButtonEmpty onClick={goTo} iconType="arrowLeft" iconSide="left" flush="left">
|
||||
{i18n.BACK_TO_ALL}
|
||||
</ButtonEmpty>
|
||||
{i18n.CREATE_TITLE}
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Create />
|
||||
</ObservabilityPageTemplate>
|
||||
);
|
||||
});
|
||||
|
||||
CreateCasePage.displayName = 'CreateCasePage';
|
118
x-pack/plugins/observability/public/pages/cases/empty_page.tsx
Normal file
118
x-pack/plugins/observability/public/pages/cases/empty_page.tsx
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
* 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 {
|
||||
EuiButton,
|
||||
EuiEmptyPrompt,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
IconType,
|
||||
EuiCard,
|
||||
} from '@elastic/eui';
|
||||
import React, { MouseEventHandler, ReactNode, useMemo } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const EmptyPrompt = styled(EuiEmptyPrompt)`
|
||||
align-self: center; /* Corrects horizontal centering in IE11 */
|
||||
max-width: 60em;
|
||||
`;
|
||||
|
||||
EmptyPrompt.displayName = 'EmptyPrompt';
|
||||
|
||||
interface EmptyPageActions {
|
||||
icon?: IconType;
|
||||
label: string;
|
||||
target?: string;
|
||||
url: string;
|
||||
descriptionTitle?: string;
|
||||
description?: string;
|
||||
fill?: boolean;
|
||||
onClick?: MouseEventHandler<HTMLButtonElement | HTMLAnchorElement>;
|
||||
}
|
||||
|
||||
export type EmptyPageActionsProps = Record<string, EmptyPageActions>;
|
||||
|
||||
interface EmptyPageProps {
|
||||
actions: EmptyPageActionsProps;
|
||||
'data-test-subj'?: string;
|
||||
message?: ReactNode;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const EmptyPageComponent = React.memo<EmptyPageProps>(({ actions, message, title, ...rest }) => {
|
||||
const titles = Object.keys(actions);
|
||||
const maxItemWidth = 283;
|
||||
const renderActions = useMemo(
|
||||
() =>
|
||||
Object.values(actions)
|
||||
.filter((a) => a.label && a.url)
|
||||
.map(
|
||||
(
|
||||
{ icon, label, target, url, descriptionTitle, description, onClick, fill = true },
|
||||
idx
|
||||
) =>
|
||||
descriptionTitle != null || description != null ? (
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
style={{ maxWidth: maxItemWidth }}
|
||||
key={`empty-page-${titles[idx]}-action`}
|
||||
>
|
||||
<EuiCard
|
||||
title={descriptionTitle ?? false}
|
||||
description={description ?? false}
|
||||
footer={
|
||||
/* eslint-disable-next-line @elastic/eui/href-or-on-click */
|
||||
<EuiButton
|
||||
href={url}
|
||||
onClick={onClick}
|
||||
iconType={icon}
|
||||
target={target}
|
||||
fill={fill}
|
||||
data-test-subj={`empty-page-${titles[idx]}-action`}
|
||||
>
|
||||
{label}
|
||||
</EuiButton>
|
||||
}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
) : (
|
||||
<EuiFlexItem
|
||||
grow={false}
|
||||
style={{ maxWidth: maxItemWidth }}
|
||||
key={`empty-page-${titles[idx]}-action`}
|
||||
>
|
||||
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
|
||||
<EuiButton
|
||||
href={url}
|
||||
onClick={onClick}
|
||||
iconType={icon}
|
||||
target={target}
|
||||
data-test-subj={`empty-page-${titles[idx]}-action`}
|
||||
>
|
||||
{label}
|
||||
</EuiButton>
|
||||
</EuiFlexItem>
|
||||
)
|
||||
),
|
||||
[actions, titles]
|
||||
);
|
||||
|
||||
return (
|
||||
<EmptyPrompt
|
||||
iconType="logoObservability"
|
||||
title={<h2>{title}</h2>}
|
||||
body={message && <p>{message}</p>}
|
||||
actions={<EuiFlexGroup justifyContent="center">{renderActions}</EuiFlexGroup>}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
EmptyPageComponent.displayName = 'EmptyPageComponent';
|
||||
|
||||
export const EmptyPage = React.memo(EmptyPageComponent);
|
||||
EmptyPage.displayName = 'EmptyPage';
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { EmptyPage } from './empty_page';
|
||||
import * as i18n from '../../components/app/cases/translations';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
|
||||
export const CaseFeatureNoPermissions = React.memo(() => {
|
||||
const docLinks = useKibana().services.docLinks;
|
||||
const actions = useMemo(
|
||||
() => ({
|
||||
savedObject: {
|
||||
icon: 'documents',
|
||||
label: i18n.GO_TO_DOCUMENTATION,
|
||||
url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}s`,
|
||||
target: '_blank',
|
||||
},
|
||||
}),
|
||||
[docLinks]
|
||||
);
|
||||
|
||||
return (
|
||||
<EmptyPage
|
||||
actions={actions}
|
||||
message={i18n.CASES_FEATURE_NO_PERMISSIONS_MSG}
|
||||
data-test-subj="no_feature_permissions"
|
||||
title={i18n.CASES_FEATURE_NO_PERMISSIONS_TITLE}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CaseFeatureNoPermissions.displayName = 'CaseSavedObjectNoPermissions';
|
|
@ -1,51 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiCallOut, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import React from 'react';
|
||||
import { ExperimentalBadge } from '../../components/shared/experimental_badge';
|
||||
import { RouteParams } from '../../routes';
|
||||
import { usePluginContext } from '../../hooks/use_plugin_context';
|
||||
|
||||
interface CasesProps {
|
||||
routeParams: RouteParams<'/cases'>;
|
||||
}
|
||||
|
||||
export function CasesPage(props: CasesProps) {
|
||||
const { ObservabilityPageTemplate } = usePluginContext();
|
||||
return (
|
||||
<ObservabilityPageTemplate
|
||||
pageHeader={{
|
||||
pageTitle: (
|
||||
<>
|
||||
{i18n.translate('xpack.observability.casesTitle', { defaultMessage: 'Cases' })}{' '}
|
||||
<ExperimentalBadge />
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiCallOut
|
||||
title={i18n.translate('xpack.observability.casesDisclaimerTitle', {
|
||||
defaultMessage: 'Coming soon',
|
||||
})}
|
||||
color="warning"
|
||||
iconType="beaker"
|
||||
>
|
||||
<p>
|
||||
{i18n.translate('xpack.observability.casesDisclaimerText', {
|
||||
defaultMessage: 'This is the future home of cases.',
|
||||
})}
|
||||
</p>
|
||||
</EuiCallOut>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</ObservabilityPageTemplate>
|
||||
);
|
||||
}
|
59
x-pack/plugins/observability/public/pages/cases/links.ts
Normal file
59
x-pack/plugins/observability/public/pages/cases/links.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* 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 { useCallback } from 'react';
|
||||
import { isEmpty } from 'lodash/fp';
|
||||
import { useKibana } from '../../utils/kibana_react';
|
||||
|
||||
export const getCaseDetailsUrl = ({ id, subCaseId }: { id: string; subCaseId?: string }) => {
|
||||
if (subCaseId) {
|
||||
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(subCaseId)}`;
|
||||
}
|
||||
return `/${encodeURIComponent(id)}`;
|
||||
};
|
||||
interface FormatUrlOptions {
|
||||
absolute: boolean;
|
||||
}
|
||||
|
||||
export type FormatUrl = (path: string, options?: Partial<FormatUrlOptions>) => string;
|
||||
export const useFormatUrl = (appId: string) => {
|
||||
const { getUrlForApp } = useKibana().services.application;
|
||||
const formatUrl = useCallback<FormatUrl>(
|
||||
(path: string, { absolute = false } = {}) => {
|
||||
const pathArr = path.split('?');
|
||||
const formattedPath = `${pathArr[0]}${isEmpty(pathArr[1]) ? '' : `?${pathArr[1]}`}`;
|
||||
return getUrlForApp(`${appId}`, {
|
||||
path: formattedPath,
|
||||
absolute,
|
||||
});
|
||||
},
|
||||
[appId, getUrlForApp]
|
||||
);
|
||||
return { formatUrl };
|
||||
};
|
||||
|
||||
export const getCaseDetailsUrlWithCommentId = ({
|
||||
id,
|
||||
commentId,
|
||||
subCaseId,
|
||||
}: {
|
||||
id: string;
|
||||
commentId: string;
|
||||
subCaseId?: string;
|
||||
}) => {
|
||||
if (subCaseId) {
|
||||
return `/${encodeURIComponent(id)}/sub-cases/${encodeURIComponent(
|
||||
subCaseId
|
||||
)}/${encodeURIComponent(commentId)}`;
|
||||
}
|
||||
return `/${encodeURIComponent(id)}/${encodeURIComponent(commentId)}`;
|
||||
};
|
||||
|
||||
export const getCreateCaseUrl = () => `/create`;
|
||||
|
||||
export const getConfigureCasesUrl = () => `/configure`;
|
||||
export const getCaseUrl = () => `/`;
|
|
@ -36,6 +36,8 @@ import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav';
|
|||
import { ConfigSchema } from '.';
|
||||
import { createObservabilityRuleTypeRegistry } from './rules/create_observability_rule_type_registry';
|
||||
import { createLazyObservabilityPageTemplate } from './components/shared';
|
||||
import { CASES_APP_ID } from './components/app/cases/constants';
|
||||
import { CasesUiStart } from '../../cases/public';
|
||||
|
||||
export type ObservabilityPublicSetup = ReturnType<Plugin['setup']>;
|
||||
|
||||
|
@ -46,6 +48,7 @@ export interface ObservabilityPublicPluginsSetup {
|
|||
}
|
||||
|
||||
export interface ObservabilityPublicPluginsStart {
|
||||
cases: CasesUiStart;
|
||||
home?: HomePublicPluginStart;
|
||||
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
|
||||
data: DataPublicPluginStart;
|
||||
|
@ -63,6 +66,7 @@ export class Plugin
|
|||
ObservabilityPublicPluginsStart
|
||||
> {
|
||||
private readonly appUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
|
||||
private readonly casesAppUpdater$ = new BehaviorSubject<AppUpdater>(() => ({}));
|
||||
private readonly navigationRegistry = createNavigationRegistry();
|
||||
|
||||
constructor(private readonly initializerContext: PluginInitializerContext<ConfigSchema>) {
|
||||
|
@ -111,7 +115,6 @@ export class Plugin
|
|||
mount,
|
||||
updater$,
|
||||
});
|
||||
|
||||
if (config.unsafe.alertingExperience.enabled) {
|
||||
coreSetup.application.register({
|
||||
id: 'observability-alerts',
|
||||
|
@ -127,14 +130,14 @@ export class Plugin
|
|||
|
||||
if (config.unsafe.cases.enabled) {
|
||||
coreSetup.application.register({
|
||||
id: 'observability-cases',
|
||||
id: CASES_APP_ID,
|
||||
title: 'Cases',
|
||||
appRoute: '/app/observability/cases',
|
||||
order: 8050,
|
||||
category,
|
||||
euiIconType,
|
||||
mount,
|
||||
updater$,
|
||||
updater$: this.casesAppUpdater$,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -188,7 +191,7 @@ export class Plugin
|
|||
};
|
||||
}
|
||||
public start({ application }: CoreStart) {
|
||||
toggleOverviewLinkInNav(this.appUpdater$, application);
|
||||
toggleOverviewLinkInNav(this.appUpdater$, this.casesAppUpdater$, application);
|
||||
|
||||
const PageTemplate = createLazyObservabilityPageTemplate({
|
||||
currentAppId$: application.currentAppId$,
|
||||
|
|
|
@ -13,8 +13,12 @@ import { LandingPage } from '../pages/landing';
|
|||
import { OverviewPage } from '../pages/overview';
|
||||
import { jsonRt } from './json_rt';
|
||||
import { AlertsPage } from '../pages/alerts';
|
||||
import { CasesPage } from '../pages/cases';
|
||||
import { CreateCasePage } from '../pages/cases/create_case';
|
||||
import { ExploratoryViewPage } from '../components/shared/exploratory_view';
|
||||
import { CaseDetailsPage } from '../pages/cases/case_details';
|
||||
import { ConfigureCasesPage } from '../pages/cases/configure_cases';
|
||||
import { AllCasesPage } from '../pages/cases/all_cases';
|
||||
import { casesBreadcrumbs } from '../hooks/use_breadcrumbs';
|
||||
import { alertStatusRt } from '../../common/typings';
|
||||
|
||||
export type RouteParams<T extends keyof typeof routes> = DecodeParams<typeof routes[T]['params']>;
|
||||
|
@ -78,24 +82,36 @@ export const routes = {
|
|||
],
|
||||
},
|
||||
'/cases': {
|
||||
handler: (routeParams: any) => {
|
||||
return <CasesPage routeParams={routeParams} />;
|
||||
handler: () => {
|
||||
return <AllCasesPage />;
|
||||
},
|
||||
params: {},
|
||||
breadcrumb: [casesBreadcrumbs.cases],
|
||||
},
|
||||
'/cases/create': {
|
||||
handler: () => {
|
||||
return <CreateCasePage />;
|
||||
},
|
||||
params: {},
|
||||
breadcrumb: [casesBreadcrumbs.cases, casesBreadcrumbs.create],
|
||||
},
|
||||
'/cases/configure': {
|
||||
handler: () => {
|
||||
return <ConfigureCasesPage />;
|
||||
},
|
||||
params: {},
|
||||
breadcrumb: [casesBreadcrumbs.cases, casesBreadcrumbs.configure],
|
||||
},
|
||||
'/cases/:detailName': {
|
||||
handler: () => {
|
||||
return <CaseDetailsPage />;
|
||||
},
|
||||
params: {
|
||||
query: t.partial({
|
||||
rangeFrom: t.string,
|
||||
rangeTo: t.string,
|
||||
refreshPaused: jsonRt.pipe(t.boolean),
|
||||
refreshInterval: jsonRt.pipe(t.number),
|
||||
path: t.partial({
|
||||
detailName: t.string,
|
||||
}),
|
||||
},
|
||||
breadcrumb: [
|
||||
{
|
||||
text: i18n.translate('xpack.observability.cases.breadcrumb', {
|
||||
defaultMessage: 'Cases',
|
||||
}),
|
||||
},
|
||||
],
|
||||
breadcrumb: [casesBreadcrumbs.cases],
|
||||
},
|
||||
'/alerts': {
|
||||
handler: (routeParams: any) => {
|
||||
|
|
|
@ -14,6 +14,7 @@ import { toggleOverviewLinkInNav } from './toggle_overview_link_in_nav';
|
|||
describe('toggleOverviewLinkInNav', () => {
|
||||
let applicationStart: ReturnType<typeof applicationServiceMock.createStartContract>;
|
||||
let subjectMock: jest.Mocked<Subject<AppUpdater>>;
|
||||
let casesMock: jest.Mocked<Subject<AppUpdater>>;
|
||||
|
||||
beforeEach(() => {
|
||||
applicationStart = applicationServiceMock.createStartContract();
|
||||
|
@ -34,7 +35,7 @@ describe('toggleOverviewLinkInNav', () => {
|
|||
},
|
||||
};
|
||||
|
||||
toggleOverviewLinkInNav(subjectMock, applicationStart);
|
||||
toggleOverviewLinkInNav(subjectMock, casesMock, applicationStart);
|
||||
|
||||
expect(subjectMock.next).toHaveBeenCalledTimes(1);
|
||||
const updater = subjectMock.next.mock.calls[0][0]!;
|
||||
|
@ -54,7 +55,7 @@ describe('toggleOverviewLinkInNav', () => {
|
|||
},
|
||||
};
|
||||
|
||||
toggleOverviewLinkInNav(subjectMock, applicationStart);
|
||||
toggleOverviewLinkInNav(subjectMock, casesMock, applicationStart);
|
||||
|
||||
expect(subjectMock.next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
|
@ -7,13 +7,22 @@
|
|||
|
||||
import { Subject } from 'rxjs';
|
||||
import { AppNavLinkStatus, AppUpdater, ApplicationStart } from '../../../../src/core/public';
|
||||
import { CASES_APP_ID } from '../common/const';
|
||||
|
||||
export function toggleOverviewLinkInNav(
|
||||
updater$: Subject<AppUpdater>,
|
||||
casesUpdater$: Subject<AppUpdater>,
|
||||
{ capabilities }: ApplicationStart
|
||||
) {
|
||||
const { apm, logs, metrics, uptime } = capabilities.navLinks;
|
||||
const { apm, logs, metrics, uptime, [CASES_APP_ID]: cases } = capabilities.navLinks;
|
||||
const someVisible = Object.values({ apm, logs, metrics, uptime }).some((visible) => visible);
|
||||
|
||||
// if cases is enabled then we want to show it in the sidebar but not the navigation unless one of the other features
|
||||
// is enabled
|
||||
if (cases) {
|
||||
casesUpdater$.next(() => ({ navLinkStatus: AppNavLinkStatus.visible }));
|
||||
}
|
||||
|
||||
if (!someVisible) {
|
||||
updater$.next(() => ({
|
||||
navLinkStatus: AppNavLinkStatus.hidden,
|
||||
|
|
19
x-pack/plugins/observability/public/utils/kibana_react.ts
Normal file
19
x-pack/plugins/observability/public/utils/kibana_react.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* 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 { CoreStart } from 'kibana/public';
|
||||
import { useKibana } from '../../../../../src/plugins/kibana_react/public';
|
||||
import { Storage } from '../../../../../src/plugins/kibana_utils/public';
|
||||
import { ObservabilityPublicPluginsStart } from '../plugin';
|
||||
|
||||
export type StartServices = CoreStart &
|
||||
ObservabilityPublicPluginsStart & {
|
||||
storage: Storage;
|
||||
};
|
||||
const useTypedKibana = () => useKibana<StartServices>();
|
||||
|
||||
export { useTypedKibana as useKibana };
|
|
@ -5,7 +5,13 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import {
|
||||
PluginInitializerContext,
|
||||
Plugin,
|
||||
CoreSetup,
|
||||
DEFAULT_APP_CATEGORIES,
|
||||
} from '../../../../src/core/server';
|
||||
import { RuleDataClient } from '../../rule_registry/server';
|
||||
import { ObservabilityConfig } from '.';
|
||||
import {
|
||||
|
@ -14,23 +20,65 @@ import {
|
|||
AnnotationsAPI,
|
||||
} from './lib/annotations/bootstrap_annotations';
|
||||
import type { RuleRegistryPluginSetupContract } from '../../rule_registry/server';
|
||||
import { PluginSetupContract as FeaturesSetup } from '../../features/server';
|
||||
import { uiSettings } from './ui_settings';
|
||||
import { registerRoutes } from './routes/register_routes';
|
||||
import { getGlobalObservabilityServerRouteRepository } from './routes/get_global_observability_server_route_repository';
|
||||
import { CASES_APP_ID, OBSERVABILITY } from '../common/const';
|
||||
|
||||
export type ObservabilityPluginSetup = ReturnType<ObservabilityPlugin['setup']>;
|
||||
|
||||
interface PluginSetup {
|
||||
features: FeaturesSetup;
|
||||
ruleRegistry: RuleRegistryPluginSetupContract;
|
||||
}
|
||||
|
||||
export class ObservabilityPlugin implements Plugin<ObservabilityPluginSetup> {
|
||||
constructor(private readonly initContext: PluginInitializerContext) {
|
||||
this.initContext = initContext;
|
||||
}
|
||||
|
||||
public setup(
|
||||
core: CoreSetup,
|
||||
plugins: {
|
||||
ruleRegistry: RuleRegistryPluginSetupContract;
|
||||
}
|
||||
) {
|
||||
public setup(core: CoreSetup, plugins: PluginSetup) {
|
||||
plugins.features.registerKibanaFeature({
|
||||
id: CASES_APP_ID,
|
||||
name: i18n.translate('xpack.observability.featureRegistry.linkObservabilityTitle', {
|
||||
defaultMessage: 'Cases',
|
||||
}),
|
||||
order: 1100,
|
||||
category: DEFAULT_APP_CATEGORIES.observability,
|
||||
app: [CASES_APP_ID, 'kibana'],
|
||||
catalogue: [OBSERVABILITY],
|
||||
cases: [OBSERVABILITY],
|
||||
privileges: {
|
||||
all: {
|
||||
app: [CASES_APP_ID, 'kibana'],
|
||||
catalogue: [OBSERVABILITY],
|
||||
cases: {
|
||||
all: [OBSERVABILITY],
|
||||
},
|
||||
api: [],
|
||||
savedObject: {
|
||||
all: [],
|
||||
read: [],
|
||||
},
|
||||
ui: ['crud_cases', 'read_cases'], // uiCapabilities[CASES_APP_ID].crud_cases or read_cases
|
||||
},
|
||||
read: {
|
||||
app: [CASES_APP_ID, 'kibana'],
|
||||
catalogue: [OBSERVABILITY],
|
||||
cases: {
|
||||
read: [OBSERVABILITY],
|
||||
},
|
||||
api: [],
|
||||
savedObject: {
|
||||
all: [],
|
||||
read: [],
|
||||
},
|
||||
ui: ['read_cases'], // uiCapabilities[uiCapabilities[CASES_APP_ID]].read_cases
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const config = this.initContext.config.get<ObservabilityConfig>();
|
||||
|
||||
let annotationsApiPromise: Promise<AnnotationsAPI> | undefined;
|
||||
|
|
|
@ -24,6 +24,7 @@
|
|||
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
|
||||
{ "path": "../alerting/tsconfig.json" },
|
||||
{ "path": "../licensing/tsconfig.json" },
|
||||
{ "path": "../cases/tsconfig.json" },
|
||||
{ "path": "../lens/tsconfig.json" },
|
||||
{ "path": "../rule_registry/tsconfig.json" },
|
||||
{ "path": "../translations/tsconfig.json" }
|
||||
|
|
|
@ -80,7 +80,7 @@ describe('Callout', () => {
|
|||
});
|
||||
|
||||
it('dismiss the callout correctly', () => {
|
||||
const wrapper = mount(<CallOut {...defaultProps} messages={[]} />);
|
||||
const wrapper = mount(<CallOut {...defaultProps} />);
|
||||
expect(wrapper.find(`[data-test-subj="callout-dismiss-md5-hex"]`).exists()).toBeTruthy();
|
||||
wrapper.find(`button[data-test-subj="callout-dismiss-md5-hex"]`).simulate('click');
|
||||
wrapper.update();
|
||||
|
|
|
@ -35,11 +35,9 @@ const CallOutComponent = ({
|
|||
type,
|
||||
]);
|
||||
|
||||
return showCallOut ? (
|
||||
return showCallOut && !isEmpty(messages) ? (
|
||||
<EuiCallOut title={title} color={type} iconType="gear" data-test-subj={`case-callout-${id}`}>
|
||||
{!isEmpty(messages) && (
|
||||
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
|
||||
)}
|
||||
<EuiDescriptionList data-test-subj={`callout-messages-${id}`} listItems={messages} />
|
||||
<EuiButton
|
||||
data-test-subj={`callout-dismiss-${id}`}
|
||||
color={type === 'success' ? 'secondary' : type}
|
||||
|
|
|
@ -11,7 +11,7 @@ import md5 from 'md5';
|
|||
import * as i18n from './translations';
|
||||
import { ErrorMessage } from './types';
|
||||
|
||||
export const savedObjectReadOnlyErrorMessage: ErrorMessage = {
|
||||
export const permissionsReadOnlyErrorMessage: ErrorMessage = {
|
||||
id: 'read-only-privileges-error',
|
||||
title: i18n.READ_ONLY_FEATURE_TITLE,
|
||||
description: <>{i18n.READ_ONLY_FEATURE_MSG}</>,
|
||||
|
|
|
@ -12,7 +12,7 @@ import { useGetUserCasesPermissions } from '../../common/lib/kibana';
|
|||
import { SpyRoute } from '../../common/utils/route/spy_routes';
|
||||
import { AllCases } from '../components/all_cases';
|
||||
|
||||
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
import { CaseFeatureNoPermissions } from './feature_no_permissions';
|
||||
import { SecurityPageName } from '../../app/types';
|
||||
|
||||
|
@ -24,8 +24,8 @@ export const CasesPage = React.memo(() => {
|
|||
<WrapperPage>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={savedObjectReadOnlyErrorMessage.title}
|
||||
messages={[{ ...savedObjectReadOnlyErrorMessage, title: '' }]}
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<AllCases userCanCrud={userPermissions?.crud ?? false} />
|
||||
|
|
|
@ -16,7 +16,7 @@ import { useGetUserCasesPermissions } from '../../common/lib/kibana';
|
|||
import { getCaseUrl } from '../../common/components/link_to';
|
||||
import { navTabs } from '../../app/home/home_navigations';
|
||||
import { CaseView } from '../components/case_view';
|
||||
import { savedObjectReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
import { permissionsReadOnlyErrorMessage, CaseCallOut } from '../components/callout';
|
||||
|
||||
export const CaseDetailsPage = React.memo(() => {
|
||||
const history = useHistory();
|
||||
|
@ -37,8 +37,8 @@ export const CaseDetailsPage = React.memo(() => {
|
|||
<WrapperPage noPadding>
|
||||
{userPermissions != null && !userPermissions?.crud && userPermissions?.read && (
|
||||
<CaseCallOut
|
||||
title={savedObjectReadOnlyErrorMessage.title}
|
||||
messages={[{ ...savedObjectReadOnlyErrorMessage, title: '' }]}
|
||||
title={permissionsReadOnlyErrorMessage.title}
|
||||
messages={[{ ...permissionsReadOnlyErrorMessage, title: '' }]}
|
||||
/>
|
||||
)}
|
||||
<CaseView
|
||||
|
|
|
@ -17309,10 +17309,6 @@
|
|||
"xpack.observability.alertsTable.viewInAppButtonLabel": "アプリで表示",
|
||||
"xpack.observability.alertsTitle": "アラート",
|
||||
"xpack.observability.breadcrumbs.observability": "オブザーバビリティ",
|
||||
"xpack.observability.cases.breadcrumb": "ケース",
|
||||
"xpack.observability.casesDisclaimerText": "これは将来のケースのホームです。",
|
||||
"xpack.observability.casesDisclaimerTitle": "まもなくリリース",
|
||||
"xpack.observability.casesTitle": "ケース",
|
||||
"xpack.observability.emptySection.apps.alert.description": "503 エラーが累積していますか?サービスは応答していますか?CPUとRAMの使用量が跳ね上がっていますか?このような警告を、事後にではなく、発生と同時に把握しましょう。",
|
||||
"xpack.observability.emptySection.apps.alert.link": "アラートの作成",
|
||||
"xpack.observability.emptySection.apps.alert.title": "アラートが見つかりません。",
|
||||
|
|
|
@ -17546,10 +17546,6 @@
|
|||
"xpack.observability.alertsTable.viewInAppButtonLabel": "在应用中查看",
|
||||
"xpack.observability.alertsTitle": "告警",
|
||||
"xpack.observability.breadcrumbs.observability": "可观测性",
|
||||
"xpack.observability.cases.breadcrumb": "案例",
|
||||
"xpack.observability.casesDisclaimerText": "这是案例的未来之家。",
|
||||
"xpack.observability.casesDisclaimerTitle": "即将推出",
|
||||
"xpack.observability.casesTitle": "案例",
|
||||
"xpack.observability.emptySection.apps.alert.description": "503 错误是否越来越多?服务是否响应?CPU 和 RAM 利用率是否激增?实时查看警告,而不是事后再进行剖析。",
|
||||
"xpack.observability.emptySection.apps.alert.link": "创建告警",
|
||||
"xpack.observability.emptySection.apps.alert.title": "未找到告警。",
|
||||
|
|
|
@ -114,6 +114,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
'infrastructure',
|
||||
'logs',
|
||||
'maps',
|
||||
'observabilityCases',
|
||||
'uptime',
|
||||
'siem',
|
||||
'fleet',
|
||||
|
|
|
@ -60,6 +60,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
canvas: ['all', 'read', 'minimal_all', 'minimal_read', 'generate_report'],
|
||||
infrastructure: ['all', 'read'],
|
||||
logs: ['all', 'read'],
|
||||
observabilityCases: ['all', 'read'],
|
||||
uptime: ['all', 'read'],
|
||||
apm: ['all', 'read'],
|
||||
ml: ['all', 'read'],
|
||||
|
|
|
@ -33,6 +33,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
maps: ['all', 'read'],
|
||||
canvas: ['all', 'read'],
|
||||
infrastructure: ['all', 'read'],
|
||||
observabilityCases: ['all', 'read'],
|
||||
logs: ['all', 'read'],
|
||||
uptime: ['all', 'read'],
|
||||
apm: ['all', 'read'],
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('feature controls', function () {
|
||||
this.tags('skipFirefox');
|
||||
loadTestFile(require.resolve('./observability_security'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getPageObjects, getService }: FtrProviderContext) {
|
||||
const esArchiver = getService('esArchiver');
|
||||
const security = getService('security');
|
||||
const PageObjects = getPageObjects([
|
||||
'common',
|
||||
'observability',
|
||||
'error',
|
||||
'security',
|
||||
'spaceSelector',
|
||||
]);
|
||||
const appsMenu = getService('appsMenu');
|
||||
const testSubjects = getService('testSubjects');
|
||||
describe('observability security feature controls', function () {
|
||||
this.tags(['skipFirefox']);
|
||||
before(async () => {
|
||||
await esArchiver.load('x-pack/test/functional/es_archives/cases/default');
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await esArchiver.unload('x-pack/test/functional/es_archives/cases/default');
|
||||
});
|
||||
|
||||
describe('observability cases all privileges', () => {
|
||||
before(async () => {
|
||||
await security.role.create('cases_observability_all_role', {
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [
|
||||
{ spaces: ['*'], base: [], feature: { observabilityCases: ['all'], logs: ['all'] } },
|
||||
],
|
||||
});
|
||||
|
||||
await security.user.create('cases_observability_all_user', {
|
||||
password: 'cases_observability_all_user-password',
|
||||
roles: ['cases_observability_all_role'],
|
||||
full_name: 'test user',
|
||||
});
|
||||
|
||||
await PageObjects.security.forceLogout();
|
||||
|
||||
await PageObjects.security.login(
|
||||
'cases_observability_all_user',
|
||||
'cases_observability_all_user-password',
|
||||
{
|
||||
expectSpaceSelector: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await PageObjects.security.forceLogout();
|
||||
await Promise.all([
|
||||
security.role.delete('cases_observability_all_role'),
|
||||
security.user.delete('cases_observability_all_user'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows observability/cases navlink', async () => {
|
||||
const navLinks = (await appsMenu.readLinks()).map((link) => link.text).slice(0, 2);
|
||||
expect(navLinks).to.eql(['Overview', 'Cases']);
|
||||
});
|
||||
|
||||
it(`landing page shows "Create new case" button`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl('observabilityCases');
|
||||
await PageObjects.observability.expectCreateCaseButtonEnabled();
|
||||
});
|
||||
|
||||
it(`doesn't show read-only badge`, async () => {
|
||||
await PageObjects.observability.expectNoReadOnlyCallout();
|
||||
});
|
||||
|
||||
it(`allows a case to be created`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl('observabilityCases');
|
||||
|
||||
await testSubjects.click('createNewCaseBtn');
|
||||
|
||||
await PageObjects.observability.expectCreateCase();
|
||||
});
|
||||
|
||||
it(`allows a case to be edited`, async () => {
|
||||
await PageObjects.common.navigateToUrl(
|
||||
'observabilityCases',
|
||||
'4c32e6b0-c3c5-11eb-b389-3fadeeafa60f',
|
||||
{
|
||||
shouldUseHashForSubUrl: false,
|
||||
}
|
||||
);
|
||||
await PageObjects.observability.expectAddCommentButton();
|
||||
});
|
||||
});
|
||||
|
||||
describe('observability cases read-only privileges', () => {
|
||||
before(async () => {
|
||||
await security.role.create('cases_observability_read_role', {
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [
|
||||
{
|
||||
spaces: ['*'],
|
||||
base: [],
|
||||
feature: { observabilityCases: ['read'], logs: ['all'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await security.user.create('cases_observability_read_user', {
|
||||
password: 'cases_observability_read_user-password',
|
||||
roles: ['cases_observability_read_role'],
|
||||
full_name: 'test user',
|
||||
});
|
||||
|
||||
await PageObjects.security.login(
|
||||
'cases_observability_read_user',
|
||||
'cases_observability_read_user-password',
|
||||
{
|
||||
expectSpaceSelector: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await security.role.delete('cases_observability_read_role');
|
||||
await security.user.delete('cases_observability_read_user');
|
||||
});
|
||||
|
||||
it('shows observability/cases navlink', async () => {
|
||||
const navLinks = (await appsMenu.readLinks()).map((link) => link.text).slice(0, 2);
|
||||
expect(navLinks).to.eql(['Overview', 'Cases']);
|
||||
});
|
||||
|
||||
it(`landing page shows disabled "Create new case" button`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl('observabilityCases');
|
||||
await PageObjects.observability.expectCreateCaseButtonDisabled();
|
||||
});
|
||||
|
||||
it(`shows read-only callout`, async () => {
|
||||
await PageObjects.observability.expectReadOnlyCallout();
|
||||
});
|
||||
|
||||
it(`does not allow a case to be created`, async () => {
|
||||
await PageObjects.common.navigateToUrl('observabilityCases', 'create', {
|
||||
shouldUseHashForSubUrl: false,
|
||||
});
|
||||
|
||||
// expect redirection to observability cases landing
|
||||
await PageObjects.observability.expectCreateCaseButtonDisabled();
|
||||
});
|
||||
|
||||
it(`does not allow a case to be edited`, async () => {
|
||||
await PageObjects.common.navigateToUrl(
|
||||
'observabilityCases',
|
||||
'4c32e6b0-c3c5-11eb-b389-3fadeeafa60f',
|
||||
{
|
||||
shouldUseHashForSubUrl: false,
|
||||
}
|
||||
);
|
||||
await PageObjects.observability.expectAddCommentButtonDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no observability privileges', () => {
|
||||
before(async () => {
|
||||
await security.role.create('no_observability_privileges_role', {
|
||||
elasticsearch: { cluster: [], indices: [], run_as: [] },
|
||||
kibana: [
|
||||
{
|
||||
feature: {
|
||||
discover: ['all'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await security.user.create('no_observability_privileges_user', {
|
||||
password: 'no_observability_privileges_user-password',
|
||||
roles: ['no_observability_privileges_role'],
|
||||
full_name: 'test user',
|
||||
});
|
||||
|
||||
await PageObjects.security.login(
|
||||
'no_observability_privileges_user',
|
||||
'no_observability_privileges_user-password',
|
||||
{
|
||||
expectSpaceSelector: false,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await security.role.delete('no_observability_privileges_role');
|
||||
await security.user.delete('no_observability_privileges_user');
|
||||
});
|
||||
|
||||
it(`returns a 403`, async () => {
|
||||
await PageObjects.common.navigateToActualUrl('observabilityCases');
|
||||
await PageObjects.observability.expectForbidden();
|
||||
});
|
||||
|
||||
it.skip(`create new case returns a 403`, async () => {
|
||||
await PageObjects.common.navigateToUrl('observabilityCases', 'create', {
|
||||
shouldUseHashForSubUrl: false,
|
||||
});
|
||||
await PageObjects.observability.expectForbidden();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
15
x-pack/test/functional/apps/observability/index.ts
Normal file
15
x-pack/test/functional/apps/observability/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Observability specs', function () {
|
||||
this.tags('ciGroup6');
|
||||
loadTestFile(require.resolve('./feature_controls'));
|
||||
});
|
||||
}
|
|
@ -60,6 +60,7 @@ export default async function ({ readConfigFile }) {
|
|||
resolve(__dirname, './apps/reporting_management'),
|
||||
resolve(__dirname, './apps/management'),
|
||||
resolve(__dirname, './apps/reporting'),
|
||||
resolve(__dirname, './apps/observability'),
|
||||
|
||||
// This license_management file must be last because it is destructive.
|
||||
resolve(__dirname, './apps/license_management'),
|
||||
|
@ -94,6 +95,7 @@ export default async function ({ readConfigFile }) {
|
|||
'--xpack.discoverEnhanced.actions.exploreDataInContextMenu.enabled=true',
|
||||
'--timelion.ui.enabled=true',
|
||||
'--savedObjects.maxImportPayloadBytes=10485760', // for OSS test management/_import_objects
|
||||
'--xpack.observability.unsafe.cases.enabled=true',
|
||||
],
|
||||
},
|
||||
uiSettings: {
|
||||
|
|
BIN
x-pack/test/functional/es_archives/cases/default/data.json.gz
Normal file
BIN
x-pack/test/functional/es_archives/cases/default/data.json.gz
Normal file
Binary file not shown.
322
x-pack/test/functional/es_archives/cases/default/mappings.json
Normal file
322
x-pack/test/functional/es_archives/cases/default/mappings.json
Normal file
|
@ -0,0 +1,322 @@
|
|||
{
|
||||
"type": "index",
|
||||
"value": {
|
||||
"aliases": {
|
||||
".kibana": {
|
||||
}
|
||||
},
|
||||
"index": ".kibana_1",
|
||||
"mappings": {
|
||||
"properties": {
|
||||
"cases": {
|
||||
"properties": {
|
||||
"closed_at": {
|
||||
"type": "date"
|
||||
},
|
||||
"closed_by": {
|
||||
"properties": {
|
||||
"username": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connector": {
|
||||
"properties": {
|
||||
"fields": {
|
||||
"properties": {
|
||||
"key": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"value": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"name": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"type": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "date"
|
||||
},
|
||||
"created_by": {
|
||||
"properties": {
|
||||
"email": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"full_name": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"username": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"external_service": {
|
||||
"properties": {
|
||||
"connector_id": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"connector_name": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"external_id": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"external_title": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"external_url": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"pushed_at": {
|
||||
"type": "date"
|
||||
},
|
||||
"pushed_by": {
|
||||
"properties": {
|
||||
"username": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"owner": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"settings": {
|
||||
"properties": {
|
||||
"syncAlerts": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"tags": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"title": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"type": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "date"
|
||||
},
|
||||
"updated_by": {
|
||||
"properties": {
|
||||
"email": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"full_name": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"username": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"coreMigrationVersion": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"migrationVersion": {
|
||||
"properties": {
|
||||
"cases": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
}
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"fields": {
|
||||
"keyword": {
|
||||
"ignore_above": 256,
|
||||
"type": "keyword"
|
||||
}
|
||||
},
|
||||
"type": "text"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "date"
|
||||
}
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"index": {
|
||||
"auto_expand_replicas": "0-1",
|
||||
"number_of_replicas": "1",
|
||||
"number_of_shards": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@ import { GrokDebuggerPageObject } from './grok_debugger_page';
|
|||
import { WatcherPageObject } from './watcher_page';
|
||||
import { ReportingPageObject } from './reporting_page';
|
||||
import { AccountSettingsPageObject } from './account_settings_page';
|
||||
import { ObservabilityPageProvider } from './observability_page';
|
||||
import { InfraHomePageProvider } from './infra_home_page';
|
||||
import { InfraLogsPageProvider } from './infra_logs_page';
|
||||
import { GisPageObject } from './gis_page';
|
||||
|
@ -82,4 +83,5 @@ export const pageObjects = {
|
|||
navigationalSearch: NavigationalSearchPageObject,
|
||||
banners: BannersPageObject,
|
||||
detections: DetectionsPageObject,
|
||||
observability: ObservabilityPageProvider,
|
||||
};
|
||||
|
|
59
x-pack/test/functional/page_objects/observability_page.ts
Normal file
59
x-pack/test/functional/page_objects/observability_page.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
|
||||
import { FtrProviderContext } from '../ftr_provider_context';
|
||||
|
||||
export function ObservabilityPageProvider({ getService, getPageObjects }: FtrProviderContext) {
|
||||
const testSubjects = getService('testSubjects');
|
||||
const find = getService('find');
|
||||
|
||||
return {
|
||||
async expectCreateCaseButtonEnabled() {
|
||||
const button = await testSubjects.find('createNewCaseBtn', 20000);
|
||||
const disabledAttr = await button.getAttribute('disabled');
|
||||
expect(disabledAttr).to.be(null);
|
||||
},
|
||||
|
||||
async expectCreateCaseButtonDisabled() {
|
||||
const button = await testSubjects.find('createNewCaseBtn', 20000);
|
||||
const disabledAttr = await button.getAttribute('disabled');
|
||||
expect(disabledAttr).to.be('true');
|
||||
},
|
||||
|
||||
async expectReadOnlyCallout() {
|
||||
await testSubjects.existOrFail('case-callout-e41900b01c9ef0fa81dd6ff326083fb3');
|
||||
},
|
||||
|
||||
async expectNoReadOnlyCallout() {
|
||||
await testSubjects.missingOrFail('case-callout-e41900b01c9ef0fa81dd6ff326083fb3');
|
||||
},
|
||||
|
||||
async expectCreateCase() {
|
||||
await testSubjects.existOrFail('case-creation-form-steps');
|
||||
},
|
||||
|
||||
async expectAddCommentButton() {
|
||||
const button = await testSubjects.find('submit-comment', 20000);
|
||||
const disabledAttr = await button.getAttribute('disabled');
|
||||
expect(disabledAttr).to.be(null);
|
||||
},
|
||||
|
||||
async expectAddCommentButtonDisabled() {
|
||||
const button = await testSubjects.find('submit-comment', 20000);
|
||||
const disabledAttr = await button.getAttribute('disabled');
|
||||
expect(disabledAttr).to.be('true');
|
||||
},
|
||||
|
||||
async expectForbidden() {
|
||||
const h2 = await find.byCssSelector('body', 20000);
|
||||
const text = await h2.getVisibleText();
|
||||
expect(text).to.contain('Kibana feature privileges required');
|
||||
},
|
||||
};
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue