[7.x] [Observability] [Cases] Cases in the observability app (#101487) (#101971)

* [Observability] [Cases] Cases in the observability app (#101487)

* Fix archive

Signed-off-by: Tyler Smalley <tyler.smalley@elastic.co>

* fix data

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Tyler Smalley <tyler.smalley@elastic.co>
This commit is contained in:
Steph Milovic 2021-06-15 15:33:45 -06:00 committed by GitHub
parent 13f56cb16e
commit 3bba37ff68
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
82 changed files with 2949 additions and 206 deletions

View file

@ -148,6 +148,7 @@ export const applicationUsageSchema = {
maps: commonSchema,
ml: commonSchema,
monitoring: commonSchema,
observabilityCases: commonSchema,
'observability-overview': commonSchema,
osquery: commonSchema,
security_account: commonSchema,

View file

@ -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": {

View file

@ -95,6 +95,9 @@ export default async function ({ readConfigFile }) {
pathname: '/app/home',
hash: '/',
},
observabilityCases: {
pathname: '/app/observability/cases',
},
},
junit: {
reportName: 'Chrome UI Functional Tests',

View file

@ -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}
/>
)}

View file

@ -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',

View file

@ -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"

View file

@ -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;
}

View file

@ -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();

View file

@ -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}

View file

@ -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}</>,

View file

@ -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,

View file

@ -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 (

View file

@ -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,

View file

@ -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}

View file

@ -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>
}
/>
);

View file

@ -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 (

View file

@ -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',
});

View file

@ -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}
</>
)}

View file

@ -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) {

View file

@ -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>

View file

@ -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;
}

View file

@ -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>
),

View file

@ -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,
}),
]
: []),
];
}
}

View file

@ -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],

View file

@ -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 } : {}),

View 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';

View file

@ -7,14 +7,16 @@
"observability"
],
"optionalPlugins": [
"licensing",
"home",
"usageCollection",
"lens"
"lens",
"licensing",
"usageCollection"
],
"requiredPlugins": [
"data",
"alerting",
"cases",
"data",
"features",
"ruleRegistry",
"triggersActionsUi"
],

View file

@ -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';

View file

@ -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');
});
});

View file

@ -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);

View file

@ -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);
});
});

View file

@ -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));

View file

@ -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();
});
});

View file

@ -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);

View file

@ -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',
}
);

View file

@ -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';
}

View file

@ -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, {}];

View file

@ -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,
});
});

View file

@ -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';

View file

@ -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();
});
});

View file

@ -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';

View file

@ -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 }),
})
);
});
});

View file

@ -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';

View file

@ -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',
});

View file

@ -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%;
`;

View file

@ -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) {

View file

@ -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;
}

View file

@ -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,
};
};

View file

@ -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';

View file

@ -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';

View file

@ -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 />;
}

View file

@ -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);

View file

@ -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';

View 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';

View file

@ -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';

View file

@ -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>
);
}

View 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 = () => `/`;

View file

@ -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$,

View file

@ -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) => {

View file

@ -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();
});

View file

@ -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,

View 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 };

View file

@ -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;

View file

@ -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" }

View file

@ -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();

View file

@ -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}

View file

@ -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}</>,

View file

@ -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} />

View file

@ -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

View file

@ -17296,10 +17296,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": "アラートが見つかりません。",

View file

@ -17532,10 +17532,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": "未找到告警。",

View file

@ -114,6 +114,7 @@ export default function ({ getService }: FtrProviderContext) {
'infrastructure',
'logs',
'maps',
'observabilityCases',
'uptime',
'siem',
'fleet',

View file

@ -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'],

View file

@ -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'],

View 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('feature controls', function () {
this.tags('skipFirefox');
loadTestFile(require.resolve('./observability_security'));
});
}

View file

@ -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();
});
});
});
}

View 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'));
});
}

View file

@ -59,6 +59,7 @@ export default async function ({ readConfigFile }) {
resolve(__dirname, './apps/reporting_management'),
resolve(__dirname, './apps/management'),
resolve(__dirname, './apps/reporting'),
resolve(__dirname, './apps/observability'),
//This upgrade assistant file needs to be run after the rest of the jobs because of
//it being destructive.
@ -97,6 +98,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: {

View 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"
}
}
}
}

View file

@ -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';
@ -84,4 +85,5 @@ export const pageObjects = {
navigationalSearch: NavigationalSearchPageObject,
banners: BannersPageObject,
detections: DetectionsPageObject,
observability: ObservabilityPageProvider,
};

View 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');
},
};
}