Commit 55c23a09 authored by Grzegorz Bizon's avatar Grzegorz Bizon

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
parents f0763584 f7ef78a7
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;
...@@ -7,6 +7,7 @@ import DropdownHint from './dropdown_hint'; ...@@ -7,6 +7,7 @@ import DropdownHint from './dropdown_hint';
import DropdownEmoji from './dropdown_emoji'; import DropdownEmoji from './dropdown_emoji';
import DropdownNonUser from './dropdown_non_user'; import DropdownNonUser from './dropdown_non_user';
import DropdownUser from './dropdown_user'; import DropdownUser from './dropdown_user';
import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
export default class FilteredSearchDropdownManager { export default class FilteredSearchDropdownManager {
...@@ -90,6 +91,11 @@ export default class FilteredSearchDropdownManager { ...@@ -90,6 +91,11 @@ export default class FilteredSearchDropdownManager {
gl: DropdownEmoji, gl: DropdownEmoji,
element: this.container.querySelector('#js-dropdown-my-reaction'), 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) => { supportedTokens.forEach((type) => {
......
...@@ -3,10 +3,10 @@ import { ...@@ -3,10 +3,10 @@ import {
getParameterByName, getParameterByName,
getUrlParamsArray, getUrlParamsArray,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
import { visitUrl } from '../lib/utils/url_utility'; import { visitUrl } from '../lib/utils/url_utility';
import Flash from '../flash'; import Flash from '../flash';
import FilteredSearchContainer from './container'; import FilteredSearchContainer from './container';
import FilteredSearchTokenKeys from './filtered_search_token_keys';
import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesRoot from './recent_searches_root';
import RecentSearchesStore from './stores/recent_searches_store'; import RecentSearchesStore from './stores/recent_searches_store';
import RecentSearchesService from './services/recent_searches_service'; import RecentSearchesService from './services/recent_searches_service';
...@@ -23,7 +23,7 @@ export default class FilteredSearchManager { ...@@ -23,7 +23,7 @@ export default class FilteredSearchManager {
isGroup = false, isGroup = false,
isGroupAncestor = true, isGroupAncestor = true,
isGroupDecendent = false, isGroupDecendent = false,
filteredSearchTokenKeys = FilteredSearchTokenKeys, filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters', stateFiltersSelector = '.issues-state-filters',
}) { }) {
this.isGroup = isGroup; this.isGroup = isGroup;
......
const tokenKeys = [{ export default class FilteredSearchTokenKeys {
key: 'author', constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) {
type: 'string', this.tokenKeys = tokenKeys;
param: 'username', this.alternativeTokenKeys = alternativeTokenKeys;
symbol: '@', this.conditions = conditions;
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 = [{ this.tokenKeysWithAlternative = this.tokenKeys.concat(this.alternativeTokenKeys);
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 { get() {
static get() { return this.tokenKeys;
return tokenKeys;
} }
static getKeys() { getKeys() {
return tokenKeys.map(i => i.key); return this.tokenKeys.map(i => i.key);
} }
static getAlternatives() { getAlternatives() {
return alternativeTokenKeys; return this.alternativeTokenKeys;
} }
static getConditions() { getConditions() {
return conditions; return this.conditions;
} }
static searchByKey(key) { searchByKey(key) {
return tokenKeys.find(tokenKey => tokenKey.key === key) || null; return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
} }
static searchBySymbol(symbol) { searchBySymbol(symbol) {
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null; return this.tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
} }
static searchByKeyParam(keyParam) { searchByKeyParam(keyParam) {
return tokenKeysWithAlternative.find((tokenKey) => { return this.tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key; let tokenKeyParam = tokenKey.key;
// Replace hyphen with underscore to compare keyParam with tokenKeyParam // Replace hyphen with underscore to compare keyParam with tokenKeyParam
...@@ -112,12 +47,12 @@ export default class FilteredSearchTokenKeys { ...@@ -112,12 +47,12 @@ export default class FilteredSearchTokenKeys {
}) || null; }) || null;
} }
static searchByConditionUrl(url) { searchByConditionUrl(url) {
return conditions.find(condition => condition.url === url) || null; return this.conditions.find(condition => condition.url === url) || null;
} }
static searchByConditionKeyValue(key, value) { searchByConditionKeyValue(key, value) {
return conditions return this.conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null; .find(condition => condition.tokenKey === key && condition.value === value) || null;
} }
} }
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;
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);
}
}
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,
});
});
...@@ -3,4 +3,5 @@ ...@@ -3,4 +3,5 @@
export const FILTERED_SEARCH = { export const FILTERED_SEARCH = {
MERGE_REQUESTS: 'merge_requests', MERGE_REQUESTS: 'merge_requests',
ISSUES: 'issues', ISSUES: 'issues',
ADMIN_RUNNERS: 'admin/runners',
}; };
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants'; import { FILTERED_SEARCH } from '~/pages/constants';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.ISSUES, page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true, isGroupDecendent: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
projectSelect(); projectSelect();
}); });
import projectSelect from '~/project_select'; import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; 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 { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true, isGroupDecendent: true,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
projectSelect(); projectSelect();
}); });
...@@ -4,12 +4,14 @@ import IssuableIndex from '~/issuable_index'; ...@@ -4,12 +4,14 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/shortcuts_navigation'; import ShortcutsNavigation from '~/shortcuts_navigation';
import UsersSelect from '~/users_select'; import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; 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 { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.ISSUES, page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
new IssuableIndex(ISSUABLE_INDEX.ISSUE); new IssuableIndex(ISSUABLE_INDEX.ISSUE);
......
...@@ -2,12 +2,14 @@ import IssuableIndex from '~/issuable_index'; ...@@ -2,12 +2,14 @@ import IssuableIndex from '~/issuable_index';
import ShortcutsNavigation from '~/shortcuts_navigation'; import ShortcutsNavigation from '~/shortcuts_navigation';
import UsersSelect from '~/users_select'; import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search'; 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 { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants'; import { ISSUABLE_INDEX } from '~/pages/projects/constants';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({ initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS, page: FILTERED_SEARCH.MERGE_REQUESTS,
filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys,
}); });
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new
......
...@@ -3,11 +3,10 @@ class Admin::RunnersController < Admin::ApplicationController ...@@ -3,11 +3,10 @@ class Admin::RunnersController < Admin::ApplicationController
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def index def index
sort = params[:sort] == 'contacted_asc' ? { contacted_at: :asc } : { id: :desc } finder = Admin::RunnersFinder.new(params: params)
@runners = Ci::Runner.order(sort) @runners = finder.execute
@runners = @runners.search(params[:search]) if params[:search].present? @active_runners_count = Ci::Runner.online.count
@runners = @runners.page(params[:page]).per(30) @sort = finder.sort_key
@active_runners_cnt = Ci::Runner.online.count
end end
# rubocop: enable CodeReuse/ActiveRecord # rubocop: enable CodeReuse/ActiveRecord
......
# 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
...@@ -24,7 +24,8 @@ module SortingHelper ...@@ -24,7 +24,8 @@ module SortingHelper
sort_value_recently_updated => sort_title_recently_updated, sort_value_recently_updated => sort_title_recently_updated,
sort_value_popularity => sort_title_popularity, sort_value_popularity => sort_title_popularity,
sort_value_priority => sort_title_priority, 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 end
...@@ -241,6 +242,10 @@ module SortingHelper ...@@ -241,6 +242,10 @@ module SortingHelper
s_('SortOptions|Most popular') s_('SortOptions|Most popular')
end end
def sort_title_contacted_date
s_('SortOptions|Last Contact')
end
# Values. # Values.
def sort_value_access_level_asc def sort_value_access_level_asc
'access_level_asc' 'access_level_asc'
...@@ -361,4 +366,8 @@ module SortingHelper ...@@ -361,4 +366,8 @@ module SortingHelper
def sort_value_upvotes def sort_value_upvotes
'upvotes_desc' 'upvotes_desc'
end end
def sort_value_contacted_date
'contacted_asc'
end
end end
...@@ -11,7 +11,9 @@ module Ci ...@@ -11,7 +11,9 @@ module Ci
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
ONLINE_CONTACT_TIMEOUT = 1.hour ONLINE_CONTACT_TIMEOUT = 1.hour
UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes 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 FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
ignore_column :is_shared ignore_column :is_shared
...@@ -29,6 +31,13 @@ module Ci ...@@ -29,6 +31,13 @@ module Ci
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :paused, -> { where(active: false) } scope :paused, -> { where(active: false) }
scope :online, -> { where('contacted_at > ?', contact_time_deadline) } 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) } scope :ordered, -> { order(id: :desc) }
# BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb` # BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb`
......
%tr{ id: dom_id(runner) } .gl-responsive-table-row{ id: dom_id(runner) }
%td = render layout: 'runner_table_cell', locals: { label: _('Type') } do
- if runner.instance_type? - if runner.instance_type?
%span.badge.badge-success shared %span.badge.badge-success shared
- elsif runner.group_type? - elsif runner.group_type?
...@@ -11,41 +11,50 @@ ...@@ -11,41 +11,50 @@
- unless runner.active? - unless runner.active?
%span.badge.badge-danger paused %span.badge.badge-danger paused
%td = render layout: 'runner_table_cell', locals: { label: _('Runner token') } do
= link_to admin_runner_path(runner) do = link_to runner.short_sha, admin_runner_path(runner)
= runner.short_sha
%td = render layout: 'runner_table_cell', locals: { label: _('Description') } do
= runner.description = runner.description
%td
= render layout: 'runner_table_cell', locals: { label: _('Version') } do
= runner.version = runner.version
%td
= render layout: 'runner_table_cell', locals: { label: _('IP Address') } do
= runner.ip_address = runner.ip_address
%td
= render layout: 'runner_table_cell', locals: { label: _('Projects') } do
- if runner.instance_type? || runner.group_type? - if runner.instance_type? || runner.group_type?
n/a = _('n/a')
- else - else
= runner.projects.count(:all) = runner.projects.count(:all)
%td
#{runner.builds.count(:all)} = render layout: 'runner_table_cell', locals: { label: _('Jobs') } do
%td = runner.builds.count(:all)
= render layout: 'runner_table_cell', locals: { label: _('Tags') } do
- runner.tag_list.sort.each do |tag| - runner.tag_list.sort.each do |tag|
%span.badge.badge-primary %span.badge.badge-primary
= tag = tag
%td
= render layout: 'runner_table_cell', locals: { label: _('Last contact') } do
- if runner.contacted_at - if runner.contacted_at
= time_ago_with_tooltip runner.contacted_at = time_ago_with_tooltip runner.contacted_at
- else - else
Never = _('Never')
%td.admin-runner-btn-group-cell
.float-right.btn-group .table-section.table-button-footer.section-10
= 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 .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') = icon('pencil')
&nbsp; .btn-group
- if runner.active? - 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 = 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') = icon('pause')
- else - 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 = 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') = 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 .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') = icon('remove')
.table-section.section-10
.table-mobile-header{ role: 'rowheader' }= label
.table-mobile-content
= yield
- 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)
- breadcrumb_title "Runners" - breadcrumb_title _('Runners')
- @no_container = true - @no_container = true
%div{ class: container_class } %div{ class: container_class }
.bs-callout .bs-callout
%p %p
A 'Runner' is a process which runs a job. = (_"A 'Runner' is a process which runs a job. You can setup as many Runners as you need.")
You can setup as many Runners as you need.
%br %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 %br
%div %div
%span Each Runner can be in one of the following states: %span= _('Each Runner can be in one of the following states:')
%ul %ul
%li %li
%span.badge.badge-success shared %span.badge.badge-success shared
\- Runner runs jobs from all unassigned projects \-
= _('Runner runs jobs from all unassigned projects')
%li %li
%span.badge.badge-success group %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 %li
%span.badge.badge-info specific %span.badge.badge-info specific
\- Runner runs jobs from assigned projects \-
= _('Runner runs jobs from assigned projects')
%li %li
%span.badge.badge-warning locked %span.badge.badge-warning locked
\- Runner cannot be assigned to other projects \-
= _('Runner cannot be assigned to other projects')
%li %li
%span.badge.badge-danger paused %span.badge.badge-danger paused
\- Runner will not receive any new jobs \-
= _('Runner will not receive any new jobs')
.bs-callout.clearfix .bs-callout.clearfix
.float-left .float-left
%p %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 .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', 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', = render partial: 'ci/runner/how_to_setup_shared_runner',
locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token } locals: { registration_token: Gitlab::CurrentSettings.runners_registration_token }
.append-bottom-20.clearfix .bs-callout
.float-left %p
= form_tag admin_runners_path, id: 'runners-search', class: 'form-inline', method: :get do = _('Runners currently online: %{active_runners_count}') % { active_runners_count: @active_runners_count }
.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'
.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? - if @runners.any?
.runners-content .runners-content.content-list
.table-holder .table-holder
%table.table .gl-responsive-table-row.table-row-header{ role: 'row' }
%thead - [_('Type'), _('Runner token'), _('Description'), _('Version'), _('IP Address'), _('Projects'), _('Jobs'), _('Tags'), _('Last contact')].each do |label|
%tr .table-section.section-10{ role: 'rowheader' }= label
%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
- @runners.each do |runner| - @runners.each do |runner|
= render "admin/runners/runner", runner: runner = render 'admin/runners/runner', runner: runner
= paginate @runners, theme: "gitlab" = paginate @runners, theme: 'gitlab'
- else - else
.nothing-here-block No runners found .nothing-here-block= _('No runners found')
---
title: Add a filter bar to the admin runners view and add a state filter
merge_request: 19625
author: Alexis Reigel
type: added
...@@ -15,7 +15,7 @@ GET /runners?scope=active ...@@ -15,7 +15,7 @@ GET /runners?scope=active
| Attribute | Type | Required | Description | | 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" curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners"
...@@ -60,7 +60,7 @@ GET /runners/all?scope=online ...@@ -60,7 +60,7 @@ GET /runners/all?scope=online
| Attribute | Type | Required | Description | | 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" curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/runners/all"
......
...@@ -9,12 +9,12 @@ module API ...@@ -9,12 +9,12 @@ module API
success Entities::Runner success Entities::Runner
end end
params do 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' desc: 'The scope of specific runners to show'
use :pagination use :pagination
end end
get do 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 present paginate(runners), with: Entities::Runner
end end
...@@ -22,7 +22,7 @@ module API ...@@ -22,7 +22,7 @@ module API
success Entities::Runner success Entities::Runner
end end
params do 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' desc: 'The scope of specific runners to show'
use :pagination use :pagination
end end
...@@ -114,7 +114,7 @@ module API ...@@ -114,7 +114,7 @@ module API
success Entities::Runner success Entities::Runner
end end
params do 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' desc: 'The scope of specific runners to show'
use :pagination use :pagination
end end
...@@ -160,15 +160,10 @@ module API ...@@ -160,15 +160,10 @@ module API
end end
helpers do helpers do
def filter_runners(runners, scope, options = {}) def filter_runners(runners, scope, allowed_scopes: ::Ci::Runner::AVAILABLE_SCOPES)
return runners unless scope.present? return runners unless scope.present?
available_scopes = ::Ci::Runner::AVAILABLE_SCOPES unless allowed_scopes.include?(scope)
if options[:without]
available_scopes = available_scopes - options[:without]
end
if (available_scopes & [scope]).empty?
render_api_error!('Scope contains invalid value', 400) render_api_error!('Scope contains invalid value', 400)
end end
......
...@@ -3463,6 +3463,9 @@ msgstr "" ...@@ -3463,6 +3463,9 @@ msgstr ""
msgid "Last commit" msgid "Last commit"
msgstr "" msgstr ""
msgid "Last contact"
msgstr ""
msgid "Last edited %{date}" msgid "Last edited %{date}"
msgstr "" msgstr ""
...@@ -3977,6 +3980,9 @@ msgstr "" ...@@ -3977,6 +3980,9 @@ msgstr ""
msgid "No repository" msgid "No repository"
msgstr "" msgstr ""
msgid "No runners found"
msgstr ""
msgid "No schedules" msgid "No schedules"
msgstr "" msgstr ""
...@@ -4438,6 +4444,9 @@ msgstr "" ...@@ -4438,6 +4444,9 @@ msgstr ""
msgid "Preferences|Navigation theme" msgid "Preferences|Navigation theme"
msgstr "" msgstr ""
msgid "Press Enter or click to search"
msgstr ""
msgid "Preview" msgid "Preview"
msgstr "" msgstr ""
...@@ -4909,6 +4918,9 @@ msgstr "" ...@@ -4909,6 +4918,9 @@ msgstr ""
msgid "Real-time features" msgid "Real-time features"
msgstr "" msgstr ""
msgid "Recent searches"
msgstr ""
msgid "Reference:" msgid "Reference:"
msgstr "" msgstr ""
...@@ -5111,9 +5123,24 @@ msgstr "" ...@@ -5111,9 +5123,24 @@ msgstr ""
msgid "Run untagged jobs" msgid "Run untagged jobs"
msgstr "" 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" msgid "Runner token"
msgstr "" msgstr ""
msgid "Runner will not receive any new jobs"
msgstr ""
msgid "Runners" msgid "Runners"
msgstr "" msgstr ""
...@@ -5123,6 +5150,12 @@ msgstr "" ...@@ -5123,6 +5150,12 @@ msgstr ""
msgid "Runners can be placed on separate users, servers, and even on your local machine." msgid "Runners can be placed on separate users, servers, and even on your local machine."
msgstr "" 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" msgid "Runners page"
msgstr "" msgstr ""
...@@ -5195,6 +5228,9 @@ msgstr "" ...@@ -5195,6 +5228,9 @@ msgstr ""
msgid "Search milestones" msgid "Search milestones"
msgstr "" msgstr ""
msgid "Search or filter results..."
msgstr ""
msgid "Search or jump to…" msgid "Search or jump to…"
msgstr "" msgstr ""
...@@ -5473,6 +5509,9 @@ msgstr "" ...@@ -5473,6 +5509,9 @@ msgstr ""
msgid "SortOptions|Largest repository" msgid "SortOptions|Largest repository"
msgstr "" msgstr ""
msgid "SortOptions|Last Contact"
msgstr ""
msgid "SortOptions|Last created" msgid "SortOptions|Last created"
msgstr "" msgstr ""
...@@ -6346,6 +6385,9 @@ msgstr "" ...@@ -6346,6 +6385,9 @@ msgstr ""
msgid "Twitter" msgid "Twitter"
msgstr "" msgstr ""
msgid "Type"
msgstr ""
msgid "Unable to load the diff. %{button_try_again}" msgid "Unable to load the diff. %{button_try_again}"
msgstr "" msgstr ""
...@@ -6484,6 +6526,9 @@ msgstr "" ...@@ -6484,6 +6526,9 @@ msgstr ""
msgid "Verified" msgid "Verified"
msgstr "" msgstr ""
msgid "Version"
msgstr ""
msgid "View file @ " msgid "View file @ "
msgstr "" msgstr ""
...@@ -6748,6 +6793,9 @@ msgstr "" ...@@ -6748,6 +6793,9 @@ msgstr ""
msgid "You can only edit files when you are on a branch" msgid "You can only edit files when you are on a branch"
msgstr "" 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}" 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 "" msgstr ""
...@@ -7127,6 +7175,9 @@ msgstr "" ...@@ -7127,6 +7175,9 @@ msgstr ""
msgid "mrWidget|to be merged automatically when the pipeline succeeds" msgid "mrWidget|to be merged automatically when the pipeline succeeds"
msgstr "" msgstr ""
msgid "n/a"
msgstr ""
msgid "new merge request" msgid "new merge request"
msgstr "" msgstr ""
......
...@@ -2,6 +2,8 @@ require 'spec_helper' ...@@ -2,6 +2,8 @@ require 'spec_helper'
describe "Admin Runners" do describe "Admin Runners" do
include StubENV include StubENV
include FilteredSearchHelpers
include SortingHelper
before do before do
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
...@@ -12,40 +14,109 @@ describe "Admin Runners" do ...@@ -12,40 +14,109 @@ describe "Admin Runners" do
let(:pipeline) { create(:ci_pipeline) } let(:pipeline) { create(:ci_pipeline) }
context "when there are runners" do context "when there are runners" do
before do it 'has all necessary texts' do
runner = FactoryBot.create(:ci_runner, contacted_at: Time.now) runner = create(:ci_runner, contacted_at: Time.now)
FactoryBot.create(:ci_build, pipeline: pipeline, runner_id: runner.id) create(:ci_build, pipeline: pipeline, runner_id: runner.id)
visit admin_runners_path visit admin_runners_path
end
it 'has all necessary texts' do
expect(page).to have_text "Setup a shared Runner manually" expect(page).to have_text "Setup a shared Runner manually"
expect(page).to have_text "Runners currently online: 1" expect(page).to have_text "Runners currently online: 1"
end end
describe 'search' do describe 'search', :js do
before do before do
FactoryBot.create :ci_runner, description: 'runner-foo' create(:ci_runner, description: 'runner-foo')
FactoryBot.create :ci_runner, description: 'runner-bar' create(:ci_runner, description: 'runner-bar')
visit admin_runners_path
end end
it 'shows correct runner when description matches' do it 'shows correct runner when description matches' do
search_form = find('#runners-search') input_filtered_search_keys('runner-foo')
search_form.fill_in 'search', with: 'runner-foo'
search_form.click_button 'Search'
expect(page).to have_content("runner-foo") expect(page).to have_content("runner-foo")
expect(page).not_to have_content("runner-bar") expect(page).not_to have_content("runner-bar")
end end
it 'shows no runner when description does not match' do it 'shows no runner when description does not match' do
search_form = find('#runners-search') input_filtered_search_keys('runner-baz')
search_form.fill_in 'search', with: 'runner-baz'
search_form.click_button 'Search'
expect(page).to have_text 'No runners found' expect(page).to have_text 'No runners found'
end end
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 end
context "when there are no runners" do context "when there are no runners" do
...@@ -76,7 +147,7 @@ describe "Admin Runners" do ...@@ -76,7 +147,7 @@ describe "Admin Runners" do
context 'shared runner' do context 'shared runner' do
it 'shows the label and does not show the project count' 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 visit admin_runners_path
...@@ -89,8 +160,8 @@ describe "Admin Runners" do ...@@ -89,8 +160,8 @@ describe "Admin Runners" do
context 'specific runner' do context 'specific runner' do
it 'shows the label and the project count' do it 'shows the label and the project count' do
project = create :project project = create(:project)
runner = create :ci_runner, :project, projects: [project] runner = create(:ci_runner, :project, projects: [project])
visit admin_runners_path visit admin_runners_path
...@@ -103,11 +174,11 @@ describe "Admin Runners" do ...@@ -103,11 +174,11 @@ describe "Admin Runners" do
end end
describe "Runner show page" do describe "Runner show page" do
let(:runner) { FactoryBot.create :ci_runner } let(:runner) { create(:ci_runner) }
before do before do
@project1 = FactoryBot.create(:project) @project1 = create(:project)
@project2 = FactoryBot.create(:project) @project2 = create(:project)
visit admin_runner_path(runner) visit admin_runner_path(runner)
end end
......
# 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
import Vue from 'vue'; import Vue from 'vue';
import eventHub from '~/filtered_search/event_hub'; import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue'; 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 createComponent = (propsData) => {
const Component = Vue.extend(RecentSearchesDropdownContent); const Component = Vue.extend(RecentSearchesDropdownContent);
...@@ -18,14 +18,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); ...@@ -18,14 +18,14 @@ const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim();
describe('RecentSearchesDropdownContent', () => { describe('RecentSearchesDropdownContent', () => {
const propsDataWithoutItems = { const propsDataWithoutItems = {
items: [], items: [],
allowedKeys: FilteredSearchTokenKeys.getKeys(), allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
}; };
const propsDataWithItems = { const propsDataWithItems = {
items: [ items: [
'foo', 'foo',
'author:@root label:~foo bar', 'author:@root label:~foo bar',
], ],
allowedKeys: FilteredSearchTokenKeys.getKeys(), allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
}; };
let vm; let vm;
......
import DropdownUtils from '~/filtered_search/dropdown_utils'; import DropdownUtils from '~/filtered_search/dropdown_utils';
import DropdownUser from '~/filtered_search/dropdown_user'; import DropdownUser from '~/filtered_search/dropdown_user';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer'; 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('Dropdown User', () => {
describe('getSearchInput', () => { describe('getSearchInput', () => {
...@@ -14,7 +14,7 @@ describe('Dropdown User', () => { ...@@ -14,7 +14,7 @@ describe('Dropdown User', () => {
spyOn(DropdownUtils, 'getSearchInput').and.callFake(() => {}); spyOn(DropdownUtils, 'getSearchInput').and.callFake(() => {});
dropdownUser = new DropdownUser({ dropdownUser = new DropdownUser({
tokenKeys: FilteredSearchTokenKeys, tokenKeys: IssuableFilteredTokenKeys,
}); });
}); });
......
import DropdownUtils from '~/filtered_search/dropdown_utils'; import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; 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'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
describe('Dropdown Utils', () => { describe('Dropdown Utils', () => {
...@@ -137,7 +137,7 @@ describe('Dropdown Utils', () => { ...@@ -137,7 +137,7 @@ describe('Dropdown Utils', () => {
`); `);
input = document.getElementById('test'); input = document.getElementById('test');
allowedKeys = FilteredSearchTokenKeys.getKeys(); allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
}); });
function config() { function config() {
......
import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service';
import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error';
import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; 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 '~/lib/utils/common_utils';
import DropdownUtils from '~/filtered_search/dropdown_utils'; import DropdownUtils from '~/filtered_search/dropdown_utils';
import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens';
...@@ -86,7 +86,7 @@ describe('Filtered Search Manager', function () { ...@@ -86,7 +86,7 @@ describe('Filtered Search Manager', function () {
expect(RecentSearchesService.isAvailable).toHaveBeenCalled(); expect(RecentSearchesService.isAvailable).toHaveBeenCalled();
expect(RecentSearchesStoreSpy).toHaveBeenCalledWith({ expect(RecentSearchesStoreSpy).toHaveBeenCalledWith({
isLocalStorageAvailable, isLocalStorageAvailable,
allowedKeys: FilteredSearchTokenKeys.getKeys(), allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
}); });
}); });
}); });
......
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys'; import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
describe('Filtered Search Token Keys', () => { describe('Filtered Search Token Keys', () => {
describe('get', () => { const tokenKeys = [{
let tokenKeys; key: 'author',
type: 'string',
param: 'username',
symbol: '@',
icon: 'pencil',
tag: '@author',
}];
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}];
beforeEach(() => { describe('get', () => {
tokenKeys = FilteredSearchTokenKeys.get();
});
it('should return tokenKeys', () => { it('should return tokenKeys', () => {
expect(tokenKeys !== null).toBe(true); expect(new FilteredSearchTokenKeys().get() !== null).toBe(true);
}); });
it('should return tokenKeys as an array', () => { it('should return tokenKeys as an array', () => {
expect(tokenKeys instanceof Array).toBe(true); expect(new FilteredSearchTokenKeys().get() instanceof Array).toBe(true);
}); });
}); });
describe('getKeys', () => { describe('getKeys', () => {
it('should return keys', () => { it('should return keys', () => {
const getKeys = FilteredSearchTokenKeys.getKeys(); const getKeys = new FilteredSearchTokenKeys(tokenKeys).getKeys();
const keys = FilteredSearchTokenKeys.get().map(i => i.key); const keys = new FilteredSearchTokenKeys(tokenKeys).get().map(i => i.key);
keys.forEach((key, i) => { keys.forEach((key, i) => {
expect(key).toEqual(getKeys[i]); expect(key).toEqual(getKeys[i]);
...@@ -29,88 +39,78 @@ describe('Filtered Search Token Keys', () => { ...@@ -29,88 +39,78 @@ describe('Filtered Search Token Keys', () => {
}); });
describe('getConditions', () => { describe('getConditions', () => {
let conditions;
beforeEach(() => {
conditions = FilteredSearchTokenKeys.getConditions();
});
it('should return conditions', () => { it('should return conditions', () => {
expect(conditions !== null).toBe(true); expect(new FilteredSearchTokenKeys().getConditions() !== null).toBe(true);
}); });
it('should return conditions as an array', () => { it('should return conditions as an array', () => {
expect(conditions instanceof Array).toBe(true); expect(new FilteredSearchTokenKeys().getConditions() instanceof Array).toBe(true);
}); });
}); });
describe('searchByKey', () => { describe('searchByKey', () => {
it('should return null when key not found', () => { 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); expect(tokenKey === null).toBe(true);
}); });
it('should return tokenKey when found by key', () => { it('should return tokenKey when found by key', () => {
const tokenKeys = FilteredSearchTokenKeys.get(); const result = new FilteredSearchTokenKeys(tokenKeys).searchByKey(tokenKeys[0].key);
const result = FilteredSearchTokenKeys.searchByKey(tokenKeys[0].key);
expect(result).toEqual(tokenKeys[0]); expect(result).toEqual(tokenKeys[0]);
}); });
}); });
describe('searchBySymbol', () => { describe('searchBySymbol', () => {
it('should return null when symbol not found', () => { 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); expect(tokenKey === null).toBe(true);
}); });
it('should return tokenKey when found by symbol', () => { it('should return tokenKey when found by symbol', () => {
const tokenKeys = FilteredSearchTokenKeys.get(); const result = new FilteredSearchTokenKeys(tokenKeys).searchBySymbol(tokenKeys[0].symbol);
const result = FilteredSearchTokenKeys.searchBySymbol(tokenKeys[0].symbol);
expect(result).toEqual(tokenKeys[0]); expect(result).toEqual(tokenKeys[0]);
}); });
}); });
describe('searchByKeyParam', () => { describe('searchByKeyParam', () => {
it('should return null when key param not found', () => { 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); expect(tokenKey === null).toBe(true);
}); });
it('should return tokenKey when found by key param', () => { it('should return tokenKey when found by key param', () => {
const tokenKeys = FilteredSearchTokenKeys.get(); const result = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]); expect(result).toEqual(tokenKeys[0]);
}); });
it('should return alternative tokenKey when found by key param', () => { it('should return alternative tokenKey when found by key param', () => {
const tokenKeys = FilteredSearchTokenKeys.getAlternatives(); const result = new FilteredSearchTokenKeys(tokenKeys).searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
const result = FilteredSearchTokenKeys.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]); expect(result).toEqual(tokenKeys[0]);
}); });
}); });
describe('searchByConditionUrl', () => { describe('searchByConditionUrl', () => {
it('should return null when condition url not found', () => { 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); expect(condition === null).toBe(true);
}); });
it('should return condition when found by url', () => { it('should return condition when found by url', () => {
const conditions = FilteredSearchTokenKeys.getConditions(); const result = new FilteredSearchTokenKeys([], [], conditions)
const result = FilteredSearchTokenKeys.searchByConditionUrl(conditions[0].url); .searchByConditionUrl(conditions[0].url);
expect(result).toBe(conditions[0]); expect(result).toBe(conditions[0]);
}); });
}); });
describe('searchByConditionKeyValue', () => { describe('searchByConditionKeyValue', () => {
it('should return null when condition tokenKey and value not found', () => { 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); expect(condition === null).toBe(true);
}); });
it('should return condition when found by tokenKey and value', () => { it('should return condition when found by tokenKey and value', () => {
const conditions = FilteredSearchTokenKeys.getConditions(); const result = new FilteredSearchTokenKeys([], [], conditions)
const result = FilteredSearchTokenKeys
.searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value); .searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
expect(result).toEqual(conditions[0]); expect(result).toEqual(conditions[0]);
}); });
......
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'; import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
describe('Filtered Search Tokenizer', () => { describe('Filtered Search Tokenizer', () => {
const allowedKeys = FilteredSearchTokenKeys.getKeys(); const allowedKeys = IssuableFilteredSearchTokenKeys.getKeys();
describe('processTokens', () => { describe('processTokens', () => {
it('returns for input containing only search value', () => { it('returns for input containing only search value', () => {
......
...@@ -223,7 +223,7 @@ describe Ci::Runner do ...@@ -223,7 +223,7 @@ describe Ci::Runner do
subject { described_class.online } subject { described_class.online }
before do 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) @runner2 = create(:ci_runner, :instance, contacted_at: 1.second.ago)
end end
...@@ -300,6 +300,17 @@ describe Ci::Runner do ...@@ -300,6 +300,17 @@ describe Ci::Runner do
end end
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 describe '#can_pick?' do
set(:pipeline) { create(:ci_pipeline) } set(:pipeline) { create(:ci_pipeline) }
let(:build) { create(:ci_build, pipeline: pipeline) } let(:build) { create(:ci_build, pipeline: pipeline) }
......
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