mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 17:59:23 -04:00
[Security Serverless] - no net new capability to initiate/create investigate guides in timelines for 'essential tier' (#8700) (#181562)
## Summary Addresses https://github.com/elastic/security-team/issues/8700 With these changes we disable Interactive Investigation guides interactions buttons (timelines + OSquery interactive actions) for 'Essential tier' in Serverless. For Investigation guides in the Detection rules: **Create rule page -> Advanced settings - > Investigation guide** osquery and timeline buttons should be inactive and have an upgrade callout <img width="1033" alt="Screenshot 2024-04-24 at 15 08 32" src="91f5b5e0
-9efe-4ee3-b0c8-e3e75c4782b7"> **Rule details page -> Investigations guide** buttons in the markdown should be inactive and have an upgrade callout <img width="736" alt="Screenshot 2024-04-24 at 15 09 07" src="a1eca15a
-ea0a-4346-b193-d452a38849f1"> ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] [Tests](https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/5754) --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
bc87224b41
commit
13a968a447
16 changed files with 152 additions and 73 deletions
|
@ -12,6 +12,10 @@ export enum ProductFeatureSecurityKey {
|
|||
* Enables Investigation guide in Timeline
|
||||
*/
|
||||
investigationGuide = 'investigation_guide',
|
||||
/**
|
||||
* Enables Investigation guide interactions (e.g., osquery, timelines, etc.)
|
||||
*/
|
||||
investigationGuideInteractions = 'investigation_guide_interactions',
|
||||
/**
|
||||
* Enables access to the Endpoint List and associated views that allows management of hosts
|
||||
* running endpoint security
|
||||
|
|
|
@ -42,6 +42,16 @@ export const securityDefaultProductFeaturesConfig: DefaultSecurityProductFeature
|
|||
},
|
||||
},
|
||||
},
|
||||
[ProductFeatureSecurityKey.investigationGuideInteractions]: {
|
||||
privileges: {
|
||||
all: {
|
||||
ui: ['investigation-guide-interactions'],
|
||||
},
|
||||
read: {
|
||||
ui: ['investigation-guide-interactions'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
[ProductFeatureSecurityKey.threatIntelligence]: {
|
||||
privileges: {
|
||||
|
|
|
@ -15,6 +15,14 @@ export const UPGRADE_INVESTIGATION_GUIDE = (requiredLicense: string) =>
|
|||
},
|
||||
});
|
||||
|
||||
export const UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS = (requiredLicense: string) =>
|
||||
i18n.translate('securitySolutionPackages.markdown.investigationGuideInteractions.upsell', {
|
||||
defaultMessage: 'Upgrade to {requiredLicense} to make use of investigation guide interactions',
|
||||
values: {
|
||||
requiredLicense,
|
||||
},
|
||||
});
|
||||
|
||||
export const UPGRADE_ALERT_ASSIGNMENTS = (requiredLicense: string) =>
|
||||
i18n.translate('securitySolutionPackages.alertAssignments.upsell', {
|
||||
defaultMessage: 'Upgrade to {requiredLicense} to make use of alert assignments',
|
||||
|
|
|
@ -21,6 +21,7 @@ export type UpsellingSectionId =
|
|||
|
||||
export type UpsellingMessageId =
|
||||
| 'investigation_guide'
|
||||
| 'investigation_guide_interactions'
|
||||
| 'alert_assignments'
|
||||
| 'alert_suppression_rule_form'
|
||||
| 'alert_suppression_rule_details';
|
||||
|
|
|
@ -39,6 +39,7 @@ import { useInsertTimeline } from '../components/use_insert_timeline';
|
|||
import * as timelineMarkdownPlugin from '../../common/components/markdown_editor/plugins/timeline';
|
||||
import { DetailsPanel } from '../../timelines/components/side_panel';
|
||||
import { useFetchAlertData } from './use_fetch_alert_data';
|
||||
import { useUpsellingMessage } from '../../common/hooks/use_upselling';
|
||||
|
||||
const TimelineDetailsPanel = () => {
|
||||
const { browserFields, runtimeMappings } = useSourcererDataView(SourcererScopeName.detections);
|
||||
|
@ -69,6 +70,8 @@ const CaseContainerComponent: React.FC = () => {
|
|||
[detectionsFormatUrl, detectionsUrlSearch]
|
||||
);
|
||||
|
||||
const interactionsUpsellingMessage = useUpsellingMessage('investigation_guide_interactions');
|
||||
|
||||
const showAlertDetails = useCallback(
|
||||
(alertId: string, index: string) => {
|
||||
if (isSecurityFlyoutEnabled) {
|
||||
|
@ -187,7 +190,7 @@ const CaseContainerComponent: React.FC = () => {
|
|||
editor_plugins: {
|
||||
parsingPlugin: timelineMarkdownPlugin.parser,
|
||||
processingPluginRenderer: timelineMarkdownPlugin.renderer,
|
||||
uiPlugin: timelineMarkdownPlugin.plugin,
|
||||
uiPlugin: timelineMarkdownPlugin.plugin({ interactionsUpsellingMessage }),
|
||||
},
|
||||
hooks: {
|
||||
useInsertTimeline,
|
||||
|
|
|
@ -74,9 +74,15 @@ const MarkdownEditorComponent = forwardRef<MarkdownEditorRef, MarkdownEditorProp
|
|||
}, [autoFocusDisabled]);
|
||||
|
||||
const insightsUpsellingMessage = useUpsellingMessage('investigation_guide');
|
||||
const interactionsUpsellingMessage = useUpsellingMessage('investigation_guide_interactions');
|
||||
const uiPluginsWithState = useMemo(() => {
|
||||
return includePlugins ? uiPlugins({ insightsUpsellingMessage }) : undefined;
|
||||
}, [insightsUpsellingMessage, includePlugins]);
|
||||
return includePlugins
|
||||
? uiPlugins({
|
||||
insightsUpsellingMessage,
|
||||
interactionsUpsellingMessage,
|
||||
})
|
||||
: undefined;
|
||||
}, [includePlugins, insightsUpsellingMessage, interactionsUpsellingMessage]);
|
||||
|
||||
// @ts-expect-error update types
|
||||
useImperativeHandle(ref, () => {
|
||||
|
|
|
@ -22,16 +22,24 @@ export const platinumOnlyPluginTokens = [insightMarkdownPlugin.insightPrefix];
|
|||
|
||||
export const uiPlugins = ({
|
||||
insightsUpsellingMessage,
|
||||
interactionsUpsellingMessage,
|
||||
}: {
|
||||
insightsUpsellingMessage: string | null;
|
||||
interactionsUpsellingMessage: string | null;
|
||||
}) => {
|
||||
const currentPlugins = nonStatefulUiPlugins.map((plugin) => plugin.name);
|
||||
const insightPluginWithLicense = insightMarkdownPlugin.plugin({
|
||||
insightsUpsellingMessage,
|
||||
});
|
||||
const timelinePluginWithLicense = timelineMarkdownPlugin.plugin({
|
||||
interactionsUpsellingMessage,
|
||||
});
|
||||
const osqueryPluginWithLicense = osqueryMarkdownPlugin.plugin({
|
||||
interactionsUpsellingMessage,
|
||||
});
|
||||
if (currentPlugins.includes(insightPluginWithLicense.name) === false) {
|
||||
nonStatefulUiPlugins.push(timelineMarkdownPlugin.plugin);
|
||||
nonStatefulUiPlugins.push(osqueryMarkdownPlugin.plugin);
|
||||
nonStatefulUiPlugins.push(timelinePluginWithLicense);
|
||||
nonStatefulUiPlugins.push(osqueryPluginWithLicense);
|
||||
nonStatefulUiPlugins.push(insightPluginWithLicense);
|
||||
} else {
|
||||
// When called for the second time we need to update insightMarkdownPlugin
|
||||
|
|
|
@ -14,10 +14,10 @@ import {
|
|||
DEFAULT_TO,
|
||||
} from '../../../../../../common/constants';
|
||||
import { KibanaServices } from '../../../../lib/kibana';
|
||||
import { licenseService } from '../../../../hooks/use_license';
|
||||
import type { DefaultTimeRangeSetting } from '../../../../utils/default_date_settings';
|
||||
import { plugin, renderer as Renderer } from '.';
|
||||
import type { InvestigateInTimelineButtonProps } from '../../../event_details/table/investigate_in_timeline_button';
|
||||
import { useUpsellingMessage } from '../../../../hooks/use_upselling';
|
||||
|
||||
jest.mock('../../../../lib/kibana');
|
||||
const mockGetServices = KibanaServices.get as jest.Mock;
|
||||
|
@ -59,24 +59,12 @@ const mockTimeRange = (
|
|||
}));
|
||||
};
|
||||
|
||||
jest.mock('../../../../hooks/use_license', () => {
|
||||
const licenseServiceInstance = {
|
||||
isPlatinumPlus: jest.fn(),
|
||||
isEnterprise: jest.fn(() => true),
|
||||
};
|
||||
return {
|
||||
licenseService: licenseServiceInstance,
|
||||
useLicense: () => {
|
||||
return licenseServiceInstance;
|
||||
},
|
||||
};
|
||||
});
|
||||
const licenseServiceMock = licenseService as jest.Mocked<typeof licenseService>;
|
||||
jest.mock('../../../../hooks/use_upselling');
|
||||
|
||||
describe('insight component renderer', () => {
|
||||
describe('when license is at least platinum plus', () => {
|
||||
describe('when there is no upselling message', () => {
|
||||
beforeAll(() => {
|
||||
licenseServiceMock.isPlatinumPlus.mockReturnValue(true);
|
||||
(useUpsellingMessage as jest.Mock).mockReturnValue(null);
|
||||
mockTimeRange(null);
|
||||
});
|
||||
it('renders correctly with valid date strings with no timestamp from results', () => {
|
||||
|
@ -106,9 +94,9 @@ describe('insight component renderer', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when license is not at least platinum plus', () => {
|
||||
describe('when there is an upselling message', () => {
|
||||
beforeAll(() => {
|
||||
licenseServiceMock.isPlatinumPlus.mockReturnValue(false);
|
||||
(useUpsellingMessage as jest.Mock).mockReturnValue('Go for Platinum!');
|
||||
mockTimeRange(null);
|
||||
});
|
||||
it('renders a disabled eui button with label', () => {
|
||||
|
|
|
@ -28,6 +28,7 @@ import {
|
|||
EuiSelect,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiToolTip,
|
||||
} from '@elastic/eui';
|
||||
import numeral from '@elastic/numeral';
|
||||
import { css } from '@emotion/react';
|
||||
|
@ -36,6 +37,7 @@ import { FormattedMessage } from '@kbn/i18n-react';
|
|||
import type { Filter } from '@kbn/es-query';
|
||||
import { FilterStateStore } from '@kbn/es-query';
|
||||
import { useForm, FormProvider, useController } from 'react-hook-form';
|
||||
import { useUpsellingMessage } from '../../../../hooks/use_upselling';
|
||||
import { useAppToasts } from '../../../../hooks/use_app_toasts';
|
||||
import { useKibana } from '../../../../lib/kibana';
|
||||
import { useInsightQuery } from './use_insight_query';
|
||||
|
@ -240,19 +242,21 @@ const InsightComponent = ({
|
|||
relativeFrom,
|
||||
relativeTo,
|
||||
}: InsightComponentProps) => {
|
||||
const isPlatinum = useLicense().isPlatinumPlus();
|
||||
const insightsUpsellingMessage = useUpsellingMessage('investigation_guide');
|
||||
|
||||
if (isPlatinum === false) {
|
||||
if (insightsUpsellingMessage) {
|
||||
return (
|
||||
<>
|
||||
<EuiButton
|
||||
isDisabled={true}
|
||||
iconSide={'left'}
|
||||
iconType={'timeline'}
|
||||
data-test-subj="insight-investigate-in-timeline-button"
|
||||
>
|
||||
{`${label}`}
|
||||
</EuiButton>
|
||||
<EuiToolTip content={insightsUpsellingMessage}>
|
||||
<EuiButton
|
||||
isDisabled={true}
|
||||
iconSide={'left'}
|
||||
iconType={'timeline'}
|
||||
data-test-subj="insight-investigate-in-timeline-button"
|
||||
>
|
||||
{`${label}`}
|
||||
</EuiButton>
|
||||
</EuiToolTip>
|
||||
<div>{description}</div>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -156,19 +156,26 @@ const OsqueryEditorComponent = ({
|
|||
|
||||
const OsqueryEditor = React.memo(OsqueryEditorComponent);
|
||||
|
||||
export const plugin = {
|
||||
name: 'osquery',
|
||||
button: {
|
||||
label: 'Osquery',
|
||||
iconType: 'logoOsquery',
|
||||
},
|
||||
helpText: (
|
||||
<div>
|
||||
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
|
||||
{'!{osquery{options}}'}
|
||||
</EuiCodeBlock>
|
||||
<EuiSpacer size="s" />
|
||||
</div>
|
||||
),
|
||||
editor: OsqueryEditor,
|
||||
export const plugin = ({
|
||||
interactionsUpsellingMessage,
|
||||
}: {
|
||||
interactionsUpsellingMessage: string | null;
|
||||
}) => {
|
||||
return {
|
||||
name: 'osquery',
|
||||
button: {
|
||||
label: interactionsUpsellingMessage ?? 'Osquery',
|
||||
iconType: 'logoOsquery',
|
||||
isDisabled: !!interactionsUpsellingMessage,
|
||||
},
|
||||
helpText: (
|
||||
<div>
|
||||
<EuiCodeBlock language="md" fontSize="l" paddingSize="s" isCopyable>
|
||||
{'!{osquery{options}}'}
|
||||
</EuiCodeBlock>
|
||||
<EuiSpacer size="s" />
|
||||
</div>
|
||||
),
|
||||
editor: OsqueryEditor,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -10,8 +10,9 @@ import React, { useCallback, useContext, useMemo, useState } from 'react';
|
|||
import { reduce } from 'lodash';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import styled from 'styled-components';
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import { EuiButton, EuiToolTip } from '@elastic/eui';
|
||||
import type { EcsSecurityExtension as Ecs } from '@kbn/securitysolution-ecs';
|
||||
import { useUpsellingMessage } from '../../../../hooks/use_upselling';
|
||||
import { BasicAlertDataContext } from '../../../event_details/investigation_guide_view';
|
||||
import { expandDottedObject } from '../../../../../../common/utils/expand_dotted';
|
||||
import OsqueryLogo from './osquery_icon/osquery.svg';
|
||||
|
@ -40,6 +41,8 @@ export const OsqueryRenderer = ({
|
|||
|
||||
const handleClose = useCallback(() => setShowFlyout(false), [setShowFlyout]);
|
||||
|
||||
const interactionsUpsellingMessage = useUpsellingMessage('investigation_guide_interactions');
|
||||
|
||||
const ecsData = useMemo(() => {
|
||||
const fieldsMap: Record<string, string> = reduce(
|
||||
data,
|
||||
|
@ -54,12 +57,18 @@ export const OsqueryRenderer = ({
|
|||
|
||||
return (
|
||||
<>
|
||||
<StyledEuiButton iconType={OsqueryLogo} onClick={handleOpen}>
|
||||
{configuration.label ??
|
||||
i18n.translate('xpack.securitySolution.markdown.osquery.runOsqueryButtonLabel', {
|
||||
defaultMessage: 'Run Osquery',
|
||||
})}
|
||||
</StyledEuiButton>
|
||||
<EuiToolTip content={interactionsUpsellingMessage}>
|
||||
<StyledEuiButton
|
||||
iconType={OsqueryLogo}
|
||||
onClick={handleOpen}
|
||||
disabled={!!interactionsUpsellingMessage}
|
||||
>
|
||||
{configuration.label ??
|
||||
i18n.translate('xpack.securitySolution.markdown.osquery.runOsqueryButtonLabel', {
|
||||
defaultMessage: 'Run Osquery',
|
||||
})}
|
||||
</StyledEuiButton>
|
||||
</EuiToolTip>
|
||||
{showFlyout && (
|
||||
<OsqueryFlyout
|
||||
defaultValues={{
|
||||
|
|
|
@ -75,18 +75,25 @@ const TimelineEditorComponent: React.FC<TimelineEditorProps> = ({ onClosePopover
|
|||
|
||||
const TimelineEditor = memo(TimelineEditorComponent);
|
||||
|
||||
export const plugin: EuiMarkdownEditorUiPlugin = {
|
||||
name: ID,
|
||||
button: {
|
||||
label: i18n.INSERT_TIMELINE,
|
||||
iconType: 'timeline',
|
||||
},
|
||||
helpText: (
|
||||
<EuiCodeBlock language="md" paddingSize="s" fontSize="l">
|
||||
{'[title](url)'}
|
||||
</EuiCodeBlock>
|
||||
),
|
||||
editor: function editor({ node, onSave, onCancel }) {
|
||||
return <TimelineEditor onClosePopover={onCancel} onInsert={onSave} />;
|
||||
},
|
||||
export const plugin = ({
|
||||
interactionsUpsellingMessage,
|
||||
}: {
|
||||
interactionsUpsellingMessage: string | null;
|
||||
}): EuiMarkdownEditorUiPlugin => {
|
||||
return {
|
||||
name: ID,
|
||||
button: {
|
||||
label: interactionsUpsellingMessage ?? i18n.INSERT_TIMELINE,
|
||||
iconType: 'timeline',
|
||||
isDisabled: !!interactionsUpsellingMessage,
|
||||
},
|
||||
helpText: (
|
||||
<EuiCodeBlock language="md" paddingSize="s" fontSize="l">
|
||||
{'[title](url)'}
|
||||
</EuiCodeBlock>
|
||||
),
|
||||
editor: function editor({ node, onSave, onCancel }) {
|
||||
return <TimelineEditor onClosePopover={onCancel} onInsert={onSave} />;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import React, { useCallback, memo } from 'react';
|
||||
import { EuiToolTip, EuiLink } from '@elastic/eui';
|
||||
|
||||
import { useUpsellingMessage } from '../../../../hooks/use_upselling';
|
||||
import { useTimelineClick } from '../../../../utils/timeline/use_timeline_click';
|
||||
import type { TimelineProps } from './types';
|
||||
import * as i18n from './translations';
|
||||
|
@ -20,6 +21,8 @@ export const TimelineMarkDownRendererComponent: React.FC<TimelineProps> = ({
|
|||
}) => {
|
||||
const { addError } = useAppToasts();
|
||||
|
||||
const interactionsUpsellingMessage = useUpsellingMessage('investigation_guide_interactions');
|
||||
|
||||
const handleTimelineClick = useTimelineClick();
|
||||
|
||||
const onError = useCallback(
|
||||
|
@ -37,8 +40,12 @@ export const TimelineMarkDownRendererComponent: React.FC<TimelineProps> = ({
|
|||
[id, graphEventId, handleTimelineClick, onError]
|
||||
);
|
||||
return (
|
||||
<EuiToolTip content={i18n.TIMELINE_ID(id ?? '')}>
|
||||
<EuiLink onClick={onClickTimeline} data-test-subj={`markdown-timeline-link-${id}`}>
|
||||
<EuiToolTip content={interactionsUpsellingMessage ?? i18n.TIMELINE_ID(id ?? '')}>
|
||||
<EuiLink
|
||||
onClick={onClickTimeline}
|
||||
disabled={!!interactionsUpsellingMessage}
|
||||
data-test-subj={`markdown-timeline-link-${id}`}
|
||||
>
|
||||
{title}
|
||||
</EuiLink>
|
||||
</EuiToolTip>
|
||||
|
|
|
@ -11,6 +11,8 @@ import { render } from '@testing-library/react';
|
|||
import { removeExternalLinkText } from '@kbn/securitysolution-io-ts-utils';
|
||||
import { TestProviders } from '../../mock';
|
||||
import { MarkdownRenderer } from './renderer';
|
||||
import { UpsellingService } from '@kbn/security-solution-upselling/service';
|
||||
import { UpsellingProvider } from '../upselling_provider';
|
||||
|
||||
jest.mock('../../utils/default_date_settings', () => {
|
||||
const original = jest.requireActual('../../utils/default_date_settings');
|
||||
|
@ -59,6 +61,8 @@ jest.mock('../../hooks/use_app_toasts', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
const mockUpselling = new UpsellingService();
|
||||
|
||||
describe('Markdown', () => {
|
||||
describe('markdown links', () => {
|
||||
const markdownWithLink = 'A link to an external site [External Site](https://google.com)';
|
||||
|
@ -114,7 +118,9 @@ describe('Markdown', () => {
|
|||
test('displays an upgrade message with a premium markdown plugin', () => {
|
||||
const { queryByText, getByText } = render(
|
||||
<TestProviders>
|
||||
<MarkdownRenderer>{`!{investigate{"label": "", "providers": [[{"field": "event.id", "value": "{{kibana.alert.original_event.id}}", "queryType": "phrase", "excluded": "false"}]]}}`}</MarkdownRenderer>
|
||||
<UpsellingProvider upsellingService={mockUpselling}>
|
||||
<MarkdownRenderer>{`!{investigate{"label": "", "providers": [[{"field": "event.id", "value": "{{kibana.alert.original_event.id}}", "queryType": "phrase", "excluded": "false"}]]}}`}</MarkdownRenderer>
|
||||
</UpsellingProvider>
|
||||
</TestProviders>
|
||||
);
|
||||
|
||||
|
|
|
@ -23,6 +23,7 @@ export const PLI_PRODUCT_FEATURES: PliProductFeatures = {
|
|||
ProductFeatureKey.advancedInsights,
|
||||
ProductFeatureKey.assistant,
|
||||
ProductFeatureKey.investigationGuide,
|
||||
ProductFeatureKey.investigationGuideInteractions,
|
||||
ProductFeatureKey.threatIntelligence,
|
||||
ProductFeatureKey.casesConnectors,
|
||||
ProductFeatureKey.externalRuleActions,
|
||||
|
|
|
@ -14,7 +14,10 @@ import type {
|
|||
} from '@kbn/security-solution-upselling/service/types';
|
||||
import type { UpsellingService } from '@kbn/security-solution-upselling/service';
|
||||
import React from 'react';
|
||||
import { UPGRADE_INVESTIGATION_GUIDE } from '@kbn/security-solution-upselling/messages';
|
||||
import {
|
||||
UPGRADE_INVESTIGATION_GUIDE,
|
||||
UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS,
|
||||
} from '@kbn/security-solution-upselling/messages';
|
||||
import { ProductFeatureKey } from '@kbn/security-solution-features/keys';
|
||||
import type { ProductFeatureKeyType } from '@kbn/security-solution-features';
|
||||
import {
|
||||
|
@ -163,4 +166,11 @@ export const upsellingMessages: UpsellingMessages = [
|
|||
getProductTypeByPLI(ProductFeatureKey.investigationGuide) ?? ''
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'investigation_guide_interactions',
|
||||
pli: ProductFeatureKey.investigationGuideInteractions,
|
||||
message: UPGRADE_INVESTIGATION_GUIDE_INTERACTIONS(
|
||||
getProductTypeByPLI(ProductFeatureKey.investigationGuideInteractions) ?? ''
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue