Commit 4cf5de97 authored by Max Woolf's avatar Max Woolf

Merge branch '346284-leverage-pipeline-counts-resolver' into 'master'

Count on-demand scans with PipelineScopeCounts

See merge request gitlab-org/gitlab!79851
parents db2bd47a 9bdccd69
...@@ -7,13 +7,7 @@ import { ...@@ -7,13 +7,7 @@ import {
toggleQueryPollingByVisibility, toggleQueryPollingByVisibility,
} from '~/pipelines/components/graph/utils'; } from '~/pipelines/components/graph/utils';
import onDemandScanCounts from '../graphql/on_demand_scan_counts.query.graphql'; import onDemandScanCounts from '../graphql/on_demand_scan_counts.query.graphql';
import { import { HELP_PAGE_PATH, PIPELINE_TABS_KEYS, PIPELINES_COUNT_POLL_INTERVAL } from '../constants';
HELP_PAGE_PATH,
PIPELINE_TABS_KEYS,
PIPELINES_COUNT_POLL_INTERVAL,
PIPELINES_SCOPE_RUNNING,
PIPELINES_SCOPE_FINISHED,
} from '../constants';
import AllTab from './tabs/all.vue'; import AllTab from './tabs/all.vue';
import RunningTab from './tabs/running.vue'; import RunningTab from './tabs/running.vue';
import FinishedTab from './tabs/finished.vue'; import FinishedTab from './tabs/finished.vue';
...@@ -42,8 +36,6 @@ export default { ...@@ -42,8 +36,6 @@ export default {
variables() { variables() {
return { return {
fullPath: this.projectPath, fullPath: this.projectPath,
runningScope: PIPELINES_SCOPE_RUNNING,
finishedScope: PIPELINES_SCOPE_FINISHED,
}; };
}, },
context() { context() {
...@@ -52,7 +44,7 @@ export default { ...@@ -52,7 +44,7 @@ export default {
update(data) { update(data) {
return Object.fromEntries( return Object.fromEntries(
PIPELINE_TABS_KEYS.map((key) => { PIPELINE_TABS_KEYS.map((key) => {
const { count } = data[key].pipelines; const count = data?.project?.pipelineCounts?.[key] ?? data[key]?.pipelines?.count ?? 0;
return [key, count]; return [key, count];
}), }),
); );
......
...@@ -21,7 +21,12 @@ import { ...@@ -21,7 +21,12 @@ import {
import handlesErrors from '../../mixins/handles_errors'; import handlesErrors from '../../mixins/handles_errors';
import Actions from '../actions.vue'; import Actions from '../actions.vue';
import EmptyState from '../empty_state.vue'; import EmptyState from '../empty_state.vue';
import { PIPELINES_PER_PAGE, PIPELINES_POLL_INTERVAL, ACTION_COLUMN } from '../../constants'; import {
PIPELINES_PER_PAGE,
MAX_PIPELINES_COUNT,
PIPELINES_POLL_INTERVAL,
ACTION_COLUMN,
} from '../../constants';
const defaultCursor = { const defaultCursor = {
first: PIPELINES_PER_PAGE, first: PIPELINES_PER_PAGE,
...@@ -72,6 +77,11 @@ export default { ...@@ -72,6 +77,11 @@ export default {
type: Number, type: Number,
required: true, required: true,
}, },
maxItemsCount: {
type: Number,
required: false,
default: MAX_PIPELINES_COUNT,
},
emptyStateTitle: { emptyStateTitle: {
type: String, type: String,
required: false, required: false,
...@@ -137,6 +147,10 @@ export default { ...@@ -137,6 +147,10 @@ export default {
}; };
}, },
computed: { computed: {
formattedCount() {
const { itemsCount, maxItemsCount } = this;
return itemsCount === maxItemsCount ? `${itemsCount}+` : itemsCount;
},
pipelineNodes() { pipelineNodes() {
return this.pipelines?.nodes ?? []; return this.pipelines?.nodes ?? [];
}, },
...@@ -214,7 +228,7 @@ export default { ...@@ -214,7 +228,7 @@ export default {
<template #title> <template #title>
<span class="gl-white-space-nowrap"> <span class="gl-white-space-nowrap">
{{ title }} {{ title }}
<gl-badge size="sm" class="gl-tab-counter-badge">{{ itemsCount }}</gl-badge> <gl-badge size="sm" class="gl-tab-counter-badge">{{ formattedCount }}</gl-badge>
</span> </span>
</template> </template>
<template v-if="$apollo.queries.pipelines.loading || hasPipelines"> <template v-if="$apollo.queries.pipelines.loading || hasPipelines">
......
...@@ -15,7 +15,7 @@ import dastProfileDelete from '../../graphql/dast_profile_delete.mutation.graphq ...@@ -15,7 +15,7 @@ import dastProfileDelete from '../../graphql/dast_profile_delete.mutation.graphq
import handlesErrors from '../../mixins/handles_errors'; import handlesErrors from '../../mixins/handles_errors';
import { removeProfile } from '../../graphql/cache_utils'; import { removeProfile } from '../../graphql/cache_utils';
import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql'; import dastProfilesQuery from '../../graphql/dast_profiles.query.graphql';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants'; import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT, MAX_DAST_PROFILES_COUNT } from '../../constants';
import BaseTab from './base_tab.vue'; import BaseTab from './base_tab.vue';
export default { export default {
...@@ -34,6 +34,7 @@ export default { ...@@ -34,6 +34,7 @@ export default {
}, },
mixins: [handlesErrors], mixins: [handlesErrors],
inject: ['projectPath'], inject: ['projectPath'],
maxItemsCount: MAX_DAST_PROFILES_COUNT,
tableFields: SAVED_TAB_TABLE_FIELDS, tableFields: SAVED_TAB_TABLE_FIELDS,
deleteScanModalId: `delete-scan-modal`, deleteScanModalId: `delete-scan-modal`,
i18n: { i18n: {
...@@ -145,6 +146,7 @@ export default { ...@@ -145,6 +146,7 @@ export default {
<template> <template>
<base-tab <base-tab
:max-items-count="$options.maxItemsCount"
:query="$options.query" :query="$options.query"
:query-variables="$options.queryVariables" :query-variables="$options.queryVariables"
:title="$options.i18n.title" :title="$options.i18n.title"
......
...@@ -3,7 +3,11 @@ import { GlIcon } from '@gitlab/ui'; ...@@ -3,7 +3,11 @@ import { GlIcon } from '@gitlab/ui';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import DastScanSchedule from 'ee/security_configuration/dast_profiles/components/dast_scan_schedule.vue'; import DastScanSchedule from 'ee/security_configuration/dast_profiles/components/dast_scan_schedule.vue';
import scheduledDastProfilesQuery from '../../graphql/scheduled_dast_profiles.query.graphql'; import scheduledDastProfilesQuery from '../../graphql/scheduled_dast_profiles.query.graphql';
import { SCHEDULED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from '../../constants'; import {
SCHEDULED_TAB_TABLE_FIELDS,
LEARN_MORE_TEXT,
MAX_DAST_PROFILES_COUNT,
} from '../../constants';
import BaseTab from './base_tab.vue'; import BaseTab from './base_tab.vue';
export default { export default {
...@@ -14,6 +18,7 @@ export default { ...@@ -14,6 +18,7 @@ export default {
DastScanSchedule, DastScanSchedule,
}, },
inject: ['timezones'], inject: ['timezones'],
maxItemsCount: MAX_DAST_PROFILES_COUNT,
tableFields: SCHEDULED_TAB_TABLE_FIELDS, tableFields: SCHEDULED_TAB_TABLE_FIELDS,
i18n: { i18n: {
title: __('Scheduled'), title: __('Scheduled'),
...@@ -30,6 +35,7 @@ export default { ...@@ -30,6 +35,7 @@ export default {
<template> <template>
<base-tab <base-tab
:max-items-count="$options.maxItemsCount"
:query="$options.query" :query="$options.query"
:title="$options.i18n.title" :title="$options.i18n.title"
:fields="$options.tableFields" :fields="$options.tableFields"
......
...@@ -13,6 +13,8 @@ export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled', 's ...@@ -13,6 +13,8 @@ export const PIPELINE_TABS_KEYS = ['all', 'running', 'finished', 'scheduled', 's
export const PIPELINES_PER_PAGE = 20; export const PIPELINES_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 3000; export const PIPELINES_POLL_INTERVAL = 3000;
export const PIPELINES_COUNT_POLL_INTERVAL = 3000; export const PIPELINES_COUNT_POLL_INTERVAL = 3000;
export const MAX_PIPELINES_COUNT = 1000;
export const MAX_DAST_PROFILES_COUNT = 100;
// Pipeline scopes // Pipeline scopes
export const PIPELINES_SCOPE_RUNNING = 'RUNNING'; export const PIPELINES_SCOPE_RUNNING = 'RUNNING';
......
query onDemandScanCounts( query onDemandScanCounts($fullPath: ID!) {
$fullPath: ID! project(fullPath: $fullPath) {
$runningScope: PipelineScopeEnum
$finishedScope: PipelineScopeEnum
) {
all: project(fullPath: $fullPath) {
id id
pipelines(source: "ondemand_dast_scan") { pipelineCounts(source: "ondemand_dast_scan") {
count all
} running
} finished
running: project(fullPath: $fullPath) {
id
pipelines(source: "ondemand_dast_scan", scope: $runningScope) {
count
}
}
finished: project(fullPath: $fullPath) {
id
pipelines(source: "ondemand_dast_scan", scope: $finishedScope) {
count
} }
} }
scheduled: project(fullPath: $fullPath) { scheduled: project(fullPath: $fullPath) {
......
...@@ -2,20 +2,21 @@ ...@@ -2,20 +2,21 @@
module Projects::OnDemandScansHelper module Projects::OnDemandScansHelper
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def on_demand_scans_data(project) def on_demand_scans_data(current_user, project)
on_demand_scans = project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan]) pipelines_counter = Gitlab::PipelineScopeCounts.new(current_user, project, {
running_scans_count, finished_scans_count = count_running_and_finished_scans(on_demand_scans) source: "ondemand_dast_scan"
saved_scans = ::Dast::ProfilesFinder.new({ project_id: project.id }).execute })
scheduled_scans_count = saved_scans.count { |scan| scan.dast_profile_schedule } scheduled_scans_count = ::Dast::ProfilesFinder.new({ project_id: project.id, has_dast_profile_schedule: true }).execute.count
saved_scans_count = ::Dast::ProfilesFinder.new({ project_id: project.id }).execute.count
common_data(project).merge({ common_data(project).merge({
'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path(project), 'project-on-demand-scan-counts-etag' => graphql_etag_project_on_demand_scan_counts_path(project),
'on-demand-scan-counts' => { 'on-demand-scan-counts' => {
all: on_demand_scans.length, all: pipelines_counter.all,
running: running_scans_count, running: pipelines_counter.running,
finished: finished_scans_count, finished: pipelines_counter.finished,
scheduled: scheduled_scans_count, scheduled: scheduled_scans_count,
saved: saved_scans.count saved: saved_scans_count
}.to_json, }.to_json,
'new-dast-scan-path' => new_project_on_demand_scan_path(project), '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'),
...@@ -43,19 +44,4 @@ module Projects::OnDemandScansHelper ...@@ -43,19 +44,4 @@ module Projects::OnDemandScansHelper
'project-path' => project.path_with_namespace 'project-path' => project.path_with_namespace
} }
end end
def count_running_and_finished_scans(on_demand_scans)
running_scans_count = 0
finished_scans_count = 0
on_demand_scans.each do |pipeline|
if %w[success failed canceled].include?(pipeline.status)
finished_scans_count += 1
elsif pipeline.status == "running"
running_scans_count += 1
end
end
[running_scans_count, finished_scans_count]
end
end end
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
- page_title s_('OnDemandScans|On-demand Scans') - page_title s_('OnDemandScans|On-demand Scans')
- add_page_specific_style 'page_bundles/ci_status' - add_page_specific_style 'page_bundles/ci_status'
#js-on-demand-scans{ data: on_demand_scans_data(@project) } #js-on-demand-scans{ data: on_demand_scans_data(@current_user, @project) }
...@@ -39,9 +39,9 @@ RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do ...@@ -39,9 +39,9 @@ RSpec.describe 'On-demand DAST scans (GraphQL fixtures)' do
}) })
expect_graphql_errors_to_be_empty expect_graphql_errors_to_be_empty
expect(graphql_data_at(:all, :pipelines, :count)).to be(4) expect(graphql_data_at(:project, :pipelineCounts, :all)).to be(4)
expect(graphql_data_at(:running, :pipelines, :count)).to be(2) expect(graphql_data_at(:project, :pipelineCounts, :running)).to be(2)
expect(graphql_data_at(:finished, :pipelines, :count)).to be(2) expect(graphql_data_at(:project, :pipelineCounts, :finished)).to be(2)
end end
end end
......
...@@ -122,6 +122,18 @@ describe('BaseTab', () => { ...@@ -122,6 +122,18 @@ describe('BaseTab', () => {
}); });
describe('when the app loads', () => { describe('when the app loads', () => {
it('formats the items count if it hit its max value', () => {
const itemsCount = 10;
createComponent({
propsData: {
itemsCount,
maxItemsCount: itemsCount,
},
});
expect(findTitle().text()).toMatchInterpolatedText(`All ${itemsCount}+`);
});
it('controls the pipelines query with a visibility check', () => { it('controls the pipelines query with a visibility check', () => {
jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility'); jest.spyOn(graphQlUtils, 'toggleQueryPollingByVisibility');
createComponent(); createComponent();
......
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { merge } from 'lodash'; import { merge, cloneDeep } from 'lodash';
import dastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/dast_profiles.query.graphql.json'; import dastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/dast_profiles.query.graphql.json';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue'; import SavedTab from 'ee/on_demand_scans/components/tabs/saved.vue';
...@@ -11,7 +11,11 @@ import dastProfilesQuery from 'ee/on_demand_scans/graphql/dast_profiles.query.gr ...@@ -11,7 +11,11 @@ import dastProfilesQuery from 'ee/on_demand_scans/graphql/dast_profiles.query.gr
import dastProfileRunMutation from 'ee/on_demand_scans/graphql/dast_profile_run.mutation.graphql'; import dastProfileRunMutation from 'ee/on_demand_scans/graphql/dast_profile_run.mutation.graphql';
import dastProfileDeleteMutation from 'ee/on_demand_scans/graphql/dast_profile_delete.mutation.graphql'; import dastProfileDeleteMutation from 'ee/on_demand_scans/graphql/dast_profile_delete.mutation.graphql';
import { createRouter } from 'ee/on_demand_scans/router'; import { createRouter } from 'ee/on_demand_scans/router';
import { SAVED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants'; import {
SAVED_TAB_TABLE_FIELDS,
LEARN_MORE_TEXT,
MAX_DAST_PROFILES_COUNT,
} from 'ee/on_demand_scans/constants';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue'; import ScanTypeBadge from 'ee/security_configuration/dast_profiles/components/dast_scan_type_badge.vue';
import flushPromises from 'helpers/flush_promises'; import flushPromises from 'helpers/flush_promises';
...@@ -119,14 +123,17 @@ describe('Saved tab', () => { ...@@ -119,14 +123,17 @@ describe('Saved tab', () => {
it('renders the base tab with the correct props', () => { it('renders the base tab with the correct props', () => {
createComponent(); createComponent();
expect(findBaseTab().props('title')).toBe(s__('OnDemandScans|Scan library')); expect(cloneDeep(findBaseTab().props())).toEqual({
expect(findBaseTab().props('itemsCount')).toBe(itemsCount); isActive: true,
expect(findBaseTab().props('query')).toBe(dastProfilesQuery); title: s__('OnDemandScans|Scan library'),
expect(findBaseTab().props('emptyStateTitle')).toBe( itemsCount,
s__('OnDemandScans|There are no saved scans.'), maxItemsCount: MAX_DAST_PROFILES_COUNT,
); query: dastProfilesQuery,
expect(findBaseTab().props('emptyStateText')).toBe(LEARN_MORE_TEXT); queryVariables: {},
expect(findBaseTab().props('fields')).toBe(SAVED_TAB_TABLE_FIELDS); emptyStateTitle: s__('OnDemandScans|There are no saved scans.'),
emptyStateText: LEARN_MORE_TEXT,
fields: SAVED_TAB_TABLE_FIELDS,
});
}); });
it('fetches the profiles', () => { it('fetches the profiles', () => {
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { merge } from 'lodash'; import { merge, cloneDeep } from 'lodash';
import scheduledDastProfilesMock from 'test_fixtures/graphql/on_demand_scans/graphql/scheduled_dast_profiles.query.graphql.json'; 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 mockTimezones from 'test_fixtures/timezones/abbr.json';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
...@@ -10,7 +10,11 @@ import createMockApollo from 'helpers/mock_apollo_helper'; ...@@ -10,7 +10,11 @@ import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import scheduledDastProfilesQuery from 'ee/on_demand_scans/graphql/scheduled_dast_profiles.query.graphql'; import scheduledDastProfilesQuery from 'ee/on_demand_scans/graphql/scheduled_dast_profiles.query.graphql';
import { createRouter } from 'ee/on_demand_scans/router'; import { createRouter } from 'ee/on_demand_scans/router';
import { SCHEDULED_TAB_TABLE_FIELDS, LEARN_MORE_TEXT } from 'ee/on_demand_scans/constants'; import {
SCHEDULED_TAB_TABLE_FIELDS,
LEARN_MORE_TEXT,
MAX_DAST_PROFILES_COUNT,
} from 'ee/on_demand_scans/constants';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { stripTimezoneFromISODate } from '~/lib/utils/datetime/date_format_utility'; import { stripTimezoneFromISODate } from '~/lib/utils/datetime/date_format_utility';
import DastScanSchedule from 'ee/security_configuration/dast_profiles/components/dast_scan_schedule.vue'; import DastScanSchedule from 'ee/security_configuration/dast_profiles/components/dast_scan_schedule.vue';
...@@ -81,14 +85,17 @@ describe('Scheduled tab', () => { ...@@ -81,14 +85,17 @@ describe('Scheduled tab', () => {
it('renders the base tab with the correct props', () => { it('renders the base tab with the correct props', () => {
createComponent(); createComponent();
expect(findBaseTab().props('title')).toBe(__('Scheduled')); expect(cloneDeep(findBaseTab().props())).toEqual({
expect(findBaseTab().props('itemsCount')).toBe(itemsCount); isActive: true,
expect(findBaseTab().props('query')).toBe(scheduledDastProfilesQuery); title: __('Scheduled'),
expect(findBaseTab().props('emptyStateTitle')).toBe( itemsCount,
s__('OnDemandScans|There are no scheduled scans.'), maxItemsCount: MAX_DAST_PROFILES_COUNT,
); query: scheduledDastProfilesQuery,
expect(findBaseTab().props('emptyStateText')).toBe(LEARN_MORE_TEXT); queryVariables: {},
expect(findBaseTab().props('fields')).toBe(SCHEDULED_TAB_TABLE_FIELDS); emptyStateTitle: s__('OnDemandScans|There are no scheduled scans.'),
emptyStateText: LEARN_MORE_TEXT,
fields: SCHEDULED_TAB_TABLE_FIELDS,
});
}); });
it('fetches the profiles', () => { it('fetches the profiles', () => {
......
...@@ -13,6 +13,7 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -13,6 +13,7 @@ RSpec.describe Projects::OnDemandScansHelper do
end end
describe '#on_demand_scans_data' do describe '#on_demand_scans_data' do
let_it_be(:current_user) { create(:user) }
let_it_be(:dast_profile) { create(:dast_profile, project: project) } let_it_be(:dast_profile) { create(:dast_profile, project: project) }
let_it_be(:dast_profile_with_schedule) { create(:dast_profile, project: project) } let_it_be(:dast_profile_with_schedule) { create(:dast_profile, project: project) }
let_it_be(:dast_profile_schedule) { create(:dast_profile_schedule, project: project, dast_profile: dast_profile_with_schedule)} let_it_be(:dast_profile_schedule) { create(:dast_profile_schedule, project: project, dast_profile: dast_profile_with_schedule)}
...@@ -22,10 +23,11 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -22,10 +23,11 @@ RSpec.describe Projects::OnDemandScansHelper do
create_list(:ci_pipeline, 8, :success, project: project, ref: 'master', source: :ondemand_dast_scan) 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) 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) allow(helper).to receive(:graphql_etag_project_on_demand_scan_counts_path).and_return(graphql_etag_project_on_demand_scan_counts_path)
project.add_developer(current_user)
end end
it 'returns proper data' do it 'returns proper data' do
expect(helper.on_demand_scans_data(project)).to match( expect(helper.on_demand_scans_data(current_user, project)).to match(
'project-path' => "foo/bar", 'project-path' => "foo/bar",
'new-dast-scan-path' => "/#{project.full_path}/-/on_demand_scans/new", 'new-dast-scan-path' => "/#{project.full_path}/-/on_demand_scans/new",
'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'), 'empty-state-svg-path' => match_asset_path('/assets/illustrations/empty-state/ondemand-scan-empty.svg'),
......
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