Merge branch 'feature/runner-state-filter-for-admin-view' into 'master'

Feature: State filter for admin runners view

See merge request gitlab-org/gitlab-ce!19625
This commit is contained in:
Grzegorz Bizon 2018-09-14 09:03:44 +00:00
commit 55c23a0935
33 changed files with 631 additions and 249 deletions

View File

@ -0,0 +1,14 @@
import FilteredSearchTokenKeys from './filtered_search_token_keys';
const tokenKeys = [{
key: 'status',
type: 'string',
param: 'status',
symbol: '',
icon: 'signal',
tag: 'status',
}];
const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
export default AdminRunnersFilteredSearchTokenKeys;

View File

@ -7,6 +7,7 @@ import DropdownHint from './dropdown_hint';
import DropdownEmoji from './dropdown_emoji';
import DropdownNonUser from './dropdown_non_user';
import DropdownUser from './dropdown_user';
import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager {
@ -90,6 +91,11 @@ export default class FilteredSearchDropdownManager {
gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'),
},
status: {
reference: null,
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-status'),
},
};
supportedTokens.forEach((type) => {

View File

@ -3,10 +3,10 @@ import {
getParameterByName,
getUrlParamsArray,
} from '~/lib/utils/common_utils';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash';
import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service';
@ -23,7 +23,7 @@ export default class FilteredSearchManager {
isGroup = false,
isGroupAncestor = true,
isGroupDecendent = false,
filteredSearchTokenKeys = FilteredSearchTokenKeys,
filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
}) {
this.isGroup = isGroup;

View File

@ -1,103 +1,38 @@
const tokenKeys = [{
key: 'author',
type: 'string',
param: 'username',
symbol: '@',
icon: 'pencil',
tag: '@author',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
icon: 'user',
tag: '@assignee',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
icon: 'clock-o',
tag: '%milestone',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'tag',
tag: '~label',
}];
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
key: 'my-reaction',
type: 'string',
param: 'emoji',
symbol: '',
icon: 'thumbs-up',
tag: 'emoji',
});
}
const alternativeTokenKeys = [{
key: 'label',
type: 'string',
param: 'name',
symbol: '~',
}];
const tokenKeysWithAlternative = tokenKeys.concat(alternativeTokenKeys);
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}, {
url: 'milestone_title=No+Milestone',
tokenKey: 'milestone',
value: 'none',
}, {
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: 'upcoming',
}, {
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: 'started',
}, {
url: 'label_name[]=No+Label',
tokenKey: 'label',
value: 'none',
}];
export default class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) {
this.tokenKeys = tokenKeys;
this.alternativeTokenKeys = alternativeTokenKeys;
this.conditions = conditions;
this.tokenKeysWithAlternative = this.tokenKeys.concat(this.alternativeTokenKeys);
}
static getKeys() {
return tokenKeys.map(i => i.key);
get() {
return this.tokenKeys;
}
static getAlternatives() {
return alternativeTokenKeys;
getKeys() {
return this.tokenKeys.map(i => i.key);
}
static getConditions() {
return conditions;
getAlternatives() {
return this.alternativeTokenKeys;
}
static searchByKey(key) {
return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
getConditions() {
return this.conditions;
}
static searchBySymbol(symbol) {
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
searchByKey(key) {
return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
static searchByKeyParam(keyParam) {
return tokenKeysWithAlternative.find((tokenKey) => {
searchBySymbol(symbol) {
return this.tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
}
searchByKeyParam(keyParam) {
return this.tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key;
// Replace hyphen with underscore to compare keyParam with tokenKeyParam
@ -112,12 +47,12 @@ export default class FilteredSearchTokenKeys {
}) || null;
}
static searchByConditionUrl(url) {
return conditions.find(condition => condition.url === url) || null;
searchByConditionUrl(url) {
return this.conditions.find(condition => condition.url === url) || null;
}
static searchByConditionKeyValue(key, value) {
return conditions
searchByConditionKeyValue(key, value) {
return this.conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null;
}
}

View File

@ -0,0 +1,77 @@
import FilteredSearchTokenKeys from './filtered_search_token_keys';
const tokenKeys = [{
key: 'author',
type: 'string',
param: 'username',
symbol: '@',
icon: 'pencil',
tag: '@author',
}, {
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
icon: 'user',
tag: '@assignee',
}, {
key: 'milestone',
type: 'string',
param: 'title',
symbol: '%',
icon: 'clock-o',
tag: '%milestone',
}, {
key: 'label',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'tag',
tag: '~label',
}];
if (gon.current_user_id) {
// Appending tokenkeys only logged-in
tokenKeys.push({
key: 'my-reaction',
type: 'string',
param: 'emoji',
symbol: '',
icon: 'thumbs-up',
tag: 'emoji',
});
}
const alternativeTokenKeys = [{
key: 'label',
type: 'string',
param: 'name',
symbol: '~',
}];
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}, {
url: 'milestone_title=No+Milestone',
tokenKey: 'milestone',
value: 'none',
}, {
url: 'milestone_title=%23upcoming',
tokenKey: 'milestone',
value: 'upcoming',
}, {
url: 'milestone_title=%23started',
tokenKey: 'milestone',
value: 'started',
}, {
url: 'label_name[]=No+Label',
tokenKey: 'label',
value: 'none',
}];
const IssuableFilteredSearchTokenKeys =
new FilteredSearchTokenKeys(tokenKeys, alternativeTokenKeys, conditions);
export default IssuableFilteredSearchTokenKeys;

View File

@ -0,0 +1,9 @@
import FilteredSearchDropdown from './filtered_search_dropdown';
export default class NullDropdown extends FilteredSearchDropdown {
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [], this.config);
super.renderContent(forceShowList);
}
}

View File

@ -0,0 +1,10 @@
import initFilteredSearch from '~/pages/search/init_filtered_search';
import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ADMIN_RUNNERS,
filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys,
});
});

View File

@ -3,4 +3,5 @@
export const FILTERED_SEARCH = {
MERGE_REQUESTS: 'merge_requests',
ISSUES: 'issues',
ADMIN_RUNNERS: 'admin/runners',
};

View File

@ -1,11 +1,13 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
projectSelect();
});

View File

@ -1,11 +1,13 @@
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
projectSelect();
});

View File

@ -4,12 +4,14 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
new IssuableIndex(ISSUABLE_INDEX.ISSUE);

View File

@ -2,12 +2,14 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/shortcuts_navigation';
import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
});
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new

View File

@ -3,11 +3,10 @@ class Admin::RunnersController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord
def index
sort = params[:sort] == 'contacted_asc' ? { contacted_at: :asc } : { id: :desc }
@runners = Ci::Runner.order(sort)
@runners = @runners.search(params[:search]) if params[:search].present?
@runners = @runners.page(params[:page]).per(30)
@active_runners_cnt = Ci::Runner.online.count
finder = Admin::RunnersFinder.new(params: params)
@runners = finder.execute
@active_runners_count = Ci::Runner.online.count
@sort = finder.sort_key
end
# rubocop: enable CodeReuse/ActiveRecord

View File

@ -0,0 +1,53 @@
# frozen_string_literal: true
class Admin::RunnersFinder < UnionFinder
NUMBER_OF_RUNNERS_PER_PAGE = 30
def initialize(params:)
@params = params
end
def execute
search!
filter_by_status!
sort!
paginate!
@runners
end
def sort_key
if @params[:sort] == 'contacted_asc'
'contacted_asc'
else
'created_date'
end
end
private
def search!
@runners =
if @params[:search].present?
Ci::Runner.search(@params[:search])
else
Ci::Runner.all
end
end
def filter_by_status!
status = @params[:status_status]
if status.present? && Ci::Runner::AVAILABLE_STATUSES.include?(status)
@runners = @runners.public_send(status) # rubocop:disable GitlabSecurity/PublicSend
end
end
def sort!
sort = sort_key == 'contacted_asc' ? { contacted_at: :asc } : { created_at: :desc }
@runners = @runners.order(sort)
end
def paginate!
@runners = @runners.page(@params[:page]).per(NUMBER_OF_RUNNERS_PER_PAGE)
end
end

View File

@ -24,7 +24,8 @@ module SortingHelper
sort_value_recently_updated => sort_title_recently_updated,
sort_value_popularity => sort_title_popularity,
sort_value_priority => sort_title_priority,
sort_value_upvotes => sort_title_upvotes
sort_value_upvotes => sort_title_upvotes,
sort_value_contacted_date => sort_title_contacted_date
}
end
@ -241,6 +242,10 @@ module SortingHelper
s_('SortOptions|Most popular')
end
def sort_title_contacted_date
s_('SortOptions|Last Contact')
end
# Values.
def sort_value_access_level_asc
'access_level_asc'
@ -361,4 +366,8 @@ module SortingHelper
def sort_value_upvotes
'upvotes_desc'
end
def sort_value_contacted_date
'contacted_asc'
end
end

View File

@ -11,7 +11,9 @@ module Ci
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
ONLINE_CONTACT_TIMEOUT = 1.hour
UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes
AVAILABLE_SCOPES = %w[specific shared active paused online].freeze
AVAILABLE_TYPES = %w[specific shared].freeze
AVAILABLE_STATUSES = %w[active paused online offline].freeze
AVAILABLE_SCOPES = (AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
ignore_column :is_shared
@ -29,6 +31,13 @@ module Ci
scope :active, -> { where(active: true) }
scope :paused, -> { where(active: false) }
scope :online, -> { where('contacted_at > ?', contact_time_deadline) }
# The following query using negation is cheaper than using `contacted_at <= ?`
# because there are less runners online than have been created. The
# resulting query is quickly finding online ones and then uses the regular
# indexed search and rejects the ones that are in the previous set. If we
# did `contacted_at <= ?` the query would effectively have to do a seq
# scan.
scope :offline, -> { where.not(id: online) }
scope :ordered, -> { order(id: :desc) }
# BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb`

View File

@ -1,5 +1,5 @@
%tr{ id: dom_id(runner) }
%td
.gl-responsive-table-row{ id: dom_id(runner) }
= render layout: 'runner_table_cell', locals: { label: _('Type') } do
- if runner.instance_type?
%span.badge.badge-success shared
- elsif runner.group_type?
@ -11,41 +11,50 @@
- unless runner.active?
%span.badge.badge-danger paused
%td
= link_to admin_runner_path(runner) do
= runner.short_sha
%td
= render layout: 'runner_table_cell', locals: { label: _('Runner token') } do
= link_to runner.short_sha, admin_runner_path(runner)
= render layout: 'runner_table_cell', locals: { label: _('Description') } do
= runner.description
%td
= render layout: 'runner_table_cell', locals: { label: _('Version') } do
= runner.version
%td
= render layout: 'runner_table_cell', locals: { label: _('IP Address') } do
= runner.ip_address
%td
= render layout: 'runner_table_cell', locals: { label: _('Projects') } do
- if runner.instance_type? || runner.group_type?
n/a
= _('n/a')
- else
= runner.projects.count(:all)
%td
#{runner.builds.count(:all)}
%td
= render layout: 'runner_table_cell', locals: { label: _('Jobs') } do
= runner.builds.count(:all)
= render layout: 'runner_table_cell', locals: { label: _('Tags') } do
- runner.tag_list.sort.each do |tag|
%span.badge.badge-primary
= tag
%td
= render layout: 'runner_table_cell', locals: { label: _('Last contact') } do
- if runner.contacted_at
= time_ago_with_tooltip runner.contacted_at
- else
Never
%td.admin-runner-btn-group-cell
.float-right.btn-group
= link_to admin_runner_path(runner), class: 'btn btn-sm btn-default has-tooltip', title: 'Edit', ref: 'tooltip', aria: { label: 'Edit' }, data: { placement: 'top', container: 'body'} do
= icon('pencil')
&nbsp;
- if runner.active?
= link_to [:pause, :admin, runner], method: :get, class: 'btn btn-sm btn-default has-tooltip', title: 'Pause', ref: 'tooltip', aria: { label: 'Pause' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
= icon('pause')
- else
= link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-sm has-tooltip', title: 'Resume', ref: 'tooltip', aria: { label: 'Resume' }, data: { placement: 'top', container: 'body'} do
= icon('play')
= link_to [:admin, runner], method: :delete, class: 'btn btn-danger btn-sm has-tooltip', title: 'Remove', ref: 'tooltip', aria: { label: 'Remove' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
= icon('remove')
= _('Never')
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
= link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
= icon('pencil')
.btn-group
- if runner.active?
= link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('pause')
- else
= link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
= icon('play')
.btn-group
= link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('remove')

View File

@ -0,0 +1,4 @@
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= label
.table-mobile-content
= yield

View File

@ -0,0 +1,11 @@
- sorted_by = sort_options_hash[@sort]
.dropdown.inline.prepend-left-10
%button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' } }
= sorted_by
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort
%li
= sortable_item(sort_title_created_date, page_filter_path(sort: sort_value_created_date, label: true), sorted_by)
= sortable_item(sort_title_contacted_date, page_filter_path(sort: sort_value_contacted_date, label: true), sorted_by)

View File

@ -1,77 +1,107 @@
- breadcrumb_title "Runners"
- breadcrumb_title _('Runners')
- @no_container = true
%div{ class: container_class }
.bs-callout
%p
A 'Runner' is a process which runs a job.
You can setup as many Runners as you need.
= (_"A 'Runner' is a process which runs a job. You can setup as many Runners as you need.")
%br
Runners can be placed on separate users, servers, even on your local machine.
= _('Runners can be placed on separate users, servers, even on your local machine.')
%br
%div
%span Each Runner can be in one of the following states:
%span= _('Each Runner can be in one of the following states:')
%ul
%li
%span.badge.badge-success shared
\- Runner runs jobs from all unassigned projects
\-
= _('Runner runs jobs from all unassigned projects')
%li
%span.badge.badge-success group
\- Runner runs jobs from all unassigned projects in its group
\-
= _('Runner runs jobs from all unassigned projects in its group')
%li
%span.badge.badge-info specific
\- Runner runs jobs from assigned projects
\-
= _('Runner runs jobs from assigned projects')
%li
%span.badge.badge-warning locked
\- Runner cannot be assigned to other projects
\-
= _('Runner cannot be assigned to other projects')
%li
%span.badge.badge-danger paused
\- Runner will not receive any new jobs
\-
= _('Runner will not receive any new jobs')
.bs-callout.clearfix
.float-left
%p
You can reset runners registration token by pressing a button below.
= _('You can reset runners registration token by pressing a button below.')
.prepend-top-10
= button_to _("Reset runners registration token"), reset_runners_token_admin_application_settings_path,
= button_to _('Reset runners registration token'), reset_runners_token_admin_application_settings_path,
method: :put, class: 'btn btn-default',
data: { confirm: _("Are you sure you want to reset registration token?") }
data: { confirm: _('Are you sure you want to reset registration token?') }
= render partial: 'ci/runner/how_to_setup_shared_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token }
.append-bottom-20.clearfix
.float-left
= form_tag admin_runners_path, id: 'runners-search', class: 'form-inline', method: :get do
.form-group
= search_field_tag :search, params[:search], class: 'form-control input-short', placeholder: 'Runner description or token', spellcheck: false
= submit_tag 'Search', class: 'btn'
.bs-callout
%p
= _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
.float-right.light
Runners currently online: #{@active_runners_cnt}
%br
.row-content-block.second-block
= form_tag admin_runners_path, id: 'runners-search', method: :get, class: 'filter-form js-filter-form' do
.filtered-search-wrapper
.filtered-search-box
= dropdown_tag(custom_icon('icon_history'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content',
title: _('Recent searches') }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
%input.form-control.filtered-search{ { id: 'filtered-search-runners', placeholder: _('Search or filter results...') } }
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { action: 'submit' } }
= button_tag class: %w[btn btn-link] do
= icon('search')
%span
= _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
= button_tag class: %w[btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%i.fa{ class: "#{'{{icon}}'}" }
%span.js-filter-hint
{{hint}}
%span.js-filter-tag.dropdown-light-content
{{tag}}
#js-dropdown-admin-runner-status.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
= button_tag class: %w[btn btn-link] do
= status.titleize
= button_tag class: %w[clear-search hidden] do
= icon('times')
.filter-dropdown-container
= render 'sort_dropdown'
- if @runners.any?
.runners-content
.runners-content.content-list
.table-holder
%table.table
%thead
%tr
%th Type
%th Runner token
%th Description
%th Version
%th IP Address
%th Projects
%th Jobs
%th Tags
%th= link_to 'Last contact', admin_runners_path(safe_params.slice(:search).merge(sort: 'contacted_asc'))
%th
.gl-responsive-table-row.table-row-header{ role: 'row' }
- [_('Type'), _('Runner token'), _('Description'), _('Version'), _('IP Address'), _('Projects'), _('Jobs'), _('Tags'), _('Last contact')].each do |label|
.table-section.section-10{ role: 'rowheader' }= label
- @runners.each do |runner|
= render "admin/runners/runner", runner: runner
= paginate @runners, theme: "gitlab"
- @runners.each do |runner|
= render 'admin/runners/runner', runner: runner
= paginate @runners, theme: 'gitlab'
- else
.nothing-here-block No runners found
.nothing-here-block= _('No runners found')

View File

@ -0,0 +1,5 @@
---
title: Add a filter bar to the admin runners view and add a state filter
merge_request: 19625
author: Alexis Reigel
type: added

View File

@ -15,7 +15,7 @@ GET /runners?scope=active
| Attribute | Type | Required | Description |
|-----------|---------|----------|---------------------|
| `scope` | string | no | The scope of specific runners to show, one of: `active`, `paused`, `online`; showing all runners if none provided |
| `scope` | string | no | The scope of specific runners to show, one of: `active`, `paused`, `online`, `offline`; showing all runners if none provided |
```
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners"
@ -60,7 +60,7 @@ GET /runners/all?scope=online
| Attribute | Type | Required | Description |
|-----------|---------|----------|---------------------|
| `scope` | string | no | The scope of runners to show, one of: `specific`, `shared`, `active`, `paused`, `online`; showing all runners if none provided |
| `scope` | string | no | The scope of runners to show, one of: `specific`, `shared`, `active`, `paused`, `online`, `offline`; showing all runners if none provided |
```
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners/all"

View File

@ -9,12 +9,12 @@ module API
success Entities::Runner
end
params do
optional :scope, type: String, values: %w[active paused online],
optional :scope, type: String, values: Ci::Runner::AVAILABLE_STATUSES,
desc: 'The scope of specific runners to show'
use :pagination
end
get do
runners = filter_runners(current_user.ci_owned_runners, params[:scope], without: %w(specific shared))
runners = filter_runners(current_user.ci_owned_runners, params[:scope], allowed_scopes: Ci::Runner::AVAILABLE_STATUSES)
present paginate(runners), with: Entities::Runner
end
@ -22,7 +22,7 @@ module API
success Entities::Runner
end
params do
optional :scope, type: String, values: %w[active paused online specific shared],
optional :scope, type: String, values: Ci::Runner::AVAILABLE_SCOPES,
desc: 'The scope of specific runners to show'
use :pagination
end
@ -114,7 +114,7 @@ module API
success Entities::Runner
end
params do
optional :scope, type: String, values: %w[active paused online specific shared],
optional :scope, type: String, values: Ci::Runner::AVAILABLE_SCOPES,
desc: 'The scope of specific runners to show'
use :pagination
end
@ -160,15 +160,10 @@ module API
end
helpers do
def filter_runners(runners, scope, options = {})
def filter_runners(runners, scope, allowed_scopes: ::Ci::Runner::AVAILABLE_SCOPES)
return runners unless scope.present?
available_scopes = ::Ci::Runner::AVAILABLE_SCOPES
if options[:without]
available_scopes = available_scopes - options[:without]
end
if (available_scopes & [scope]).empty?
unless allowed_scopes.include?(scope)
render_api_error!('Scope contains invalid value', 400)
end

View File

@ -3463,6 +3463,9 @@ msgstr ""
msgid "Last commit"
msgstr ""
msgid "Last contact"
msgstr ""
msgid "Last edited %{date}"
msgstr ""
@ -3977,6 +3980,9 @@ msgstr ""
msgid "No repository"
msgstr ""
msgid "No runners found"
msgstr ""
msgid "No schedules"
msgstr ""
@ -4438,6 +4444,9 @@ msgstr ""
msgid "Preferences|Navigation theme"
msgstr ""
msgid "Press Enter or click to search"
msgstr ""
msgid "Preview"
msgstr ""
@ -4909,6 +4918,9 @@ msgstr ""
msgid "Real-time features"
msgstr ""
msgid "Recent searches"
msgstr ""
msgid "Reference:"
msgstr ""
@ -5111,9 +5123,24 @@ msgstr ""
msgid "Run untagged jobs"
msgstr ""
msgid "Runner cannot be assigned to other projects"
msgstr ""
msgid "Runner runs jobs from all unassigned projects"
msgstr ""
msgid "Runner runs jobs from all unassigned projects in its group"
msgstr ""
msgid "Runner runs jobs from assigned projects"
msgstr ""
msgid "Runner token"
msgstr ""
msgid "Runner will not receive any new jobs"
msgstr ""
msgid "Runners"
msgstr ""
@ -5123,6 +5150,12 @@ msgstr ""
msgid "Runners can be placed on separate users, servers, and even on your local machine."
msgstr ""
msgid "Runners can be placed on separate users, servers, even on your local machine."
msgstr ""
msgid "Runners currently online: %{active_runners_count}"
msgstr ""
msgid "Runners page"
msgstr ""
@ -5195,6 +5228,9 @@ msgstr ""
msgid "Search milestones"
msgstr ""
msgid "Search or filter results..."
msgstr ""
msgid "Search or jump to…"
msgstr ""
@ -5473,6 +5509,9 @@ msgstr ""
msgid "SortOptions|Largest repository"
msgstr ""
msgid "SortOptions|Last Contact"
msgstr ""
msgid "SortOptions|Last created"
msgstr ""
@ -6346,6 +6385,9 @@ msgstr ""
msgid "Twitter"
msgstr ""
msgid "Type"
msgstr ""
msgid "Unable to load the diff. %{button_try_again}"
msgstr ""
@ -6484,6 +6526,9 @@ msgstr ""
msgid "Verified"
msgstr ""
msgid "Version"
msgstr ""
msgid "View file @ "
msgstr ""
@ -6748,6 +6793,9 @@ msgstr ""
msgid "You can only edit files when you are on a branch"
msgstr ""
msgid "You can reset runners registration token by pressing a button below."
msgstr ""
msgid "You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}"
msgstr ""
@ -7127,6 +7175,9 @@ msgstr ""
msgid "mrWidget|to be merged automatically when the pipeline succeeds"
msgstr ""
msgid "n/a"
msgstr ""
msgid "new merge request"
msgstr ""

View File

@ -2,6 +2,8 @@ require 'spec_helper'
describe "Admin Runners" do
include StubENV
include FilteredSearchHelpers
include SortingHelper
before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
@ -12,40 +14,109 @@ describe "Admin Runners" do
let(:pipeline) { create(:ci_pipeline) }
context "when there are runners" do
before do
runner = FactoryBot.create(:ci_runner, contacted_at: Time.now)
FactoryBot.create(:ci_build, pipeline: pipeline, runner_id: runner.id)
visit admin_runners_path
end
it 'has all necessary texts' do
runner = create(:ci_runner, contacted_at: Time.now)
create(:ci_build, pipeline: pipeline, runner_id: runner.id)
visit admin_runners_path
expect(page).to have_text "Setup a shared Runner manually"
expect(page).to have_text "Runners currently online: 1"
end
describe 'search' do
describe 'search', :js do
before do
FactoryBot.create :ci_runner, description: 'runner-foo'
FactoryBot.create :ci_runner, description: 'runner-bar'
create(:ci_runner, description: 'runner-foo')
create(:ci_runner, description: 'runner-bar')
visit admin_runners_path
end
it 'shows correct runner when description matches' do
search_form = find('#runners-search')
search_form.fill_in 'search', with: 'runner-foo'
search_form.click_button 'Search'
input_filtered_search_keys('runner-foo')
expect(page).to have_content("runner-foo")
expect(page).not_to have_content("runner-bar")
end
it 'shows no runner when description does not match' do
search_form = find('#runners-search')
search_form.fill_in 'search', with: 'runner-baz'
search_form.click_button 'Search'
input_filtered_search_keys('runner-baz')
expect(page).to have_text 'No runners found'
end
end
describe 'filter by status', :js do
it 'shows correct runner when status matches' do
create(:ci_runner, description: 'runner-active', active: true)
create(:ci_runner, description: 'runner-paused', active: false)
visit admin_runners_path
expect(page).to have_content 'runner-active'
expect(page).to have_content 'runner-paused'
input_filtered_search_keys('status:active')
expect(page).to have_content 'runner-active'
expect(page).not_to have_content 'runner-paused'
end
it 'shows no runner when status does not match' do
create(:ci_runner, :online, description: 'runner-active', active: true)
create(:ci_runner, :online, description: 'runner-paused', active: false)
visit admin_runners_path
input_filtered_search_keys('status:offline')
expect(page).not_to have_content 'runner-active'
expect(page).not_to have_content 'runner-paused'
expect(page).to have_text 'No runners found'
end
end
it 'shows correct runner when status is selected and search term is entered', :js do
create(:ci_runner, description: 'runner-a-1', active: true)
create(:ci_runner, description: 'runner-a-2', active: false)
create(:ci_runner, description: 'runner-b-1', active: true)
visit admin_runners_path
input_filtered_search_keys('status:active')
expect(page).to have_content 'runner-a-1'
expect(page).to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
input_filtered_search_keys('status:active runner-a')
expect(page).to have_content 'runner-a-1'
expect(page).not_to have_content 'runner-b-1'
expect(page).not_to have_content 'runner-a-2'
end
it 'sorts by last contact date', :js do
create(:ci_runner, description: 'runner-1', created_at: '2018-07-12 15:37', contacted_at: '2018-07-12 15:37')
create(:ci_runner, description: 'runner-2', created_at: '2018-07-12 16:37', contacted_at: '2018-07-12 16:37')
visit admin_runners_path
within '.runners-content .gl-responsive-table-row:nth-child(2)' do
expect(page).to have_content 'runner-2'
end
within '.runners-content .gl-responsive-table-row:nth-child(3)' do
expect(page).to have_content 'runner-1'
end
sorting_by 'Last Contact'
within '.runners-content .gl-responsive-table-row:nth-child(2)' do
expect(page).to have_content 'runner-1'
end
within '.runners-content .gl-responsive-table-row:nth-child(3)' do
expect(page).to have_content 'runner-2'
end
end
end
context "when there are no runners" do
@ -76,7 +147,7 @@ describe "Admin Runners" do
context 'shared runner' do
it 'shows the label and does not show the project count' do
runner = create :ci_runner, :instance
runner = create(:ci_runner, :instance)
visit admin_runners_path
@ -89,8 +160,8 @@ describe "Admin Runners" do
context 'specific runner' do
it 'shows the label and the project count' do
project = create :project
runner = create :ci_runner, :project, projects: [project]
project = create(:project)
runner = create(:ci_runner, :project, projects: [project])
visit admin_runners_path
@ -103,11 +174,11 @@ describe "Admin Runners" do
end
describe "Runner show page" do
let(:runner) { FactoryBot.create :ci_runner }
let(:runner) { create(:ci_runner) }
before do
@project1 = FactoryBot.create(:project)
@project2 = FactoryBot.create(:project)
@project1 = create(:project)
@project2 = create(:project)
visit admin_runner_path(runner)
end

View File

@ -0,0 +1,65 @@
# frozen_string_literal: true
require 'spec_helper'
describe Admin::RunnersFinder do
describe '#execute' do
context 'with empty params' do
it 'returns all runners' do
runner1 = create :ci_runner, active: true
runner2 = create :ci_runner, active: false
expect(described_class.new(params: {}).execute).to match_array [runner1, runner2]
end
end
context 'filter by search term' do
it 'calls Ci::Runner.search' do
expect(Ci::Runner).to receive(:search).with('term').and_call_original
described_class.new(params: { search: 'term' }).execute
end
end
context 'filter by status' do
it 'calls the corresponding scope on Ci::Runner' do
expect(Ci::Runner).to receive(:paused).and_call_original
described_class.new(params: { status_status: 'paused' }).execute
end
end
context 'sort' do
context 'without sort param' do
it 'sorts by created_at' do
runner1 = create :ci_runner, created_at: '2018-07-12 07:00'
runner2 = create :ci_runner, created_at: '2018-07-12 08:00'
runner3 = create :ci_runner, created_at: '2018-07-12 09:00'
expect(described_class.new(params: {}).execute).to eq [runner3, runner2, runner1]
end
end
context 'with sort param' do
it 'sorts by specified attribute' do
runner1 = create :ci_runner, contacted_at: 1.minute.ago
runner2 = create :ci_runner, contacted_at: 3.minutes.ago
runner3 = create :ci_runner, contacted_at: 2.minutes.ago
expect(described_class.new(params: { sort: 'contacted_asc' }).execute).to eq [runner2, runner3, runner1]
end
end
end
context 'paginate' do
it 'returns the runners for the specified page' do
stub_const('Admin::RunnersFinder::NUMBER_OF_RUNNERS_PER_PAGE', 1)
runner1 = create :ci_runner, created_at: '2018-07-12 07:00'
runner2 = create :ci_runner, created_at: '2018-07-12 08:00'
expect(described_class.new(params: { page: 1 }).execute).to eq [runner2]
expect(described_class.new(params: { page: 2 }).execute).to eq [runner1]
end
end
end
end

View File

@ -1,7 +1,7 @@
import Vue from 'vue';
import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
const createComponent = (propsData) => {
const Component = Vue.extend(RecentSearchesDropdownContent);
@ -18,14 +18,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim();
describe('RecentSearchesDropdownContent', () => {
const propsDataWithoutItems = {
items: [],
allowedKeys: FilteredSearchTokenKeys.getKeys(),
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
};
const propsDataWithItems = {
items: [
'foo',
'author:@root label:~foo bar',
],
allowedKeys: FilteredSearchTokenKeys.getKeys(),
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
};
let vm;

View File

@ -1,7 +1,7 @@
import DropdownUtils from '~/filtered_search/dropdown_utils';
import DropdownUser from '~/filtered_search/dropdown_user';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import IssuableFilteredTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
describe('Dropdown User', () => {
describe('getSearchInput', () => {
@ -14,7 +14,7 @@ describe('Dropdown User', () => {
spyOn(DropdownUtils, 'getSearchInput').and.callFake(() => {});
dropdownUser = new DropdownUser({
tokenKeys: FilteredSearchTokenKeys,
tokenKeys: IssuableFilteredTokenKeys,
});
});

View File

@ -1,6 +1,6 @@
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Dropdown Utils', () => {
@ -137,7 +137,7 @@ describe('Dropdown Utils', () => {
`);
input = document.getElementById('test');
allowedKeys = FilteredSearchTokenKeys.getKeys();
allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
});
function config() {

View File

@ -1,7 +1,7 @@
import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import '~/lib/utils/common_utils';
import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
@ -86,7 +86,7 @@ describe('Filtered Search Manager', function () {
expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
expect(RecentSearchesStoreSpy).toHaveBeenCalledWith({
isLocalStorageAvailable,
allowedKeys: FilteredSearchTokenKeys.getKeys(),
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
});
});
});

View File

@ -1,26 +1,36 @@
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
describe('Filtered Search Token Keys', () => {
describe('get', () => {
let tokenKeys;
const tokenKeys = [{
key: 'author',
type: 'string',
param: 'username',
symbol: '@',
icon: 'pencil',
tag: '@author',
}];
beforeEach(() => {
tokenKeys = FilteredSearchTokenKeys.get();
});
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}];
describe('get', () => {
it('should return tokenKeys', () => {
expect(tokenKeys !== null).toBe(true);
expect(new FilteredSearchTokenKeys().get() !== null).toBe(true);
});
it('should return tokenKeys as an array', () => {
expect(tokenKeys instanceof Array).toBe(true);
expect(new FilteredSearchTokenKeys().get() instanceof Array).toBe(true);
});
});
describe('getKeys', () => {
it('should return keys', () => {
const getKeys = FilteredSearchTokenKeys.getKeys();
const keys = FilteredSearchTokenKeys.get().map(i => i.key);
const getKeys = new FilteredSearchTokenKeys(tokenKeys).getKeys();
const keys = new FilteredSearchTokenKeys(tokenKeys).get().map(i => i.key);
keys.forEach((key, i) => {
expect(key).toEqual(getKeys[i]);
@ -29,88 +39,78 @@ describe('Filtered Search Token Keys', () => {
});
describe('getConditions', () => {
let conditions;
beforeEach(() => {
conditions = FilteredSearchTokenKeys.getConditions();
});
it('should return conditions', () => {
expect(conditions !== null).toBe(true);
expect(new FilteredSearchTokenKeys().getConditions() !== null).toBe(true);
});
it('should return conditions as an array', () => {
expect(conditions instanceof Array).toBe(true);
expect(new FilteredSearchTokenKeys().getConditions() instanceof Array).toBe(true);
});
});
describe('searchByKey', () => {
it('should return null when key not found', () => {
const tokenKey = FilteredSearchTokenKeys.searchByKey('notakey');
const tokenKey = new FilteredSearchTokenKeys(tokenKeys).searchByKey('notakey');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key', () => {
const tokenKeys = FilteredSearchTokenKeys.get();
const result = FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
const result = new FilteredSearchTokenKeys(tokenKeys).searchByKey(tokenKeys[0].key);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchBySymbol', () => {
it('should return null when symbol not found', () => {
const tokenKey = FilteredSearchTokenKeys.searchBySymbol('notasymbol');
const tokenKey = new FilteredSearchTokenKeys(tokenKeys).searchBySymbol('notasymbol');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by symbol', () => {
const tokenKeys = FilteredSearchTokenKeys.get();
const result = FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
const result = new FilteredSearchTokenKeys(tokenKeys).searchBySymbol(tokenKeys[0].symbol);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByKeyParam', () => {
it('should return null when key param not found', () => {
const tokenKey = FilteredSearchTokenKeys.searchByKeyParam('notakeyparam');
const tokenKey = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam('notakeyparam');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key param', () => {
const tokenKeys = FilteredSearchTokenKeys.get();
const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
const result = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
it('should return alternative tokenKey when found by key param', () => {
const tokenKeys = FilteredSearchTokenKeys.getAlternatives();
const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
const result = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
});
describe('searchByConditionUrl', () => {
it('should return null when condition url not found', () => {
const condition = FilteredSearchTokenKeys.searchByConditionUrl(null);
const condition = new FilteredSearchTokenKeys([], [], conditions).searchByConditionUrl(null);
expect(condition === null).toBe(true);
});
it('should return condition when found by url', () => {
const conditions = FilteredSearchTokenKeys.getConditions();
const result = FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url);
const result = new FilteredSearchTokenKeys([], [], conditions)
.searchByConditionUrl(conditions[0].url);
expect(result).toBe(conditions[0]);
});
});
describe('searchByConditionKeyValue', () => {
it('should return null when condition tokenKey and value not found', () => {
const condition = FilteredSearchTokenKeys.searchByConditionKeyValue(null, null);
const condition = new FilteredSearchTokenKeys([], [], conditions)
.searchByConditionKeyValue(null, null);
expect(condition === null).toBe(true);
});
it('should return condition when found by tokenKey and value', () => {
const conditions = FilteredSearchTokenKeys.getConditions();
const result = FilteredSearchTokenKeys
const result = new FilteredSearchTokenKeys([], [], conditions)
.searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
expect(result).toEqual(conditions[0]);
});

View File

@ -1,8 +1,8 @@
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
describe('Filtered Search Tokenizer', () => {
const allowedKeys = FilteredSearchTokenKeys.getKeys();
const allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
describe('processTokens', () => {
it('returns for input containing only search value', () => {

View File

@ -223,7 +223,7 @@ describe Ci::Runner do
subject { described_class.online }
before do
@runner1 = create(:ci_runner, :instance, contacted_at: 1.year.ago)
@runner1 = create(:ci_runner, :instance, contacted_at: 1.hour.ago)
@runner2 = create(:ci_runner, :instance, contacted_at: 1.second.ago)
end
@ -300,6 +300,17 @@ describe Ci::Runner do
end
end
describe '.offline' do
subject { described_class.offline }
before do
@runner1 = create(:ci_runner, :instance, contacted_at: 1.hour.ago)
@runner2 = create(:ci_runner, :instance, contacted_at: 1.second.ago)
end
it { is_expected.to eq([@runner1])}
end
describe '#can_pick?' do
set(:pipeline) { create(:ci_pipeline) }
let(:build) { create(:ci_build, pipeline: pipeline) }