Commit ebe5d683 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '222374-security-reports-summary-pipelines-sd' into 'master'

Show security reports summary in pipelines' Security Dashboard

See merge request gitlab-org/gitlab!35060
parents 2d18b0b9 5d45ffe1
......@@ -116,7 +116,6 @@ export default {
<span class="gl-white-space-nowrap gl-ml-2" :class="{ 'gl-pl-5': !isSelected(option) }">
{{ option.name }}
</span>
<slot v-bind="{ filter, option }"></slot>
</span>
</button>
</div>
......
<script>
import { mapGetters, mapActions } from 'vuex';
import { n__ } from '~/locale';
import { camelCase } from 'lodash';
import DashboardFilter from './filter.vue';
import GlToggleVuex from '~/vue_shared/components/gl_toggle_vuex.vue';
......@@ -10,43 +8,11 @@ export default {
DashboardFilter,
GlToggleVuex,
},
props: {
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapGetters('filters', ['visibleFilters']),
},
methods: {
...mapActions('filters', ['setFilter']),
/**
* This method lets us match some data coming from the API with values that are currently
* hardcoded in the frontend.
* We are considering moving the whole thing to the backend so that we can rely on a SSoT.
* https://gitlab.com/gitlab-org/gitlab/-/issues/217373
*/
getOptionEnrichedData(filter, option) {
if (filter.id === 'report_type') {
const { id: optionId } = option;
const optionData = this.securityReportSummary[camelCase(optionId)];
if (!optionData) {
return null;
}
const { vulnerabilitiesCount, scannedResourcesCount } = optionData;
const enrichedData = [];
if (vulnerabilitiesCount !== undefined) {
enrichedData.push(n__('%d vulnerability', '%d vulnerabilities', vulnerabilitiesCount));
}
if (scannedResourcesCount !== undefined) {
enrichedData.push(n__('%d url scanned', '%d urls scanned', scannedResourcesCount));
}
return enrichedData.join(', ');
}
return null;
},
},
};
</script>
......@@ -58,19 +24,9 @@ export default {
v-for="filter in visibleFilters"
:key="filter.id"
class="col-sm-6 col-md-4 col-lg-2 p-2 js-filter"
:class="`js-filter-${filter.id}`"
:filter="filter"
@setFilter="setFilter"
>
<template #default="{ option }">
<span
v-if="getOptionEnrichedData(filter, option)"
class="gl-text-gray-500 gl-white-space-nowrap"
>
&nbsp;({{ getOptionEnrichedData(filter, option) }})
</span>
</template>
</dashboard-filter>
/>
<div class="ml-lg-auto p-2">
<strong>{{ s__('SecurityReports|Hide dismissed') }}</strong>
<gl-toggle-vuex
......
<script>
import { mapActions } from 'vuex';
import { GlEmptyState } from '@gitlab/ui';
import { s__ } from '~/locale';
import SecurityReportsSummary from './security_reports_summary.vue';
import SecurityDashboard from './security_dashboard_vuex.vue';
import { fetchPolicies } from '~/lib/graphql';
import pipelineSecurityReportSummaryQuery from '../graphql/pipeline_security_report_summary.query.graphql';
......@@ -10,6 +12,7 @@ export default {
name: 'PipelineSecurityDashboard',
components: {
GlEmptyState,
SecurityReportsSummary,
SecurityDashboard,
},
mixins: [glFeatureFlagsMixin()],
......@@ -20,11 +23,12 @@ export default {
variables() {
return {
fullPath: this.projectFullPath,
pipelineId: this.pipelineId,
pipelineIid: this.pipelineIid,
};
},
update(data) {
return data?.project?.pipelines?.nodes?.[0]?.securityReportSummary;
const summary = data?.project?.pipeline?.securityReportSummary;
return Object.keys(summary).length ? summary : null;
},
skip() {
return !this.glFeatures.pipelinesSecurityReportSummary;
......@@ -44,6 +48,10 @@ export default {
type: Number,
required: true,
},
pipelineIid: {
type: Number,
required: true,
},
projectId: {
type: Number,
required: true,
......@@ -70,6 +78,19 @@ export default {
default: '',
},
},
computed: {
emptyStateProps() {
return {
svgPath: this.emptyStateSvgPath,
title: s__('SecurityReports|No vulnerabilities found for this pipeline'),
description: s__(
`SecurityReports|While it's rare to have no vulnerabilities for your pipeline, it can happen. In any event, we ask that you double check your settings to make sure all security scanning jobs have passed successfully.`,
),
primaryButtonLink: this.dashboardDocumentation,
primaryButtonText: s__('SecurityReports|Learn more about setting up your dashboard'),
};
},
},
created() {
this.setSourceBranch(this.sourceBranch);
},
......@@ -80,26 +101,23 @@ export default {
</script>
<template>
<security-dashboard
:vulnerabilities-endpoint="vulnerabilitiesEndpoint"
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
:lock-to-project="{ id: projectId }"
:pipeline-id="pipelineId"
:loading-error-illustrations="loadingErrorIllustrations"
:security-report-summary="securityReportSummary"
>
<template #emptyState>
<gl-empty-state
:title="s__('SecurityReports|No vulnerabilities found for this pipeline')"
:svg-path="emptyStateSvgPath"
:description="
s__(
`SecurityReports|While it's rare to have no vulnerabilities for your pipeline, it can happen. In any event, we ask that you double check your settings to make sure all security scanning jobs have passed successfully.`,
)
"
:primary-button-link="dashboardDocumentation"
:primary-button-text="s__('SecurityReports|Learn more about setting up your dashboard')"
/>
</template>
</security-dashboard>
<div>
<security-reports-summary
v-if="securityReportSummary"
:summary="securityReportSummary"
class="gl-mt-5"
/>
<security-dashboard
:vulnerabilities-endpoint="vulnerabilitiesEndpoint"
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
:lock-to-project="{ id: projectId }"
:pipeline-id="pipelineId"
:loading-error-illustrations="loadingErrorIllustrations"
:security-report-summary="securityReportSummary"
>
<template #emptyState>
<gl-empty-state v-bind="emptyStateProps" />
</template>
</security-dashboard>
</div>
</template>
......@@ -61,11 +61,6 @@ export default {
required: false,
default: () => ({}),
},
securityReportSummary: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapState('vulnerabilities', [
......@@ -168,7 +163,7 @@ export default {
<security-dashboard-layout>
<template #header>
<vulnerability-count-list v-if="shouldShowCountList" />
<filters :security-report-summary="securityReportSummary" />
<filters />
</template>
<security-dashboard-table>
......
<script>
import { GlButton, GlCard, GlCollapse, GlCollapseToggleDirective, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import AccessorUtilities from '~/lib/utils/accessor';
import { getFormattedSummary } from '../helpers';
import { COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY as LOCAL_STORAGE_KEY } from '../constants';
export default {
name: 'SecurityReportsSummary',
components: {
GlButton,
GlCard,
GlCollapse,
GlSprintf,
},
directives: {
collapseToggle: GlCollapseToggleDirective,
},
props: {
summary: {
type: Object,
required: true,
},
},
data() {
return {
isVisible: true,
};
},
computed: {
collapseButtonLabel() {
return this.isVisible ? __('Hide details') : __('Show details');
},
formattedSummary() {
return getFormattedSummary(this.summary);
},
},
watch: {
isVisible(isVisible) {
if (!this.localStorageUsable) {
return;
}
if (isVisible) {
localStorage.removeItem(LOCAL_STORAGE_KEY);
} else {
localStorage.setItem(LOCAL_STORAGE_KEY, '1');
}
},
},
created() {
this.localStorageUsable = AccessorUtilities.isLocalStorageAccessSafe();
if (this.localStorageUsable) {
const shouldHideSummaryDetails = Boolean(localStorage.getItem(LOCAL_STORAGE_KEY));
this.isVisible = !shouldHideSummaryDetails;
}
},
};
</script>
<template>
<gl-card body-class="gl-py-0" header-class="gl-border-b-0">
<template #header>
<div class="row">
<div class="col-7">
<strong>{{ s__('SecurityReports|Scan details') }}</strong>
</div>
<div v-if="localStorageUsable" class="col-5 gl-text-right">
<gl-button
v-collapse-toggle.security-reports-summary-details
data-testid="collapse-button"
>
{{ collapseButtonLabel }}
</gl-button>
</div>
</div>
</template>
<gl-collapse id="security-reports-summary-details" v-model="isVisible" class="gl-pb-3">
<div v-for="[scanType, scanSummary] in formattedSummary" :key="scanType" class="row gl-my-3">
<div class="col-6 col-md-4 col-lg-2">
{{ scanType }}
</div>
<div class="col-6 col-md-8 col-lg-10">
<gl-sprintf
:message="
n__('%d vulnerability', '%d vulnerabilities', scanSummary.vulnerabilitiesCount)
"
/>
<template v-if="scanSummary.scannedResourcesCount !== undefined">
(<gl-sprintf
:message="n__('%d URL scanned', '%d URLs scanned', scanSummary.scannedResourcesCount)"
/>)
</template>
</div>
</div>
</gl-collapse>
</gl-card>
</template>
/* eslint-disable import/prefer-default-export */
export const COLLAPSE_SECURITY_REPORTS_SUMMARY_LOCAL_STORAGE_KEY =
'hide_pipelines_security_reports_summary_details';
query ($fullPath: ID!, $pipelineId: ID!) {
query($fullPath: ID!, $pipelineIid: ID!) {
project(fullPath: $fullPath) {
pipelines(id:$pipelineId) {
nodes {
securityReportSummary {
dast {
vulnerabilitiesCount
scannedResourcesCount
}
sast {
scannedResourcesCount
}
containerScanning {
vulnerabilitiesCount
}
dependencyScanning {
vulnerabilitiesCount
}
pipeline(iid: $pipelineIid) {
securityReportSummary {
dast {
vulnerabilitiesCount
scannedResourcesCount
}
sast {
vulnerabilitiesCount
}
containerScanning {
vulnerabilitiesCount
}
dependencyScanning {
vulnerabilitiesCount
}
}
}
}
}
\ No newline at end of file
}
import isPlainObject from 'lodash/isPlainObject';
import { s__ } from '~/locale';
import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils';
import { ALL, BASE_FILTERS } from 'ee/security_dashboard/store/modules/filters/constants';
import { REPORT_TYPES, SEVERITY_LEVELS } from 'ee/security_dashboard/store/constants';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
......@@ -46,4 +48,51 @@ export const initFirstClassVulnerabilityFilters = projects => {
return filters;
};
/**
* Provided a security reports summary from the GraphQL API, this returns an array of arrays
* representing a properly formatted report ready to be displayed in the UI. Each sub-array consists
* of the user-friend report's name, and the summary's payload. Note that summary entries are
* considered empty and are filtered out of the return if the payload is `null` or don't include
* a vulnerabilitiesCount property. Report types whose name can't be matched to a user-friendly
* name are filtered out as well.
*
* Take the following summary for example:
* {
* containerScanning: { vulnerabilitiesCount: 123 },
* invalidReportType: { vulnerabilitiesCount: 123 },
* dast: null,
* }
*
* The formatted summary would look like this:
* [
* ['containerScanning', { vulnerabilitiesCount: 123 }]
* ]
*
* Note that `invalidReportType` was filtered out as it can't be matched with a user-friendly name,
* and the DAST report was omitted because it's empty (`null`).
*
* @param {Object} rawSummary
* @returns {Array}
*/
export const getFormattedSummary = (rawSummary = {}) => {
if (!isPlainObject(rawSummary)) {
return [];
}
// Convert keys to snake case so they can be matched against REPORT_TYPES keys for translation
const snakeCasedSummary = convertObjectPropsToSnakeCase(rawSummary);
// Convert object to an array of entries to make it easier to loop through
const summaryEntries = Object.entries(snakeCasedSummary);
// Filter out empty entries as we don't want to display those in the summary
const withoutEmptyEntries = summaryEntries.filter(
([, scanSummary]) => scanSummary?.vulnerabilitiesCount !== undefined,
);
// Replace keys with translations found in REPORT_TYPES if available
const formattedEntries = withoutEmptyEntries.map(([scanType, scanSummary]) => {
const name = REPORT_TYPES[scanType];
return name ? [name, scanSummary] : null;
});
// Filter out keys that could not be matched with any translation and are thus considered invalid
return formattedEntries.filter(entry => entry !== null);
};
export default () => ({});
......@@ -16,6 +16,7 @@ export default () => {
dashboardDocumentation,
emptyStateSvgPath,
pipelineId,
pipelineIid,
projectId,
sourceBranch,
vulnerabilitiesEndpoint,
......@@ -41,6 +42,7 @@ export default () => {
props: {
projectId: parseInt(projectId, 10),
pipelineId: parseInt(pipelineId, 10),
pipelineIid: parseInt(pipelineIid, 10),
vulnerabilitiesEndpoint,
vulnerabilityFeedbackHelpPath,
sourceBranch,
......
......@@ -12,6 +12,7 @@
#js-security-report-app{ data: { dashboard_documentation: help_page_path('user/application_security/security_dashboard/index'),
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
pipeline_id: pipeline.id,
pipeline_iid: pipeline.iid,
project_id: project.id,
source_branch: pipeline.source_ref,
vulnerabilities_endpoint: vulnerabilities_endpoint_path,
......
......@@ -10,8 +10,6 @@ describe('Filter component', () => {
let wrapper;
let store;
const findReportTypeFilter = () => wrapper.find('.js-filter-report_type');
const createWrapper = (props = {}) => {
wrapper = mount(Filters, {
localVue,
......@@ -44,24 +42,4 @@ describe('Filter component', () => {
expect(wrapper.findAll('.js-toggle')).toHaveLength(1);
});
});
describe('Report type', () => {
it.each`
dastProps | string
${{ vulnerabilitiesCount: 0, scannedResourcesCount: 123 }} | ${'(0 vulnerabilities, 123 urls scanned)'}
${{ vulnerabilitiesCount: 481, scannedResourcesCount: 0 }} | ${'(481 vulnerabilities, 0 urls scanned)'}
${{ vulnerabilitiesCount: 1, scannedResourcesCount: 1 }} | ${'(1 vulnerability, 1 url scanned)'}
${{ vulnerabilitiesCount: 321 }} | ${'(321 vulnerabilities)'}
${{ scannedResourcesCount: 890 }} | ${'(890 urls scanned)'}
${{ vulnerabilitiesCount: 0 }} | ${'(0 vulnerabilities)'}
${{ scannedResourcesCount: 0 }} | ${'(0 urls scanned)'}
`('shows security report summary $string', ({ dastProps, string }) => {
createWrapper({
securityReportSummary: {
dast: dastProps,
},
});
expect(findReportTypeFilter().text()).toContain(string);
});
});
});
......@@ -2,6 +2,7 @@ import Vuex from 'vuex';
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import PipelineSecurityDashboard from 'ee/security_dashboard/components/pipeline_security_dashboard.vue';
import SecurityReportsSummary from 'ee/security_dashboard/components/security_reports_summary.vue';
import SecurityDashboard from 'ee/security_dashboard/components/security_dashboard_vuex.vue';
const localVue = createLocalVue();
......@@ -10,6 +11,7 @@ localVue.use(Vuex);
const dashboardDocumentation = '/help/docs';
const emptyStateSvgPath = '/svgs/empty/svg';
const pipelineId = 1234;
const pipelineIid = 4321;
const projectId = 5678;
const sourceBranch = 'feature-branch-1';
const vulnerabilitiesEndpoint = '/vulnerabilities';
......@@ -39,13 +41,11 @@ describe('Pipeline Security Dashboard component', () => {
wrapper = shallowMount(PipelineSecurityDashboard, {
localVue,
store,
data() {
return { securityReportSummary: {} };
},
propsData: {
dashboardDocumentation,
emptyStateSvgPath,
pipelineId,
pipelineIid,
projectId,
sourceBranch,
vulnerabilitiesEndpoint,
......@@ -53,6 +53,12 @@ describe('Pipeline Security Dashboard component', () => {
loadingErrorIllustrations,
},
...options,
data() {
return {
securityReportSummary: {},
...options?.data,
};
},
});
};
......@@ -95,7 +101,39 @@ describe('Pipeline Security Dashboard component', () => {
it('renders empty state component with correct props', () => {
const emptyState = wrapper.find(GlEmptyState);
expect(emptyState.attributes('title')).toBe('No vulnerabilities found for this pipeline');
expect(emptyState.props()).toMatchObject({
svgPath: '/svgs/empty/svg',
title: 'No vulnerabilities found for this pipeline',
description: `While it's rare to have no vulnerabilities for your pipeline, it can happen. In any event, we ask that you double check your settings to make sure all security scanning jobs have passed successfully.`,
primaryButtonLink: '/help/docs',
primaryButtonText: 'Learn more about setting up your dashboard',
});
});
});
describe('security reports summary', () => {
const securityReportSummary = {
dast: {
vulnerabilitiesCount: 123,
},
};
it('shows the summary if it is non-empty', () => {
factory({
data: {
securityReportSummary,
},
});
expect(wrapper.contains(SecurityReportsSummary)).toBe(true);
});
it('does not show the summary if it is empty', () => {
factory({
data: {
securityReportSummary: null,
},
});
expect(wrapper.contains(SecurityReportsSummary)).toBe(false);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { trimText } from 'helpers/text_helper';
import AccessorUtilities from '~/lib/utils/accessor';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import SecurityReportsSummary from 'ee/security_dashboard/components/security_reports_summary.vue';
describe('Security reports summary component', () => {
useLocalStorageSpy();
let wrapper;
const createWrapper = options => {
wrapper = shallowMount(SecurityReportsSummary, {
propsData: {
summary: {},
...options?.propsData,
},
stubs: {
GlSprintf,
GlCard: { template: '<div><slot name="header" /><slot /></div>' },
},
...options,
});
};
const findToggleButton = () => wrapper.find('[data-testid="collapse-button"]');
beforeEach(() => {
jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(true);
});
afterEach(() => {
wrapper.destroy();
localStorage.clear();
});
it.each`
dastProps | string
${{ vulnerabilitiesCount: 0, scannedResourcesCount: 123 }} | ${'0 vulnerabilities (123 URLs scanned)'}
${{ vulnerabilitiesCount: 481, scannedResourcesCount: 0 }} | ${'481 vulnerabilities (0 URLs scanned)'}
${{ vulnerabilitiesCount: 1, scannedResourcesCount: 1 }} | ${'1 vulnerability (1 URL scanned)'}
${{ vulnerabilitiesCount: 321 }} | ${'321 vulnerabilities'}
${{ vulnerabilitiesCount: 0 }} | ${'0 vulnerabilities'}
`('shows security report summary $string', ({ dastProps, string }) => {
createWrapper({
propsData: {
summary: {
dast: dastProps,
},
},
});
expect(trimText(wrapper.text())).toContain(string);
});
it.each`
dastProps
${{ scannedResourcesCount: 890 }}
${{ scannedResourcesCount: 0 }}
`(
'does not show the scanned resources count if there is no vulnerabilities count',
({ dastProps }) => {
createWrapper({
propsData: {
summary: {
dast: dastProps,
},
},
});
expect(trimText(wrapper.text())).not.toContain('URLs scanned');
},
);
it.each`
summaryProp | string
${{ dast: { vulnerabilitiesCount: 123 } }} | ${'DAST'}
${{ sast: { vulnerabilitiesCount: 123 } }} | ${'SAST'}
${{ containerScanning: { vulnerabilitiesCount: 123 } }} | ${'Container Scanning'}
${{ dependencyScanning: { vulnerabilitiesCount: 123 } }} | ${'Dependency Scanning'}
`('shows user-friendly scanner name for $string', ({ summaryProp, string }) => {
createWrapper({
propsData: {
summary: summaryProp,
},
});
expect(trimText(wrapper.text())).toContain(string);
});
it.each`
summaryProp | report
${{ dast: null }} | ${'DAST'}
${{ sast: null }} | ${'SAST'}
${{ containerScanning: null }} | ${'Container Scanning'}
${{ dependencyScanning: null }} | ${'Dependency Scanning'}
`('does not show $report report if scanner did not run', ({ summaryProp, report }) => {
createWrapper({
propsData: {
summary: summaryProp,
},
});
expect(trimText(wrapper.text())).not.toContain(report);
});
describe('collapsible behavior', () => {
const LOCAL_STORAGE_KEY = 'hide_pipelines_security_reports_summary_details';
describe('initially visible', () => {
beforeEach(() => {
createWrapper();
});
it('set local storage item to 1 when summary is hidden', async () => {
wrapper.setData({ isVisible: false });
await wrapper.vm.$nextTick();
expect(localStorage.setItem).toHaveBeenCalledWith(LOCAL_STORAGE_KEY, '1');
});
it('toggle button has the correct label', () => {
expect(findToggleButton().text()).toBe('Hide details');
});
});
describe('initially hidden', () => {
beforeEach(() => {
localStorage.setItem(LOCAL_STORAGE_KEY, '1');
createWrapper();
});
it('removes local storage item when summary is shown', async () => {
wrapper.setData({ isVisible: true });
await wrapper.vm.$nextTick();
expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_KEY);
});
it('toggle button has the correct label', () => {
expect(findToggleButton().text()).toBe('Show details');
});
});
});
describe('when localStorage is unavailable', () => {
beforeEach(() => {
jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(false);
createWrapper();
});
it('does not show the collapse button', () => {
expect(findToggleButton().exists()).toBe(false);
});
});
});
import { getFormattedSummary } from 'ee/security_dashboard/helpers';
describe('getFormattedSummary', () => {
it('returns a properly formatted array given a valid, non-empty summary', () => {
const summary = {
dast: { vulnerabilitiesCount: 0 },
containerScanning: { vulnerabilitiesCount: 1 },
dependencyScanning: { vulnerabilitiesCount: 2 },
};
expect(getFormattedSummary(summary)).toEqual([
['DAST', summary.dast],
['Container Scanning', summary.containerScanning],
['Dependency Scanning', summary.dependencyScanning],
]);
});
it('filters empty reports out', () => {
const summary = {
dast: { vulnerabilitiesCount: 0 },
containerScanning: null,
dependencyScanning: {},
};
expect(getFormattedSummary(summary)).toEqual([['DAST', summary.dast]]);
});
it('filters invalid report types out', () => {
const summary = {
dast: { vulnerabilitiesCount: 0 },
invalidReportType: { vulnerabilitiesCount: 1 },
};
expect(getFormattedSummary(summary)).toEqual([['DAST', summary.dast]]);
});
it.each([undefined, [], [1], 'hello world', 123])(
'returns an empty array when summary is %s',
summary => {
expect(getFormattedSummary(summary)).toEqual([]);
},
);
});
......@@ -249,11 +249,6 @@ msgid_plural "%d unresolved threads"
msgstr[0] ""
msgstr[1] ""
msgid "%d url scanned"
msgid_plural "%d urls scanned"
msgstr[0] ""
msgstr[1] ""
msgid "%d vulnerability"
msgid_plural "%d vulnerabilities"
msgstr[0] ""
......@@ -11703,6 +11698,9 @@ msgid_plural "Hide charts"
msgstr[0] ""
msgstr[1] ""
msgid "Hide details"
msgstr ""
msgid "Hide file browser"
msgstr ""
......@@ -20093,6 +20091,9 @@ msgstr ""
msgid "SecurityReports|Return to dashboard"
msgstr ""
msgid "SecurityReports|Scan details"
msgstr ""
msgid "SecurityReports|Security Dashboard"
msgstr ""
......@@ -20780,6 +20781,9 @@ msgstr ""
msgid "Show complete raw log"
msgstr ""
msgid "Show details"
msgstr ""
msgid "Show file browser"
msgstr ""
......
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