Commit 1e650461 authored by Grzegorz Bizon's avatar Grzegorz Bizon

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

Feature: State filter for admin runners view (EE-port)

See merge request gitlab-org/gitlab-ee!6948
parents c7222a9b 378f58a8
import FilteredSearchTokenKeysIssues from 'ee/filtered_search/filtered_search_token_keys_issues';
import IssuesFilteredSearchTokenKeysEE from 'ee/filtered_search/issues_filtered_search_token_keys';
import FilteredSearchContainer from '../filtered_search/container';
import FilteredSearchManager from '../filtered_search/filtered_search_manager';
......@@ -9,7 +9,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager {
isGroupDecendent: true,
stateFiltersSelector: '.issues-state-filters',
isGroup: true,
filteredSearchTokenKeys: FilteredSearchTokenKeysIssues,
filteredSearchTokenKeys: IssuesFilteredSearchTokenKeysEE,
});
this.store = store;
......
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;
......@@ -8,6 +8,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 {
......@@ -91,6 +92,12 @@ 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'),
},
weight: {
reference: null,
gl: DropdownWeight,
......
......@@ -3,10 +3,10 @@ import {
getParameterByName,
getUrlParamsArray,
} from '~/lib/utils/common_utils';
import IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_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 = IssuesFilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
}) {
this.isGroup = isGroup;
......
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);
export default class FilteredSearchTokenKeys {
constructor(tokenKeys = [], alternativeTokenKeys = [], conditions = []) {
this.tokenKeys = tokenKeys;
this.alternativeTokenKeys = alternativeTokenKeys;
this.conditions = conditions;
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',
}];
this.tokenKeysWithAlternative = this.tokenKeys.concat(this.alternativeTokenKeys);
}
export default class FilteredSearchTokenKeys {
static get() {
return tokenKeys;
get() {
return this.tokenKeys;
}
static getKeys() {
return tokenKeys.map(i => i.key);
getKeys() {
return this.tokenKeys.map(i => i.key);
}
static getAlternatives() {
return alternativeTokenKeys;
getAlternatives() {
return this.alternativeTokenKeys;
}
static getConditions() {
return conditions;
getConditions() {
return this.conditions;
}
static searchByKey(key) {
return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
searchByKey(key) {
return this.tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
static searchBySymbol(symbol) {
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
searchBySymbol(symbol) {
return this.tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
}
static searchByKeyParam(keyParam) {
return tokenKeysWithAlternative.find((tokenKey) => {
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;
}
}
import FilteredSearchTokenKeys from './filtered_search_token_keys';
export 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',
});
}
export const alternativeTokenKeys = [{
key: 'label',
type: 'string',
param: 'name',
symbol: '~',
}];
export 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 IssuesFilteredSearchTokenKeys =
new FilteredSearchTokenKeys(tokenKeys, alternativeTokenKeys, conditions);
export default IssuesFilteredSearchTokenKeys;
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 @@
export const FILTERED_SEARCH = {
MERGE_REQUESTS: 'merge_requests',
ISSUES: 'issues',
ADMIN_RUNNERS: 'admin/runners',
};
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import FilteredSearchTokenKeysIssues from 'ee/filtered_search/filtered_search_token_keys_issues';
import IssuesFilteredSearchTokenKeysEE from 'ee/filtered_search/issues_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: FilteredSearchTokenKeysIssues,
isGroupDecendent: true,
filteredSearchTokenKeys: IssuesFilteredSearchTokenKeysEE,
});
projectSelect();
});
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_filtered_search_token_keys';
import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true,
filteredSearchTokenKeys: IssuesFilteredSearchTokenKeys,
});
projectSelect();
});
......@@ -6,12 +6,12 @@ import UsersSelect from '~/users_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import { FILTERED_SEARCH } from '~/pages/constants';
import { ISSUABLE_INDEX } from '~/pages/projects/constants';
import FilteredSearchTokenKeysIssues from 'ee/filtered_search/filtered_search_token_keys_issues';
import IssuesFilteredSearchTokenKeysEE from 'ee/filtered_search/issues_filtered_search_token_keys';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
filteredSearchTokenKeys: FilteredSearchTokenKeysIssues,
filteredSearchTokenKeys: IssuesFilteredSearchTokenKeysEE,
});
new IssuableIndex(ISSUABLE_INDEX.ISSUE);
......
......@@ -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 IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_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: IssuesFilteredSearchTokenKeys,
});
new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new
new ShortcutsNavigation(); // eslint-disable-line no-new
......
......@@ -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)
finder = Admin::RunnersFinder.new(params: params)
@runners = finder.execute
@active_runners_cnt = Ci::Runner.online.count
@sort = finder.sort_key
end
# 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
......@@ -29,6 +29,7 @@ module SortingHelper
sort_value_popularity => sort_title_popularity,
sort_value_priority => sort_title_priority,
sort_value_upvotes => sort_title_upvotes,
sort_value_contacted_date => sort_title_contacted_date,
sort_value_weight => sort_title_weight
}
end
......@@ -258,6 +259,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'
......@@ -387,6 +392,10 @@ module SortingHelper
'upvotes_desc'
end
def sort_value_contacted_date
'contacted_asc'
end
def sort_value_weight
'weight'
end
......
......@@ -12,7 +12,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
......@@ -30,6 +32,7 @@ module Ci
scope :active, -> { where(active: true) }
scope :paused, -> { where(active: false) }
scope :online, -> { where('contacted_at > ?', contact_time_deadline) }
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`
......
%tr{ id: dom_id(runner) }
%td
.gl-responsive-table-row{ id: dom_id(runner) }
= render layout: 'runner_table_cell', locals: { label: _('Type') } do
- if runner.instance_type?
%span.badge.badge-success shared
- elsif runner.group_type?
......@@ -11,41 +11,50 @@
- unless runner.active?
%span.badge.badge-danger paused
%td
= link_to admin_runner_path(runner) do
= runner.short_sha
%td
= render layout: 'runner_table_cell', locals: { label: _('Runner token') } do
= link_to runner.short_sha, admin_runner_path(runner)
= render layout: 'runner_table_cell', locals: { label: _('Description') } do
= runner.description
%td
= render layout: 'runner_table_cell', locals: { label: _('Version') } do
= runner.version
%td
= render layout: 'runner_table_cell', locals: { label: _('IP Address') } do
= runner.ip_address
%td
= render layout: 'runner_table_cell', locals: { label: _('Projects') } do
- if runner.instance_type? || runner.group_type?
n/a
= _('n/a')
- else
= runner.projects.count(:all)
%td
#{runner.builds.count(:all)}
%td
= render layout: 'runner_table_cell', locals: { label: _('Jobs') } do
= runner.builds.count(:all)
= render layout: 'runner_table_cell', locals: { label: _('Tags') } do
- runner.tag_list.sort.each do |tag|
%span.badge.badge-primary
= tag
%td
= render layout: 'runner_table_cell', locals: { label: _('Last contact') } do
- if runner.contacted_at
= time_ago_with_tooltip runner.contacted_at
- else
Never
%td.admin-runner-btn-group-cell
.float-right.btn-group
= link_to admin_runner_path(runner), class: 'btn btn-sm btn-default has-tooltip', title: 'Edit', ref: 'tooltip', aria: { label: 'Edit' }, data: { placement: 'top', container: 'body'} do
= icon('pencil')
&nbsp;
- if runner.active?
= link_to [:pause, :admin, runner], method: :get, class: 'btn btn-sm btn-default has-tooltip', title: 'Pause', ref: 'tooltip', aria: { label: 'Pause' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
= icon('pause')
- else
= link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-sm has-tooltip', title: 'Resume', ref: 'tooltip', aria: { label: 'Resume' }, data: { placement: 'top', container: 'body'} do
= icon('play')
= link_to [:admin, runner], method: :delete, class: 'btn btn-danger btn-sm has-tooltip', title: 'Remove', ref: 'tooltip', aria: { label: 'Remove' }, data: { placement: 'top', container: 'body', confirm: "Are you sure?" } do
= icon('remove')
= _('Never')
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
= link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
= icon('pencil')
.btn-group
- if runner.active?
= link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('pause')
- else
= link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
= icon('play')
.btn-group
= link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= icon('remove')
.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
%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'
.float-right.light
Runners currently online: #{@active_runners_cnt}
.bs-callout
%p
= _('Runners currently online: %{active_runners_cnt}') % { active_runners_cnt: @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.btn.btn-link
= icon('search')
%span
= _('Press Enter or click to search')
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link
-# 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.btn.btn-link
= status.titleize
%button.clear-search.hidden{ type: 'button' }
= 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')
---
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
| 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"
......
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
const weightTokenKey = {
key: 'weight',
type: 'string',
param: '',
symbol: '',
icon: 'balance-scale',
tag: 'number',
};
const weightConditions = [{
url: 'weight=None',
tokenKey: 'weight',
value: 'None',
}, {
url: 'weight=Any',
tokenKey: 'weight',
value: 'Any',
}];
const alternativeTokenKeys = [{
key: 'assignee',
type: 'string',
param: 'username',
symbol: '@',
}];
export default class FilteredSearchTokenKeysIssues extends FilteredSearchTokenKeys {
static init(availableFeatures) {
this.availableFeatures = availableFeatures;
}
static get() {
const tokenKeys = Array.from(super.get());
// Enable multiple assignees when available
if (this.availableFeatures && this.availableFeatures.multipleAssignees) {
const assigneeTokenKey = tokenKeys.find(tk => tk.key === 'assignee');
assigneeTokenKey.type = 'array';
assigneeTokenKey.param = 'username[]';
}
tokenKeys.push(weightTokenKey);
return tokenKeys;
}
static getKeys() {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
return tokenKeys.map(i => i.key);
}
static getAlternatives() {
return alternativeTokenKeys.concat(super.getAlternatives());
}
static getConditions() {
const conditions = super.getConditions();
return conditions.concat(weightConditions);
}
static searchByKey(key) {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
return tokenKeys.find(tokenKey => tokenKey.key === key) || null;
}
static searchBySymbol(symbol) {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
return tokenKeys.find(tokenKey => tokenKey.symbol === symbol) || null;
}
static searchByKeyParam(keyParam) {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const alternatives = FilteredSearchTokenKeysIssues.getAlternatives();
const tokenKeysWithAlternative = tokenKeys.concat(alternatives);
return tokenKeysWithAlternative.find((tokenKey) => {
let tokenKeyParam = tokenKey.key;
// Replace hyphen with underscore to compare keyParam with tokenKeyParam
// e.g. 'my-reaction' => 'my_reaction'
tokenKeyParam = tokenKeyParam.replace('-', '_');
if (tokenKey.param) {
tokenKeyParam += `_${tokenKey.param}`;
}
return keyParam === tokenKeyParam;
}) || null;
}
static searchByConditionUrl(url) {
const conditions = FilteredSearchTokenKeysIssues.getConditions();
return conditions.find(condition => condition.url === url) || null;
}
static searchByConditionKeyValue(key, value) {
const conditions = FilteredSearchTokenKeysIssues.getConditions();
return conditions
.find(condition => condition.tokenKey === key && condition.value === value) || null;
}
}
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import {
tokenKeys,
alternativeTokenKeys,
conditions,
} from '~/filtered_search/issues_filtered_search_token_keys';
const weightTokenKey = {
key: 'weight',
type: 'string',
param: '',
symbol: '',
icon: 'balance-scale',
tag: 'number',
};
const weightConditions = [
{
url: 'weight=None',
tokenKey: 'weight',
value: 'None',
},
{
url: 'weight=Any',
tokenKey: 'weight',
value: 'Any',
},
];
const IssuesFilteredSearchTokenKeysEE = new FilteredSearchTokenKeys(
[...tokenKeys, weightTokenKey],
alternativeTokenKeys,
[...conditions, ...weightConditions],
);
// cannot be an arrow function because it needs FilteredSearchTokenKeys instance
IssuesFilteredSearchTokenKeysEE.init = function init(availableFeatures) {
// Enable multiple assignees when available
if (availableFeatures && availableFeatures.multipleAssignees) {
const assigneeTokenKey = this.tokenKeys.find(tk => tk.key === 'assignee');
assigneeTokenKey.type = 'array';
assigneeTokenKey.param = 'username[]';
}
};
export default IssuesFilteredSearchTokenKeysEE;
/* eslint-disable class-methods-use-this */
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import IssuesFilteredSearchTokenKeysEE from 'ee/filtered_search/issues_filtered_search_token_keys';
const AUTHOR_PARAM_KEY = 'author_username';
......@@ -8,7 +8,7 @@ export default class FilteredSearchServiceDesk extends FilteredSearchManager {
constructor(supportBotData) {
super({
page: 'service_desk',
filteredSearchTokenKeys: FilteredSearchTokenKeys,
filteredSearchTokenKeys: IssuesFilteredSearchTokenKeysEE,
});
this.supportBotData = supportBotData;
......
import FilteredSearchTokenKeysIssues from 'ee/filtered_search/filtered_search_token_keys_issues';
import IssuesFilteredSearchTokenKeysEE from 'ee/filtered_search/issues_filtered_search_token_keys';
describe('Filtered Search Token Keys (Issues EE)', () => {
const weightTokenKey = {
......@@ -14,10 +14,10 @@ describe('Filtered Search Token Keys (Issues EE)', () => {
let tokenKeys;
beforeEach(() => {
FilteredSearchTokenKeysIssues.init({
IssuesFilteredSearchTokenKeysEE.init({
multipleAssignees: true,
});
tokenKeys = FilteredSearchTokenKeysIssues.get();
tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
});
it('should return tokenKeys', () => {
......@@ -34,7 +34,7 @@ describe('Filtered Search Token Keys (Issues EE)', () => {
});
it('should always return the same array', () => {
const tokenKeys2 = FilteredSearchTokenKeysIssues.get();
const tokenKeys2 = IssuesFilteredSearchTokenKeysEE.get();
expect(tokenKeys).toEqual(tokenKeys2);
});
......@@ -47,8 +47,8 @@ describe('Filtered Search Token Keys (Issues EE)', () => {
describe('getKeys', () => {
it('should return keys', () => {
const getKeys = FilteredSearchTokenKeysIssues.getKeys();
const keys = FilteredSearchTokenKeysIssues.get().map(i => i.key);
const getKeys = IssuesFilteredSearchTokenKeysEE.getKeys();
const keys = IssuesFilteredSearchTokenKeysEE.get().map(i => i.key);
keys.forEach((key, i) => {
expect(key).toEqual(getKeys[i]);
......@@ -60,7 +60,7 @@ describe('Filtered Search Token Keys (Issues EE)', () => {
let conditions;
beforeEach(() => {
conditions = FilteredSearchTokenKeysIssues.getConditions();
conditions = IssuesFilteredSearchTokenKeysEE.getConditions();
});
it('should return conditions', () => {
......@@ -79,89 +79,89 @@ describe('Filtered Search Token Keys (Issues EE)', () => {
describe('searchByKey', () => {
it('should return null when key not found', () => {
const tokenKey = FilteredSearchTokenKeysIssues.searchByKey('notakey');
const tokenKey = IssuesFilteredSearchTokenKeysEE.searchByKey('notakey');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const result = FilteredSearchTokenKeysIssues.searchByKey(tokenKeys[0].key);
const tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
const result = IssuesFilteredSearchTokenKeysEE.searchByKey(tokenKeys[0].key);
expect(result).toEqual(tokenKeys[0]);
});
it('should return weight tokenKey when found by weight key', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
const match = tokenKeys.find(tk => tk.key === weightTokenKey.key);
const result = FilteredSearchTokenKeysIssues.searchByKey(weightTokenKey.key);
const result = IssuesFilteredSearchTokenKeysEE.searchByKey(weightTokenKey.key);
expect(result).toEqual(match);
});
});
describe('searchBySymbol', () => {
it('should return null when symbol not found', () => {
const tokenKey = FilteredSearchTokenKeysIssues.searchBySymbol('notasymbol');
const tokenKey = IssuesFilteredSearchTokenKeysEE.searchBySymbol('notasymbol');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by symbol', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const result = FilteredSearchTokenKeysIssues.searchBySymbol(tokenKeys[0].symbol);
const tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
const result = IssuesFilteredSearchTokenKeysEE.searchBySymbol(tokenKeys[0].symbol);
expect(result).toEqual(tokenKeys[0]);
});
it('should return weight tokenKey when found by weight symbol', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
const match = tokenKeys.find(tk => tk.key === weightTokenKey.key);
const result = FilteredSearchTokenKeysIssues.searchBySymbol(weightTokenKey.symbol);
const result = IssuesFilteredSearchTokenKeysEE.searchBySymbol(weightTokenKey.symbol);
expect(result).toEqual(match);
});
});
describe('searchByKeyParam', () => {
it('should return null when key param not found', () => {
const tokenKey = FilteredSearchTokenKeysIssues.searchByKeyParam('notakeyparam');
const tokenKey = IssuesFilteredSearchTokenKeysEE.searchByKeyParam('notakeyparam');
expect(tokenKey === null).toBe(true);
});
it('should return tokenKey when found by key param', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const result = FilteredSearchTokenKeysIssues
const tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
const result = IssuesFilteredSearchTokenKeysEE
.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
it('should return alternative tokenKey when found by key param', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.getAlternatives();
const result = FilteredSearchTokenKeysIssues
const tokenKeys = IssuesFilteredSearchTokenKeysEE.getAlternatives();
const result = IssuesFilteredSearchTokenKeysEE
.searchByKeyParam(`${tokenKeys[0].key}_${tokenKeys[0].param}`);
expect(result).toEqual(tokenKeys[0]);
});
it('should return weight tokenKey when found by weight key param', () => {
const tokenKeys = FilteredSearchTokenKeysIssues.get();
const tokenKeys = IssuesFilteredSearchTokenKeysEE.get();
const match = tokenKeys.find(tk => tk.key === weightTokenKey.key);
const result = FilteredSearchTokenKeysIssues.searchByKeyParam(weightTokenKey.key);
const result = IssuesFilteredSearchTokenKeysEE.searchByKeyParam(weightTokenKey.key);
expect(result).toEqual(match);
});
});
describe('searchByConditionUrl', () => {
it('should return null when condition url not found', () => {
const condition = FilteredSearchTokenKeysIssues.searchByConditionUrl(null);
const condition = IssuesFilteredSearchTokenKeysEE.searchByConditionUrl(null);
expect(condition === null).toBe(true);
});
it('should return condition when found by url', () => {
const conditions = FilteredSearchTokenKeysIssues.getConditions();
const result = FilteredSearchTokenKeysIssues
const conditions = IssuesFilteredSearchTokenKeysEE.getConditions();
const result = IssuesFilteredSearchTokenKeysEE
.searchByConditionUrl(conditions[0].url);
expect(result).toBe(conditions[0]);
});
it('should return weight condition when found by weight url', () => {
const conditions = FilteredSearchTokenKeysIssues.getConditions();
const conditions = IssuesFilteredSearchTokenKeysEE.getConditions();
const weightConditions = conditions.filter(c => c.tokenKey === 'weight');
const result = FilteredSearchTokenKeysIssues
const result = IssuesFilteredSearchTokenKeysEE
.searchByConditionUrl(weightConditions[0].url);
expect(result).toBe(weightConditions[0]);
});
......@@ -169,22 +169,22 @@ describe('Filtered Search Token Keys (Issues EE)', () => {
describe('searchByConditionKeyValue', () => {
it('should return null when condition tokenKey and value not found', () => {
const condition = FilteredSearchTokenKeysIssues
const condition = IssuesFilteredSearchTokenKeysEE
.searchByConditionKeyValue(null, null);
expect(condition === null).toBe(true);
});
it('should return condition when found by tokenKey and value', () => {
const conditions = FilteredSearchTokenKeysIssues.getConditions();
const result = FilteredSearchTokenKeysIssues
const conditions = IssuesFilteredSearchTokenKeysEE.getConditions();
const result = IssuesFilteredSearchTokenKeysEE
.searchByConditionKeyValue(conditions[0].tokenKey, conditions[0].value);
expect(result).toEqual(conditions[0]);
});
it('should return weight condition when found by weight tokenKey and value', () => {
const conditions = FilteredSearchTokenKeysIssues.getConditions();
const conditions = IssuesFilteredSearchTokenKeysEE.getConditions();
const weightConditions = conditions.filter(c => c.tokenKey === 'weight');
const result = FilteredSearchTokenKeysIssues
const result = IssuesFilteredSearchTokenKeysEE
.searchByConditionKeyValue(weightConditions[0].tokenKey, weightConditions[0].value);
expect(result).toEqual(weightConditions[0]);
});
......
......@@ -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], only: 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,12 @@ module API
end
helpers do
def filter_runners(runners, scope, options = {})
def filter_runners(runners, scope, only: nil)
return runners unless scope.present?
available_scopes = ::Ci::Runner::AVAILABLE_SCOPES
if options[:without]
available_scopes = available_scopes - options[:without]
end
available_scopes = only || ::Ci::Runner::AVAILABLE_SCOPES
if (available_scopes & [scope]).empty?
unless available_scopes.include?(scope)
render_api_error!('Scope contains invalid value', 400)
end
......
......@@ -4349,6 +4349,9 @@ msgstr ""
msgid "Last commit"
msgstr ""
msgid "Last contact"
msgstr ""
msgid "Last edited %{date}"
msgstr ""
......@@ -5061,6 +5064,9 @@ msgstr ""
msgid "No repository"
msgstr ""
msgid "No runners found"
msgstr ""
msgid "No schedules"
msgstr ""
......@@ -5576,6 +5582,9 @@ msgstr ""
msgid "Preferences|Navigation theme"
msgstr ""
msgid "Press Enter or click to search"
msgstr ""
msgid "Preview"
msgstr ""
......@@ -6209,6 +6218,9 @@ msgstr ""
msgid "Real-time features"
msgstr ""
msgid "Recent searches"
msgstr ""
msgid "Reference:"
msgstr ""
......@@ -6438,9 +6450,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 ""
......@@ -6450,6 +6477,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_cnt}"
msgstr ""
msgid "Runners page"
msgstr ""
......@@ -6552,6 +6585,9 @@ msgstr ""
msgid "Search milestones"
msgstr ""
msgid "Search or filter results..."
msgstr ""
msgid "Search or jump to…"
msgstr ""
......@@ -6919,6 +6955,9 @@ msgstr ""
msgid "SortOptions|Largest repository"
msgstr ""
msgid "SortOptions|Last Contact"
msgstr ""
msgid "SortOptions|Last created"
msgstr ""
......@@ -8377,6 +8416,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 ""
......@@ -9073,6 +9115,9 @@ msgstr ""
msgid "mrWidget|to be merged automatically when the pipeline succeeds"
msgstr ""
msgid "n/a"
msgstr ""
msgid "new merge request"
msgstr ""
......
......@@ -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)
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
end
it 'has all necessary texts' do
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
......
# 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 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 IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_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: IssuesFilteredSearchTokenKeys.getKeys(),
};
const propsDataWithItems = {
items: [
'foo',
'author:@root label:~foo bar',
],
allowedKeys: FilteredSearchTokenKeys.getKeys(),
allowedKeys: IssuesFilteredSearchTokenKeys.getKeys(),
};
let vm;
......
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 IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_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: IssuesFilteredSearchTokenKeys,
});
});
......
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 IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_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 = IssuesFilteredSearchTokenKeys.getKeys();
});
function config() {
......
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 IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_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: IssuesFilteredSearchTokenKeys.getKeys(),
});
});
});
......
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',
}];
const conditions = [{
url: 'assignee_id=0',
tokenKey: 'assignee',
value: 'none',
}];
beforeEach(() => {
tokenKeys = FilteredSearchTokenKeys.get();
});
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]);
});
......
import FilteredSearchTokenKeys from '~/filtered_search/filtered_search_token_keys';
import IssuesFilteredSearchTokenKeys from '~/filtered_search/issues_filtered_search_token_keys';
import FilteredSearchTokenizer from '~/filtered_search/filtered_search_tokenizer';
describe('Filtered Search Tokenizer', () => {
const allowedKeys = FilteredSearchTokenKeys.getKeys();
const allowedKeys = IssuesFilteredSearchTokenKeys.getKeys();
describe('processTokens', () => {
it('returns for input containing only search value', () => {
......
......@@ -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) }
......
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