Commit 3a6ec5d5 authored by Savas Vedova's avatar Savas Vedova Committed by Enrique Alcántara

Move scan policy logic to modal

parent 58776173
<script>
import PoliciesHeader from './policies_header.vue';
export default {
components: {
PoliciesHeader,
},
};
</script>
<template>
<policies-header />
</template>
<script>
import { GlAlert, GlSprintf, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import ScanNewPolicyModal from './scan_new_policy_modal.vue';
export default {
components: {
GlSprintf,
GlButton,
GlAlert,
ScanNewPolicyModal,
},
inject: ['documentationPath', 'assignedPolicyProject', 'newPolicyPath'],
i18n: {
title: s__('NetworkPolicies|Policies'),
subtitle: s__(
'NetworkPolicies|Enforce security for this project. %{linkStart}More information.%{linkEnd}',
),
newPolicyButtonText: s__('NetworkPolicies|New policy'),
editPolicyButtonText: s__('NetworkPolicies|Edit policy project'),
},
data() {
return {
projectIsBeingLinked: false,
showAlert: false,
alertVariant: '',
alertText: '',
modalVisible: false,
};
},
computed: {
hasAssignedPolicyProject() {
return Boolean(this.assignedPolicyProject?.id);
},
},
methods: {
updateAlertText({ text, variant }) {
this.projectIsBeingLinked = false;
if (text) {
this.showAlert = true;
this.alertVariant = variant;
this.alertText = text;
}
},
isUpdatingProject() {
this.projectIsBeingLinked = true;
this.showAlert = false;
this.alertVariant = '';
this.alertText = '';
},
dismissAlert() {
this.showAlert = false;
},
showNewPolicyModal() {
this.modalVisible = true;
},
},
};
</script>
<template>
<div>
<gl-alert
v-if="showAlert"
class="gl-mt-3"
data-testid="policy-project-alert"
:dismissible="true"
:variant="alertVariant"
@dismiss="dismissAlert"
>
{{ alertText }}
</gl-alert>
<header class="gl-my-6 gl-display-flex gl-align-items-flex-start">
<div class="gl-flex-grow-1 gl-my-0">
<h2 class="gl-mt-0">
{{ $options.i18n.title }}
</h2>
<p data-testid="policies-subheader">
<gl-sprintf :message="$options.i18n.subtitle">
<template #link="{ content }">
<gl-button class="gl-pb-1!" variant="link" :href="documentationPath" target="_blank">
{{ content }}
</gl-button>
</template>
</gl-sprintf>
</p>
</div>
<gl-button
data-testid="edit-project-policy-button"
class="gl-mr-4"
:loading="projectIsBeingLinked"
@click="showNewPolicyModal"
>
{{ $options.i18n.editPolicyButtonText }}
</gl-button>
<gl-button data-testid="new-policy-button" variant="confirm" :href="newPolicyPath">
{{ $options.i18n.newPolicyButtonText }}
</gl-button>
<scan-new-policy-modal
:visible="modalVisible"
@close="modalVisible = false"
@project-updated="updateAlertText"
@updating-project="isUpdatingProject"
/>
</header>
</div>
</template>
<script>
import { GlAlert, GlButton, GlDropdown, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
import assignSecurityPolicyProject from '../graphql/mutations/assign_security_policy_project.mutation.graphql';
import InstanceProjectSelector from './instance_project_selector.vue';
import { GlButton, GlDropdown, GlSprintf, GlAlert, GlModal } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import assignSecurityPolicyProject from '../../graphql/mutations/assign_security_policy_project.mutation.graphql';
import InstanceProjectSelector from '../instance_project_selector.vue';
export default {
PROJECT_SELECTOR_HEIGHT: 204,
i18n: {
assignError: s__(
'SecurityOrchestration|An error occurred assigning your security policy project',
),
assignSuccess: s__('SecurityOrchestration|Security policy project was linked successfully'),
disabledButtonTooltip: s__(
'SecurityOrchestration|Only owners can update Security Policy Project',
),
securityProject: s__(
'SecurityOrchestration|A security policy project can enforce policies for a given project, group, or instance. With a security policy project, you can specify security policies that are important to you and enforce them with every commit. %{linkStart}More information.%{linkEnd}',
modal: {
okTitle: __('Save'),
header: s__('SecurityOrchestration|Select security project'),
},
save: {
ok: s__('SecurityOrchestration|Security policy project was linked successfully'),
error: s__('SecurityOrchestration|An error occurred assigning your security policy project'),
},
disabledWarning: s__('SecurityOrchestration|Only owners can update Security Policy Project'),
description: s__(
'SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}',
),
},
components: {
GlAlert,
GlButton,
GlDropdown,
GlSprintf,
GlModal,
GlAlert,
InstanceProjectSelector,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['disableSecurityPolicyProject', 'documentationPath', 'projectPath'],
inject: [
'disableSecurityPolicyProject',
'documentationPath',
'projectPath',
'assignedPolicyProject',
],
props: {
assignedPolicyProject: {
type: Object,
visible: {
type: Boolean,
required: false,
default: () => {
return { id: '', name: '' };
},
default: false,
},
},
data() {
return {
currentProjectId: this.assignedPolicyProject.id,
selectedProject: this.assignedPolicyProject,
isAssigningProject: false,
showAssignError: false,
showAssignSuccess: false,
selectedProject: { ...this.assignedPolicyProject },
hasSelectedNewProject: false,
};
},
computed: {
hasSelectedNewProject() {
return this.currentProjectId !== this.selectedProject.id;
selectedProjectId() {
return this.selectedProject?.id || '';
},
selectedProjectName() {
return this.selectedProject?.name || '';
},
isModalOkButtonDisabled() {
return this.disableSecurityPolicyProject || !this.hasSelectedNewProject;
},
methods: {
dismissAlert(type) {
this[type] = false;
},
methods: {
async saveChanges() {
this.isAssigningProject = true;
this.showAssignError = false;
this.showAssignSuccess = false;
const { id } = this.selectedProject;
this.$emit('updating-project');
try {
const { data } = await this.$apollo.mutate({
mutation: assignSecurityPolicyProject,
variables: {
input: {
projectPath: this.projectPath,
securityPolicyProjectId: id,
securityPolicyProjectId: this.selectedProjectId,
},
},
});
if (data?.securityPolicyProjectAssign?.errors?.length) {
this.showAssignError = true;
} else {
this.showAssignSuccess = true;
this.currentProjectId = id;
throw new Error(data.securityPolicyProjectAssign.errors);
}
this.$emit('project-updated', { text: this.$options.i18n.save.ok, variant: 'success' });
} catch {
this.showAssignError = true;
this.$emit('project-updated', { text: this.$options.i18n.save.error, variant: 'danger' });
} finally {
this.isAssigningProject = false;
this.hasSelectedNewProject = false;
}
},
setSelectedProject(data) {
this.hasSelectedNewProject = true;
this.selectedProject = data;
this.$refs.dropdown.hide(true);
this.$refs.dropdown.hide();
},
closeModal() {
this.$emit('close');
},
},
};
</script>
<template>
<section>
<gl-alert
v-if="showAssignError"
class="gl-mt-3"
data-testid="policy-project-assign-error"
variant="danger"
:dismissible="true"
@dismiss="dismissAlert('showAssignError')"
<gl-modal
v-bind="$attrs"
ref="modal"
cancel-variant="light"
size="sm"
modal-id="scan-new-policy"
:scrollable="false"
:ok-title="$options.i18n.modal.okTitle"
:title="$options.i18n.modal.header"
:ok-disabled="isModalOkButtonDisabled"
:visible="visible"
@ok="saveChanges"
@change="closeModal"
>
{{ $options.i18n.assignError }}
</gl-alert>
<div>
<gl-alert
v-else-if="showAssignSuccess"
class="gl-mt-3"
data-testid="policy-project-assign-success"
variant="success"
:dismissible="true"
@dismiss="dismissAlert('showAssignSuccess')"
v-if="disableSecurityPolicyProject"
class="gl-mb-4"
variant="warning"
:dismissible="false"
>
{{ $options.i18n.assignSuccess }}
{{ $options.i18n.disabledWarning }}
</gl-alert>
<h2 class="gl-mb-8">
{{ s__('SecurityOrchestration|Create a policy') }}
</h2>
<div class="gl-w-half">
<h4>
{{ s__('SecurityOrchestration|Security policy project') }}
</h4>
<gl-dropdown
ref="dropdown"
class="gl-w-full gl-pb-5 security-policy-dropdown"
class="gl-w-full gl-pb-5"
menu-class="gl-w-full! gl-max-w-full!"
:disabled="disableSecurityPolicyProject"
:text="selectedProject.name || ''"
:text="selectedProjectName"
>
<instance-project-selector
class="gl-w-full"
......@@ -135,7 +135,7 @@ export default {
/>
</gl-dropdown>
<div class="gl-pb-5">
<gl-sprintf :message="$options.i18n.securityProject">
<gl-sprintf :message="$options.i18n.description">
<template #link="{ content }">
<gl-button class="gl-pb-1!" variant="link" :href="documentationPath" target="_blank">
{{ content }}
......@@ -143,24 +143,6 @@ export default {
</template>
</gl-sprintf>
</div>
<span
v-gl-tooltip="{
disabled: !disableSecurityPolicyProject,
title: $options.i18n.disabledButtonTooltip,
placement: 'bottom',
}"
data-testid="disabled-button-tooltip"
>
<gl-button
data-testid="save-policy-project"
variant="confirm"
:disabled="disableSecurityPolicyProject || !hasSelectedNewProject"
:loading="isAssigningProject"
@click="saveChanges"
>
{{ __('Save changes') }}
</gl-button>
</span>
</div>
</section>
</gl-modal>
</template>
......@@ -2,7 +2,7 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import SecurityPolicyProjectSelector from './components/security_policy_project_selector.vue';
import SecurityPoliciesApp from './components/policies/policies_app.vue';
Vue.use(VueApollo);
......@@ -16,24 +16,22 @@ export default () => {
assignedPolicyProject,
disableSecurityPolicyProject,
documentationPath,
newPolicyPath,
projectPath,
} = el.dataset;
const policyProject = JSON.parse(assignedPolicyProject);
const props = policyProject ? { assignedPolicyProject: policyProject } : {};
return new Vue({
apolloProvider,
el,
provide: {
assignedPolicyProject: JSON.parse(assignedPolicyProject),
disableSecurityPolicyProject: parseBoolean(disableSecurityPolicyProject),
documentationPath,
newPolicyPath,
projectPath,
},
render(createElement) {
return createElement(SecurityPolicyProjectSelector, {
props,
});
return createElement(SecurityPoliciesApp);
},
});
};
......@@ -4,4 +4,5 @@
#js-security-policies-list{ data: { assigned_policy_project: assigned_policy_project(project).to_json,
disable_security_policy_project: disable_security_policy_project.to_s,
documentation_path: help_page_path('user/project/clusters/protect/container_network_security/quick_start_guide'),
new_policy_path: new_project_threat_monitoring_policy_path(project),
project_path: project.full_path } }
import PoliciesApp from 'ee/threat_monitoring/components/policies/policies_app.vue';
import PoliciesHeader from 'ee/threat_monitoring/components/policies/policies_header.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Policies App', () => {
let wrapper;
const findPoliciesHeader = () => wrapper.findComponent(PoliciesHeader);
beforeEach(() => {
wrapper = shallowMountExtended(PoliciesApp);
});
afterEach(() => {
wrapper.destroy();
});
it('mounts the policies header component', () => {
expect(findPoliciesHeader().exists()).toBe(true);
});
});
import { GlAlert, GlButton, GlSprintf } from '@gitlab/ui';
import PoliciesHeader from 'ee/threat_monitoring/components/policies/policies_header.vue';
import ScanNewPolicyModal from 'ee/threat_monitoring/components/policies/scan_new_policy_modal.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('Policies Header Component', () => {
let wrapper;
const documentationPath = '/path/to/docs';
const newPolicyPath = '/path/to/new/policy/page';
const findAlert = () => wrapper.findComponent(GlAlert);
const findScanNewPolicyModal = () => wrapper.findComponent(ScanNewPolicyModal);
const findHeader = () => wrapper.findByRole('heading');
const findMoreInformationLink = () => wrapper.findComponent(GlButton);
const findEditPolicyProjectButton = () => wrapper.findByTestId('edit-project-policy-button');
const findNewPolicyButton = () => wrapper.findByTestId('new-policy-button');
const findSubheader = () => wrapper.findByTestId('policies-subheader');
const createWrapper = ({ provide } = {}) => {
wrapper = shallowMountExtended(PoliciesHeader, {
provide: {
documentationPath,
newPolicyPath,
assignedPolicyProject: null,
...provide,
},
stubs: {
GlSprintf,
GlButton,
},
});
};
afterEach(() => {
wrapper.destroy();
});
beforeEach(() => {
createWrapper();
});
it('displays New policy button with correct text and link', () => {
expect(findNewPolicyButton().text()).toBe('New policy');
expect(findNewPolicyButton().attributes('href')).toBe(newPolicyPath);
});
it('displays the Edit policy project button', () => {
expect(findEditPolicyProjectButton().text()).toBe('Edit policy project');
});
it('does not display the alert component by default', () => {
expect(findAlert().exists()).toBe(false);
});
it('displays the alert component when scan new modal policy emits events', async () => {
const text = 'Project was linked successfully.';
findScanNewPolicyModal().vm.$emit('project-updated', {
text,
variant: 'success',
});
// When the project is updated it displays the output message.
await wrapper.vm.$nextTick();
expect(findAlert().text()).toBe(text);
// When the project is being updated once again, it removes the alert so that
// the new one will be displayed.
findScanNewPolicyModal().vm.$emit('updating-project');
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(false);
});
it('mounts the scan new policy modal', () => {
expect(findScanNewPolicyModal().exists()).toBe(true);
});
it('displays scan new policy modal when the action button is clicked', async () => {
await findEditPolicyProjectButton().trigger('click');
expect(findScanNewPolicyModal().props().visible).toBe(true);
});
it('displays the header', () => {
expect(findHeader().text()).toBe('Policies');
});
it('displays the subheader', () => {
expect(findSubheader().text()).toContain('Enforce security for this project.');
expect(findMoreInformationLink().attributes('href')).toBe(documentationPath);
});
});
import { GlDropdown } from '@gitlab/ui';
import { GlDropdown, GlModal, GlAlert } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import InstanceProjectSelector from 'ee/threat_monitoring/components/instance_project_selector.vue';
import SecurityPolicyProjectSelector from 'ee/threat_monitoring/components/security_policy_project_selector.vue';
import ScanNewPolicyModal from 'ee/threat_monitoring/components/policies/scan_new_policy_modal.vue';
import assignSecurityPolicyProject from 'ee/threat_monitoring/graphql/mutations/assign_security_policy_project.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
apolloFailureResponse,
mockAssignSecurityPolicyProjectResponses,
} from '../mocks/mock_apollo';
import { stubComponent } from 'helpers/stub_component';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mockAssignSecurityPolicyProjectResponses } from '../../mocks/mock_apollo';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('SecurityPolicyProjectSelector Component', () => {
describe('ScanNewPolicyModal Component', () => {
let wrapper;
let projectUpdatedListener;
const findSaveButton = () => wrapper.findByTestId('save-policy-project');
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findErrorAlert = () => wrapper.findByTestId('policy-project-assign-error');
const findInstanceProjectSelector = () => wrapper.findComponent(InstanceProjectSelector);
const findSuccessAlert = () => wrapper.findByTestId('policy-project-assign-success');
const findTooltip = () => wrapper.findByTestId('disabled-button-tooltip');
const findAlert = () => wrapper.findComponent(GlAlert);
const findModal = () => wrapper.findComponent(GlModal);
const selectProject = async () => {
findInstanceProjectSelector().vm.$emit('projectClicked', {
const selectProject = async (
project = {
id: 'gid://gitlab/Project/1',
name: 'Test 1',
});
},
) => {
findInstanceProjectSelector().vm.$emit('projectClicked', project);
await wrapper.vm.$nextTick();
findModal().vm.$emit('ok');
await wrapper.vm.$nextTick();
findSaveButton().vm.$emit('click');
await waitForPromises();
};
const createWrapper = ({
mount = shallowMountExtended,
mutationResult = mockAssignSecurityPolicyProjectResponses.success,
propsData = {},
provide = {},
} = {}) => {
wrapper = mount(SecurityPolicyProjectSelector, {
wrapper = mountExtended(ScanNewPolicyModal, {
localVue,
apolloProvider: createMockApollo([[assignSecurityPolicyProject, mutationResult]]),
directives: {
GlTooltip: createMockDirective(),
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
},
propsData,
provide: {
disableSecurityPolicyProject: false,
documentationPath: 'test/path/index.md',
projectPath: 'path/to/project',
assignedPolicyProject: null,
...provide,
},
});
projectUpdatedListener = jest.fn();
wrapper.vm.$on('project-updated', projectUpdatedListener);
};
const createWrapperAndSelectProject = async (data) => {
createWrapper(data);
await selectProject();
};
afterEach(() => {
......@@ -67,72 +73,69 @@ describe('SecurityPolicyProjectSelector Component', () => {
createWrapper();
});
it.each`
findComponent | state | title
${findDropdown} | ${true} | ${'does display the dropdown'}
${findInstanceProjectSelector} | ${true} | ${'does display the project selector'}
${findErrorAlert} | ${false} | ${'does not display the error alert'}
${findSuccessAlert} | ${false} | ${'does not display the success alert'}
`('$title', ({ findComponent, state }) => {
expect(findComponent().exists()).toBe(state);
it('passes down correct properties/attributes to the gl-modal component', () => {
expect(findModal().props()).toMatchObject({
modalId: 'scan-new-policy',
size: 'sm',
visible: false,
title: 'Select security project',
});
it('renders the "Save Changes" button', () => {
const button = findSaveButton();
expect(button.exists()).toBe(true);
expect(button.attributes('disabled')).toBe('true');
expect(findModal().attributes()).toEqual({
'ok-disabled': 'true',
'ok-title': 'Save',
'cancel-variant': 'light',
});
});
it('does not display a tooltip', () => {
const tooltip = getBinding(findTooltip().element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(true);
it('does not display a warning', () => {
expect(findAlert().exists()).toBe(false);
});
});
it('emits close event when gl-modal emits change event', () => {
createWrapper();
findModal().vm.$emit('change');
expect(wrapper.emitted('close')).toEqual([[]]);
});
describe('project selection', () => {
it('enables the "Save Changes" button if a new project is selected', async () => {
it('enables the "Save" button only if a new project is selected', async () => {
createWrapper({
mount: mountExtended,
propsData: { assignedPolicyProject: { id: 'gid://gitlab/Project/0', name: 'Test 0' } },
provide: { assignedPolicyProject: { id: 'gid://gitlab/Project/0', name: 'Test 0' } },
});
const button = findSaveButton();
expect(button.attributes('disabled')).toBe('disabled');
expect(findModal().attributes('ok-disabled')).toBe('true');
findInstanceProjectSelector().vm.$emit('projectClicked', {
id: 'gid://gitlab/Project/1',
name: 'Test 1',
});
await wrapper.vm.$nextTick();
expect(button.attributes('disabled')).toBe(undefined);
expect(findModal().attributes('ok-disabled')).toBeUndefined();
});
it('displays an alert if the security policy project selection succeeds', async () => {
createWrapper({ mount: mountExtended });
expect(findErrorAlert().exists()).toBe(false);
expect(findSuccessAlert().exists()).toBe(false);
await selectProject();
expect(findErrorAlert().exists()).toBe(false);
expect(findSuccessAlert().exists()).toBe(true);
it('emits an event with success message', async () => {
await createWrapperAndSelectProject();
expect(projectUpdatedListener).toHaveBeenCalledWith({
text: 'Security policy project was linked successfully',
variant: 'success',
});
});
it('shows an alert if the security policy project selection fails', async () => {
createWrapper({
mount: mountExtended,
it('emits an event with an error message', async () => {
await createWrapperAndSelectProject({
mutationResult: mockAssignSecurityPolicyProjectResponses.failure,
});
expect(findErrorAlert().exists()).toBe(false);
expect(findSuccessAlert().exists()).toBe(false);
await selectProject();
expect(findErrorAlert().exists()).toBe(true);
expect(findSuccessAlert().exists()).toBe(false);
});
it('shows an alert if GraphQL fails', async () => {
createWrapper({ mount: mountExtended, mutationResult: apolloFailureResponse });
expect(findErrorAlert().exists()).toBe(false);
expect(findSuccessAlert().exists()).toBe(false);
await selectProject();
expect(findErrorAlert().exists()).toBe(true);
expect(findSuccessAlert().exists()).toBe(false);
expect(projectUpdatedListener).toHaveBeenCalledWith({
text: 'An error occurred assigning your security policy project',
variant: 'danger',
});
});
});
......@@ -142,12 +145,11 @@ describe('SecurityPolicyProjectSelector Component', () => {
});
it('disables the dropdown', () => {
expect(findDropdown().attributes('disabled')).toBe('true');
expect(findDropdown().props('disabled')).toBe(true);
});
it('displays a tooltip', () => {
const tooltip = getBinding(findTooltip().element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
it('displays a warning', () => {
expect(findAlert().text()).toBe('Only owners can update Security Policy Project');
});
});
});
......@@ -21819,6 +21819,12 @@ msgstr ""
msgid "NetworkPolicies|Edit policy"
msgstr ""
msgid "NetworkPolicies|Edit policy project"
msgstr ""
msgid "NetworkPolicies|Enforce security for this project. %{linkStart}More information.%{linkEnd}"
msgstr ""
msgid "NetworkPolicies|Enforcement status"
msgstr ""
......@@ -21861,6 +21867,9 @@ msgstr ""
msgid "NetworkPolicies|Please %{installLinkStart}install%{installLinkEnd} and %{configureLinkStart}configure a Kubernetes Agent for this project%{configureLinkEnd} to enable alerts."
msgstr ""
msgid "NetworkPolicies|Policies"
msgstr ""
msgid "NetworkPolicies|Policies are a specification of how groups of pods are allowed to communicate with each other's network endpoints."
msgstr ""
......@@ -29313,22 +29322,19 @@ msgstr ""
msgid "SecurityConfiguration|You can quickly enable all security scanning tools by enabling %{linkStart}Auto DevOps%{linkEnd}."
msgstr ""
msgid "SecurityOrchestration|A security policy project can enforce policies for a given project, group, or instance. With a security policy project, you can specify security policies that are important to you and enforce them with every commit. %{linkStart}More information.%{linkEnd}"
msgstr ""
msgid "SecurityOrchestration|An error occurred assigning your security policy project"
msgstr ""
msgid "SecurityOrchestration|Create a policy"
msgid "SecurityOrchestration|Only owners can update Security Policy Project"
msgstr ""
msgid "SecurityOrchestration|Only owners can update Security Policy Project"
msgid "SecurityOrchestration|Security policy project was linked successfully"
msgstr ""
msgid "SecurityOrchestration|Security policy project"
msgid "SecurityOrchestration|Select a project to store your security policies in. %{linkStart}More information.%{linkEnd}"
msgstr ""
msgid "SecurityOrchestration|Security policy project was linked successfully"
msgid "SecurityOrchestration|Select security project"
msgstr ""
msgid "SecurityPolicies|+%{count} more"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment