Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.11] Show all related docs for a finding (#1006) #1012

Merged
merged 1 commit into from
May 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions cypress/integration/3_alerts.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,21 +203,20 @@ describe('Alerts', () => {
});
});

// Confirm the rule document ID is present
cy.get('[data-test-subj="finding-details-flyout-rule-document-id"]')
.invoke('text')
.then((text) => expect(text).to.not.equal('-'));

// Confirm the rule index
cy.get('[data-test-subj="finding-details-flyout-rule-document-index"]').contains(indexName);

// Confirm there is atleast one row of document
cy.get('tbody > tr').should('have.length.least', 1);

// Confirm the rule document matches
// The EuiCodeEditor used for this component stores each line of the JSON in an array of elements;
// so this test formats the expected document into an array of strings,
// and matches each entry with the corresponding element line.
const document = JSON.stringify(JSON.parse('{"winlog.event_id": 2003}'), null, 2);
const documentLines = document.split('\n');
cy.get('[data-test-subj="finding-details-flyout-rule-document"]')
cy.get('[data-test-subj="finding-details-flyout-document-toggle-0"]').click({ force: true });
cy.get('[data-test-subj="finding-details-flyout-rule-document-0"]')
.get('[class="euiCodeBlock__line"]')
.each((lineElement, lineIndex) => {
let line = lineElement.text();
Expand Down
220 changes: 135 additions & 85 deletions public/pages/Findings/components/FindingDetailsFlyout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
EuiAccordion,
EuiBadge,
EuiBadgeGroup,
EuiButton,
EuiButtonIcon,
EuiCodeBlock,
EuiFlexGroup,
Expand All @@ -27,12 +26,14 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
EuiIcon,
EuiTabs,
EuiTab,
EuiLoadingContent,
EuiEmptyPrompt,
EuiLoadingSpinner,
EuiBasicTableColumn,
EuiInMemoryTable,
EuiToolTip,
EuiEmptyPrompt,
} from '@elastic/eui';
import { capitalizeFirstLetter, renderTime } from '../../../utils/helpers';
import { DEFAULT_EMPTY_DATA, ROUTES } from '../../../utils/constants';
Expand All @@ -43,7 +44,7 @@ import { OpenSearchService, IndexPatternsService, CorrelationService } from '../
import { RuleTableItem } from '../../Rules/utils/helpers';
import { CreateIndexPatternForm } from './CreateIndexPatternForm';
import { FindingItemType } from '../containers/Findings/Findings';
import { CorrelationFinding, RuleItemInfoBase } from '../../../../types';
import { CorrelationFinding, FindingDocumentItem, RuleItemInfoBase } from '../../../../types';
import { FindingFlyoutTabId, FindingFlyoutTabs } from '../utils/constants';
import { DataStore } from '../../../store/DataStore';
import { CorrelationsTable } from './CorrelationsTable/CorrelationsTable';
Expand All @@ -67,11 +68,12 @@ interface FindingDetailsFlyoutState {
ruleViewerFlyoutData: RuleTableItem | null;
indexPatternId?: string;
isCreateIndexPatternModalVisible: boolean;
selectedTab: { id: string; content: React.ReactNode | null };
selectedTab: { id: FindingFlyoutTabId; content: React.ReactNode | null };
correlatedFindings: CorrelationFinding[];
allRules: { [id: string]: RuleSource };
isDocumentLoading: boolean;
loadingIndexPatternId: boolean;
areCorrelationsLoading: boolean;
docIdToExpandedRowMap: { [id: string]: JSX.Element };
}

export default class FindingDetailsFlyout extends Component<
Expand All @@ -97,9 +99,10 @@ export default class FindingDetailsFlyout extends Component<
),
},
correlatedFindings: [],
isDocumentLoading: true,
loadingIndexPatternId: true,
areCorrelationsLoading: true,
allRules: {},
docIdToExpandedRowMap: {},
};
}

Expand Down Expand Up @@ -148,7 +151,7 @@ export default class FindingDetailsFlyout extends Component<
}
})
.finally(() => {
this.setState({ isDocumentLoading: false });
this.setState({ loadingIndexPatternId: false });
});

this.getCorrelations();
Expand All @@ -167,9 +170,25 @@ export default class FindingDetailsFlyout extends Component<
});
}

componentDidUpdate(
prevProps: Readonly<FindingDetailsFlyoutProps>,
prevState: Readonly<FindingDetailsFlyoutState>,
snapshot?: any
): void {
if (prevState.docIdToExpandedRowMap !== this.state.docIdToExpandedRowMap) {
this.setState({
selectedTab: {
id: this.state.selectedTab.id,
content: this.getTabContent(this.state.selectedTab.id, this.state.loadingIndexPatternId),
},
});
}
}

renderTags = () => {
const { finding } = this.props;
const tags = finding.queries[0].tags || [];

return (
tags && (
<EuiBadgeGroup gutterSize={'s'}>
Expand Down Expand Up @@ -297,97 +316,127 @@ export default class FindingDetailsFlyout extends Component<
return patternId;
};

renderFindingDocuments(isDocumentLoading: boolean) {
const {
finding: { index, document_list, related_doc_ids },
} = this.props;
const documents = document_list;
const docId = related_doc_ids[0];
const matchedDocuments = documents.filter((doc) => doc.id === docId);
const document = matchedDocuments.length > 0 ? matchedDocuments[0].document : '';
toggleDocumentDetails(item: FindingDocumentItem) {
const docIdToExpandedRowMapValues = { ...this.state.docIdToExpandedRowMap };
let formattedDocument = '';
try {
formattedDocument = document ? JSON.stringify(JSON.parse(document), null, 2) : '';
formattedDocument = document ? JSON.stringify(JSON.parse(item.document), null, 2) : '';
} catch {
// no-op
}

const { indexPatternId } = this.state;
if (docIdToExpandedRowMapValues[item.id]) {
delete docIdToExpandedRowMapValues[item.id];
} else {
docIdToExpandedRowMapValues[item.id] = (
<EuiFormRow fullWidth={true}>
<EuiCodeBlock
language="json"
isCopyable
data-test-subj={`finding-details-flyout-rule-document-${item.itemIdx}`}
>
{formattedDocument}
</EuiCodeBlock>
</EuiFormRow>
);
}

this.setState({ docIdToExpandedRowMap: docIdToExpandedRowMapValues });
}

return document ? (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<EuiTitle size={'s'}>
<h3>Documents</h3>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
isLoading={isDocumentLoading}
renderFindingDocuments(loadingIndexPatternId: boolean) {
const {
finding: { index, document_list, related_doc_ids },
} = this.props;
const { indexPatternId, docIdToExpandedRowMap } = this.state;
const relatedDocIdsSet = new Set(related_doc_ids);
const relatedDocuments: FindingDocumentItem[] = [];
document_list.forEach((documentInfo) => {
if (documentInfo.found && relatedDocIdsSet.has(documentInfo.id)) {
relatedDocuments.push({ ...documentInfo, itemIdx: relatedDocuments.length });
}
});

if (relatedDocuments.length === 0) {
return (
<>
<EuiTitle size={'s'}>
<h3>Documents</h3>
</EuiTitle>
<EuiSpacer />
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={<h2>Document not found</h2>}
body={<p>The document that generated this finding could not be loaded.</p>}
/>
</>
);
}

const actions = [
{
render: ({ id }: FindingDocumentItem) => (
<EuiToolTip title="View surrounding documents">
<EuiButtonIcon
disabled={loadingIndexPatternId}
iconType={'popout'}
data-test-subj={'finding-details-flyout-view-surrounding-documents'}
onClick={() => {
if (indexPatternId) {
window.open(
`discover#/context/${indexPatternId}/${related_doc_ids[0]}`,
'_blank'
);
window.open(`discover#/context/${indexPatternId}/${id}`, '_blank');
} else {
this.setState({ ...this.state, isCreateIndexPatternModalVisible: true });
}
}}
>
View surrounding documents
<EuiIcon type={'popout'} />
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>

<EuiSpacer size={'s'} />

<EuiFlexGroup>
<EuiFlexItem>
<EuiFormRow
label={'Document ID'}
data-test-subj={'finding-details-flyout-rule-document-id'}
>
<EuiText>{docId || DEFAULT_EMPTY_DATA}</EuiText>
</EuiFormRow>
</EuiFlexItem>

<EuiFlexItem>
<EuiFormRow
label={'Index'}
data-test-subj={'finding-details-flyout-rule-document-index'}
>
<EuiText>{index || DEFAULT_EMPTY_DATA}</EuiText>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>

<EuiSpacer size={'m'} />
/>
</EuiToolTip>
),
},
];

const documentsColumns: EuiBasicTableColumn<FindingDocumentItem>[] = [
{
name: '',
render: (item: FindingDocumentItem) => (
<EuiButtonIcon
onClick={() => this.toggleDocumentDetails(item)}
aria-label={docIdToExpandedRowMap[item.id] ? 'Collapse' : 'Expand'}
iconType={docIdToExpandedRowMap[item.id] ? 'arrowUp' : 'arrowDown'}
data-test-subj={`finding-details-flyout-document-toggle-${item.itemIdx}`}
/>
),
width: '30',
isExpander: true,
},
{
field: 'id',
name: 'Document Id',
},
{
name: 'Actions',
actions,
},
];

<EuiFormRow fullWidth={true}>
<EuiCodeBlock
language="json"
isCopyable
data-test-subj={'finding-details-flyout-rule-document'}
>
{formattedDocument}
</EuiCodeBlock>
</EuiFormRow>
</>
) : (
return (
<>
<EuiTitle size={'s'}>
<h3>Documents</h3>
</EuiTitle>
<EuiSpacer />
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={<h2>Document not found</h2>}
body={<p>The document that generated this finding could not be loaded.</p>}
<EuiFormRow label={'Index'} data-test-subj={`finding-details-flyout-rule-document-index`}>
<EuiText size="s">{index || DEFAULT_EMPTY_DATA}</EuiText>
</EuiFormRow>
<EuiSpacer />
<EuiInMemoryTable
columns={documentsColumns}
items={relatedDocuments}
itemId="id"
itemIdToExpandedRowMap={docIdToExpandedRowMap}
isExpandable={true}
hasActions={true}
pagination={true}
/>
</>
);
Expand All @@ -397,6 +446,7 @@ export default class FindingDetailsFlyout extends Component<
const {
finding: { related_doc_ids },
} = this.props;

if (this.state.isCreateIndexPatternModalVisible) {
return (
<EuiModal
Expand Down Expand Up @@ -446,7 +496,7 @@ export default class FindingDetailsFlyout extends Component<
}
}

private getTabContent(tabId: FindingFlyoutTabId, isDocumentLoading = false) {
private getTabContent(tabId: FindingFlyoutTabId, loadingIndexPatternId = false) {
switch (tabId) {
case FindingFlyoutTabId.CORRELATIONS:
const logTypes = new Set<string>();
Expand All @@ -470,11 +520,11 @@ export default class FindingDetailsFlyout extends Component<
);
case FindingFlyoutTabId.DETAILS:
default:
return this.createFindingDetails(isDocumentLoading);
return this.createFindingDetails(loadingIndexPatternId);
}
}

private createFindingDetails(isDocumentLoading: boolean) {
private createFindingDetails(loadingIndexPatternId: boolean) {
const {
finding: { queries },
} = this.props;
Expand All @@ -487,7 +537,7 @@ export default class FindingDetailsFlyout extends Component<
<EuiSpacer size={'m'} />
{this.renderRuleDetails(queries)}
<EuiSpacer size="l" />
{this.renderFindingDocuments(isDocumentLoading)}
{this.renderFindingDocuments(loadingIndexPatternId)}
</>
);
}
Expand All @@ -504,7 +554,7 @@ export default class FindingDetailsFlyout extends Component<
timestamp,
},
} = this.props;
const { isDocumentLoading } = this.state;
const { loadingIndexPatternId } = this.state;
return (
<EuiFlyout
onClose={DataStore.findings.closeFlyout}
Expand Down Expand Up @@ -587,7 +637,7 @@ export default class FindingDetailsFlyout extends Component<
this.setState({
selectedTab: {
id: tab.id,
content: this.getTabContent(tab.id, isDocumentLoading),
content: this.getTabContent(tab.id, loadingIndexPatternId),
},
});
}}
Expand Down
4 changes: 4 additions & 0 deletions types/Finding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export interface FindingDocument {
document: string;
}

export interface FindingDocumentItem extends FindingDocument {
itemIdx: number;
}

/**
* API interfaces
*/
Expand Down
Loading