Commit 6b995273 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents f6191b35 bb74de9d
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import RunnerContactedStateBadge from '../runner_contacted_state_badge.vue';
import RunnerStatusBadge from '../runner_status_badge.vue';
import RunnerPausedBadge from '../runner_paused_badge.vue';
import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
export default {
components: {
RunnerContactedStateBadge,
RunnerStatusBadge,
RunnerPausedBadge,
},
directives: {
......@@ -25,16 +23,12 @@ export default {
return !this.runner.active;
},
},
i18n: {
I18N_LOCKED_RUNNER_DESCRIPTION,
I18N_PAUSED_RUNNER_DESCRIPTION,
},
};
</script>
<template>
<div>
<runner-contacted-state-badge :runner="runner" size="sm" />
<runner-status-badge :runner="runner" size="sm" />
<runner-paused-badge v-if="paused" size="sm" />
</div>
</template>
<script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { __, s__, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import {
I18N_ONLINE_RUNNER_DESCRIPTION,
I18N_OFFLINE_RUNNER_DESCRIPTION,
I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION,
I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION,
I18N_STALE_RUNNER_DESCRIPTION,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
STATUS_OFFLINE,
STATUS_STALE,
} from '../constants';
export default {
......@@ -29,31 +31,38 @@ export default {
if (this.runner.contactedAt) {
return getTimeago().format(this.runner.contactedAt);
}
return null;
// Prevent "just now" from being rendered, in case data is missing.
return __('n/a');
},
badge() {
switch (this.runner.status) {
switch (this.runner?.status) {
case STATUS_ONLINE:
return {
variant: 'success',
label: s__('Runners|online'),
tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, {
tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, {
timeAgo: this.contactedAtTimeAgo,
}),
};
case STATUS_NOT_CONNECTED:
return {
variant: 'muted',
label: s__('Runners|not connected'),
tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
};
case STATUS_OFFLINE:
return {
variant: 'muted',
label: s__('Runners|offline'),
tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, {
tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, {
timeAgo: this.contactedAtTimeAgo,
}),
};
case STATUS_NOT_CONNECTED:
case STATUS_STALE:
return {
variant: 'muted',
label: s__('Runners|not connected'),
tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
variant: 'warning',
label: s__('Runners|stale'),
tooltip: I18N_STALE_RUNNER_DESCRIPTION,
};
default:
return null;
......
......@@ -7,6 +7,7 @@ import {
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
STATUS_STALE,
PARAM_KEY_STATUS,
} from '../../constants';
......@@ -16,6 +17,7 @@ const options = [
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
{ value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') },
{ value: STATUS_STALE, title: s__('Runners|Stale') },
];
export const statusTokenConfig = {
......
......@@ -14,15 +14,18 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
// Status
export const I18N_ONLINE_RUNNER_DESCRIPTION = s__(
export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
'Runners|Runner is online; last contact was %{timeAgo}',
);
export const I18N_OFFLINE_RUNNER_DESCRIPTION = s__(
'Runners|No recent contact from this runner; last contact was %{timeAgo}',
);
export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__(
'Runners|This runner has never connected to this instance',
);
export const I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
'Runners|No recent contact from this runner; last contact was %{timeAgo}',
);
export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months',
);
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
......@@ -54,9 +57,11 @@ export const PROJECT_TYPE = 'PROJECT_TYPE';
export const STATUS_ACTIVE = 'ACTIVE';
export const STATUS_PAUSED = 'PAUSED';
export const STATUS_ONLINE = 'ONLINE';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_STALE = 'STALE';
// CiRunnerAccessLevel
......
......@@ -10,5 +10,5 @@ fragment RunnerNode on CiRunner {
locked
tagList
contactedAt
status
status(legacyMode: null)
}
......@@ -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
......
......@@ -130,10 +130,11 @@ class Event < ApplicationRecord
# Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)",
actions[:pushed],
%w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]],
"Note", actions[:commented])
where(
'action IN (?) OR (target_type IN (?) AND action IN (?))',
[actions[:pushed], actions[:commented]],
%w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]]
)
end
def limit_recent(limit = 20, offset = nil)
......
......@@ -144,7 +144,8 @@ The Pages daemon doesn't listen to the outside world.
1. Set the external URL for GitLab Pages in `/etc/gitlab/gitlab.rb`:
```ruby
pages_external_url 'http://example.io'
external_url "http://gitlab.example.com" # external_url here is only for reference
pages_external_url "http://pages.example.com" # not a subdomain of external_url
```
1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
......@@ -169,7 +170,8 @@ outside world.
1. In `/etc/gitlab/gitlab.rb` specify the following configuration:
```ruby
pages_external_url 'https://example.io'
external_url "https://gitlab.example.com" # external_url here is only for reference
pages_external_url "https://pages.example.com" # not a subdomain of external_url
pages_nginx['redirect_http_to_https'] = true
```
......@@ -288,7 +290,8 @@ world. Custom domains are supported, but no TLS.
1. In `/etc/gitlab/gitlab.rb` specify the following configuration:
```ruby
pages_external_url "http://example.io"
external_url "http://gitlab.example.com" # external_url here is only for reference
pages_external_url "http://pages.example.com" # not a subdomain of external_url
nginx['listen_addresses'] = ['192.0.2.1'] # The primary IP of the GitLab instance
pages_nginx['enable'] = false
gitlab_pages['external_http'] = ['192.0.2.2:80', '[2001:db8::2]:80'] # The secondary IPs for the GitLab Pages daemon
......@@ -318,7 +321,8 @@ world. Custom domains and TLS are supported.
1. In `/etc/gitlab/gitlab.rb` specify the following configuration:
```ruby
pages_external_url "https://example.io"
external_url "https://gitlab.example.com" # external_url here is only for reference
pages_external_url "https://pages.example.com" # not a subdomain of external_url
nginx['listen_addresses'] = ['192.0.2.1'] # The primary IP of the GitLab instance
pages_nginx['enable'] = false
gitlab_pages['external_http'] = ['192.0.2.2:80', '[2001:db8::2]:80'] # The secondary IPs for the GitLab Pages daemon
......
......@@ -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. |
......@@ -60,7 +60,7 @@ export default {
:placeholder="$options.i18n.bannerMessagePlaceholder"
/>
</gl-form-group>
<gl-button variant="success" type="submit">{{ $options.i18n.buttonText }}</gl-button>
<gl-button variant="confirm" type="submit">{{ $options.i18n.buttonText }}</gl-button>
</gl-form>
</section>
</template>
......@@ -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
......
......@@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe API::Search, factory_default: :keep do
RSpec.describe API::Search, factory_default: :keep, quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/347078' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:namespace) { create_default(:namespace).freeze }
......
......@@ -30,7 +30,7 @@ RSpec.describe SearchController, type: :request do
end
end
describe 'GET /search' do
describe 'GET /search', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/347078' do
context 'when elasticsearch is enabled', :elastic, :clean_gitlab_redis_shared_state, :sidekiq_inline do
before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
......
......@@ -21,6 +21,7 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord
def activity_dates
return {} if @projects.empty?
return @activity_dates if @activity_dates.present?
date_interval = "INTERVAL '#{@contributor_time_instance.now.utc_offset} seconds'"
......@@ -29,13 +30,13 @@ module Gitlab
# project_features for the (currently) 3 different contribution types
date_from = @contributor_time_instance.now.years_ago(1)
repo_events = event_created_at(date_from, :repository)
.where(action: :pushed, target_type: nil)
.where(action: :pushed)
issue_events = event_created_at(date_from, :issues)
.where(action: [:created, :closed], target_type: "Issue")
mr_events = event_created_at(date_from, :merge_requests)
.where(action: [:merged, :created, :closed], target_type: "MergeRequest")
note_events = event_created_at(date_from, :merge_requests)
.where(action: :commented, target_type: "Note")
.where(action: :commented)
events = Event
.select("date(created_at + #{date_interval}) AS date", 'COUNT(*) AS num_events')
......@@ -84,11 +85,19 @@ module Gitlab
# use IN(project_ids...) instead. It's the intersection of two users so
# the list will be (relatively) short
@contributed_project_ids ||= projects.distinct.pluck(:id)
authed_projects = ProjectFeature
.with_feature_available_for_user(feature, current_user)
.where(project_id: @contributed_project_ids)
.reorder(nil)
.select(:project_id)
# no need to check feature access of current user, if the contributor opted-in
# to show all private events anyway - otherwise they would get filtered out again
authed_projects = if @contributor.include_private_contributions?
@contributed_project_ids.join(",")
else
ProjectFeature
.with_feature_available_for_user(feature, current_user)
.where(project_id: @contributed_project_ids)
.reorder(nil)
.select(:project_id)
.to_sql
end
conditions = t[:created_at].gteq(date_from.beginning_of_day)
.and(t[:created_at].lteq(@contributor_time_instance.today.end_of_day))
......@@ -97,7 +106,7 @@ module Gitlab
Event.reorder(nil)
.select(:created_at)
.where(conditions)
.where("events.project_id in (#{authed_projects.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
.where("events.project_id in (#{authed_projects})") # rubocop:disable GitlabSecurity/SqlInjection
end
# rubocop: enable CodeReuse/ActiveRecord
end
......
......@@ -23448,6 +23448,9 @@ msgstr ""
msgid "Next file in diff"
msgstr ""
msgid "Next scan"
msgstr ""
msgid "Next unresolved discussion"
msgstr ""
......@@ -24349,6 +24352,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 ""
......@@ -24385,6 +24391,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 ""
......@@ -30130,6 +30139,9 @@ msgstr ""
msgid "Runners|New runner, has not connected yet"
msgstr ""
msgid "Runners|No contact from this runner in over 3 months"
msgstr ""
msgid "Runners|No recent contact from this runner; last contact was %{timeAgo}"
msgstr ""
......@@ -30235,6 +30247,9 @@ msgstr ""
msgid "Runners|Something went wrong while fetching the tags suggestions"
msgstr ""
msgid "Runners|Stale"
msgstr ""
msgid "Runners|Status"
msgstr ""
......@@ -30331,6 +30346,9 @@ msgstr ""
msgid "Runners|specific"
msgstr ""
msgid "Runners|stale"
msgstr ""
msgid "Running"
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
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerContactedStateBadge from '~/runner/components/runner_contacted_state_badge.vue';
import RunnerStatusBadge from '~/runner/components/runner_status_badge.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_NOT_CONNECTED } from '~/runner/constants';
import {
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
STATUS_NOT_CONNECTED,
} from '~/runner/constants';
describe('RunnerTypeBadge', () => {
let wrapper;
......@@ -10,14 +15,14 @@ describe('RunnerTypeBadge', () => {
const findBadge = () => wrapper.findComponent(GlBadge);
const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip');
const createComponent = ({ runner = {} } = {}) => {
wrapper = shallowMount(RunnerContactedStateBadge, {
const createComponent = (props = {}) => {
wrapper = shallowMount(RunnerStatusBadge, {
propsData: {
runner: {
contactedAt: '2021-01-01T00:00:00Z',
contactedAt: '2020-12-31T23:59:00Z',
status: STATUS_ONLINE,
...runner,
},
...props,
},
directives: {
GlTooltip: createMockDirective(),
......@@ -27,6 +32,7 @@ describe('RunnerTypeBadge', () => {
beforeEach(() => {
jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2021-01-01T00:00:00Z'));
});
afterEach(() => {
......@@ -36,8 +42,6 @@ describe('RunnerTypeBadge', () => {
});
it('renders online state', () => {
jest.setSystemTime(new Date('2021-01-01T00:01:00Z'));
createComponent();
expect(wrapper.text()).toBe('online');
......@@ -45,11 +49,23 @@ describe('RunnerTypeBadge', () => {
expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago');
});
it('renders offline state', () => {
jest.setSystemTime(new Date('2021-01-02T00:00:00Z'));
it('renders not connected state', () => {
createComponent({
runner: {
contactedAt: null,
status: STATUS_NOT_CONNECTED,
},
});
expect(wrapper.text()).toBe('not connected');
expect(findBadge().props('variant')).toBe('muted');
expect(getTooltip().value).toMatch('This runner has never connected');
});
it('renders offline state', () => {
createComponent({
runner: {
contactedAt: '2020-12-31T00:00:00Z',
status: STATUS_OFFLINE,
},
});
......@@ -61,26 +77,40 @@ describe('RunnerTypeBadge', () => {
);
});
it('renders not connected state', () => {
it('renders stale state', () => {
createComponent({
runner: {
contactedAt: null,
status: STATUS_NOT_CONNECTED,
contactedAt: '2020-01-01T00:00:00Z',
status: STATUS_STALE,
},
});
expect(wrapper.text()).toBe('not connected');
expect(findBadge().props('variant')).toBe('muted');
expect(getTooltip().value).toMatch('This runner has never connected');
expect(wrapper.text()).toBe('stale');
expect(findBadge().props('variant')).toBe('warning');
expect(getTooltip().value).toBe('No contact from this runner in over 3 months');
});
it('does not fail when data is missing', () => {
createComponent({
runner: {
status: null,
},
describe('does not fail when data is missing', () => {
it('contacted_at is missing', () => {
createComponent({
runner: {
contactedAt: null,
status: STATUS_ONLINE,
},
});
expect(wrapper.text()).toBe('online');
expect(getTooltip().value).toBe('Runner is online; last contact was n/a');
});
expect(wrapper.text()).toBe('');
it('status is missing', () => {
createComponent({
runner: {
status: null,
},
});
expect(wrapper.text()).toBe('');
});
});
});
......@@ -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
......
......@@ -50,7 +50,8 @@ RSpec.describe Gitlab::ContributionsCalendar do
Event.create!(
project: project,
action: action,
target: @targets[project],
target_type: @targets[project].class.name,
target_id: @targets[project].id,
author: contributor,
created_at: DateTime.new(day.year, day.month, day.day, hour)
)
......@@ -66,8 +67,11 @@ RSpec.describe Gitlab::ContributionsCalendar do
end
context "when the user has opted-in for private contributions" do
it "shows private and public events to all users" do
before do
contributor.update_column(:include_private_contributions, true)
end
it "shows private and public events to all users" do
create_event(private_project, today)
create_event(public_project, today)
......@@ -75,6 +79,23 @@ RSpec.describe Gitlab::ContributionsCalendar do
expect(calendar(user).activity_dates[today]).to eq(2)
expect(calendar(contributor).activity_dates[today]).to eq(2)
end
# tests for bug https://gitlab.com/gitlab-org/gitlab/-/merge_requests/74826
it "still counts correct with feature access levels set to private" do
create_event(private_project, today)
private_project.project_feature.update_attribute(:issues_access_level, ProjectFeature::PRIVATE)
private_project.project_feature.update_attribute(:repository_access_level, ProjectFeature::PRIVATE)
private_project.project_feature.update_attribute(:merge_requests_access_level, ProjectFeature::PRIVATE)
expect(calendar.activity_dates[today]).to eq(1)
expect(calendar(user).activity_dates[today]).to eq(1)
expect(calendar(contributor).activity_dates[today]).to eq(1)
end
it "does not fail if there are no contributed projects" do
expect(calendar.activity_dates[today]).to eq(nil)
end
end
it "counts the diff notes on merge request" do
......@@ -169,6 +190,12 @@ RSpec.describe Gitlab::ContributionsCalendar do
expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3)
end
it "includes diff notes on merge request" do
e1 = create_event(public_project, today, 0, :commented, :diff_note_on_merge_request)
expect(calendar.events_by_date(today)).to contain_exactly(e1)
end
context 'when the user cannot read cross project' do
before do
allow(Ability).to receive(:allowed?).and_call_original
......
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