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

Automatic merge of gitlab-org/gitlab master

parents f6191b35 bb74de9d
<script> <script>
import { GlTooltipDirective } from '@gitlab/ui'; 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 RunnerPausedBadge from '../runner_paused_badge.vue';
import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_PAUSED_RUNNER_DESCRIPTION } from '../../constants';
export default { export default {
components: { components: {
RunnerContactedStateBadge, RunnerStatusBadge,
RunnerPausedBadge, RunnerPausedBadge,
}, },
directives: { directives: {
...@@ -25,16 +23,12 @@ export default { ...@@ -25,16 +23,12 @@ export default {
return !this.runner.active; return !this.runner.active;
}, },
}, },
i18n: {
I18N_LOCKED_RUNNER_DESCRIPTION,
I18N_PAUSED_RUNNER_DESCRIPTION,
},
}; };
</script> </script>
<template> <template>
<div> <div>
<runner-contacted-state-badge :runner="runner" size="sm" /> <runner-status-badge :runner="runner" size="sm" />
<runner-paused-badge v-if="paused" size="sm" /> <runner-paused-badge v-if="paused" size="sm" />
</div> </div>
</template> </template>
<script> <script>
import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; import { GlBadge, GlTooltipDirective } from '@gitlab/ui';
import { s__, sprintf } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility'; import { getTimeago } from '~/lib/utils/datetime_utility';
import { import {
I18N_ONLINE_RUNNER_DESCRIPTION, I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION,
I18N_OFFLINE_RUNNER_DESCRIPTION,
I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION,
I18N_STALE_RUNNER_DESCRIPTION,
STATUS_ONLINE, STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED, STATUS_NOT_CONNECTED,
STATUS_OFFLINE,
STATUS_STALE,
} from '../constants'; } from '../constants';
export default { export default {
...@@ -29,31 +31,38 @@ export default { ...@@ -29,31 +31,38 @@ export default {
if (this.runner.contactedAt) { if (this.runner.contactedAt) {
return getTimeago().format(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() { badge() {
switch (this.runner.status) { switch (this.runner?.status) {
case STATUS_ONLINE: case STATUS_ONLINE:
return { return {
variant: 'success', variant: 'success',
label: s__('Runners|online'), label: s__('Runners|online'),
tooltip: sprintf(I18N_ONLINE_RUNNER_DESCRIPTION, { tooltip: sprintf(I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION, {
timeAgo: this.contactedAtTimeAgo, timeAgo: this.contactedAtTimeAgo,
}), }),
}; };
case STATUS_NOT_CONNECTED:
return {
variant: 'muted',
label: s__('Runners|not connected'),
tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION,
};
case STATUS_OFFLINE: case STATUS_OFFLINE:
return { return {
variant: 'muted', variant: 'muted',
label: s__('Runners|offline'), label: s__('Runners|offline'),
tooltip: sprintf(I18N_OFFLINE_RUNNER_DESCRIPTION, { tooltip: sprintf(I18N_OFFLINE_RUNNER_TIMEAGO_DESCRIPTION, {
timeAgo: this.contactedAtTimeAgo, timeAgo: this.contactedAtTimeAgo,
}), }),
}; };
case STATUS_NOT_CONNECTED: case STATUS_STALE:
return { return {
variant: 'muted', variant: 'warning',
label: s__('Runners|not connected'), label: s__('Runners|stale'),
tooltip: I18N_NOT_CONNECTED_RUNNER_DESCRIPTION, tooltip: I18N_STALE_RUNNER_DESCRIPTION,
}; };
default: default:
return null; return null;
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
STATUS_ONLINE, STATUS_ONLINE,
STATUS_OFFLINE, STATUS_OFFLINE,
STATUS_NOT_CONNECTED, STATUS_NOT_CONNECTED,
STATUS_STALE,
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
} from '../../constants'; } from '../../constants';
...@@ -16,6 +17,7 @@ const options = [ ...@@ -16,6 +17,7 @@ const options = [
{ value: STATUS_ONLINE, title: s__('Runners|Online') }, { value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') }, { value: STATUS_OFFLINE, title: s__('Runners|Offline') },
{ value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') }, { value: STATUS_NOT_CONNECTED, title: s__('Runners|Not connected') },
{ value: STATUS_STALE, title: s__('Runners|Stale') },
]; ];
export const statusTokenConfig = { export const statusTokenConfig = {
......
...@@ -14,15 +14,18 @@ export const I18N_GROUP_RUNNER_DESCRIPTION = s__( ...@@ -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'); export const I18N_PROJECT_RUNNER_DESCRIPTION = s__('Runners|Associated with one or more projects');
// Status // Status
export const I18N_ONLINE_RUNNER_DESCRIPTION = s__( export const I18N_ONLINE_RUNNER_TIMEAGO_DESCRIPTION = s__(
'Runners|Runner is online; last contact was %{timeAgo}', '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__( export const I18N_NOT_CONNECTED_RUNNER_DESCRIPTION = s__(
'Runners|This runner has never connected to this instance', '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_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs'); export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
...@@ -54,9 +57,11 @@ export const PROJECT_TYPE = 'PROJECT_TYPE'; ...@@ -54,9 +57,11 @@ export const PROJECT_TYPE = 'PROJECT_TYPE';
export const STATUS_ACTIVE = 'ACTIVE'; export const STATUS_ACTIVE = 'ACTIVE';
export const STATUS_PAUSED = 'PAUSED'; export const STATUS_PAUSED = 'PAUSED';
export const STATUS_ONLINE = 'ONLINE'; export const STATUS_ONLINE = 'ONLINE';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED'; export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_STALE = 'STALE';
// CiRunnerAccessLevel // CiRunnerAccessLevel
......
...@@ -10,5 +10,5 @@ fragment RunnerNode on CiRunner { ...@@ -10,5 +10,5 @@ fragment RunnerNode on CiRunner {
locked locked
tagList tagList
contactedAt contactedAt
status status(legacyMode: null)
} }
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
module TimeZoneHelper module TimeZoneHelper
TIME_ZONE_FORMAT_ATTRS = { TIME_ZONE_FORMAT_ATTRS = {
short: %i[identifier name offset], short: %i[identifier name offset],
abbr: %i[identifier abbr],
full: %i[identifier name abbr offset formatted_offset] full: %i[identifier name abbr offset formatted_offset]
}.freeze }.freeze
private_constant :TIME_ZONE_FORMAT_ATTRS private_constant :TIME_ZONE_FORMAT_ATTRS
......
...@@ -130,10 +130,11 @@ class Event < ApplicationRecord ...@@ -130,10 +130,11 @@ class Event < ApplicationRecord
# Update Gitlab::ContributionsCalendar#activity_dates if this changes # Update Gitlab::ContributionsCalendar#activity_dates if this changes
def contributions def contributions
where("action = ? OR (target_type IN (?) AND action IN (?)) OR (target_type = ? AND action = ?)", where(
actions[:pushed], 'action IN (?) OR (target_type IN (?) AND action IN (?))',
%w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]], [actions[:pushed], actions[:commented]],
"Note", actions[:commented]) %w(MergeRequest Issue), [actions[:created], actions[:closed], actions[:merged]]
)
end end
def limit_recent(limit = 20, offset = nil) def limit_recent(limit = 20, offset = nil)
......
...@@ -144,7 +144,8 @@ The Pages daemon doesn't listen to the outside world. ...@@ -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`: 1. Set the external URL for GitLab Pages in `/etc/gitlab/gitlab.rb`:
```ruby ```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). 1. [Reconfigure GitLab](../restart_gitlab.md#omnibus-gitlab-reconfigure).
...@@ -169,7 +170,8 @@ outside world. ...@@ -169,7 +170,8 @@ outside world.
1. In `/etc/gitlab/gitlab.rb` specify the following configuration: 1. In `/etc/gitlab/gitlab.rb` specify the following configuration:
```ruby ```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 pages_nginx['redirect_http_to_https'] = true
``` ```
...@@ -288,7 +290,8 @@ world. Custom domains are supported, but no TLS. ...@@ -288,7 +290,8 @@ world. Custom domains are supported, but no TLS.
1. In `/etc/gitlab/gitlab.rb` specify the following configuration: 1. In `/etc/gitlab/gitlab.rb` specify the following configuration:
```ruby ```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 nginx['listen_addresses'] = ['192.0.2.1'] # The primary IP of the GitLab instance
pages_nginx['enable'] = false 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 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. ...@@ -318,7 +321,8 @@ world. Custom domains and TLS are supported.
1. In `/etc/gitlab/gitlab.rb` specify the following configuration: 1. In `/etc/gitlab/gitlab.rb` specify the following configuration:
```ruby ```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 nginx['listen_addresses'] = ['192.0.2.1'] # The primary IP of the GitLab instance
pages_nginx['enable'] = false 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 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). ...@@ -5791,6 +5791,7 @@ The connection type for [`DastProfile`](#dastprofile).
| Name | Type | Description | | 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="dastprofileconnectionedges"></a>`edges` | [`[DastProfileEdge]`](#dastprofileedge) | A list of edges. |
| <a id="dastprofileconnectionnodes"></a>`nodes` | [`[DastProfile]`](#dastprofile) | A list of nodes. | | <a id="dastprofileconnectionnodes"></a>`nodes` | [`[DastProfile]`](#dastprofile) | A list of nodes. |
| <a id="dastprofileconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | | <a id="dastprofileconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
...@@ -60,7 +60,7 @@ export default { ...@@ -60,7 +60,7 @@ export default {
:placeholder="$options.i18n.bannerMessagePlaceholder" :placeholder="$options.i18n.bannerMessagePlaceholder"
/> />
</gl-form-group> </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> </gl-form>
</section> </section>
</template> </template>
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
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';
import ScheduledTab from './tabs/scheduled.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
export default { export default {
...@@ -30,6 +31,7 @@ export default { ...@@ -30,6 +31,7 @@ export default {
AllTab, AllTab,
RunningTab, RunningTab,
FinishedTab, FinishedTab,
ScheduledTab,
EmptyState, EmptyState,
}, },
inject: ['newDastScanPath', 'projectPath', 'projectOnDemandScanCountsEtag'], inject: ['newDastScanPath', 'projectPath', 'projectOnDemandScanCountsEtag'],
...@@ -73,7 +75,10 @@ export default { ...@@ -73,7 +75,10 @@ export default {
return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts; return this.liveOnDemandScanCounts ?? this.initialOnDemandScanCounts;
}, },
hasData() { 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() { tabs() {
return { return {
...@@ -89,6 +94,10 @@ export default { ...@@ -89,6 +94,10 @@ export default {
component: FinishedTab, component: FinishedTab,
itemsCount: this.onDemandScanCounts.finished, itemsCount: this.onDemandScanCounts.finished,
}, },
scheduled: {
component: ScheduledTab,
itemsCount: this.onDemandScanCounts.scheduled,
},
}; };
}, },
activeTab: { activeTab: {
......
...@@ -223,28 +223,23 @@ export default { ...@@ -223,28 +223,23 @@ export default {
<rect width="70" height="20" x="855" y="5" rx="4" /> <rect width="70" height="20" x="855" y="5" rx="4" />
</gl-skeleton-loader> </gl-skeleton-loader>
</template> </template>
<template #cell(detailedStatus)="{ item }">
<template #cell(status)="{ value }">
<div class="gl-my-3"> <div class="gl-my-3">
<ci-badge-link :status="item.detailedStatus" /> <ci-badge-link :status="value" />
</div> </div>
</template> </template>
<!-- eslint-disable-next-line vue/valid-v-slot --> <template #cell(name)="{ value }">
<template #cell(dastProfile.name)="{ item }"> <gl-truncate v-if="value" :text="value" with-tooltip />
<gl-truncate v-if="item.dastProfile" :text="item.dastProfile.name" with-tooltip />
</template> </template>
<template #cell(scanType)> <template #cell(scanType)>
{{ $options.DAST_SHORT_NAME }} {{ $options.DAST_SHORT_NAME }}
</template> </template>
<!-- eslint-disable-next-line vue/valid-v-slot --> <template #cell(targetUrl)="{ value }">
<template #cell(dastProfile.dastSiteProfile.targetUrl)="{ item }"> <gl-truncate v-if="value" :text="value" with-tooltip />
<gl-truncate
v-if="item.dastProfile"
:text="item.dastProfile.dastSiteProfile.targetUrl"
with-tooltip
/>
</template> </template>
<template #cell(createdAt)="{ item }"> <template #cell(createdAt)="{ item }">
...@@ -258,6 +253,10 @@ export default { ...@@ -258,6 +253,10 @@ export default {
<template #cell(id)="{ item }"> <template #cell(id)="{ item }">
<gl-link :href="item.path">#{{ $options.getIdFromGraphQLId(item.id) }}</gl-link> <gl-link :href="item.path">#{{ $options.getIdFromGraphQLId(item.id) }}</gl-link>
</template> </template>
<template v-for="slot in Object.keys($scopedSlots)" #[slot]="scope">
<slot :name="slot" v-bind="scope"></slot>
</template>
</gl-table> </gl-table>
<div class="gl-display-flex gl-justify-content-center"> <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 { __, s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper'; 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', { export const HELP_PAGE_PATH = helpPagePath('user/application_security/dast/index', {
anchor: 'on-demand-scans', anchor: 'on-demand-scans',
...@@ -8,40 +9,98 @@ export const LEARN_MORE_TEXT = s__( ...@@ -8,40 +9,98 @@ export const LEARN_MORE_TEXT = s__(
'OnDemandScans|%{learnMoreLinkStart}Learn more about on-demand scans%{learnMoreLinkEnd}.', '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_PER_PAGE = 20;
export const PIPELINES_POLL_INTERVAL = 1000; export const PIPELINES_POLL_INTERVAL = 1000;
export const PIPELINES_COUNT_POLL_INTERVAL = 1000; export const PIPELINES_COUNT_POLL_INTERVAL = 1000;
export const PIPELINES_SCOPE_RUNNING = 'RUNNING'; export const PIPELINES_SCOPE_RUNNING = 'RUNNING';
export const PIPELINES_SCOPE_FINISHED = 'FINISHED'; export const PIPELINES_SCOPE_FINISHED = 'FINISHED';
export const BASE_TABS_TABLE_FIELDS = [ const STATUS_COLUMN = {
{
label: __('Status'), label: __('Status'),
key: 'detailedStatus', key: 'status',
columnClass: 'gl-w-15', columnClass: 'gl-w-15',
}, };
{ const NAME_COLUMN = {
label: __('Name'), label: __('Name'),
key: 'dastProfile.name', key: 'name',
}, };
{ const SCAN_TYPE_COLUMN = {
label: s__('OnDemandScans|Scan type'), label: s__('OnDemandScans|Scan type'),
key: 'scanType', key: 'scanType',
columnClass: 'gl-w-13', columnClass: 'gl-w-13',
}, };
{ const TARGET_COLUMN = {
label: s__('OnDemandScans|Target'), label: s__('OnDemandScans|Target'),
key: 'dastProfile.dastSiteProfile.targetUrl', key: 'targetUrl',
}, };
{ const START_DATE_COLUMN = {
label: __('Start date'), label: __('Start date'),
key: 'createdAt', key: 'createdAt',
columnClass: 'gl-w-15', columnClass: 'gl-w-15',
}, };
{ const PIPELINE_ID_COLUMN = {
label: __('Pipeline'), label: __('Pipeline'),
key: 'id', key: 'id',
columnClass: 'gl-w-13', columnClass: 'gl-w-13',
};
export const BASE_TABS_TABLE_FIELDS = [
{
...STATUS_COLUMN,
formatter: (_value, _key, item) => item.detailedStatus,
},
{
...NAME_COLUMN,
formatter: (_value, _key, item) => item.dastProfile.name,
},
SCAN_TYPE_COLUMN,
{
...TARGET_COLUMN,
formatter: (_value, _key, item) => item.dastProfile.dastSiteProfile.targetUrl,
},
START_DATE_COLUMN,
PIPELINE_ID_COLUMN,
];
export const SCHEDULED_TAB_TABLE_FIELDS = [
{
...STATUS_COLUMN,
formatter: (_value, _key, item) => ({
detailsPath: item.editPath,
text: __('Scheduled'),
icon: 'status_scheduled',
group: 'scheduled',
}),
},
NAME_COLUMN,
SCAN_TYPE_COLUMN,
{
...TARGET_COLUMN,
formatter: (_value, _key, item) => item.dastSiteProfile.targetUrl,
},
{
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: s__('OnDemandScans|Repeats'),
key: 'dastProfileSchedule',
}, },
]; ];
...@@ -21,4 +21,10 @@ query onDemandScanCounts( ...@@ -21,4 +21,10 @@ query onDemandScanCounts(
count 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 () => { ...@@ -15,6 +15,7 @@ export default () => {
projectOnDemandScanCountsEtag, projectOnDemandScanCountsEtag,
} = el.dataset; } = el.dataset;
const initialOnDemandScanCounts = JSON.parse(el.dataset.onDemandScanCounts); const initialOnDemandScanCounts = JSON.parse(el.dataset.onDemandScanCounts);
const timezones = JSON.parse(el.dataset.timezones);
return new Vue({ return new Vue({
el, el,
...@@ -25,6 +26,7 @@ export default () => { ...@@ -25,6 +26,7 @@ export default () => {
newDastScanPath, newDastScanPath,
emptyStateSvgPath, emptyStateSvgPath,
projectOnDemandScanCountsEtag, projectOnDemandScanCountsEtag,
timezones,
}, },
render(h) { render(h) {
return h(OnDemandScans, { return h(OnDemandScans, {
......
...@@ -6,6 +6,8 @@ module Types ...@@ -6,6 +6,8 @@ module Types
graphql_name 'DastProfile' graphql_name 'DastProfile'
description 'Represents a DAST Profile' description 'Represents a DAST Profile'
connection_type_class(Types::CountableConnectionType)
authorize :read_on_demand_dast_scan authorize :read_on_demand_dast_scan
field :id, ::Types::GlobalIDType[::Dast::Profile], null: false, field :id, ::Types::GlobalIDType[::Dast::Profile], null: false,
......
...@@ -5,16 +5,19 @@ module Projects::OnDemandScansHelper ...@@ -5,16 +5,19 @@ module Projects::OnDemandScansHelper
def on_demand_scans_data(project) def on_demand_scans_data(project)
on_demand_scans = project.all_pipelines.where(source: Enums::Ci::Pipeline.sources[:ondemand_dast_scan]) 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) 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({ 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: on_demand_scans.length,
running: running_scan_count, running: running_scan_count,
finished: finished_scan_count finished: finished_scan_count,
scheduled: scheduled_scans.length
}.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'),
'timezones' => timezone_data(format: :abbr).to_json
}) })
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
......
...@@ -7,7 +7,7 @@ module Projects::Security::DastProfilesHelper ...@@ -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_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), 'new_dast_scanner_profile_path' => new_project_security_configuration_dast_scans_dast_scanner_profile_path(project),
'project_full_path' => project.path_with_namespace, 'project_full_path' => project.path_with_namespace,
'timezones' => timezone_data(format: :full).to_json 'timezones' => timezone_data(format: :abbr).to_json
} }
end end
end end
...@@ -160,5 +160,24 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do ...@@ -160,5 +160,24 @@ RSpec.describe 'DAST profiles (GraphQL fixtures)' do
end end
end 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
end end
...@@ -29,6 +29,7 @@ describe('OnDemandScans', () => { ...@@ -29,6 +29,7 @@ describe('OnDemandScans', () => {
all: 12, all: 12,
running: 3, running: 3,
finished: 9, finished: 9,
scheduled: 5,
}; };
const emptyInitialPipelineCounts = Object.fromEntries(PIPELINE_TABS_KEYS.map((key) => [key, 0])); 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 ...@@ -6,13 +6,18 @@ RSpec.describe Projects::OnDemandScansHelper do
let_it_be(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:path_with_namespace) { "foo/bar" } 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(: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 before do
allow(project).to receive(:path_with_namespace).and_return(path_with_namespace) allow(project).to receive(:path_with_namespace).and_return(path_with_namespace)
end end
describe '#on_demand_scans_data' do 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 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, 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)
...@@ -27,18 +32,18 @@ RSpec.describe Projects::OnDemandScansHelper do ...@@ -27,18 +32,18 @@ RSpec.describe Projects::OnDemandScansHelper do
'on-demand-scan-counts' => { 'on-demand-scan-counts' => {
all: 12, all: 12,
running: 4, running: 4,
finished: 8 finished: 8,
}.to_json scheduled: 1
}.to_json,
'timezones' => timezones.to_json
) )
end end
end end
describe '#on_demand_scans_form_data' do describe '#on_demand_scans_form_data' do
let_it_be(:timezones) { [{ identifier: "Europe/Paris" }] }
before do 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(helper).to receive(:timezone_data).with(format: :full).and_return(timezones)
allow(project).to receive(:default_branch).and_return("default-branch")
end end
it 'returns proper data' do it 'returns proper data' do
......
...@@ -9,7 +9,7 @@ RSpec.describe Projects::Security::DastProfilesHelper do ...@@ -9,7 +9,7 @@ RSpec.describe Projects::Security::DastProfilesHelper do
before do before do
allow(project).to receive(:path_with_namespace).and_return("foo/bar") 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 end
it 'returns proper data' do it 'returns proper data' do
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
require 'spec_helper' 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(:user) { create(:user) }
let_it_be(:group) { create(:group) } let_it_be(:group) { create(:group) }
let_it_be(:namespace) { create_default(:namespace).freeze } let_it_be(:namespace) { create_default(:namespace).freeze }
......
...@@ -30,7 +30,7 @@ RSpec.describe SearchController, type: :request do ...@@ -30,7 +30,7 @@ RSpec.describe SearchController, type: :request do
end end
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 context 'when elasticsearch is enabled', :elastic, :clean_gitlab_redis_shared_state, :sidekiq_inline do
before do before do
stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true) stub_ee_application_setting(elasticsearch_search: true, elasticsearch_indexing: true)
......
...@@ -21,6 +21,7 @@ module Gitlab ...@@ -21,6 +21,7 @@ module Gitlab
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def activity_dates def activity_dates
return {} if @projects.empty?
return @activity_dates if @activity_dates.present? return @activity_dates if @activity_dates.present?
date_interval = "INTERVAL '#{@contributor_time_instance.now.utc_offset} seconds'" date_interval = "INTERVAL '#{@contributor_time_instance.now.utc_offset} seconds'"
...@@ -29,13 +30,13 @@ module Gitlab ...@@ -29,13 +30,13 @@ module Gitlab
# project_features for the (currently) 3 different contribution types # project_features for the (currently) 3 different contribution types
date_from = @contributor_time_instance.now.years_ago(1) date_from = @contributor_time_instance.now.years_ago(1)
repo_events = event_created_at(date_from, :repository) 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) issue_events = event_created_at(date_from, :issues)
.where(action: [:created, :closed], target_type: "Issue") .where(action: [:created, :closed], target_type: "Issue")
mr_events = event_created_at(date_from, :merge_requests) mr_events = event_created_at(date_from, :merge_requests)
.where(action: [:merged, :created, :closed], target_type: "MergeRequest") .where(action: [:merged, :created, :closed], target_type: "MergeRequest")
note_events = event_created_at(date_from, :merge_requests) note_events = event_created_at(date_from, :merge_requests)
.where(action: :commented, target_type: "Note") .where(action: :commented)
events = Event events = Event
.select("date(created_at + #{date_interval}) AS date", 'COUNT(*) AS num_events') .select("date(created_at + #{date_interval}) AS date", 'COUNT(*) AS num_events')
...@@ -84,11 +85,19 @@ module Gitlab ...@@ -84,11 +85,19 @@ module Gitlab
# use IN(project_ids...) instead. It's the intersection of two users so # use IN(project_ids...) instead. It's the intersection of two users so
# the list will be (relatively) short # the list will be (relatively) short
@contributed_project_ids ||= projects.distinct.pluck(:id) @contributed_project_ids ||= projects.distinct.pluck(:id)
authed_projects = ProjectFeature
# 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) .with_feature_available_for_user(feature, current_user)
.where(project_id: @contributed_project_ids) .where(project_id: @contributed_project_ids)
.reorder(nil) .reorder(nil)
.select(:project_id) .select(:project_id)
.to_sql
end
conditions = t[:created_at].gteq(date_from.beginning_of_day) conditions = t[:created_at].gteq(date_from.beginning_of_day)
.and(t[:created_at].lteq(@contributor_time_instance.today.end_of_day)) .and(t[:created_at].lteq(@contributor_time_instance.today.end_of_day))
...@@ -97,7 +106,7 @@ module Gitlab ...@@ -97,7 +106,7 @@ module Gitlab
Event.reorder(nil) Event.reorder(nil)
.select(:created_at) .select(:created_at)
.where(conditions) .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 end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
end end
......
...@@ -23448,6 +23448,9 @@ msgstr "" ...@@ -23448,6 +23448,9 @@ msgstr ""
msgid "Next file in diff" msgid "Next file in diff"
msgstr "" msgstr ""
msgid "Next scan"
msgstr ""
msgid "Next unresolved discussion" msgid "Next unresolved discussion"
msgstr "" msgstr ""
...@@ -24349,6 +24352,9 @@ 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}" msgid "OnDemandScans|On-demand scans run outside the DevOps cycle and find vulnerabilities in your projects. %{learnMoreLinkStart}Learn more%{learnMoreLinkEnd}"
msgstr "" msgstr ""
msgid "OnDemandScans|Repeats"
msgstr ""
msgid "OnDemandScans|Save and run scan" msgid "OnDemandScans|Save and run scan"
msgstr "" msgstr ""
...@@ -24385,6 +24391,9 @@ msgstr "" ...@@ -24385,6 +24391,9 @@ msgstr ""
msgid "OnDemandScans|There are no running scans." msgid "OnDemandScans|There are no running scans."
msgstr "" msgstr ""
msgid "OnDemandScans|There are no scheduled scans."
msgstr ""
msgid "OnDemandScans|Use existing scanner profile" msgid "OnDemandScans|Use existing scanner profile"
msgstr "" msgstr ""
...@@ -30130,6 +30139,9 @@ msgstr "" ...@@ -30130,6 +30139,9 @@ msgstr ""
msgid "Runners|New runner, has not connected yet" msgid "Runners|New runner, has not connected yet"
msgstr "" 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}" msgid "Runners|No recent contact from this runner; last contact was %{timeAgo}"
msgstr "" msgstr ""
...@@ -30235,6 +30247,9 @@ msgstr "" ...@@ -30235,6 +30247,9 @@ msgstr ""
msgid "Runners|Something went wrong while fetching the tags suggestions" msgid "Runners|Something went wrong while fetching the tags suggestions"
msgstr "" msgstr ""
msgid "Runners|Stale"
msgstr ""
msgid "Runners|Status" msgid "Runners|Status"
msgstr "" msgstr ""
...@@ -30331,6 +30346,9 @@ msgstr "" ...@@ -30331,6 +30346,9 @@ msgstr ""
msgid "Runners|specific" msgid "Runners|specific"
msgstr "" msgstr ""
msgid "Runners|stale"
msgstr ""
msgid "Running" msgid "Running"
msgstr "" msgstr ""
......
...@@ -8,11 +8,9 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do ...@@ -8,11 +8,9 @@ RSpec.describe TimeZoneHelper, '(JavaScript fixtures)' do
let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json } let(:response) { @timezones.sort_by! { |tz| tz[:name] }.to_json }
it 'timezones/short.json' do %I[short abbr full].each do |format|
@timezones = timezone_data(format: :short) it "timezones/#{format}.json" do
@timezones = timezone_data(format: format)
end end
it 'timezones/full.json' do
@timezones = timezone_data(format: :full)
end end
end end
import { GlBadge } from '@gitlab/ui'; import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; 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 { 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', () => { describe('RunnerTypeBadge', () => {
let wrapper; let wrapper;
...@@ -10,14 +15,14 @@ describe('RunnerTypeBadge', () => { ...@@ -10,14 +15,14 @@ describe('RunnerTypeBadge', () => {
const findBadge = () => wrapper.findComponent(GlBadge); const findBadge = () => wrapper.findComponent(GlBadge);
const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip'); const getTooltip = () => getBinding(findBadge().element, 'gl-tooltip');
const createComponent = ({ runner = {} } = {}) => { const createComponent = (props = {}) => {
wrapper = shallowMount(RunnerContactedStateBadge, { wrapper = shallowMount(RunnerStatusBadge, {
propsData: { propsData: {
runner: { runner: {
contactedAt: '2021-01-01T00:00:00Z', contactedAt: '2020-12-31T23:59:00Z',
status: STATUS_ONLINE, status: STATUS_ONLINE,
...runner,
}, },
...props,
}, },
directives: { directives: {
GlTooltip: createMockDirective(), GlTooltip: createMockDirective(),
...@@ -27,6 +32,7 @@ describe('RunnerTypeBadge', () => { ...@@ -27,6 +32,7 @@ describe('RunnerTypeBadge', () => {
beforeEach(() => { beforeEach(() => {
jest.useFakeTimers('modern'); jest.useFakeTimers('modern');
jest.setSystemTime(new Date('2021-01-01T00:00:00Z'));
}); });
afterEach(() => { afterEach(() => {
...@@ -36,8 +42,6 @@ describe('RunnerTypeBadge', () => { ...@@ -36,8 +42,6 @@ describe('RunnerTypeBadge', () => {
}); });
it('renders online state', () => { it('renders online state', () => {
jest.setSystemTime(new Date('2021-01-01T00:01:00Z'));
createComponent(); createComponent();
expect(wrapper.text()).toBe('online'); expect(wrapper.text()).toBe('online');
...@@ -45,11 +49,23 @@ describe('RunnerTypeBadge', () => { ...@@ -45,11 +49,23 @@ describe('RunnerTypeBadge', () => {
expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago'); expect(getTooltip().value).toBe('Runner is online; last contact was 1 minute ago');
}); });
it('renders offline state', () => { it('renders not connected state', () => {
jest.setSystemTime(new Date('2021-01-02T00:00:00Z')); 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({ createComponent({
runner: { runner: {
contactedAt: '2020-12-31T00:00:00Z',
status: STATUS_OFFLINE, status: STATUS_OFFLINE,
}, },
}); });
...@@ -61,20 +77,33 @@ describe('RunnerTypeBadge', () => { ...@@ -61,20 +77,33 @@ describe('RunnerTypeBadge', () => {
); );
}); });
it('renders not connected state', () => { it('renders stale state', () => {
createComponent({
runner: {
contactedAt: '2020-01-01T00:00:00Z',
status: STATUS_STALE,
},
});
expect(wrapper.text()).toBe('stale');
expect(findBadge().props('variant')).toBe('warning');
expect(getTooltip().value).toBe('No contact from this runner in over 3 months');
});
describe('does not fail when data is missing', () => {
it('contacted_at is missing', () => {
createComponent({ createComponent({
runner: { runner: {
contactedAt: null, contactedAt: null,
status: STATUS_NOT_CONNECTED, status: STATUS_ONLINE,
}, },
}); });
expect(wrapper.text()).toBe('not connected'); expect(wrapper.text()).toBe('online');
expect(findBadge().props('variant')).toBe('muted'); expect(getTooltip().value).toBe('Runner is online; last contact was n/a');
expect(getTooltip().value).toMatch('This runner has never connected');
}); });
it('does not fail when data is missing', () => { it('status is missing', () => {
createComponent({ createComponent({
runner: { runner: {
status: null, status: null,
...@@ -83,4 +112,5 @@ describe('RunnerTypeBadge', () => { ...@@ -83,4 +112,5 @@ describe('RunnerTypeBadge', () => {
expect(wrapper.text()).toBe(''); expect(wrapper.text()).toBe('');
}); });
});
}); });
...@@ -30,6 +30,30 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do ...@@ -30,6 +30,30 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do
end end
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 context 'with full format' do
subject(:timezone_data) { helper.timezone_data(format: :full) } subject(:timezone_data) { helper.timezone_data(format: :full) }
...@@ -64,7 +88,7 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do ...@@ -64,7 +88,7 @@ RSpec.describe TimeZoneHelper, :aggregate_failures do
subject(:timezone_data) { helper.timezone_data(format: :unknown) } subject(:timezone_data) { helper.timezone_data(format: :unknown) }
it 'raises an exception' do 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 end
end end
......
...@@ -50,7 +50,8 @@ RSpec.describe Gitlab::ContributionsCalendar do ...@@ -50,7 +50,8 @@ RSpec.describe Gitlab::ContributionsCalendar do
Event.create!( Event.create!(
project: project, project: project,
action: action, action: action,
target: @targets[project], target_type: @targets[project].class.name,
target_id: @targets[project].id,
author: contributor, author: contributor,
created_at: DateTime.new(day.year, day.month, day.day, hour) created_at: DateTime.new(day.year, day.month, day.day, hour)
) )
...@@ -66,8 +67,11 @@ RSpec.describe Gitlab::ContributionsCalendar do ...@@ -66,8 +67,11 @@ RSpec.describe Gitlab::ContributionsCalendar do
end end
context "when the user has opted-in for private contributions" do 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) 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(private_project, today)
create_event(public_project, today) create_event(public_project, today)
...@@ -75,6 +79,23 @@ RSpec.describe Gitlab::ContributionsCalendar do ...@@ -75,6 +79,23 @@ RSpec.describe Gitlab::ContributionsCalendar do
expect(calendar(user).activity_dates[today]).to eq(2) expect(calendar(user).activity_dates[today]).to eq(2)
expect(calendar(contributor).activity_dates[today]).to eq(2) expect(calendar(contributor).activity_dates[today]).to eq(2)
end 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 end
it "counts the diff notes on merge request" do it "counts the diff notes on merge request" do
...@@ -169,6 +190,12 @@ RSpec.describe Gitlab::ContributionsCalendar do ...@@ -169,6 +190,12 @@ RSpec.describe Gitlab::ContributionsCalendar do
expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3) expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3)
end 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 context 'when the user cannot read cross project' do
before do before do
allow(Ability).to receive(:allowed?).and_call_original 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