Commit c521b828 authored by Stan Hu's avatar Stan Hu

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

Feature: Runner tag filter for admin view

See merge request gitlab-org/gitlab-ce!19740
parents 177f9ca8 0853c234
......@@ -17,6 +17,14 @@ const tokenKeys = [
icon: 'cube',
tag: 'type',
},
{
key: 'tag',
type: 'array',
param: 'name[]',
symbol: '~',
icon: 'tag',
tag: '~tag',
},
];
const AdminRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys);
......
import createFlash from '../flash';
import AjaxFilter from '../droplab/plugins/ajax_filter';
import FilteredSearchDropdown from './filtered_search_dropdown';
import DropdownUtils from './dropdown_utils';
import FilteredSearchTokenizer from './filtered_search_tokenizer';
import { __ } from '~/locale';
export default class DropdownAjaxFilter extends FilteredSearchDropdown {
constructor(options = {}) {
const { tokenKeys, endpoint, symbol } = options;
super(options);
this.tokenKeys = tokenKeys;
this.endpoint = endpoint;
this.symbol = symbol;
this.config = {
AjaxFilter: this.ajaxFilterConfig(),
};
}
ajaxFilterConfig() {
return {
endpoint: `${gon.relative_url_root || ''}${this.endpoint}`,
searchKey: 'search',
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
onError() {
createFlash(__('An error occurred fetching the dropdown data.'));
},
};
}
itemClicked(e) {
super.itemClicked(e, selected =>
selected.querySelector('.dropdown-light-content').innerText.trim(),
);
}
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
super.renderContent(forceShowList);
}
getSearchInput() {
const query = DropdownUtils.getSearchInput(this.input);
const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
if (value[0] === this.symbol) {
value = value.slice(1);
}
// Removes the first character if it is a quotation so that we can search
// with multiple words
if (value[0] === '"' || value[0] === "'") {
value = value.slice(1);
}
return value;
}
init() {
this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
import Flash from '../flash';
import AjaxFilter from '../droplab/plugins/ajax_filter';
import FilteredSearchDropdown from './filtered_search_dropdown';
import { addClassIfElementExists } from '../lib/utils/dom_utils';
import DropdownUtils from './dropdown_utils';
import FilteredSearchTokenizer from './filtered_search_tokenizer';
import DropdownAjaxFilter from './dropdown_ajax_filter';
export default class DropdownUser extends FilteredSearchDropdown {
export default class DropdownUser extends DropdownAjaxFilter {
constructor(options = {}) {
const { tokenKeys } = options;
super(options);
this.config = {
AjaxFilter: {
endpoint: `${gon.relative_url_root || ''}/autocomplete/users.json`,
searchKey: 'search',
params: {
active: true,
group_id: this.getGroupId(),
project_id: this.getProjectId(),
current_user: true,
},
searchValueFunction: this.getSearchInput.bind(this),
loadingTemplate: this.loadingTemplate,
onLoadingFinished: () => {
this.hideCurrentUser();
},
onError() {
/* eslint-disable no-new */
new Flash('An error occurred fetching the dropdown data.');
/* eslint-enable no-new */
},
super({
...options,
endpoint: '/autocomplete/users.json',
symbol: '@',
});
}
ajaxFilterConfig() {
return {
...super.ajaxFilterConfig(),
params: {
active: true,
group_id: this.getGroupId(),
project_id: this.getProjectId(),
current_user: true,
},
onLoadingFinished: () => {
this.hideCurrentUser();
},
};
this.tokenKeys = tokenKeys;
}
hideCurrentUser() {
addClassIfElementExists(this.dropdown.querySelector('.js-current-user'), 'hidden');
}
itemClicked(e) {
super.itemClicked(e, selected =>
selected.querySelector('.dropdown-light-content').innerText.trim(),
);
}
renderContent(forceShowList = false) {
this.droplab.changeHookList(this.hookId, this.dropdown, [AjaxFilter], this.config);
super.renderContent(forceShowList);
}
getGroupId() {
return this.input.getAttribute('data-group-id');
}
......@@ -56,27 +36,4 @@ export default class DropdownUser extends FilteredSearchDropdown {
getProjectId() {
return this.input.getAttribute('data-project-id');
}
getSearchInput() {
const query = DropdownUtils.getSearchInput(this.input);
const { lastToken } = FilteredSearchTokenizer.processTokens(query, this.tokenKeys.get());
let value = lastToken || '';
if (value[0] === '@') {
value = value.slice(1);
}
// Removes the first character if it is a quotation so that we can search
// with multiple words
if (value[0] === '"' || value[0] === "'") {
value = value.slice(1);
}
return value;
}
init() {
this.droplab.addHook(this.input, this.dropdown, [AjaxFilter], this.config).init();
}
}
......@@ -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 DropdownAjaxFilter from './dropdown_ajax_filter';
import NullDropdown from './null_dropdown';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
......@@ -111,6 +112,15 @@ export default class FilteredSearchDropdownManager {
gl: NullDropdown,
element: this.container.querySelector('#js-dropdown-admin-runner-type'),
},
tag: {
reference: null,
gl: DropdownAjaxFilter,
extraArguments: {
endpoint: this.getRunnerTagsEndpoint(),
symbol: '~',
},
element: this.container.querySelector('#js-dropdown-runner-tag'),
},
};
supportedTokens.forEach(type => {
......@@ -146,6 +156,10 @@ export default class FilteredSearchDropdownManager {
return endpoint;
}
getRunnerTagsEndpoint() {
return `${this.baseEndpoint}/admin/runners/tag_list.json`;
}
static addWordToInput(tokenName, tokenValue = '', clicked = false, options = {}) {
const { uppercaseTokenName = false, capitalizeTokenValue = false } = options;
const input = FilteredSearchContainer.container.querySelector('.filtered-search');
......
# frozen_string_literal: true
class Admin::RunnersController < Admin::ApplicationController
before_action :runner, except: :index
before_action :runner, except: [:index, :tag_list]
def index
finder = Admin::RunnersFinder.new(params: params)
......@@ -48,6 +48,12 @@ class Admin::RunnersController < Admin::ApplicationController
end
end
def tag_list
tags = Autocomplete::ActsAsTaggableOn::TagsFinder.new(params: params).execute
render json: ActsAsTaggableOn::TagSerializer.new.represent(tags)
end
private
def runner
......
......@@ -11,6 +11,7 @@ class Admin::RunnersFinder < UnionFinder
search!
filter_by_status!
filter_by_runner_type!
filter_by_tag_list!
sort!
paginate!
......@@ -44,6 +45,14 @@ class Admin::RunnersFinder < UnionFinder
filter_by!(:type_type, Ci::Runner::AVAILABLE_TYPES)
end
def filter_by_tag_list!
tag_list = @params[:tag_name].presence
if tag_list
@runners = @runners.tagged_with(tag_list)
end
end
def sort!
@runners = @runners.order_by(sort_key)
end
......
# frozen_string_literal: true
module Autocomplete
module ActsAsTaggableOn
class TagsFinder
LIMIT = 20
def initialize(params:)
@params = params
end
def execute
tags = all_tags
tags = filter_by_name(tags)
limit(tags)
end
private
def all_tags
::ActsAsTaggableOn::Tag.all
end
def filter_by_name(tags)
return tags unless search
return tags.none if search.empty?
if search.length >= Gitlab::SQL::Pattern::MIN_CHARS_FOR_PARTIAL_MATCHING
tags.named_like(search)
else
tags.named(search)
end
end
def limit(tags)
tags.limit(LIMIT) # rubocop: disable CodeReuse/ActiveRecord
end
def search
@params[:search]
end
end
end
end
# frozen_string_literal: true
class ActsAsTaggableOn::TagEntity < Grape::Entity
expose :id
expose :name
end
# frozen_string_literal: true
class ActsAsTaggableOn::TagSerializer < BaseSerializer
entity ActsAsTaggableOn::TagEntity
end
......@@ -92,6 +92,25 @@
= button_tag class: %w[btn btn-link] do
= runner_type.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[btn btn-link] do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link
= _('No Tag')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item
%button.btn.btn-link.js-data-value
%span.dropdown-light-content
{{name}}
= button_tag class: %w[clear-search hidden] do
= icon('times')
.filter-dropdown-container
......
---
title: Add a tag filter to the admin runners view
merge_request: 19740
author: Alexis Reigel
type: added
......@@ -120,6 +120,10 @@ namespace :admin do
get :resume
get :pause
end
collection do
get :tag_list, format: :json
end
end
resources :jobs, only: :index do
......
# frozen_string_literal: true
class AddIndexToTags < ActiveRecord::Migration[5.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_tags_on_name_trigram'
disable_ddl_transaction!
def up
add_concurrent_index :tags, :name, name: INDEX_NAME, using: :gin, opclasses: { name: :gin_trgm_ops }
end
def down
remove_concurrent_index_by_name(:tags, INDEX_NAME)
end
end
......@@ -2053,6 +2053,7 @@ ActiveRecord::Schema.define(version: 20190220150130) do
t.string "name"
t.integer "taggings_count", default: 0
t.index ["name"], name: "index_tags_on_name", unique: true, using: :btree
t.index ["name"], name: "index_tags_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"}
end
create_table "term_agreements", force: :cascade do |t|
......
......@@ -13,13 +13,15 @@ GET /runners
GET /runners?scope=active
GET /runners?type=project_type
GET /runners?status=active
GET /runners?tag_list=tag1,tag2
```
| Attribute | Type | Required | Description |
|-----------|---------|----------|---------------------|
| `scope` | string | no | Deprecated: Use `type` or `status` instead. The scope of specific runners to show, one of: `active`, `paused`, `online`, `offline`; showing all runners if none provided |
| `type` | string | no | The type of runners to show, one of: `instance_type`, `group_type`, `project_type` |
| `status` | string | no | The status of runners to show, one of: `active`, `paused`, `online`, `offline` |
| Attribute | Type | Required | Description |
|-------------|----------------|----------|---------------------|
| `scope` | string | no | Deprecated: Use `type` or `status` instead. The scope of specific runners to show, one of: `active`, `paused`, `online`, `offline`; showing all runners if none provided |
| `type` | string | no | The type of runners to show, one of: `instance_type`, `group_type`, `project_type` |
| `status` | string | no | The status of runners to show, one of: `active`, `paused`, `online`, `offline` |
| `tag_list` | Array[String] | no | List of of the runner's tags |
```
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/runners"
......@@ -62,13 +64,15 @@ GET /runners/all
GET /runners/all?scope=online
GET /runners/all?type=project_type
GET /runners/all?status=active
GET /runners/all?tag_list=tag1,tag2
```
| Attribute | Type | Required | Description |
|-----------|---------|----------|---------------------|
| `scope` | string | no | Deprecated: Use `type` or `status` instead. The scope of runners to show, one of: `specific`, `shared`, `active`, `paused`, `online`, `offline`; showing all runners if none provided |
| `type` | string | no | The type of runners to show, one of: `instance_type`, `group_type`, `project_type` |
| `status` | string | no | The status of runners to show, one of: `active`, `paused`, `online`, `offline` |
| Attribute | Type | Required | Description |
|-------------|----------------|----------|---------------------|
| `scope` | string | no | Deprecated: Use `type` or `status` instead. The scope of runners to show, one of: `specific`, `shared`, `active`, `paused`, `online`, `offline`; showing all runners if none provided |
| `type` | string | no | The type of runners to show, one of: `instance_type`, `group_type`, `project_type` |
| `status` | string | no | The status of runners to show, one of: `active`, `paused`, `online`, `offline` |
| `tag_list` | Array[String] | no | List of of the runner's tags |
```
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/runners/all"
......@@ -347,14 +351,16 @@ GET /projects/:id/runners
GET /projects/:id/runners?scope=active
GET /projects/:id/runners?type=project_type
GET /projects/:id/runners?status=active
GET /projects/:id/runners?tag_list=tag1,tag2
```
| Attribute | Type | Required | Description |
|-----------|----------------|----------|---------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `scope` | string | no | Deprecated: Use `type` or `status` instead. The scope of specific runners to show, one of: `active`, `paused`, `online`, `offline`; showing all runners if none provided |
| `type` | string | no | The type of runners to show, one of: `instance_type`, `group_type`, `project_type` |
| `status` | string | no | The status of runners to show, one of: `active`, `paused`, `online`, `offline` |
| Attribute | Type | Required | Description |
|------------|----------------|----------|---------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `scope` | string | no | Deprecated: Use `type` or `status` instead. The scope of specific runners to show, one of: `active`, `paused`, `online`, `offline`; showing all runners if none provided |
| `type` | string | no | The type of runners to show, one of: `instance_type`, `group_type`, `project_type` |
| `status` | string | no | The status of runners to show, one of: `active`, `paused`, `online`, `offline` |
| `tag_list` | Array[String] | no | List of of the runner's tags |
```
curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/9/runners"
......
......@@ -17,6 +17,7 @@ module API
desc: 'The type of the runners to show'
optional :status, type: String, values: Ci::Runner::AVAILABLE_STATUSES,
desc: 'The status of the runners to show'
optional :tag_list, type: Array[String], desc: 'The tags of the runners to show'
use :pagination
end
get do
......@@ -24,6 +25,7 @@ module API
runners = filter_runners(runners, params[:scope], allowed_scopes: Ci::Runner::AVAILABLE_STATUSES)
runners = filter_runners(runners, params[:type], allowed_scopes: Ci::Runner::AVAILABLE_TYPES)
runners = filter_runners(runners, params[:status], allowed_scopes: Ci::Runner::AVAILABLE_STATUSES)
runners = runners.tagged_with(params[:tag_list]) if params[:tag_list]
present paginate(runners), with: Entities::Runner
end
......@@ -38,6 +40,7 @@ module API
desc: 'The type of the runners to show'
optional :status, type: String, values: Ci::Runner::AVAILABLE_STATUSES,
desc: 'The status of the runners to show'
optional :tag_list, type: Array[String], desc: 'The tags of the runners to show'
use :pagination
end
get 'all' do
......@@ -47,6 +50,7 @@ module API
runners = filter_runners(runners, params[:scope])
runners = filter_runners(runners, params[:type], allowed_scopes: Ci::Runner::AVAILABLE_TYPES)
runners = filter_runners(runners, params[:status], allowed_scopes: Ci::Runner::AVAILABLE_STATUSES)
runners = runners.tagged_with(params[:tag_list]) if params[:tag_list]
present paginate(runners), with: Entities::Runner
end
......@@ -139,6 +143,7 @@ module API
desc: 'The type of the runners to show'
optional :status, type: String, values: Ci::Runner::AVAILABLE_STATUSES,
desc: 'The status of the runners to show'
optional :tag_list, type: Array[String], desc: 'The tags of the runners to show'
use :pagination
end
get ':id/runners' do
......@@ -146,6 +151,7 @@ module API
runners = filter_runners(runners, params[:scope])
runners = filter_runners(runners, params[:type], allowed_scopes: Ci::Runner::AVAILABLE_TYPES)
runners = filter_runners(runners, params[:status], allowed_scopes: Ci::Runner::AVAILABLE_STATUSES)
runners = runners.tagged_with(params[:tag_list]) if params[:tag_list]
present paginate(runners), with: Entities::Runner
end
......
......@@ -615,6 +615,9 @@ msgstr ""
msgid "An error occurred creating the new branch."
msgstr ""
msgid "An error occurred fetching the dropdown data."
msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
......@@ -4943,6 +4946,9 @@ msgstr ""
msgid "No %{providerTitle} repositories available to import"
msgstr ""
msgid "No Tag"
msgstr ""
msgid "No activities found"
msgstr ""
......
......@@ -141,6 +141,56 @@ describe "Admin Runners" do
end
end
describe 'filter by tag', :js do
it 'shows correct runner when tag matches' do
create :ci_runner, description: 'runner-blue', tag_list: ['blue']
create :ci_runner, description: 'runner-red', tag_list: ['red']
visit admin_runners_path
expect(page).to have_content 'runner-blue'
expect(page).to have_content 'runner-red'
input_filtered_search_keys('tag:blue')
expect(page).to have_content 'runner-blue'
expect(page).not_to have_content 'runner-red'
end
it 'shows no runner when tag does not match' do
create :ci_runner, description: 'runner-blue', tag_list: ['blue']
create :ci_runner, description: 'runner-red', tag_list: ['blue']
visit admin_runners_path
input_filtered_search_keys('tag:red')
expect(page).not_to have_content 'runner-blue'
expect(page).not_to have_content 'runner-blue'
expect(page).to have_text 'No runners found'
end
it 'shows correct runner when tag is selected and search term is entered' do
create :ci_runner, description: 'runner-a-1', tag_list: ['blue']
create :ci_runner, description: 'runner-a-2', tag_list: ['red']
create :ci_runner, description: 'runner-b-1', tag_list: ['blue']
visit admin_runners_path
input_filtered_search_keys('tag:blue')
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('tag:blue 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
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')
......
......@@ -37,6 +37,14 @@ describe Admin::RunnersFinder do
end
end
context 'filter by tag_name' do
it 'calls the corresponding scope on Ci::Runner' do
expect(Ci::Runner).to receive(:tagged_with).with(%w[tag1 tag2]).and_call_original
described_class.new(params: { tag_name: %w[tag1 tag2] }).execute
end
end
context 'sort' do
context 'without sort param' do
it 'sorts by created_at' do
......
# frozen_string_literal: true
require 'spec_helper'
describe Autocomplete::ActsAsTaggableOn::TagsFinder do
describe '#execute' do
context 'with empty params' do
it 'returns all tags' do
tag1 = ActsAsTaggableOn::Tag.create!(name: 'tag1')
tag2 = ActsAsTaggableOn::Tag.create!(name: 'tag2')
tags = described_class.new(params: {}).execute
expect(tags).to match_array [tag1, tag2]
end
end
context 'filter by search' do
context 'with an empty search term' do
it 'returns an empty collection' do
ActsAsTaggableOn::Tag.create!(name: 'tag1')
ActsAsTaggableOn::Tag.create!(name: 'tag2')
tags = described_class.new(params: { search: '' }).execute
expect(tags).to be_empty
end
end
context 'with a search containing 2 characters' do
it 'returns the tag that strictly matches the search term' do
tag1 = ActsAsTaggableOn::Tag.create!(name: 't1')
ActsAsTaggableOn::Tag.create!(name: 't11')
tags = described_class.new(params: { search: 't1' }).execute
expect(tags).to match_array [tag1]
end
end
context 'with a search containing 3 characters' do
it 'returns the tag that partially matches the search term' do
tag1 = ActsAsTaggableOn::Tag.create!(name: 'tag1')
tag2 = ActsAsTaggableOn::Tag.create!(name: 'tag11')
tags = described_class.new(params: { search: 'ag1' }).execute
expect(tags).to match_array [tag1, tag2]
end
end
end
context 'limit' do
it 'limits the result set by the limit constant' do
stub_const("#{described_class}::LIMIT", 1)
ActsAsTaggableOn::Tag.create!(name: 'tag1')
ActsAsTaggableOn::Tag.create!(name: 'tag2')
tags = described_class.new(params: { search: 'tag' }).execute
expect(tags.count).to eq 1
end
end
end
end
......@@ -90,6 +90,17 @@ describe API::Runners do
expect(response).to have_gitlab_http_status(400)
end
it 'filters runners by tag_list' do
create(:ci_runner, :project, description: 'Runner tagged with tag1 and tag2', projects: [project], tag_list: %w[tag1 tag2])
create(:ci_runner, :project, description: 'Runner tagged with tag2', projects: [project], tag_list: ['tag2'])
get api('/runners?tag_list=tag1,tag2', user)
expect(json_response).to match_array [
a_hash_including('description' => 'Runner tagged with tag1 and tag2')
]
end
end
context 'unauthorized user' do
......@@ -181,6 +192,17 @@ describe API::Runners do
expect(response).to have_gitlab_http_status(400)
end
it 'filters runners by tag_list' do
create(:ci_runner, :project, description: 'Runner tagged with tag1 and tag2', projects: [project], tag_list: %w[tag1 tag2])
create(:ci_runner, :project, description: 'Runner tagged with tag2', projects: [project], tag_list: ['tag2'])
get api('/runners/all?tag_list=tag1,tag2', admin)
expect(json_response).to match_array [
a_hash_including('description' => 'Runner tagged with tag1 and tag2')
]
end
end
context 'without admin privileges' do
......@@ -716,6 +738,17 @@ describe API::Runners do
expect(response).to have_gitlab_http_status(400)
end
it 'filters runners by tag_list' do
create(:ci_runner, :project, description: 'Runner tagged with tag1 and tag2', projects: [project], tag_list: %w[tag1 tag2])
create(:ci_runner, :project, description: 'Runner tagged with tag2', projects: [project], tag_list: ['tag2'])
get api("/projects/#{project.id}/runners?tag_list=tag1,tag2", user)
expect(json_response).to match_array [
a_hash_including('description' => 'Runner tagged with tag1 and tag2')
]
end
end
context 'authorized user without maintainer privileges' do
......
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