diff --git a/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js new file mode 100644 index 00000000000..1f9c3f41e52 --- /dev/null +++ b/app/assets/javascripts/filtered_search/admin_runners_filtered_search_token_keys.js @@ -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; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 296571606d6..a750647f8be 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -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) => { diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 81286c54c4c..d25f6f95b22 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -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; diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 087ef5cd6f2..5d131b396a0 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -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; } } diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js new file mode 100644 index 00000000000..cce2c07dc3e --- /dev/null +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -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; diff --git a/app/assets/javascripts/filtered_search/null_dropdown.js b/app/assets/javascripts/filtered_search/null_dropdown.js new file mode 100644 index 00000000000..4cfce2a5beb --- /dev/null +++ b/app/assets/javascripts/filtered_search/null_dropdown.js @@ -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); + } +} diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js new file mode 100644 index 00000000000..ce8fd18b6a2 --- /dev/null +++ b/app/assets/javascripts/pages/admin/runners/index.js @@ -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, + }); +}); diff --git a/app/assets/javascripts/pages/constants.js b/app/assets/javascripts/pages/constants.js index 328b6541636..5e119454ce1 100644 --- a/app/assets/javascripts/pages/constants.js +++ b/app/assets/javascripts/pages/constants.js @@ -3,4 +3,5 @@ export const FILTERED_SEARCH = { MERGE_REQUESTS: 'merge_requests', ISSUES: 'issues', + ADMIN_RUNNERS: 'admin/runners', }; diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 914f804fdd3..736c6a62610 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -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(); }); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 1600faa3611..b798a254459 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -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(); }); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index 70fdb0ef40d..23edbdc5cad 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -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); diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index a7aa616319f..1fad0fb7297 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -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 diff --git a/app/controllers/admin/runners_controller.rb b/app/controllers/admin/runners_controller.rb index 090149fc1e2..2ac14ecd79c 100644 --- a/app/controllers/admin/runners_controller.rb +++ b/app/controllers/admin/runners_controller.rb @@ -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 diff --git a/app/finders/admin/runners_finder.rb b/app/finders/admin/runners_finder.rb new file mode 100644 index 00000000000..7adee486e33 --- /dev/null +++ b/app/finders/admin/runners_finder.rb @@ -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 diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index a6e65d30eda..0c59bdd6abb 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -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 diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index f41955f43e7..45fd15a6211 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -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` diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 43937b01339..9c15226f0ec 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -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') -   - - 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') diff --git a/app/views/admin/runners/_runner_table_cell.html.haml b/app/views/admin/runners/_runner_table_cell.html.haml new file mode 100644 index 00000000000..78526ee6d23 --- /dev/null +++ b/app/views/admin/runners/_runner_table_cell.html.haml @@ -0,0 +1,4 @@ +.table-section.section-10 + .table-mobile-header{ role: 'rowheader' }= label + .table-mobile-content + = yield diff --git a/app/views/admin/runners/_sort_dropdown.html.haml b/app/views/admin/runners/_sort_dropdown.html.haml new file mode 100644 index 00000000000..b201e6bf10e --- /dev/null +++ b/app/views/admin/runners/_sort_dropdown.html.haml @@ -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) + diff --git a/app/views/admin/runners/index.html.haml b/app/views/admin/runners/index.html.haml index 9280ff4d478..4dc076c95c5 100644 --- a/app/views/admin/runners/index.html.haml +++ b/app/views/admin/runners/index.html.haml @@ -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') diff --git a/changelogs/unreleased/feature-runner-state-filter-for-admin-view.yml b/changelogs/unreleased/feature-runner-state-filter-for-admin-view.yml new file mode 100644 index 00000000000..b8112bd0813 --- /dev/null +++ b/changelogs/unreleased/feature-runner-state-filter-for-admin-view.yml @@ -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 diff --git a/doc/api/runners.md b/doc/api/runners.md index ac814bbf19a..66476e7db64 100644 --- a/doc/api/runners.md +++ b/doc/api/runners.md @@ -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" diff --git a/lib/api/runners.rb b/lib/api/runners.rb index f35cf521e0a..30abd0b63e9 100644 --- a/lib/api/runners.rb +++ b/lib/api/runners.rb @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 7b6c15abd4f..db372811db3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/admin/admin_runners_spec.rb b/spec/features/admin/admin_runners_spec.rb index 5623e47eadf..026dea8d22c 100644 --- a/spec/features/admin/admin_runners_spec.rb +++ b/spec/features/admin/admin_runners_spec.rb @@ -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 diff --git a/spec/finders/admin/runners_finder_spec.rb b/spec/finders/admin/runners_finder_spec.rb new file mode 100644 index 00000000000..1e9793a5e0a --- /dev/null +++ b/spec/finders/admin/runners_finder_spec.rb @@ -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 diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js index d926663fac0..9d670afe206 100644 --- a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -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; diff --git a/spec/javascripts/filtered_search/dropdown_user_spec.js b/spec/javascripts/filtered_search/dropdown_user_spec.js index c37a964975d..b48b1456eff 100644 --- a/spec/javascripts/filtered_search/dropdown_user_spec.js +++ b/spec/javascripts/filtered_search/dropdown_user_spec.js @@ -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, }); }); diff --git a/spec/javascripts/filtered_search/dropdown_utils_spec.js b/spec/javascripts/filtered_search/dropdown_utils_spec.js index 3d6dec19eca..8792e99d461 100644 --- a/spec/javascripts/filtered_search/dropdown_utils_spec.js +++ b/spec/javascripts/filtered_search/dropdown_utils_spec.js @@ -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() { diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 8fcee36beb8..a03d5a31b41 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -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(), }); }); }); diff --git a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js index 68158cf52e4..ab0ab72720e 100644 --- a/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_token_keys_spec.js @@ -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]); }); diff --git a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js index 465f5f79931..4f9f546cbb5 100644 --- a/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_tokenizer_spec.js @@ -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', () => { diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 953af2c4710..f1d0ed15d29 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -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) }