[8.12] [Security Solution] JSON diff view for prebuilt rule upgrade flow (#172535) (#172957)

# Backport

This will backport the following commits from `main` to `8.12`:
- [[Security Solution] JSON diff view for prebuilt rule upgrade flow
(#172535)](https://github.com/elastic/kibana/pull/172535)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Nikita
Indik","email":"nikita.indik@elastic.co"},"sourceCommit":{"committedDate":"2023-12-08T15:16:42Z","message":"[Security
Solution] JSON diff view for prebuilt rule upgrade flow (#172535)\n\n##
Summary\r\n\r\n**Resolves:
https://github.com/elastic/kibana/issues/169160**\r\n**Resolves:
https://github.com/elastic/kibana/issues/166164**\r\n**Docs issue:
https://github.com/elastic/security-docs/issues/4371**\r\n\r\nThis PR
adds a new \"Updates\" tab to the prebuilt rules upgrade flyout.\r\nThis
tab shows a diff between the installed and updated rule
JSON\r\nrepresentations.\r\n\r\n<img width=\"1313\"
alt=\"Scherm­afbeelding 2023-12-05 om 02 48
37\"\r\nsrc=\"ec0f95c6-22c6-4ce6-a6cc-0ceee974c6f7\">\r\n\r\n\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [x] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[x] Functional changes are communicated to the Docs team. A ticket
or\r\nPR is opened in https://github.com/elastic/security-docs. The
following\r\ninformation is included: any feature flags used, affected
environments\r\n(Serverless, ESS, or both).
([Docs\r\nissue](https://github.com/elastic/security-docs/issues/4371))\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials
([Docs\r\nissue](https://github.com/elastic/security-docs/issues/4371))\r\n-
[ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios (will be added\r\nin
a follow-up PR)\r\n- [ ] Functional changes are covered with a test plan
and automated\r\ntests (will be added in a follow-up PR)\r\n- [x] Any UI
touched in this PR is usable by keyboard only (learn more\r\nabout
[keyboard accessibility](https://webaim.org/techniques/keyboard/))\r\n-
[x] Any UI touched in this PR does not create any new axe
failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[x] This renders correctly on smaller devices using a
responsive\r\nlayout. (Doesn't look great on phone screen, because
viewing diff\r\nrequires a lot of horizontal space. Tablets are fine
though.)\r\n- [x] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n-
[x] Functional changes are hidden behind a feature flag. If
not\r\nhidden, the PR explains why these changes are being implemented
in a\r\nlong-living feature branch.\r\n- [x] Comprehensive manual
testing is done by two engineers: the PR\r\nauthor and one of the PR
reviewers. Changes are tested in both ESS
and\r\nServerless.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Georgii Gorbachev
<georgii.gorbachev@elastic.co>","sha":"e5a6b978b8eca4ac275b72e88415e2238315a241","branchLabelMapping":{"^v8.13.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["Team:Detections
and Resp","Team:
SecuritySolution","release_note:feature","Team:Detection Rule
Management","Feature:Prebuilt Detection
Rules","v8.12.0","v8.13.0"],"number":172535,"url":"https://github.com/elastic/kibana/pull/172535","mergeCommit":{"message":"[Security
Solution] JSON diff view for prebuilt rule upgrade flow (#172535)\n\n##
Summary\r\n\r\n**Resolves:
https://github.com/elastic/kibana/issues/169160**\r\n**Resolves:
https://github.com/elastic/kibana/issues/166164**\r\n**Docs issue:
https://github.com/elastic/security-docs/issues/4371**\r\n\r\nThis PR
adds a new \"Updates\" tab to the prebuilt rules upgrade flyout.\r\nThis
tab shows a diff between the installed and updated rule
JSON\r\nrepresentations.\r\n\r\n<img width=\"1313\"
alt=\"Scherm­afbeelding 2023-12-05 om 02 48
37\"\r\nsrc=\"ec0f95c6-22c6-4ce6-a6cc-0ceee974c6f7\">\r\n\r\n\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [x] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[x] Functional changes are communicated to the Docs team. A ticket
or\r\nPR is opened in https://github.com/elastic/security-docs. The
following\r\ninformation is included: any feature flags used, affected
environments\r\n(Serverless, ESS, or both).
([Docs\r\nissue](https://github.com/elastic/security-docs/issues/4371))\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials
([Docs\r\nissue](https://github.com/elastic/security-docs/issues/4371))\r\n-
[ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios (will be added\r\nin
a follow-up PR)\r\n- [ ] Functional changes are covered with a test plan
and automated\r\ntests (will be added in a follow-up PR)\r\n- [x] Any UI
touched in this PR is usable by keyboard only (learn more\r\nabout
[keyboard accessibility](https://webaim.org/techniques/keyboard/))\r\n-
[x] Any UI touched in this PR does not create any new axe
failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[x] This renders correctly on smaller devices using a
responsive\r\nlayout. (Doesn't look great on phone screen, because
viewing diff\r\nrequires a lot of horizontal space. Tablets are fine
though.)\r\n- [x] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n-
[x] Functional changes are hidden behind a feature flag. If
not\r\nhidden, the PR explains why these changes are being implemented
in a\r\nlong-living feature branch.\r\n- [x] Comprehensive manual
testing is done by two engineers: the PR\r\nauthor and one of the PR
reviewers. Changes are tested in both ESS
and\r\nServerless.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Georgii Gorbachev
<georgii.gorbachev@elastic.co>","sha":"e5a6b978b8eca4ac275b72e88415e2238315a241"}},"sourceBranch":"main","suggestedTargetBranches":["8.12"],"targetPullRequestStates":[{"branch":"8.12","label":"v8.12.0","labelRegex":"^v(\\d+).(\\d+).\\d+$","isSourceBranch":false,"state":"NOT_CREATED"},{"branch":"main","label":"v8.13.0","labelRegex":"^v8.13.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/172535","number":172535,"mergeCommit":{"message":"[Security
Solution] JSON diff view for prebuilt rule upgrade flow (#172535)\n\n##
Summary\r\n\r\n**Resolves:
https://github.com/elastic/kibana/issues/169160**\r\n**Resolves:
https://github.com/elastic/kibana/issues/166164**\r\n**Docs issue:
https://github.com/elastic/security-docs/issues/4371**\r\n\r\nThis PR
adds a new \"Updates\" tab to the prebuilt rules upgrade flyout.\r\nThis
tab shows a diff between the installed and updated rule
JSON\r\nrepresentations.\r\n\r\n<img width=\"1313\"
alt=\"Scherm­afbeelding 2023-12-05 om 02 48
37\"\r\nsrc=\"ec0f95c6-22c6-4ce6-a6cc-0ceee974c6f7\">\r\n\r\n\r\n\r\n###
Checklist\r\n\r\nDelete any items that are not applicable to this
PR.\r\n\r\n- [x] Any text added follows [EUI's
writing\r\nguidelines](https://elastic.github.io/eui/#/guidelines/writing),
uses\r\nsentence case text and includes
[i18n\r\nsupport](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)\r\n-
[x] Functional changes are communicated to the Docs team. A ticket
or\r\nPR is opened in https://github.com/elastic/security-docs. The
following\r\ninformation is included: any feature flags used, affected
environments\r\n(Serverless, ESS, or both).
([Docs\r\nissue](https://github.com/elastic/security-docs/issues/4371))\r\n-
[
]\r\n[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)\r\nwas
added for features that require explanation or tutorials
([Docs\r\nissue](https://github.com/elastic/security-docs/issues/4371))\r\n-
[ ] [Unit or
functional\r\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\r\nwere
updated or added to match the most common scenarios (will be added\r\nin
a follow-up PR)\r\n- [ ] Functional changes are covered with a test plan
and automated\r\ntests (will be added in a follow-up PR)\r\n- [x] Any UI
touched in this PR is usable by keyboard only (learn more\r\nabout
[keyboard accessibility](https://webaim.org/techniques/keyboard/))\r\n-
[x] Any UI touched in this PR does not create any new axe
failures\r\n(run axe in
browser:\r\n[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),\r\n[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))\r\n-
[x] This renders correctly on smaller devices using a
responsive\r\nlayout. (Doesn't look great on phone screen, because
viewing diff\r\nrequires a lot of horizontal space. Tablets are fine
though.)\r\n- [x] This was checked for
[cross-browser\r\ncompatibility](https://www.elastic.co/support/matrix#matrix_browsers)\r\n-
[x] Functional changes are hidden behind a feature flag. If
not\r\nhidden, the PR explains why these changes are being implemented
in a\r\nlong-living feature branch.\r\n- [x] Comprehensive manual
testing is done by two engineers: the PR\r\nauthor and one of the PR
reviewers. Changes are tested in both ESS
and\r\nServerless.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>\r\nCo-authored-by:
Georgii Gorbachev
<georgii.gorbachev@elastic.co>","sha":"e5a6b978b8eca4ac275b72e88415e2238315a241"}}]}]
BACKPORT-->

Co-authored-by: Nikita Indik <nikita.indik@elastic.co>
This commit is contained in:
Kibana Machine 2023-12-08 11:36:18 -05:00 committed by GitHub
parent 230722552d
commit 692bde9cf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1004 additions and 70 deletions

View file

@ -922,6 +922,7 @@
"deep-freeze-strict": "^1.1.1",
"deepmerge": "^4.2.2",
"del": "^6.1.0",
"diff": "^5.1.0",
"elastic-apm-node": "^4.2.0",
"email-addresses": "^5.0.0",
"execa": "^5.1.1",
@ -1029,6 +1030,7 @@
"react": "^17.0.2",
"react-ace": "^7.0.5",
"react-color": "^2.13.8",
"react-diff-view": "^3.2.0",
"react-dom": "^17.0.2",
"react-dropzone": "^4.2.9",
"react-fast-compare": "^2.0.4",
@ -1089,6 +1091,7 @@
"type-detect": "^4.0.8",
"typescript-fsa": "^3.0.0",
"typescript-fsa-reducers": "^1.2.2",
"unidiff": "^1.0.4",
"unified": "9.2.2",
"use-resize-observer": "^9.1.0",
"usng.js": "^0.4.5",
@ -1345,6 +1348,7 @@
"@types/dedent": "^0.7.0",
"@types/deep-freeze-strict": "^1.1.0",
"@types/delete-empty": "^2.0.0",
"@types/diff": "^5.0.8",
"@types/ejs": "^3.0.6",
"@types/enzyme": "^3.10.12",
"@types/eslint": "^8.44.2",
@ -1508,7 +1512,6 @@
"debug": "^2.6.9",
"delete-empty": "^2.0.0",
"dependency-check": "^4.1.0",
"diff": "^4.0.1",
"dpdm": "3.9.0",
"ejs": "^3.1.8",
"enzyme": "^3.11.0",

View file

@ -53,10 +53,10 @@ it('rewrites ftr reports with minimal changes', async () => {
reportPath: Path.resolve(__dirname, './__fixtures__/ftr_report.xml'),
});
expect(createPatch('ftr.xml', FTR_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(`
expect(createPatch('ftr.xml', FTR_REPORT, xml)).toMatchInlineSnapshot(`
Index: ftr.xml
===================================================================
--- ftr.xml [object Object]
--- ftr.xml
+++ ftr.xml
@@ -1,53 +1,56 @@
?xml version="1.0" encoding="utf-8"?
@ -149,10 +149,10 @@ it('rewrites jest reports with minimal changes', async () => {
reportPath: Path.resolve(__dirname, './__fixtures__/jest_report.xml'),
});
expect(createPatch('jest.xml', JEST_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(`
expect(createPatch('jest.xml', JEST_REPORT, xml)).toMatchInlineSnapshot(`
Index: jest.xml
===================================================================
--- jest.xml [object Object]
--- jest.xml
+++ jest.xml
@@ -3,13 +3,17 @@
testsuite name="x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts" timestamp="2019-06-07T03:42:21" time="14.504" tests="5" failures="1" skipped="0" file="/var/lib/jenkins/workspace/elastic+kibana+master/JOB/x-pack-intake/node/immutable/kibana/x-pack/legacy/plugins/code/server/lsp/abstract_launcher.test.ts"
@ -196,10 +196,10 @@ it('rewrites mocha reports with minimal changes', async () => {
reportPath: Path.resolve(__dirname, './__fixtures__/mocha_report.xml'),
});
expect(createPatch('mocha.xml', MOCHA_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(`
expect(createPatch('mocha.xml', MOCHA_REPORT, xml)).toMatchInlineSnapshot(`
Index: mocha.xml
===================================================================
--- mocha.xml [object Object]
--- mocha.xml
+++ mocha.xml
@@ -1,13 +1,16 @@
?xml version="1.0" encoding="utf-8"?
@ -273,10 +273,10 @@ it('rewrites cypress reports with minimal changes', async () => {
reportPath: Path.resolve(__dirname, './__fixtures__/cypress_report.xml'),
});
expect(createPatch('cypress.xml', CYPRESS_REPORT, xml, { context: 0 })).toMatchInlineSnapshot(`
expect(createPatch('cypress.xml', CYPRESS_REPORT, xml)).toMatchInlineSnapshot(`
Index: cypress.xml
===================================================================
--- cypress.xml [object Object]
--- cypress.xml
+++ cypress.xml
@@ -1,25 +1,16 @@
-?xml version="1.0" encoding="UTF-8"?

View file

@ -156,6 +156,13 @@ export const allowedExperimentalValues = Object.freeze({
* Enables SentinelOne manual host manipulation actions
*/
sentinelOneManualHostActionsEnabled: false,
/*
* Enables experimental "Updates" tab in the prebuilt rule upgrade flyout.
* This tab shows the JSON diff between the installed prebuilt rule
* version and the latest available version.
*/
jsonPrebuiltRulesDiffingEnabled: false,
});
type ExperimentalConfigKeys = Array<keyof ExperimentalFeatures>;

View file

@ -5,4 +5,5 @@
* 2.0.
*/
export const DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%'];
export const DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['50%', '50%'];
export const LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS: [string, string] = ['30%', '70%'];

View file

@ -0,0 +1,252 @@
/*
* 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 { css, Global } from '@emotion/react';
import {
Diff,
useSourceExpansion,
useMinCollapsedLines,
parseDiff,
tokenize,
} from 'react-diff-view';
import 'react-diff-view/style/index.css';
import type {
RenderGutter,
HunkData,
TokenizeOptions,
DiffProps,
HunkTokens,
} from 'react-diff-view';
import unidiff from 'unidiff';
import { useEuiTheme } from '@elastic/eui';
import { Hunks } from './hunks';
import { markEdits, DiffMethod } from './mark_edits';
interface UseExpandReturn {
expandRange: (start: number, end: number) => void;
hunks: HunkData[];
}
/**
* @param {HunkData[]} hunks - An array of hunk objects representing changes in a section of a string. Sections normally span multiple lines.
* @param {string} oldSource - Original string, before changes
* @returns {UseExpandReturn} - "expandRange" is function that triggers expansion, "hunks" is an array of hunks with hidden section expanded.
*
* @description
* Sections of diff without changes are hidden by default, because they are not present in the "hunks" array.
* "useExpand" allows to show these hidden sections when user clicks on "Expand hidden <number> lines" button.
* Calling "expandRange" basically merges two adjacent hunks into one:
* - takes first hunk
* - appends all the lines between the first hunk and the second hunk
* - finally appends the second hunk
* returned "hunks" is the resulting array of hunks with hidden section expanded.
*/
const useExpand = (hunks: HunkData[], oldSource: string): UseExpandReturn => {
const [hunksWithSourceExpanded, expandRange] = useSourceExpansion(hunks, oldSource);
const hunksWithMinLinesCollapsed = useMinCollapsedLines(0, hunksWithSourceExpanded, oldSource);
return {
expandRange,
hunks: hunksWithMinLinesCollapsed,
};
};
const useTokens = (
hunks: HunkData[],
diffMethod: DiffMethod,
oldSource: string
): HunkTokens | undefined => {
if (!hunks) {
return undefined;
}
const options: TokenizeOptions = {
oldSource,
highlight: false,
enhancers: [
/*
This custom "markEdits" function is a slightly modified version of "markEdits"
enhancer from react-diff-view with added support for word-level highlighting.
*/
markEdits(hunks, diffMethod),
],
};
try {
/*
Synchroniously apply all the enhancers to the hunks and return an array of tokens.
*/
return tokenize(hunks, options);
} catch (ex) {
return undefined;
}
};
const renderGutter: RenderGutter = ({ change }) => {
/*
Custom gutter: rendering "+" or "-" so the diff is readable by colorblind people.
*/
if (change.type === 'insert') {
return <span>{'+'}</span>;
}
if (change.type === 'delete') {
return <span>{'-'}</span>;
}
return null;
};
const convertToDiffFile = (oldSource: string, newSource: string) => {
/*
"diffLines" call converts two strings of text into an array of Change objects.
*/
const changes = unidiff.diffLines(oldSource, newSource);
/*
Then "formatLines" takes an array of Change objects and turns it into a single "unified diff" string.
More info about the "unified diff" format: https://en.wikipedia.org/wiki/Diff_utility#Unified_format
Unified diff is a string with change markers added. Looks something like:
`
@@ -3,16 +3,15 @@
"author": ["Elastic"],
- "from": "now-540s",
+ "from": "now-9m",
"history_window_start": "now-14d",
`
*/
const unifiedDiff: string = unidiff.formatLines(changes, {
context: 3,
});
/*
"parseDiff" converts a unified diff string into a gitdiff-parser File object.
File object contains some metadata and the "hunks" property - an array of Hunk objects.
Hunks represent changed lines of code plus a few unchanged lines above and below for context.
*/
const [diffFile] = parseDiff(unifiedDiff, {
nearbySequences: 'zip',
});
return diffFile;
};
const TABLE_CLASS_NAME = 'rule-update-diff-table';
const CODE_CLASS_NAME = 'rule-update-diff-code';
const GUTTER_CLASS_NAME = 'rule-update-diff-gutter';
const CustomStyles: React.FC = ({ children }) => {
const { euiTheme } = useEuiTheme();
const customCss = css`
.${TABLE_CLASS_NAME} .diff-gutter-col {
width: ${euiTheme.size.xl};
}
.${CODE_CLASS_NAME}.diff-code, .${GUTTER_CLASS_NAME}.diff-gutter {
background: transparent;
}
.${GUTTER_CLASS_NAME}:nth-child(3) {
border-left: 1px solid ${euiTheme.colors.mediumShade};
}
.${GUTTER_CLASS_NAME}.diff-gutter-delete {
color: ${euiTheme.colors.dangerText};
font-weight: bold;
}
.${GUTTER_CLASS_NAME}.diff-gutter-insert {
color: ${euiTheme.colors.successText};
font-weight: bold;
}
.${CODE_CLASS_NAME}.diff-code {
padding: 0 ${euiTheme.size.l} 0 ${euiTheme.size.m};
}
.${CODE_CLASS_NAME}.diff-code-delete .diff-code-edit,
.${CODE_CLASS_NAME}.diff-code-insert .diff-code-edit {
background: transparent;
}
.${CODE_CLASS_NAME}.diff-code-delete .diff-code-edit {
color: ${euiTheme.colors.dangerText};
text-decoration: line-through;
}
.${CODE_CLASS_NAME}.diff-code-insert .diff-code-edit {
color: ${euiTheme.colors.successText};
text-decoration: underline;
}
`;
return (
<>
<Global styles={customCss} />
{children}
</>
);
};
interface DiffViewProps extends Partial<DiffProps> {
oldSource: string;
newSource: string;
diffMethod?: DiffMethod;
}
export const DiffView = ({
oldSource,
newSource,
diffMethod = DiffMethod.WORDS,
}: DiffViewProps) => {
/*
"react-diff-view" components consume diffs not as a strings, but as something they call "hunks".
So we first need to convert our "before" and "after" strings into these "hunk" objects.
"hunks" describe changed sections of code plus a few unchanged lines above and below for context.
*/
/*
"diffFile" is essentially an object containing an array of hunks plus some metadata.
*/
const diffFile = useMemo(() => convertToDiffFile(oldSource, newSource), [oldSource, newSource]);
/*
Sections of diff without changes are hidden by default, because they are not present in the "hunks" array.
"useExpand" allows to show these hidden sections when a user clicks on "Expand hidden <number> lines" button.
*/
const { expandRange, hunks } = useExpand(diffFile.hunks, oldSource);
/*
Go over each hunk and extract tokens from it. For example, split strings into words or characters,
so we can highlight them later.
*/
const tokens = useTokens(hunks, diffMethod, oldSource);
return (
<CustomStyles>
<Diff
/*
"diffType": can be either 'add', 'delete', 'modify', 'rename' or 'copy'.
Passing 'add' or 'delete' would skip rendering one of the sides in split view.
*/
diffType={diffFile.type}
hunks={hunks}
renderGutter={renderGutter}
tokens={tokens}
className={TABLE_CLASS_NAME}
gutterClassName={GUTTER_CLASS_NAME}
codeClassName={CODE_CLASS_NAME}
>
{/* eslint-disable-next-line @typescript-eslint/no-shadow */}
{(hunks) => <Hunks hunks={hunks} oldSource={oldSource} expandRange={expandRange} />}
</Diff>
</CustomStyles>
);
};

View file

@ -0,0 +1,124 @@
/*
* 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 type { ReactElement } from 'react';
import { Hunk, Decoration, getCollapsedLinesCountBetween } from 'react-diff-view';
import type { HunkData, DecorationProps } from 'react-diff-view';
import { EuiSpacer, EuiIcon, EuiLink, EuiFlexGroup, EuiText } from '@elastic/eui';
import * as i18n from './translations';
interface UnfoldButtonProps extends Omit<DecorationProps, 'children'> {
start: number;
end: number;
onExpand: (start: number, end: number) => void;
}
const UnfoldButton = ({ start, end, onExpand, ...props }: UnfoldButtonProps) => {
const expand = useCallback(() => onExpand(start, end), [onExpand, start, end]);
const linesCount = end - start;
return (
<Decoration {...props}>
<EuiFlexGroup direction="column" gutterSize="none">
{start > 1 && <EuiSpacer size="m" />}
<EuiFlexGroup justifyContent="center">
<EuiLink onClick={expand}>
<EuiFlexGroup justifyContent="center" alignItems="center" gutterSize="s">
<EuiIcon type="sortable" />
<EuiText size="s">{i18n.EXPAND_UNCHANGED_LINES(linesCount)}</EuiText>
</EuiFlexGroup>
</EuiLink>
</EuiFlexGroup>
<EuiSpacer size="m" />
</EuiFlexGroup>
</Decoration>
);
};
interface UnfoldCollapsedProps {
previousHunk: HunkData;
currentHunk?: HunkData;
linesCount: number;
onExpand: (start: number, end: number) => void;
}
const UnfoldCollapsed = ({
previousHunk,
currentHunk,
linesCount,
onExpand,
}: UnfoldCollapsedProps) => {
if (!currentHunk) {
const nextStart = previousHunk.oldStart + previousHunk.oldLines;
const collapsedLines = linesCount - nextStart + 1;
if (collapsedLines <= 0) {
return null;
}
return <UnfoldButton start={nextStart} end={linesCount + 1} onExpand={onExpand} />;
}
const collapsedLines = getCollapsedLinesCountBetween(previousHunk, currentHunk);
if (!previousHunk) {
if (!collapsedLines) {
return null;
}
return <UnfoldButton start={1} end={currentHunk.oldStart} onExpand={onExpand} />;
}
const collapsedStart = previousHunk.oldStart + previousHunk.oldLines;
const collapsedEnd = currentHunk.oldStart;
return <UnfoldButton start={collapsedStart} end={collapsedEnd} onExpand={onExpand} />;
};
interface HunksProps {
hunks: HunkData[];
oldSource: string;
expandRange: (start: number, end: number) => void;
}
export const Hunks = ({ hunks, oldSource, expandRange }: HunksProps) => {
const linesCount = oldSource.split('\n').length;
const hunkElements = hunks.reduce((children: ReactElement[], hunk: HunkData, index: number) => {
const previousElement = children[children.length - 1];
children.push(
<UnfoldCollapsed
key={`decoration-${hunk.content}`}
previousHunk={previousElement && previousElement.props.hunk}
currentHunk={hunk}
linesCount={linesCount}
onExpand={expandRange}
/>
);
children.push(<Hunk key={`hunk-${hunk.content}`} hunk={hunk} />);
const isLastHunk = index === hunks.length - 1;
if (isLastHunk && oldSource) {
children.push(
<UnfoldCollapsed
key="decoration-tail"
previousHunk={hunk}
linesCount={linesCount}
onExpand={expandRange}
/>
);
}
return children;
}, []);
return <>{hunkElements}</>;
};

View file

@ -0,0 +1,290 @@
/*
* 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 { findIndex, flatMap, flatten } from 'lodash';
import * as diff from 'diff';
import type { Change as DiffJsChange } from 'diff';
import { isDelete, isInsert, isNormal, pickRanges } from 'react-diff-view';
import type { ChangeData, HunkData, RangeTokenNode, TokenizeEnhancer } from 'react-diff-view';
enum DmpChangeType {
DELETE = -1,
EQUAL = 0,
INSERT = 1,
}
type Diff = [DmpChangeType, string];
type StringDiffFn = (oldString: string, newString: string) => DiffJsChange[];
interface JsDiff {
diffChars: StringDiffFn;
diffWords: StringDiffFn;
diffWordsWithSpace: StringDiffFn;
diffLines: StringDiffFn;
diffTrimmedLines: StringDiffFn;
diffSentences: StringDiffFn;
diffCss: StringDiffFn;
}
const jsDiff: JsDiff = diff;
export enum DiffMethod {
CHARS = 'diffChars',
WORDS = 'diffWords',
WORDS_WITH_SPACE = 'diffWordsWithSpace',
LINES = 'diffLines',
TRIMMED_LINES = 'diffTrimmedLines',
SENTENCES = 'diffSentences',
CSS = 'diffCss',
}
/**
* @param {ChangeData[]} changes - An array representing the changes in the block.
* Each hunk represents a section of a string and includes information about the changes in that section.
* Sections normally span multiple lines.
* @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction. For example, "diffWords" will tokenize the string into words.
*
* @returns {TokenizeEnhancer} A react-diff-view plugin that processes diff hunks and returns an array of tokens.
* Tokens are then used to render "added" / "removed" diff highlighting.
*
* @description
* Converts the given ChangeData array to two strings representing the old source and new source of a change block.
* The format of the strings is as follows:
*/
function findChangeBlocks(changes: ChangeData[]): ChangeData[][] {
const start = findIndex(changes, (change) => !isNormal(change));
if (start === -1) {
return [];
}
const end = findIndex(changes, (change) => !!isNormal(change), start);
if (end === -1) {
return [changes.slice(start)];
}
return [changes.slice(start, end), ...findChangeBlocks(changes.slice(end))];
}
function groupDiffs(diffs: Diff[]): [Diff[], Diff[]] {
return diffs.reduce<[Diff[], Diff[]]>(
// eslint-disable-next-line @typescript-eslint/no-shadow
([oldDiffs, newDiffs], diff) => {
const [type] = diff;
switch (type) {
case DmpChangeType.INSERT:
newDiffs.push(diff);
break;
case DmpChangeType.DELETE:
oldDiffs.push(diff);
break;
default:
oldDiffs.push(diff);
newDiffs.push(diff);
break;
}
return [oldDiffs, newDiffs];
},
[[], []]
);
}
/**
* @param {Diff[]} diffs An array of changes in the diff-match-patch format
* @returns {Diff[][]} An array of arrays, where changes are grouped by a line number.
*/
function splitDiffToLines(diffs: Diff[]): Diff[][] {
return diffs.reduce<Diff[][]>(
(lines, [type, value]) => {
const currentLines = value.split('\n');
const [currentLineRemaining, ...nextLines] = currentLines.map(
(line: string): Diff => [type, line]
);
const next: Diff[][] = [
...lines.slice(0, -1),
[...lines[lines.length - 1], currentLineRemaining],
...nextLines.map((line) => [line]),
];
return next;
},
[[]]
);
}
/**
* @param {Diff[]} diffs An array of changes within a single line in the diff-match-patch format
* @param {number} lineNumber Line number where the changes are found
* @returns {RangeTokenNode[]} Array of "edit" objects where each item contains
* info about line number and start / end character positions.
*/
function diffsToEdits(diffs: Diff[], lineNumber: number): RangeTokenNode[] {
const output = diffs.reduce<[RangeTokenNode[], number]>(
// eslint-disable-next-line @typescript-eslint/no-shadow
(output, diff) => {
const [edits, start] = output;
const [type, value] = diff;
if (type !== DmpChangeType.EQUAL) {
const edit: RangeTokenNode = {
type: 'edit',
lineNumber,
start,
length: value.length,
};
edits.push(edit);
}
return [edits, start + value.length];
},
[[], 0]
);
return output[0];
}
/**
* @param {Diff[][]} linesOfDiffs - Changes in a diff-match-patch format, grouped by a line number.
* @param {number} startLineNumber - Line number of the first line.
* @returns {RangeTokenNode[]} Flattened array of "edit" objects where each item contains
* info about line number and start / end character positions.
*/
function convertToLinesOfEdits(linesOfDiffs: Diff[][], startLineNumber: number): RangeTokenNode[] {
return flatMap(linesOfDiffs, (diffs, i) => diffsToEdits(diffs, startLineNumber + i));
}
/**
* @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction.
* @param {string} oldSource - A substring of the original source string.
* @param {string} newSource - A corresponding substring of the new source string.
* @returns {[Diff[], Diff[]]} Two arrays of changes in the diff-match-patch format.
* Every item is a tuple of two values: [<change type: addition, deletion or unchanged>, <substring>].
*
* @description Runs two strings through the chosen diffing algorithm using the "diff" library to determine
* which parts of the original string were added / removed / unchanged. Then returns an array of changes in
* the diff-match-patch diff format.
*/
function diffBy(diffMethod: DiffMethod, oldSource: string, newSource: string): [Diff[], Diff[]] {
/* Diff two substrings using the "diff" library */
const jsDiffChanges: DiffJsChange[] = jsDiff[diffMethod](oldSource, newSource);
/* Convert the result to the diff-match-patch format, because that's the format react-diff-view methods expect */
const diffs: Diff[] = diff.convertChangesToDMP(jsDiffChanges);
if (diffs.length <= 1) {
return [[], []];
}
/* Split diff-match-patch formatted diffs into two arrays: one for the old source and one for the new source */
return groupDiffs(diffs);
}
const getLineNumber = (change: ChangeData | undefined) => {
if (!change || isNormal(change)) {
return undefined;
}
return change.lineNumber;
};
/**
* @param {ChangeData[]} changes - An array of сhange objects. Each change object represents changes in a single line.
* @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction.
* @returns {[RangeTokenNode[], RangeTokenNode[]]} A tuple containing two arrays of RangeTokenNodes - one for
* the old source and another one for the new source. Each RangeTokenNode contains information about line numbers
* and character positions of changes.
*
* @description This function processes change objects and determines exactly which segments of the orginal string changed.
* It diffs old and new substrings and computes at which character position each change starts and ends,
* taking the diffing algorithm into account (by char, by word, by sentence, etc.)
*/
function diffChangeBlock(
changes: ChangeData[],
diffMethod: DiffMethod
): [RangeTokenNode[], RangeTokenNode[]] {
/*
Convert an array of change objects into two strings representing the old source and the new source of a change block.
Basically, recreate parts of the original strings from change objects so we can pass these strings to the text diffing library.
*/
const [oldSourceSnippet, newSourceSnippet] = changes.reduce(
// eslint-disable-next-line @typescript-eslint/no-shadow
([oldSourceSnippet, newSourceSnippet], change) =>
isDelete(change)
? [oldSourceSnippet + (oldSourceSnippet ? '\n' : '') + change.content, newSourceSnippet]
: [oldSourceSnippet, newSourceSnippet + (newSourceSnippet ? '\n' : '') + change.content],
['', '']
);
/*
* Run the chosen diffing algorithm with an "old" and a "new" substrings as input.
* The result is an array of changes in the diff-match-patch format.
*/
const [oldDiffs, newDiffs] = diffBy(diffMethod, oldSourceSnippet, newSourceSnippet);
if (oldDiffs.length === 0 && newDiffs.length === 0) {
return [[], []];
}
const oldStartLineNumber = getLineNumber(changes.find(isDelete));
const newStartLineNumber = getLineNumber(changes.find(isInsert));
if (oldStartLineNumber === undefined || newStartLineNumber === undefined) {
throw new Error('Could not find start line number for edit');
}
/*
* Group changes by a line number they are found in, then determine start / end character
* positions of each change.
*/
const oldEdits = convertToLinesOfEdits(splitDiffToLines(oldDiffs), oldStartLineNumber);
const newEdits = convertToLinesOfEdits(splitDiffToLines(newDiffs), newStartLineNumber);
return [oldEdits, newEdits];
}
/**
* @param {HunkData[]} hunks - An array of hunk objects.
* Each hunk represents a section of a string and includes information about the changes in that section.
* Sections normally span multiple lines.
* @param {DiffMethod} diffMethod - Diffing algorithm to use for token extraction. For example, "diffWords" will tokenize the string into words.
*
* @returns {TokenizeEnhancer} A react-diff-view plugin that processes diff hunks and returns an array of tokens.
* Tokens are then used to render "added" / "removed" diff highlighting.
*
* @description
* Converts the given ChangeData array to two strings representing the old source and new source of a change block.
* The format of the strings is as follows:
*/
export function markEdits(hunks: HunkData[], diffMethod: DiffMethod): TokenizeEnhancer {
/*
changeBlocks is an array that contains information about the lines that have changes (additions or deletions).
Unchanged lines are not included.
*/
const changeBlocks = flatMap(
hunks.map((hunk) => hunk.changes),
findChangeBlocks
);
const [oldEdits, newEdits] = changeBlocks
/*
diffChangeBlock diffs two substrings and determines character positions of changes,
taking the diffing algorithm into account (by char, by word, by sentence, etc.)
*/
.map((changes) => diffChangeBlock(changes, diffMethod))
.reduce(
// eslint-disable-next-line @typescript-eslint/no-shadow
([oldEdits, newEdits], [currentOld, currentNew]) => [
oldEdits.concat(currentOld),
newEdits.concat(currentNew),
],
[[], []]
);
return pickRanges(flatten(oldEdits), flatten(newEdits));
}

View file

@ -0,0 +1,46 @@
/*
* 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 EXPAND_UNCHANGED_LINES = (linesCount: number) =>
i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.expandHiddenDiffLinesLabel',
{
values: { linesCount },
defaultMessage:
'Expand {linesCount} unchanged {linesCount, plural, one {line} other {lines}}',
}
);
export const BASE_VERSION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.baseVersionLabel',
{
defaultMessage: 'Base version',
}
);
export const BASE_VERSION_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.baseVersionDescriptionLabel',
{
defaultMessage: 'Shows currently installed rule',
}
);
export const UPDATED_VERSION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.updatedVersionLabel',
{
defaultMessage: 'Update',
}
);
export const UPDATED_VERSION_DESCRIPTION = i18n.translate(
'xpack.securitySolution.detectionEngine.rules.upgradeRules.updatedVersionDescriptionLabel',
{
defaultMessage: 'Shows rule that will be installed',
}
);

View file

@ -0,0 +1,23 @@
/*
* 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.
*/
declare module 'unidiff' {
interface Change {
count?: number | undefined;
value: string;
added?: boolean | undefined;
removed?: boolean | undefined;
}
export interface FormatOptions {
context?: number;
}
export function diffLines(x: string, y: string): Change[];
export function formatLines(line: Change[], options?: FormatOptions): string;
}

View file

@ -33,7 +33,7 @@ import { filterEmptyThreats } from '../../../rule_creation_ui/pages/rule_creatio
import { ThreatEuiFlexGroup } from '../../../../detections/components/rules/description_step/threat_description';
import { BadgeList } from './badge_list';
import { DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import * as i18n from './translations';
const OverrideColumn = styled(EuiFlexItem)`
@ -426,12 +426,14 @@ const prepareAboutSectionListItems = (
export interface RuleAboutSectionProps extends React.ComponentProps<typeof EuiDescriptionList> {
rule: Partial<RuleResponse>;
columnWidths?: EuiDescriptionListProps['columnWidths'];
hideName?: boolean;
hideDescription?: boolean;
}
export const RuleAboutSection = ({
rule,
columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS,
hideName,
hideDescription,
...descriptionListProps
@ -445,7 +447,7 @@ export const RuleAboutSection = ({
type={descriptionListProps.type ?? 'column'}
rowGutterSize={descriptionListProps.rowGutterSize ?? 'm'}
listItems={aboutSectionListItems}
columnWidths={DESCRIPTION_LIST_COLUMN_WIDTHS}
columnWidths={columnWidths}
data-test-subj="listItemColumnStepRuleDescription"
{...descriptionListProps}
/>

View file

@ -52,7 +52,7 @@ import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/
import { useKibana } from '../../../../common/lib/kibana/kibana_react';
import { TechnicalPreviewBadge } from '../../../../detections/components/rules/technical_preview_badge';
import { BadgeList } from './badge_list';
import { DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import * as i18n from './translations';
import { useIsExperimentalFeatureEnabled } from '../../../../common/hooks/use_experimental_features';
import type { ExperimentalFeatures } from '../../../../../common/experimental_features';
@ -724,6 +724,7 @@ const prepareDefinitionSectionListItems = (
export interface RuleDefinitionSectionProps
extends React.ComponentProps<typeof EuiDescriptionList> {
rule: Partial<RuleResponse>;
columnWidths?: EuiDescriptionListProps['columnWidths'];
isInteractive?: boolean;
dataTestSubj?: string;
}
@ -731,6 +732,7 @@ export interface RuleDefinitionSectionProps
export const RuleDefinitionSection = ({
rule,
isInteractive = false,
columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS,
dataTestSubj,
...descriptionListProps
}: RuleDefinitionSectionProps) => {
@ -756,7 +758,7 @@ export const RuleDefinitionSection = ({
type={descriptionListProps.type ?? 'column'}
rowGutterSize={descriptionListProps.rowGutterSize ?? 'm'}
listItems={definitionSectionListItems}
columnWidths={DESCRIPTION_LIST_COLUMN_WIDTHS}
columnWidths={columnWidths}
data-test-subj="listItemColumnStepRuleDescription"
{...descriptionListProps}
/>

View file

@ -21,11 +21,15 @@ import {
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
import type { EuiTabbedContentTab, EuiTabbedContentProps } from '@elastic/eui';
import type { EuiTabbedContentTab, EuiTabbedContentProps, EuiFlyoutProps } from '@elastic/eui';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { RuleOverviewTab, useOverviewTabSections } from './rule_overview_tab';
import { RuleInvestigationGuideTab } from './rule_investigation_guide_tab';
import {
DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS,
LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS,
} from './constants';
import * as i18n from './translations';
@ -95,13 +99,15 @@ const tabPaddingClassName = css`
padding: 0 ${euiThemeVars.euiSizeM} ${euiThemeVars.euiSizeXL} ${euiThemeVars.euiSizeM};
`;
const TabContentPadding: React.FC = ({ children }) => (
export const TabContentPadding: React.FC = ({ children }) => (
<div className={tabPaddingClassName}>{children}</div>
);
interface RuleDetailsFlyoutProps {
rule: RuleResponse;
ruleActions?: React.ReactNode;
size?: EuiFlyoutProps['size'];
extraTabs?: EuiTabbedContentTab[];
dataTestSubj?: string;
closeFlyout: () => void;
}
@ -109,6 +115,8 @@ interface RuleDetailsFlyoutProps {
export const RuleDetailsFlyout = ({
rule,
ruleActions,
size = 'm',
extraTabs = [],
dataTestSubj,
closeFlyout,
}: RuleDetailsFlyoutProps) => {
@ -122,13 +130,18 @@ export const RuleDetailsFlyout = ({
<TabContentPadding>
<RuleOverviewTab
rule={rule}
columnWidths={
size === 'l'
? LARGE_DESCRIPTION_LIST_COLUMN_WIDTHS
: DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS
}
expandedOverviewSections={expandedOverviewSections}
toggleOverviewSection={toggleOverviewSection}
/>
</TabContentPadding>
),
}),
[rule, expandedOverviewSections, toggleOverviewSection]
[rule, size, expandedOverviewSections, toggleOverviewSection]
);
const investigationGuideTab: EuiTabbedContentTab = useMemo(
@ -146,11 +159,11 @@ export const RuleDetailsFlyout = ({
const tabs = useMemo(() => {
if (rule.note) {
return [overviewTab, investigationGuideTab];
return [...extraTabs, overviewTab, investigationGuideTab];
} else {
return [overviewTab];
return [...extraTabs, overviewTab];
}
}, [overviewTab, investigationGuideTab, rule.note]);
}, [overviewTab, investigationGuideTab, rule.note, extraTabs]);
const [selectedTabId, setSelectedTabId] = useState<string>(tabs[0].id);
const selectedTab = tabs.find((tab) => tab.id === selectedTabId) ?? tabs[0];
@ -168,7 +181,7 @@ export const RuleDetailsFlyout = ({
return (
<EuiFlyout
size="m"
size={size}
onClose={closeFlyout}
ownFocus={false}
key="prebuilt-rules-flyout"

View file

@ -0,0 +1,76 @@
/*
* 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 { omit } from 'lodash';
import stringify from 'json-stable-stringify';
import {
EuiSpacer,
EuiPanel,
EuiHorizontalRule,
EuiFlexGroup,
EuiTitle,
EuiIconTip,
} from '@elastic/eui';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema/rule_schemas.gen';
import { DiffView } from './json_diff/diff_view';
import * as i18n from './json_diff/translations';
const sortAndStringifyJson = (jsObject: Record<string, unknown>): string =>
stringify(jsObject, { space: 2 });
interface RuleDiffTabProps {
oldRule: RuleResponse;
newRule: RuleResponse;
}
export const RuleDiffTab = ({ oldRule, newRule }: RuleDiffTabProps) => {
const [oldSource, newSource] = useMemo(() => {
const visibleOldRuleProperties = omit(oldRule, 'revision');
const visibleNewRuleProperties = omit(newRule, 'revision');
return [
sortAndStringifyJson(visibleOldRuleProperties),
sortAndStringifyJson(visibleNewRuleProperties),
];
}, [oldRule, newRule]);
return (
<>
<EuiSpacer size="m" />
<EuiPanel color="transparent" hasBorder>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexGroup alignItems="baseline" gutterSize="xs">
<EuiIconTip
color="subdued"
content={i18n.BASE_VERSION_DESCRIPTION}
type="iInCircle"
size="m"
display="block"
/>
<EuiTitle size="xxxs">
<h6>{i18n.BASE_VERSION}</h6>
</EuiTitle>
</EuiFlexGroup>
<EuiFlexGroup alignItems="baseline" gutterSize="xs">
<EuiIconTip
color="subdued"
content={i18n.UPDATED_VERSION_DESCRIPTION}
type="iInCircle"
size="m"
/>
<EuiTitle size="xxxs">
<h6>{i18n.UPDATED_VERSION}</h6>
</EuiTitle>
</EuiFlexGroup>
</EuiFlexGroup>
<EuiHorizontalRule margin="s" size="full" />
<DiffView oldSource={oldSource} newSource={newSource} />
</EuiPanel>
</>
);
};

View file

@ -14,11 +14,13 @@ import {
EuiHorizontalRule,
useGeneratedHtmlId,
} from '@elastic/eui';
import type { EuiDescriptionListProps } from '@elastic/eui';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { RuleAboutSection, Description } from './rule_about_section';
import { RuleDefinitionSection } from './rule_definition_section';
import { RuleScheduleSection } from './rule_schedule_section';
import { RuleSetupGuideSection } from './rule_setup_guide_section';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import * as i18n from './translations';
@ -87,52 +89,56 @@ const ExpandableSection = ({ title, isOpen, toggle, children }: ExpandableSectio
interface RuleOverviewTabProps {
rule: RuleResponse;
columnWidths?: EuiDescriptionListProps['columnWidths'];
expandedOverviewSections: Record<keyof typeof defaultOverviewOpenSections, boolean>;
toggleOverviewSection: Record<keyof typeof defaultOverviewOpenSections, () => void>;
}
export const RuleOverviewTab = ({
rule,
columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS,
expandedOverviewSections,
toggleOverviewSection,
}: RuleOverviewTabProps) => (
<>
<EuiSpacer size="m" />
<ExpandableSection
title={i18n.ABOUT_SECTION_LABEL}
isOpen={expandedOverviewSections.about}
toggle={toggleOverviewSection.about}
>
{rule.description && <Description description={rule.description} />}
<RuleAboutSection rule={rule} hideDescription hideName />
</ExpandableSection>
<EuiHorizontalRule margin="m" />
<ExpandableSection
title={i18n.DEFINITION_SECTION_LABEL}
isOpen={expandedOverviewSections.definition}
toggle={toggleOverviewSection.definition}
>
<RuleDefinitionSection rule={rule} />
</ExpandableSection>
<EuiHorizontalRule margin="m" />
<ExpandableSection
title={i18n.SCHEDULE_SECTION_LABEL}
isOpen={expandedOverviewSections.schedule}
toggle={toggleOverviewSection.schedule}
>
<RuleScheduleSection rule={rule} />
</ExpandableSection>
{rule.setup && (
<>
<EuiHorizontalRule margin="m" />
<ExpandableSection
title={i18n.SETUP_GUIDE_SECTION_LABEL}
isOpen={expandedOverviewSections.setup}
toggle={toggleOverviewSection.setup}
>
<RuleSetupGuideSection setup={rule.setup} />
</ExpandableSection>
</>
)}
</>
);
}: RuleOverviewTabProps) => {
return (
<>
<EuiSpacer size="m" />
<ExpandableSection
title={i18n.ABOUT_SECTION_LABEL}
isOpen={expandedOverviewSections.about}
toggle={toggleOverviewSection.about}
>
{rule.description && <Description description={rule.description} />}
<RuleAboutSection rule={rule} columnWidths={columnWidths} hideDescription hideName />
</ExpandableSection>
<EuiHorizontalRule margin="m" />
<ExpandableSection
title={i18n.DEFINITION_SECTION_LABEL}
isOpen={expandedOverviewSections.definition}
toggle={toggleOverviewSection.definition}
>
<RuleDefinitionSection rule={rule} columnWidths={columnWidths} />
</ExpandableSection>
<EuiHorizontalRule margin="m" />
<ExpandableSection
title={i18n.SCHEDULE_SECTION_LABEL}
isOpen={expandedOverviewSections.schedule}
toggle={toggleOverviewSection.schedule}
>
<RuleScheduleSection rule={rule} columnWidths={columnWidths} />
</ExpandableSection>
{rule.setup && (
<>
<EuiHorizontalRule margin="m" />
<ExpandableSection
title={i18n.SETUP_GUIDE_SECTION_LABEL}
isOpen={expandedOverviewSections.setup}
toggle={toggleOverviewSection.setup}
>
<RuleSetupGuideSection setup={rule.setup} />
</ExpandableSection>
</>
)}
</>
);
};

View file

@ -7,9 +7,10 @@
import React from 'react';
import { EuiDescriptionList, EuiText } from '@elastic/eui';
import type { EuiDescriptionListProps } from '@elastic/eui';
import type { RuleResponse } from '../../../../../common/api/detection_engine/model/rule_schema';
import { getHumanizedDuration } from '../../../../detections/pages/detection_engine/rules/helpers';
import { DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import { DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS } from './constants';
import * as i18n from './translations';
interface IntervalProps {
@ -35,10 +36,12 @@ const From = ({ from, interval }: FromProps) => (
export interface RuleScheduleSectionProps extends React.ComponentProps<typeof EuiDescriptionList> {
rule: Partial<RuleResponse>;
columnWidths?: EuiDescriptionListProps['columnWidths'];
}
export const RuleScheduleSection = ({
rule,
columnWidths = DEFAULT_DESCRIPTION_LIST_COLUMN_WIDTHS,
...descriptionListProps
}: RuleScheduleSectionProps) => {
if (!rule.interval || !rule.from) {
@ -64,7 +67,7 @@ export const RuleScheduleSection = ({
type={descriptionListProps.type ?? 'column'}
rowGutterSize={descriptionListProps.rowGutterSize ?? 'm'}
listItems={ruleSectionListItems}
columnWidths={DESCRIPTION_LIST_COLUMN_WIDTHS}
columnWidths={columnWidths}
{...descriptionListProps}
/>
</div>

View file

@ -21,6 +21,13 @@ export const INVESTIGATION_GUIDE_TAB_LABEL = i18n.translate(
}
);
export const UPDATES_TAB_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.updatesTabLabel',
{
defaultMessage: 'Updates',
}
);
export const DISMISS_BUTTON_LABEL = i18n.translate(
'xpack.securitySolution.detectionEngine.ruleDetails.dismissButtonLabel',
{

View file

@ -8,6 +8,7 @@
import type { Dispatch, SetStateAction } from 'react';
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { EuiButton } from '@elastic/eui';
import type { EuiTabbedContentTab } from '@elastic/eui';
import { useIsUpgradingSecurityPackages } from '../../../../rule_management/logic/use_upgrade_security_packages';
import { useInstalledSecurityJobs } from '../../../../../common/components/ml/hooks/use_installed_security_jobs';
import { useBoolState } from '../../../../../common/hooks/use_bool_state';
@ -24,10 +25,15 @@ import type { UpgradePrebuiltRulesTableFilterOptions } from './use_filter_prebui
import { useFilterPrebuiltRulesToUpgrade } from './use_filter_prebuilt_rules_to_upgrade';
import { useAsyncConfirmation } from '../rules_table/use_async_confirmation';
import { useRuleDetailsFlyout } from '../../../../rule_management/components/rule_details/use_rule_details_flyout';
import { RuleDetailsFlyout } from '../../../../rule_management/components/rule_details/rule_details_flyout';
import * as i18n from './translations';
import {
RuleDetailsFlyout,
TabContentPadding,
} from '../../../../rule_management/components/rule_details/rule_details_flyout';
import { RuleDiffTab } from '../../../../rule_management/components/rule_details/rule_diff_tab';
import { MlJobUpgradeModal } from '../../../../../detections/components/modals/ml_job_upgrade_modal';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import * as ruleDetailsI18n from '../../../../rule_management/components/rule_details/translations';
import * as i18n from './translations';
export interface UpgradePrebuiltRulesTableState {
/**
@ -111,6 +117,10 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
tags: [],
});
const isJsonPrebuiltRulesDiffingEnabled = useIsExperimentalFeatureEnabled(
'jsonPrebuiltRulesDiffingEnabled'
);
const isUpgradingSecurityPackages = useIsUpgradingSecurityPackages();
const {
@ -257,6 +267,29 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
actions,
]);
const extraTabs = useMemo<EuiTabbedContentTab[]>(() => {
const activeRule =
isJsonPrebuiltRulesDiffingEnabled &&
previewedRule &&
filteredRules.find(({ id }) => id === previewedRule.id);
if (!activeRule) {
return [];
}
return [
{
id: 'updates',
name: ruleDetailsI18n.UPDATES_TAB_LABEL,
content: (
<TabContentPadding>
<RuleDiffTab oldRule={activeRule.current_rule} newRule={activeRule.target_rule} />
</TabContentPadding>
),
},
];
}, [previewedRule, filteredRules, isJsonPrebuiltRulesDiffingEnabled]);
return (
<UpgradePrebuiltRulesTableContext.Provider value={providerValue}>
<>
@ -271,6 +304,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
{previewedRule && (
<RuleDetailsFlyout
rule={previewedRule}
size={isJsonPrebuiltRulesDiffingEnabled ? 'l' : 'm'}
dataTestSubj="updatePrebuiltRulePreview"
closeFlyout={closeRulePreview}
ruleActions={
@ -286,6 +320,7 @@ export const UpgradePrebuiltRulesTableContextProvider = ({
{i18n.UPDATE_BUTTON_LABEL}
</EuiButton>
}
extraTabs={extraTabs}
/>
)}
</>

View file

@ -100,6 +100,11 @@ const calculateRuleInfos = (results: CalculateRuleDiffResult[]): RuleUpgradeInfo
const targetRule: RuleResponse = {
...convertPrebuiltRuleAssetToRuleResponse(targetVersion),
id: installedCurrentVersion.id,
revision: installedCurrentVersion.revision + 1,
created_at: installedCurrentVersion.created_at,
created_by: installedCurrentVersion.created_by,
updated_at: new Date().toISOString(),
updated_by: installedCurrentVersion.updated_by,
};
return {

View file

@ -9021,6 +9021,11 @@
resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964"
integrity sha512-sq+kwx8zA9BSugT9N+Jr8/uWjbHMZ+N/meJEzRyT3gmLq/WMtx/iSIpvdpmBUi/cvXl6Kzpvve8G2ESkabFwmg==
"@types/diff@^5.0.8":
version "5.0.8"
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.8.tgz#28dc501cc3e7c62d4c5d096afe20755170acf276"
integrity sha512-kR0gRf0wMwpxQq6ME5s+tWk9zVCfJUl98eRkD05HWWRbhPB/eu4V1IbyZAsvzC1Gn4znBJ0HN01M4DGXdBEV8Q==
"@types/ejs@^3.0.6":
version "3.0.6"
resolved "https://registry.yarnpkg.com/@types/ejs/-/ejs-3.0.6.tgz#aca442289df623bfa8e47c23961f0357847b83fe"
@ -14949,7 +14954,7 @@ diacritics@^1.3.0:
resolved "https://registry.yarnpkg.com/diacritics/-/diacritics-1.3.0.tgz#3efa87323ebb863e6696cebb0082d48ff3d6f7a1"
integrity sha1-PvqHMj67hj5mls67AILUj/PW96E=
diff-match-patch@^1.0.0, diff-match-patch@^1.0.4:
diff-match-patch@^1.0.0, diff-match-patch@^1.0.4, diff-match-patch@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37"
integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==
@ -14969,7 +14974,7 @@ diff-sequences@^29.4.3:
resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2"
integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA==
diff@5.0.0, diff@^5.0.0:
diff@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
@ -14989,6 +14994,11 @@ diff@^4.0.1:
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
diff@^5.0.0, diff@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40"
integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==
diffie-hellman@^5.0.0:
version "5.0.2"
resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@ -17546,6 +17556,11 @@ git-hooks-list@1.0.3:
resolved "https://registry.yarnpkg.com/git-hooks-list/-/git-hooks-list-1.0.3.tgz#be5baaf78203ce342f2f844a9d2b03dba1b45156"
integrity sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ==
gitdiff-parser@^0.3.1:
version "0.3.1"
resolved "https://registry.yarnpkg.com/gitdiff-parser/-/gitdiff-parser-0.3.1.tgz#5eb3e66eb7862810ba962fab762134071601baa5"
integrity sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==
github-from-package@0.0.0:
version "0.0.0"
resolved "https://registry.yarnpkg.com/github-from-package/-/github-from-package-0.0.0.tgz#97fb5d96bfde8973313f20e8288ef9a167fa64ce"
@ -25360,6 +25375,18 @@ react-colorful@^5.1.2:
resolved "https://registry.yarnpkg.com/react-colorful/-/react-colorful-5.5.1.tgz#29d9c4e496f2ca784dd2bb5053a3a4340cfaf784"
integrity sha512-M1TJH2X3RXEt12sWkpa6hLc/bbYS0H6F4rIqjQZ+RxNBstpY67d9TrFXtqdZwhpmBXcCwEi7stKqFue3ZRkiOg==
react-diff-view@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-diff-view/-/react-diff-view-3.2.0.tgz#8fbf04782d78423903a59202ce7533f6312c1cc3"
integrity sha512-p58XoqMxgt71ujpiDQTs9Za3nqTawt1E4bTzKsYSqr8I8br6cjQj1b66HxGnV8Yrc6MD6iQPqS1aZiFoGqEw+g==
dependencies:
classnames "^2.3.2"
diff-match-patch "^1.0.5"
gitdiff-parser "^0.3.1"
lodash "^4.17.21"
shallow-equal "^3.1.0"
warning "^4.0.3"
react-docgen-typescript@^2.0.0, react-docgen-typescript@^2.1.1:
version "2.2.2"
resolved "https://registry.yarnpkg.com/react-docgen-typescript/-/react-docgen-typescript-2.2.2.tgz#4611055e569edc071204aadb20e1c93e1ab1659c"
@ -27226,6 +27253,11 @@ shallow-copy@~0.0.1:
resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170"
integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA=
shallow-equal@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-3.1.0.tgz#e7a54bac629c7f248eff6c2f5b63122ba4320bec"
integrity sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==
shallowequal@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8"
@ -29525,6 +29557,13 @@ unicode-trie@^2.0.0:
pako "^0.2.5"
tiny-inflate "^1.0.0"
unidiff@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/unidiff/-/unidiff-1.0.4.tgz#45096a898285821c51e22e84be4215c05d6511cd"
integrity sha512-ynU0vsAXw0ir8roa+xPCUHmnJ5goc5BTM2Kuc3IJd8UwgaeRs7VSD5+eeaQL+xp1JtB92hu/Zy/Lgy7RZcr1pQ==
dependencies:
diff "^5.1.0"
unified@9.2.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8"
@ -30534,7 +30573,7 @@ walker@^1.0.7, walker@^1.0.8, walker@~1.0.5:
dependencies:
makeerror "1.0.12"
warning@^4.0.2:
warning@^4.0.2, warning@^4.0.3:
version "4.0.3"
resolved "https://registry.yarnpkg.com/warning/-/warning-4.0.3.tgz#16e9e077eb8a86d6af7d64aa1e05fd85b4678ca3"
integrity sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==