feat(rca): add date range filter (#189527)

Resolves https://github.com/elastic/kibana/issues/189511

## Summary

Add the global date range filter. I've refactored a bit the layout as
well to follow a bit more the design. The notes are still appended in
the main timeline: this needs to be refactored entirely.


![image](https://github.com/user-attachments/assets/f0caae11-25ca-46a9-89c1-8afecebb58fb)
This commit is contained in:
Kevin Delemme 2024-07-30 16:15:24 -04:00 committed by GitHub
parent 7e0dab954b
commit ee055ba500
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 256 additions and 141 deletions

View file

@ -4,7 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/core/public';
import type { GlobalWidgetParameters, OnWidgetAdd } from '@kbn/investigate-plugin/public';
import React from 'react';
@ -16,11 +15,5 @@ type AddWidgetUIProps = {
} & GlobalWidgetParameters;
export function AddNoteUI({ user, onWidgetAdd }: AddWidgetUIProps) {
return (
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexItem grow={false}>
<NoteWidgetControl user={user} onWidgetAdd={onWidgetAdd} />
</EuiFlexItem>
</EuiFlexGroup>
);
return <NoteWidgetControl user={user} onWidgetAdd={onWidgetAdd} />;
}

View file

@ -1,39 +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 { css } from '@emotion/css';
import React from 'react';
import { useKibana } from '../hooks/use_kibana';
const pageSectionContentClassName = css`
width: 100%;
display: flex;
flex-grow: 1;
max-block-size: calc(100vh - 96px);
`;
export function InvestigatePageTemplate({ children }: { children: React.ReactNode }) {
const {
dependencies: {
start: { observabilityShared },
},
} = useKibana();
const { PageTemplate } = observabilityShared.navigation;
return (
<PageTemplate
children={children}
pageSectionProps={{
alignment: 'horizontalCenter',
contentProps: {
className: pageSectionContentClassName,
},
paddingSize: 'none',
}}
/>
);
}

View file

@ -0,0 +1,129 @@
/*
* 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 { css } from '@emotion/css';
import type { TimeRange } from '@kbn/es-query';
import { SearchBar } from '@kbn/unified-search-plugin/public';
import React, { useEffect, useRef, useState } from 'react';
import { useKibana } from '../../hooks/use_kibana';
const parentClassName = css`
width: 100%;
`;
interface Props {
rangeFrom?: string;
rangeTo?: string;
onQuerySubmit: (payload: { dateRange: TimeRange }, isUpdate?: boolean) => void;
onRefresh?: Required<React.ComponentProps<typeof SearchBar>>['onRefresh'];
onFocus?: () => void;
onBlur?: () => void;
showSubmitButton?: boolean;
}
export function InvestigateSearchBar({
rangeFrom,
rangeTo,
onQuerySubmit,
onRefresh,
onFocus,
onBlur,
showSubmitButton = true,
}: Props) {
const {
dependencies: {
start: { unifiedSearch },
},
} = useKibana();
const [element, setElement] = useState<HTMLElement | null>(null);
const onBlurRef = useRef(onBlur);
onBlurRef.current = onBlur;
const onFocusRef = useRef(onFocus);
onFocusRef.current = onFocus;
useEffect(() => {
if (!element) {
return;
}
let inFocus = false;
function updateFocus(activeElement: Element | null | undefined) {
const thisElementContainsActiveElement = activeElement && element?.contains(activeElement);
let nextInFocus = Boolean(thisElementContainsActiveElement);
if (!nextInFocus) {
const popoverContent = document.querySelector(
'[data-test-subj=superDatePickerQuickMenu], .euiDatePopoverContent, .kbnTypeahead'
);
nextInFocus = Boolean(
activeElement &&
activeElement !== document.body &&
(activeElement === popoverContent ||
activeElement?.contains(popoverContent) ||
popoverContent?.contains(activeElement))
);
}
if (inFocus !== nextInFocus) {
inFocus = Boolean(nextInFocus);
if (inFocus) {
onFocusRef.current?.();
} else {
onBlurRef.current?.();
}
}
}
function captureFocus() {
updateFocus(document.activeElement);
}
function captureBlur(event: FocusEvent) {
updateFocus(event.relatedTarget as Element | null);
}
window.addEventListener('focus', captureFocus, true);
window.addEventListener('blur', captureBlur, true);
return () => {
window.removeEventListener('focus', captureFocus);
window.removeEventListener('blur', captureBlur);
};
}, [element]);
return (
<div
className={parentClassName}
ref={(nextElement) => {
setElement(nextElement);
}}
>
<unifiedSearch.ui.SearchBar
appName="investigate"
onQuerySubmit={({ dateRange }) => {
onQuerySubmit({ dateRange });
}}
showQueryInput={false}
showFilterBar={false}
showQueryMenu={false}
showDatePicker
showSubmitButton={showSubmitButton}
dateRangeFrom={rangeFrom}
dateRangeTo={rangeTo}
onRefresh={onRefresh}
displayStyle="inPage"
disableQueryLanguageSwitcher
/>
</div>
);
}

View file

@ -4,9 +4,8 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import datemath from '@elastic/datemath';
import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui';
import { css } from '@emotion/css';
import { i18n } from '@kbn/i18n';
import type { InvestigateWidget, InvestigateWidgetCreate } from '@kbn/investigate-plugin/public';
import { DATE_FORMAT_ID } from '@kbn/management-settings-ids';
import { AuthenticatedUser } from '@kbn/security-plugin/common';
@ -18,28 +17,9 @@ import { useKibana } from '../../hooks/use_kibana';
import { getOverridesFromGlobalParameters } from '../../utils/get_overrides_from_global_parameters';
import { AddNoteUI } from '../add_note_ui';
import { AddObservationUI } from '../add_observation_ui';
import { InvestigateSearchBar } from '../investigate_search_bar';
import { InvestigateWidgetGrid } from '../investigate_widget_grid';
const containerClassName = css`
overflow: auto;
padding: 24px 24px 24px 24px;
`;
const scrollContainerClassName = css`
min-width: 1px;
`;
const gridContainerClassName = css`
position: relative;
`;
const sideBarClassName = css`
width: 240px;
position: sticky;
top: 0;
padding: 0px 12px 32px 12px;
`;
function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
const {
core: { uiSettings },
@ -47,10 +27,9 @@ function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
start: { investigate },
},
} = useKibana();
const widgetDefinitions = useMemo(() => investigate.getWidgetDefinitions(), [investigate]);
const [range, setRange] = useDateRange();
const [searchBarFocused, setSearchBarFocused] = useState(false);
const {
addItem,
@ -61,6 +40,7 @@ function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
investigation,
lockItem,
setItemParameters,
setGlobalParameters,
unlockItem,
revision,
} = investigate.useInvestigation({
@ -76,7 +56,6 @@ function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
};
const createWidgetRef = useRef(createWidget);
createWidgetRef.current = createWidget;
useEffect(() => {
@ -138,18 +117,41 @@ function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
});
}, [revision, widgetDefinitions, uiSettings]);
const [searchBarFocused] = useState(false);
if (!investigation || !revision || !gridItems) {
return <EuiLoadingSpinner />;
}
return (
<EuiFlexGroup direction="row" className={containerClassName}>
<EuiFlexItem grow className={scrollContainerClassName}>
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="flexEnd">
<EuiFlexGroup direction="row">
<EuiFlexItem grow={8}>
<EuiFlexGroup direction="column" gutterSize="s">
<EuiFlexGroup direction="column" gutterSize="m">
<EuiFlexItem className={gridContainerClassName} grow={false}>
<EuiFlexItem>
<InvestigateSearchBar
rangeFrom={range.from}
rangeTo={range.to}
onQuerySubmit={async ({ dateRange }) => {
const nextDateRange = {
from: datemath.parse(dateRange.from)!.toISOString(),
to: datemath.parse(dateRange.to)!.toISOString(),
};
await setGlobalParameters({
...revision.parameters,
timeRange: nextDateRange,
});
setRange(nextDateRange);
}}
onFocus={() => {
setSearchBarFocused(true);
}}
onBlur={() => {
setSearchBarFocused(false);
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<InvestigateWidgetGrid
items={gridItems}
onItemsChange={async (nextGridItems) => {
@ -189,17 +191,8 @@ function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
}}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<AddNoteUI
user={user}
filters={revision.parameters.filters}
timeRange={revision.parameters.timeRange}
onWidgetAdd={(widget) => {
return createWidgetRef.current(widget);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
<AddObservationUI
filters={revision.parameters.filters}
timeRange={revision.parameters.timeRange}
@ -210,11 +203,15 @@ function InvestigateViewWithUser({ user }: { user: AuthenticatedUser }) {
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false} className={sideBarClassName}>
{i18n.translate(
'xpack.investigateApp.investigateViewWithUser.placeholderForRightSidebarFlexItemLabel',
{ defaultMessage: 'placeholder for right sidebar' }
)}
<EuiFlexItem grow={2}>
<AddNoteUI
user={user}
filters={revision.parameters.filters}
timeRange={revision.parameters.timeRange}
onWidgetAdd={(widget) => {
return createWidgetRef.current(widget);
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -5,13 +5,13 @@
* 2.0.
*/
import React, { useState } from 'react';
import { InvestigateWidgetCreate } from '@kbn/investigate-plugin/common';
import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import type { AuthenticatedUser } from '@kbn/core/public';
import { ResizableTextInput } from '../resizable_text_input';
import { i18n } from '@kbn/i18n';
import { InvestigateWidgetCreate } from '@kbn/investigate-plugin/common';
import React, { useState } from 'react';
import { createNoteWidget } from '../../widgets/note_widget/create_note_widget';
import { ResizableTextInput } from '../resizable_text_input';
interface NoteWidgetControlProps {
user: Pick<AuthenticatedUser, 'full_name' | 'username'>;
@ -46,8 +46,8 @@ export function NoteWidgetControl({ user, onWidgetAdd }: NoteWidgetControlProps)
}
return (
<EuiFlexGroup direction="row" gutterSize="xs" alignItems="center">
<EuiFlexItem grow>
<EuiFlexGroup direction="column" gutterSize="s" justifyContent="flexStart">
<EuiFlexItem grow={false}>
<ResizableTextInput
placeholder={i18n.translate('xpack.investigateApp.noteWidgetControl.placeholder', {
defaultMessage: 'Add a note to the investigation',
@ -63,20 +63,24 @@ export function NoteWidgetControl({ user, onWidgetAdd }: NoteWidgetControlProps)
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
<EuiButton
data-test-subj="investigateAppNoteWidgetControlButton"
aria-label={i18n.translate('xpack.investigateApp.noteWidgetControl.submitLabel', {
defaultMessage: 'Submit',
fullWidth
color="text"
aria-label={i18n.translate('xpack.investigateApp.noteWidgetControl.addButtonLabel', {
defaultMessage: 'Add',
})}
disabled={loading || note.trim() === ''}
display="base"
iconType="kqlFunction"
isLoading={loading}
size="m"
onClick={() => {
submit();
}}
/>
>
{i18n.translate('xpack.investigateApp.noteWidgetControl.addButtonLabel', {
defaultMessage: 'Add',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -65,7 +65,7 @@ export function ResizableTextInput({ disabled, value, onChange, onSubmit, placeh
inputRef={textAreaRef}
placeholder={placeholder}
resize="vertical"
rows={1}
rows={4}
value={value}
onChange={handleChange}
onKeyDown={(event) => {

View file

@ -0,0 +1,41 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiButton } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { InvestigateView } from '../../components/investigate_view';
import { useKibana } from '../../hooks/use_kibana';
export function InvestigateDetailsPage() {
const {
dependencies: {
start: { observabilityShared },
},
} = useKibana();
const ObservabilityPageTemplate = observabilityShared.navigation.PageTemplate;
return (
<ObservabilityPageTemplate
pageHeader={{
pageTitle: i18n.translate('xpack.investigateApp.detailsPage.title', {
defaultMessage: 'New investigation',
}),
rightSideItems: [
<EuiButton fill data-test-subj="investigateAppInvestigateDetailsPageEscalateButton">
{i18n.translate('xpack.investigateApp.investigateDetailsPage.escalateButtonLabel', {
defaultMessage: 'Escalate',
})}
</EuiButton>,
],
}}
>
<InvestigateView />
</ObservabilityPageTemplate>
);
}

View file

@ -4,50 +4,40 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { createRouter } from '@kbn/typed-react-router-config';
import * as t from 'io-ts';
import { createRouter, Outlet } from '@kbn/typed-react-router-config';
import React from 'react';
import { Redirect } from 'react-router-dom';
import { InvestigatePageTemplate } from '../components/investigate_page_template';
import { InvestigateView } from '../components/investigate_view';
import { InvestigateDetailsPage } from '../pages/details';
/**
* The array of route definitions to be used when the application
* creates the routes.
*/
const investigateRoutes = {
'/': {
element: (
<InvestigatePageTemplate>
<Outlet />
</InvestigatePageTemplate>
),
children: {
'/new': {
element: <InvestigateView />,
params: t.partial({
query: t.partial({
revision: t.string,
}),
'/new': {
element: <InvestigateDetailsPage />,
params: t.partial({
query: t.partial({
revision: t.string,
}),
}),
},
'/{id}': {
element: <InvestigateDetailsPage />,
params: t.intersection([
t.type({
path: t.type({ id: t.string }),
}),
t.partial({
query: t.partial({
revision: t.string,
}),
},
'/{id}': {
element: <InvestigateView />,
params: t.intersection([
t.type({
path: t.type({ id: t.string }),
}),
t.partial({
query: t.partial({
revision: t.string,
}),
}),
]),
},
'/': {
element: <Redirect to="/new" />,
},
},
}),
]),
},
'/': {
element: <Redirect to="/new" />,
},
};