Commit 0cf30a73 authored by Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt Committed by Frédéric Caplette

Add the "Scheduled" tab to the on-demand scans page

This adds the new "Scheduled" tab to the on-demand scans index page.

Changelog: added
EE: true
parent 18fc6343
......@@ -3,6 +3,7 @@
module TimeZoneHelper
TIME_ZONE_FORMAT_ATTRS = {
short: %i[identifier name offset],
abbr: %i[identifier abbr],
full: %i[identifier name abbr offset formatted_offset]
}.freeze
private_constant :TIME_ZONE_FORMAT_ATTRS
......
......@@ -5791,6 +5791,7 @@ The connection type for [`DastProfile`](#dastprofile).
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="dastprofileconnectioncount"></a>`count` | [`Int!`](#int) | Total count of collection. |
| <a id="dastprofileconnectionedges"></a>`edges` | [`[DastProfileEdge]`](#dastprofileedge) | A list of edges. |
| <a id="dastprofileconnectionnodes"></a>`nodes` | [`[DastProfile]`](#dastprofile) | A list of nodes. |
| <a id="dastprofileconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
......@@ -17,6 +17,7 @@ import {
import AllTab from './tabs/all.vue';
import RunningTab from './tabs/running.vue';
import FinishedTab from './tabs/finished.vue';
import ScheduledTab from './tabs/scheduled.vue';
import EmptyState from './empty_state.vue';
export default {
......@@ -30,6 +31,7 @@ export default {
AllTab,
RunningTab,
FinishedTab,
ScheduledTab,
EmptyState,
},
inject: ['newDastScanPath', 'projectPath', 'projectOnDemandScanCountsEtag'],
......@@ -73,7 +75,10 @@ export default {
return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts;
},
hasData() {
return this.onDemandScanCounts.all > 0;
// Scheduled scans aren't included in the total count yet because they are dastProfiles and
// not pipelines. When https://gitlab.com/gitlab-org/gitlab/-/issues/342950 is addressed, we
// will be able to rely on the "all" count only here.
return this.onDemandScanCounts.all + this.onDemandScanCounts.scheduled > 0;
},
tabs() {
return {
......@@ -89,6 +94,10 @@ export default {
component: FinishedTab,
itemsCount: this.onDemandScanCounts.finished,
},
scheduled: {
component: ScheduledTab,
itemsCount: this.onDemandScanCounts.scheduled,
},
};
},
activeTab: {
......
......@@ -223,28 +223,23 @@ export default {
<rect width="70" height="20" x="855" y="5" rx="4" />
</gl-skeleton-loader>
</template>
<template #cell(detailedStatus)="{ item }">
<template #cell(status)="{ value }">
<div class="gl-my-3">
<ci-badge-link :status="item.detailedStatus" />
<ci-badge-link :status="value" />
</div>
</template>
<!-- eslint-disable-next-line vue/valid-v-slot -->
<template #cell(dastProfile.name)="{ item }">
<gl-truncate v-if="item.dastProfile" :text="item.dastProfile.name" with-tooltip />
<template #cell(name)="{ value }">
<gl-truncate v-if="value" :text="value" with-tooltip />
</template>
<template #cell(scanType)>
{{ $options.DAST_SHORT_NAME }}
</template>
<!-- eslint-disable-next-line vue/valid-v-slot -->
<template #cell(dastProfile.dastSiteProfile.targetUrl)="{ item }">
<gl-truncate
v-if="item.dastProfile"
:text="item.dastProfile.dastSiteProfile.targetUrl"
with-tooltip
/>
<template #cell(targetUrl)="{ value }">
<gl-truncate v-if="value" :text="value" with-tooltip />
</template>
<template #cell(createdAt)="{ item }">
......@@ -258,6 +253,10 @@ export default {
<template #cell(id)="{ item }">
<gl-link :href="item.path">#{{ $options.getIdFromGraphQLId(item.id) }}</gl-link>
</template>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope">
<slot :name="slot" v-bind="scope"></slot>
</template>
</gl-table>
<div class="gl-display-flex gl-justify-content-center">
......
<script>
import { GlIcon } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import DastScanSchedule from 'ee/security_configuration/dast_profiles/components/dast_scan_schedule.vue';
import scheduledDastProfilesQuery from '../../graphql/scheduled_dast_profiles.query.graphql';
import { SCHEDULED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants';
import BaseTab from './base_tab.vue';
export default {
query: scheduledDastProfilesQuery,
components: {
GlIcon,
BaseTab,
DastScanSchedule,
},
inject: ['timezones'],
tableFields: SCHEDULED_TAB_TABLE_FIELDS,
i18n: {
title: __('Scheduled'),
emptyStateTitle: s__('OnDemandScans|There are no scheduled scans.'),
emptyStateText: LEARN_MORE_TEXT,
},
methods: {
getTimezoneCode(timezone) {
return this.timezones.find(({ identifier }) => identifier === timezone)?.abbr;
},
},
};
</script>
<template>
<base-tab
:query="$options.query"
:title="$options.i18n.title"
:fields="$options.tableFields"
:empty-state-title="$options.i18n.emptyStateTitle"
:empty-state-text="$options.i18n.emptyStateText"
v-bind="$attrs"
>
<template #cell(nextRun)="{ value: { date, time, timezone } }">
<div class="gl-white-space-nowrap"><gl-icon :size="12" name="calendar" /> {{ date }}</div>
<div class="gl-text-secondary gl-white-space-nowrap">
<gl-icon :size="12" name="clock" /> {{ time }} {{ getTimezoneCode(timezone) }}
</div>
</template>
<template #cell(dastProfileSchedule)="{ value }">
<dast-scan-schedule :schedule="value" />
</template>
</base-tab>
</template>
import { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
import { stripTimezoneFromISODate } from '~/lib/utils/datetime/date_format_utility';
export const HELP_PAGE_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'on-demand-scans',
......@@ -8,40 +9,98 @@ export const LEARN_MORE_TEXT = s__(
'OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}.',
);
export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished'];
export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled'];
export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000;
export const PIPELINES_COUNT_POLL_INTERVAL = 1000;
export const PIPELINES_SCOPE_RUNNING = 'RUNNING';
export const PIPELINES_SCOPE_FINISHED = 'FINISHED';
const STATUS_COLUMN = {
label: __('Status'),
key: 'status',
columnClass: 'gl-w-15',
};
const NAME_COLUMN = {
label: __('Name'),
key: 'name',
};
const SCAN_TYPE_COLUMN = {
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
columnClass: 'gl-w-13',
};
const TARGET_COLUMN = {
label: s__('OnDemandScans|Target'),
key: 'targetUrl',
};
const START_DATE_COLUMN = {
label: __('Start date'),
key: 'createdAt',
columnClass: 'gl-w-15',
};
const PIPELINE_ID_COLUMN = {
label: __('Pipeline'),
key: 'id',
columnClass: 'gl-w-13',
};
export const BASE_TABS_TABLE_FIELDS = [
{
label: __('Status'),
key: 'detailedStatus',
columnClass: 'gl-w-15',
...STATUS_COLUMN,
formatter: (_value, _key, item) => item.detailedStatus,
},
{
...NAME_COLUMN,
formatter: (_value, _key, item) => item.dastProfile.name,
},
SCAN_TYPE_COLUMN,
{
label: __('Name'),
key: 'dastProfile.name',
...TARGET_COLUMN,
formatter: (_value, _key, item) => item.dastProfile.dastSiteProfile.targetUrl,
},
START_DATE_COLUMN,
PIPELINE_ID_COLUMN,
];
export const SCHEDULED_TAB_TABLE_FIELDS = [
{
label: s__('OnDemandScans|Scan type'),
key: 'scanType',
columnClass: 'gl-w-13',
...STATUS_COLUMN,
formatter: (_value, _key, item) => ({
detailsPath: item.editPath,
text: __('Scheduled'),
icon: 'status_scheduled',
group: 'scheduled',
}),
},
NAME_COLUMN,
SCAN_TYPE_COLUMN,
{
label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl',
...TARGET_COLUMN,
formatter: (_value, _key, item) => item.dastSiteProfile.targetUrl,
},
{
label: __('Start date'),
key: 'createdAt',
columnClass: 'gl-w-15',
label: __('Next scan'),
key: 'nextRun',
formatter: (_value, _key, item) => {
const date = new Date(item.dastProfileSchedule.nextRunAt);
const time = new Date(stripTimezoneFromISODate(item.dastProfileSchedule.startsAt));
return {
date: date.toLocaleDateString(window.navigator.language, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
}),
time: time.toLocaleTimeString(window.navigator.language, {
hour: '2-digit',
minute: '2-digit',
}),
timezone: item.dastProfileSchedule.timezone,
};
},
},
{
label: __('Pipeline'),
key: 'id',
columnClass: 'gl-w-13',
label: s__('OnDemandScans|Repeats'),
key: 'dastProfileSchedule',
},
];
......@@ -21,4 +21,10 @@ query onDemandScanCounts(
count
}
}
scheduled: project(fullPath: $fullPath) {
id
pipelines: dastProfiles(hasDastProfileSchedule: true) {
count
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query ScheduledDastProfiles(
$fullPath: ID!
$after: String
$before: String
$first: Int
$last: Int
) {
project(fullPath: $fullPath) {
id
pipelines: dastProfiles(
after: $after
before: $before
first: $first
last: $last
hasDastProfileSchedule: true
) {
pageInfo {
...PageInfo
}
nodes {
id
name
dastSiteProfile {
id
targetUrl
}
dastProfileSchedule {
id
active
nextRunAt
startsAt
timezone
cadence {
unit
duration
}
}
editPath
}
}
}
}
......@@ -15,6 +15,7 @@ export default () => {
projectOnDemandScanCountsEtag,
} = el.dataset;
const initialOnDemandScanCounts = JSON.parse(el.dataset.onDemandScanCounts);
const timezones = JSON.parse(el.dataset.timezones);
return new Vue({
el,
......@@ -25,6 +26,7 @@ export default () => {
newDastScanPath,
emptyStateSvgPath,
projectOnDemandScanCountsEtag,
timezones,
},
render(h) {
return h(OnDemandScans, {
......
......@@ -6,6 +6,8 @@ module Types
graphql_name 'DastProfile'
description 'Represents a DAST Profile'
connection_type_class(Types::CountableConnectionType)
authorize :read_on_demand_dast_scan
field :id, ::Types::GlobalIDType[::Dast::Profile], null: false,
......
......@@ -5,16 +5,19 @@ module Projects::OnDemandScansHelper
def on_demand_scans_data(project)
on_demand_scans = project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan])
running_scan_count, finished_scan_count = count_running_and_finished_scans(on_demand_scans)
scheduled_scans = ::Dast::ProfilesFinder.new({ project_id: project.id, has_dast_profile_schedule: true }).execute
common_data(project).merge({
'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path(project),
'on-demand-scan-counts' => {
all: on_demand_scans.length,
running: running_scan_count,
finished: finished_scan_count
finished: finished_scan_count,
scheduled: scheduled_scans.length
}.to_json,
'new-dast-scan-path' => new_project_on_demand_scan_path(project),
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg')
'empty-state-svg-path' => image_path('illustrations/empty-state/ondemand-scan-empty.svg'),
'timezones' => timezone_data(format: :abbr).to_json
})
end
# rubocop: enable CodeReuse/ActiveRecord
......
......@@ -7,7 +7,7 @@ module Projects::Security::DastProfilesHelper
'new_dast_site_profile_path' => new_project_security_configuration_dast_scans_dast_site_profile_path(project),
'new_dast_scanner_profile_path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
'project_full_path' => project.path_with_namespace,
'timezones' => timezone_data(format: :full).to_json
'timezones' => timezone_data(format: :abbr).to_json
}
end
end
......@@ -160,5 +160,24 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do
end
end
end
describe 'scheduled_dast_profiles' do
path = 'on_demand_scans/graphql/scheduled_dast_profiles.query.graphql'
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let_it_be(:dast_profile_schedule) { create(:dast_profile_schedule, project: project, dast_profile: dast_profile)}
it "graphql/#{path}.json" do
query = get_graphql_query_as_string(path, ee: true)
post_graphql(query, current_user: current_user, variables: {
fullPath: project.full_path,
first: 20
})
expect_graphql_errors_to_be_empty
expect(graphql_data_at(:project, :pipelines, :nodes)).to have_attributes(size: 1)
end
end
end
end
......@@ -29,6 +29,7 @@ describe('OnDemandScans', () => {
all: 12,
running: 3,
finished: 9,
scheduled: 5,
};
const emptyInitialPipelineCounts = Object.fromEntries(PIPELINE_TABS_KEYS.map((key) => [key, 0]));
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { merge } from 'lodash';
import scheduledDastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/scheduled_dast_profiles.query.graphql.json';
import mockTimezones from 'test_fixtures/timezones/abbr.json';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ScheduledTab from 'ee/on_demand_scans/components/tabs/scheduled.vue';
import BaseTab from 'ee/on_demand_scans/components/tabs/base_tab.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import scheduledDastProfilesQuery from 'ee/on_demand_scans/graphql/scheduled_dast_profiles.query.graphql';
import { createRouter } from 'ee/on_demand_scans/router';
import { SCHEDULED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants';
import { __, s__ } from '~/locale';
import { stripTimezoneFromISODate } from '~/lib/utils/datetime/date_format_utility';
import DastScanSchedule from 'ee/security_configuration/dast_profiles/components/dast_scan_schedule.vue';
jest.mock('~/lib/utils/common_utils');
Vue.use(VueApollo);
describe('Scheduled tab', () => {
let wrapper;
let router;
let requestHandler;
// Props
const projectPath = '/namespace/project';
const itemsCount = 12;
// Finders
const findBaseTab = () => wrapper.findComponent(BaseTab);
const findFirstRow = () => wrapper.find('tbody > tr');
const findCellAt = (index) => findFirstRow().findAll('td').at(index);
// Helpers
const createMockApolloProvider = () => {
return createMockApollo([[scheduledDastProfilesQuery, requestHandler]]);
};
const createComponentFactory = (mountFn = shallowMountExtended) => (options = {}) => {
router = createRouter();
wrapper = mountFn(
ScheduledTab,
merge(
{
apolloProvider: createMockApolloProvider(),
router,
propsData: {
isActive: true,
itemsCount,
},
provide: {
projectPath,
timezones: mockTimezones,
},
stubs: {
BaseTab,
},
},
options,
),
);
};
const createComponent = createComponentFactory();
const createFullComponent = createComponentFactory(mountExtended);
beforeEach(() => {
requestHandler = jest.fn().mockResolvedValue(scheduledDastProfilesMock);
});
afterEach(() => {
wrapper.destroy();
router = null;
requestHandler = null;
});
it('renders the base tab with the correct props', () => {
createComponent();
expect(findBaseTab().props('title')).toBe(__('Scheduled'));
expect(findBaseTab().props('itemsCount')).toBe(itemsCount);
expect(findBaseTab().props('query')).toBe(scheduledDastProfilesQuery);
expect(findBaseTab().props('emptyStateTitle')).toBe(
s__('OnDemandScans|There are no scheduled scans.'),
);
expect(findBaseTab().props('emptyStateText')).toBe(LEARN_MORE_TEXT);
expect(findBaseTab().props('fields')).toBe(SCHEDULED_TAB_TABLE_FIELDS);
});
it('fetches the profiles', () => {
createComponent();
expect(requestHandler).toHaveBeenCalledWith({
after: null,
before: null,
first: 20,
fullPath: projectPath,
last: null,
});
});
describe('custom table cells', () => {
const [firstProfile] = scheduledDastProfilesMock.data.project.pipelines.nodes;
beforeEach(() => {
createFullComponent();
});
it('renders the next run cell', () => {
const nextRunCell = findCellAt(4);
expect(nextRunCell.text()).toContain(
new Date(firstProfile.dastProfileSchedule.nextRunAt).toLocaleDateString(
window.navigator.language,
{
year: 'numeric',
month: 'numeric',
day: 'numeric',
},
),
);
expect(nextRunCell.text()).toContain(
new Date(
stripTimezoneFromISODate(firstProfile.dastProfileSchedule.startsAt),
).toLocaleTimeString(window.navigator.language, {
hour: '2-digit',
minute: '2-digit',
}),
);
});
it('renders the schedule cell', () => {
const scheduleCell = findCellAt(5);
const dastScanScheduleComponent = scheduleCell.find(DastScanSchedule);
expect(dastScanScheduleComponent.exists()).toBe(true);
expect(dastScanScheduleComponent.props('schedule')).toEqual(firstProfile.dastProfileSchedule);
});
});
});
......@@ -6,13 +6,18 @@ RSpec.describe Projects::OnDemandScansHelper do
let_it_be(:project) { create(:project) }
let_it_be(:path_with_namespace) { "foo/bar" }
let_it_be(:graphql_etag_project_on_demand_scan_counts_path) {"/api/graphql:#{path_with_namespace}/on_demand_scans/counts" }
let_it_be(:timezones) { [{ identifier: "Europe/Paris" }] }
before do
allow(project).to receive(:path_with_namespace).and_return(path_with_namespace)
end
describe '#on_demand_scans_data' do
let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let_it_be(:dast_profile_schedule) { create(:dast_profile_schedule, project: project, dast_profile: dast_profile)}
before do
allow(helper).to receive(:timezone_data).with(format: :abbr).and_return(timezones)
create_list(:ci_pipeline, 8, :success, project: project, ref: 'master', source: :ondemand_dast_scan)
create_list(:ci_pipeline, 4, :running, project: project, ref: 'master', source: :ondemand_dast_scan)
allow(helper).to receive(:graphql_etag_project_on_demand_scan_counts_path).and_return(graphql_etag_project_on_demand_scan_counts_path)
......@@ -27,18 +32,18 @@ RSpec.describe Projects::OnDemandScansHelper do
'on-demand-scan-counts' => {
all: 12,
running: 4,
finished: 8
}.to_json
finished: 8,
scheduled: 1
}.to_json,
'timezones' => timezones.to_json
)
end
end
describe '#on_demand_scans_form_data' do
let_it_be(:timezones) { [{ identifier: "Europe/Paris" }] }
before do
allow(project).to receive(:default_branch).and_return("default-branch")
allow(helper).to receive(:timezone_data).with(format: :full).and_return(timezones)
allow(project).to receive(:default_branch).and_return("default-branch")
end
it 'returns proper data' do
......
......@@ -9,7 +9,7 @@ RSpec.describe Projects::Security::DastProfilesHelper do
before do
allow(project).to receive(:path_with_namespace).and_return("foo/bar")
allow(helper).to receive(:timezone_data).with(format: :full).and_return(timezones)
allow(helper).to receive(:timezone_data).with(format: :abbr).and_return(timezones)
end
it 'returns proper data' do
......
......@@ -23433,6 +23433,9 @@ msgstr ""
msgid "Next file in diff"
msgstr ""
msgid "Next scan"
msgstr ""
msgid "Next unresolved discussion"
msgstr ""
......@@ -24331,6 +24334,9 @@ msgstr ""
msgid "OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}"
msgstr ""
msgid "OnDemandScans|Repeats"
msgstr ""
msgid "OnDemandScans|Save and run scan"
msgstr ""
......@@ -24367,6 +24373,9 @@ msgstr ""
msgid "OnDemandScans|There are no running scans."
msgstr ""
msgid "OnDemandScans|There are no scheduled scans."
msgstr ""
msgid "OnDemandScans|Use existing scanner profile"
msgstr ""
......
......@@ -8,11 +8,9 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do
let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json }
it 'timezones/short.json' do
@timezones = timezone_data(format: :short)
end
it 'timezones/full.json' do
@timezones = timezone_data(format: :full)
%I[short abbr full].each do |format|
it "timezones/#{format}.json" do
@timezones = timezone_data(format: format)
end
end
end
......@@ -30,6 +30,30 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do
end
end
context 'with abbr format' do
subject(:timezone_data) { helper.timezone_data(format: :abbr) }
it 'matches schema' do
expect(timezone_data).not_to be_empty
timezone_data.each_with_index do |timezone_hash, i|
expect(timezone_hash.keys).to contain_exactly(
:identifier,
:abbr
), "Failed at index #{i}"
end
end
it 'formats for display' do
tz = ActiveSupport::TimeZone.all[0]
expect(timezone_data[0]).to eq(
identifier: tz.tzinfo.identifier,
abbr: tz.tzinfo.strftime('%Z')
)
end
end
context 'with full format' do
subject(:timezone_data) { helper.timezone_data(format: :full) }
......@@ -64,7 +88,7 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do
subject(:timezone_data) { helper.timezone_data(format: :unknown) }
it 'raises an exception' do
expect { timezone_data }.to raise_error ArgumentError, 'Invalid format :unknown. Valid formats are :short, :full.'
expect { timezone_data }.to raise_error ArgumentError, 'Invalid format :unknown. Valid formats are :short, :abbr, :full.'
end
end
end
......
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