Commit b852ba5b authored by Miguel Rincon's avatar Miguel Rincon

Add total counters in each runner type tab

In order for admin users to have a more comprehensive view of their
runner resources, this change adds a counter badge next to each runner
tab with the total number of runners.

Changelog: added
parent 9a1eeb7c
<script>
import { GlLink } from '@gitlab/ui';
import { GlBadge, GlLink } from '@gitlab/ui';
import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, __ } from '~/locale';
import { sprintf, __ } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
......@@ -14,7 +14,13 @@ import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import {
ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
I18N_FETCH_ERROR,
} from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
......@@ -26,6 +32,7 @@ import { captureException } from '../sentry_utils';
export default {
name: 'AdminRunnersApp',
components: {
GlBadge,
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
......@@ -35,11 +42,27 @@ export default {
RunnerTypeTabs,
},
props: {
registrationToken: {
type: String,
required: true,
},
activeRunnersCount: {
type: Number,
type: String,
required: true,
},
registrationToken: {
allRunnersCount: {
type: String,
required: true,
},
instanceRunnersCount: {
type: String,
required: true,
},
groupRunnersCount: {
type: String,
required: true,
},
projectRunnersCount: {
type: String,
required: true,
},
......@@ -89,7 +112,7 @@ export default {
},
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
active_runners_count: this.activeRunnersCount,
});
},
searchTokens() {
......@@ -118,6 +141,20 @@ export default {
this.reportToSentry(error);
},
methods: {
tabCount({ runnerType }) {
switch (runnerType) {
case null:
return this.allRunnersCount;
case INSTANCE_TYPE:
return this.instanceRunnersCount;
case GROUP_TYPE:
return this.groupRunnersCount;
case PROJECT_TYPE:
return this.projectRunnersCount;
default:
return null;
}
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
......@@ -128,15 +165,25 @@ export default {
</script>
<template>
<div>
<div class="gl-display-flex gl-align-items-center">
<div
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"
>
<runner-type-tabs
v-model="search"
class="gl-w-full"
content-class="gl-display-none"
nav-class="gl-border-none!"
/>
>
<template #title="{ tab }">
{{ tab.title }}
<gl-badge v-if="tabCount(tab)" class="gl-ml-1" size="sm">
{{ tabCount(tab) }}
</gl-badge>
</template>
</runner-type-tabs>
<registration-dropdown
class="gl-ml-auto"
class="gl-w-full gl-sm-w-auto gl-mr-auto"
:registration-token="registrationToken"
:type="$options.INSTANCE_TYPE"
right
......
......@@ -16,7 +16,16 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
// TODO `activeRunnersCount` should be implemented using a GraphQL API
// https://gitlab.com/gitlab-org/gitlab/-/issues/333806
const { activeRunnersCount, registrationToken, runnerInstallHelpPage } = el.dataset;
const {
runnerInstallHelpPage,
registrationToken,
activeRunnersCount,
allRunnersCount,
instanceRunnersCount,
groupRunnersCount,
projectRunnersCount,
} = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
......@@ -31,8 +40,15 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
render(h) {
return h(AdminRunnersApp, {
props: {
activeRunnersCount: parseInt(activeRunnersCount, 10),
registrationToken,
// All runner counts are returned as formatted
// strings, we do not use `parseInt`.
activeRunnersCount,
allRunnersCount,
instanceRunnersCount,
groupRunnersCount,
projectRunnersCount,
},
});
},
......
......@@ -51,13 +51,16 @@ export default {
};
</script>
<template>
<gl-tabs v-bind="$attrs">
<gl-tabs v-bind="$attrs" data-testid="runner-type-tabs">
<gl-tab
v-for="tab in $options.tabs"
:key="`${tab.runnerType}`"
:active="isTabActive(tab)"
:title="tab.title"
@click="onTabSelected(tab)"
/>
>
<template #title>
<slot name="title" :tab="tab">{{ tab.title }}</slot>
</template>
</gl-tab>
</gl-tabs>
</template>
......@@ -8,7 +8,6 @@ class Admin::RunnersController < Admin::ApplicationController
feature_category :runner
def index
@active_runners_count = Ci::Runner.online.count
end
def show
......
......@@ -60,6 +60,22 @@ module Ci
end
end
def admin_runners_data_attributes
{
# Runner install help page is external, located at
# https://gitlab.com/gitlab-org/gitlab-runner
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
# All runner counts are returned as formatted strings
active_runners_count: Ci::Runner.online.count.to_s,
all_runners_count: limited_counter_with_delimiter(Ci::Runner),
instance_runners_count: limited_counter_with_delimiter(Ci::Runner.instance_type),
group_runners_count: limited_counter_with_delimiter(Ci::Runner.group_type),
project_runners_count: limited_counter_with_delimiter(Ci::Runner.project_type)
}
end
def group_shared_runners_settings_data(group)
{
update_path: api_v4_groups_path(id: group.id),
......
- breadcrumb_title _('Runners')
- page_title _('Runners')
#js-admin-runners{ data: { registration_token: Gitlab::CurrentSettings.runners_registration_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', active_runners_count: @active_runners_count } }
#js-admin-runners{ data: admin_runners_data_attributes }
......@@ -12,9 +12,11 @@ RSpec.describe Admin::RunnersController do
describe '#index' do
render_views
it 'lists all runners' do
before do
get :index
end
it 'renders index template' do
expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index)
end
......
......@@ -66,11 +66,11 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
end
it 'runner type can be selected' do
expect(page).to have_link('All')
expect(page).to have_link('Instance')
expect(page).to have_link('Group')
expect(page).to have_link('Project')
it 'runner types tabs have total counts and can be selected' do
expect(page).to have_link('All 2')
expect(page).to have_link('Instance 2')
expect(page).to have_link('Group 0')
expect(page).to have_link('Project 0')
end
it 'shows runners' do
......@@ -162,10 +162,12 @@ RSpec.describe "Admin Runners" do
create(:ci_runner, :group, description: 'runner-group', groups: [group])
end
it 'shows correct runner when type matches' do
it '"All" tab is selected by default' do
visit admin_runners_path
expect(page).to have_link('All', class: 'active')
page.within('[data-testid="runner-type-tabs"]') do
expect(page).to have_link('All', class: 'active')
end
end
it 'shows correct runner when type matches' do
......@@ -174,9 +176,11 @@ RSpec.describe "Admin Runners" do
expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-group'
click_on 'Project'
page.within('[data-testid="runner-type-tabs"]') do
click_on('Project')
expect(page).to have_link('Project', class: 'active')
expect(page).to have_link('Project', class: 'active')
end
expect(page).to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
......@@ -185,9 +189,11 @@ RSpec.describe "Admin Runners" do
it 'shows no runner when type does not match' do
visit admin_runners_path
click_on 'Instance'
page.within('[data-testid="runner-type-tabs"]') do
click_on 'Instance'
expect(page).to have_link('Instance', class: 'active')
expect(page).to have_link('Instance', class: 'active')
end
expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
......@@ -200,7 +206,9 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path
click_on 'Project'
page.within('[data-testid="runner-type-tabs"]') do
click_on 'Project'
end
expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-2-project'
......@@ -224,7 +232,9 @@ RSpec.describe "Admin Runners" do
expect(page).to have_content 'runner-group'
expect(page).not_to have_content 'runner-paused-project'
click_on 'Project'
page.within('[data-testid="runner-type-tabs"]') do
click_on 'Project'
end
expect(page).to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
......
......@@ -10,6 +10,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
......@@ -33,7 +34,11 @@ import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered
import { runnersData, runnersDataPaginated } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = 2;
const mockActiveRunnersCount = '2';
const mockAllRunnersCount = '6';
const mockInstanceRunnersCount = '3';
const mockGroupRunnersCount = '2';
const mockProjectRunnersCount = '1';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
......@@ -50,6 +55,7 @@ describe('AdminRunnersApp', () => {
let mockRunnersQuery;
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
......@@ -65,8 +71,12 @@ describe('AdminRunnersApp', () => {
localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
activeRunnersCount: mockActiveRunnersCount,
registrationToken: mockRegistrationToken,
activeRunnersCount: mockActiveRunnersCount,
allRunnersCount: mockAllRunnersCount,
instanceRunnersCount: mockInstanceRunnersCount,
groupRunnersCount: mockGroupRunnersCount,
projectRunnersCount: mockProjectRunnersCount,
...props,
},
});
......@@ -85,6 +95,16 @@ describe('AdminRunnersApp', () => {
wrapper.destroy();
});
it('shows the runner tabs with a runner count', async () => {
createComponent({ mountFn: mount });
await waitForPromises();
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
`All ${mockAllRunnersCount} Instance ${mockInstanceRunnersCount} Group ${mockGroupRunnersCount} Project ${mockProjectRunnersCount}`,
);
});
it('shows the runner setup instructions', () => {
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE);
......
......@@ -14,11 +14,16 @@ describe('RunnerTypeTabs', () => {
.filter((tab) => tab.attributes('active') === 'true')
.at(0);
const createComponent = ({ value = mockSearch } = {}) => {
const createComponent = ({ props, ...options } = {}) => {
wrapper = shallowMount(RunnerTypeTabs, {
propsData: {
value,
value: mockSearch,
...props,
},
stubs: {
GlTab,
},
...options,
});
};
......@@ -31,7 +36,7 @@ describe('RunnerTypeTabs', () => {
});
it('Renders options to filter runners', () => {
expect(findTabs().wrappers.map((tab) => tab.attributes('title'))).toEqual([
expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([
'All',
'Instance',
'Group',
......@@ -40,18 +45,20 @@ describe('RunnerTypeTabs', () => {
});
it('"All" is selected by default', () => {
expect(findActiveTab().attributes('title')).toBe('All');
expect(findActiveTab().text()).toBe('All');
});
it('Another tab can be preselected by the user', () => {
createComponent({
value: {
...mockSearch,
runnerType: INSTANCE_TYPE,
props: {
value: {
...mockSearch,
runnerType: INSTANCE_TYPE,
},
},
});
expect(findActiveTab().attributes('title')).toBe('Instance');
expect(findActiveTab().text()).toBe('Instance');
});
describe('When the user selects a tab', () => {
......@@ -72,7 +79,31 @@ describe('RunnerTypeTabs', () => {
const newValue = emittedValue();
await wrapper.setProps({ value: newValue });
expect(findActiveTab().attributes('title')).toBe('Group');
expect(findActiveTab().text()).toBe('Group');
});
});
describe('When using a custom slot', () => {
const mockContent = 'content';
beforeEach(() => {
createComponent({
scopedSlots: {
title: `
<span>
{{props.tab.title}} ${mockContent}
</span>`,
},
});
});
it('Renders tabs with additional information', () => {
expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([
`All ${mockContent}`,
`Instance ${mockContent}`,
`Group ${mockContent}`,
`Project ${mockContent}`,
]);
});
});
});
......@@ -3,7 +3,7 @@
require 'spec_helper'
RSpec.describe Ci::RunnersHelper do
let_it_be(:user, refind: true) { create(:user) }
let_it_be(:user) { create(:user) }
before do
allow(helper).to receive(:current_user).and_return(user)
......@@ -12,22 +12,22 @@ RSpec.describe Ci::RunnersHelper do
describe '#runner_status_icon', :clean_gitlab_redis_cache do
it "returns - not contacted yet" do
runner = create(:ci_runner)
expect(runner_status_icon(runner)).to include("not connected yet")
expect(helper.runner_status_icon(runner)).to include("not connected yet")
end
it "returns offline text" do
runner = create(:ci_runner, contacted_at: 1.day.ago, active: true)
expect(runner_status_icon(runner)).to include("Runner is offline")
expect(helper.runner_status_icon(runner)).to include("Runner is offline")
end
it "returns online text" do
runner = create(:ci_runner, contacted_at: 1.second.ago, active: true)
expect(runner_status_icon(runner)).to include("Runner is online")
expect(helper.runner_status_icon(runner)).to include("Runner is online")
end
it "returns paused text" do
runner = create(:ci_runner, contacted_at: 1.second.ago, active: false)
expect(runner_status_icon(runner)).to include("Runner is paused")
expect(helper.runner_status_icon(runner)).to include("Runner is paused")
end
end
......@@ -42,7 +42,7 @@ RSpec.describe Ci::RunnersHelper do
context 'without sorting' do
it 'returns cached value' do
expect(runner_contacted_at(runner)).to eq(contacted_at_cached)
expect(helper.runner_contacted_at(runner)).to eq(contacted_at_cached)
end
end
......@@ -52,7 +52,7 @@ RSpec.describe Ci::RunnersHelper do
end
it 'returns cached value' do
expect(runner_contacted_at(runner)).to eq(contacted_at_cached)
expect(helper.runner_contacted_at(runner)).to eq(contacted_at_cached)
end
end
......@@ -62,11 +62,33 @@ RSpec.describe Ci::RunnersHelper do
end
it 'returns stored value' do
expect(runner_contacted_at(runner)).to eq(contacted_at_stored)
expect(helper.runner_contacted_at(runner)).to eq(contacted_at_stored)
end
end
end
describe '#admin_runners_data_attributes' do
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:instance_runner) { create(:ci_runner, :instance) }
let_it_be(:project_runner) { create(:ci_runner, :project ) }
before do
allow(helper).to receive(:current_user).and_return(admin)
end
it 'returns the data in format' do
expect(helper.admin_runners_data_attributes).to eq({
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
active_runners_count: '0',
all_runners_count: '2',
instance_runners_count: '1',
group_runners_count: '0',
project_runners_count: '1'
})
end
end
describe '#group_shared_runners_settings_data' do
let_it_be(:parent) { create(:group) }
let_it_be(:group) { create(:group, parent: parent, shared_runners_enabled: false) }
......@@ -86,7 +108,7 @@ RSpec.describe Ci::RunnersHelper do
parent_shared_runners_availability: nil
}.merge(runner_constants)
expect(group_shared_runners_settings_data(parent)).to eq result
expect(helper.group_shared_runners_settings_data(parent)).to eq result
end
it 'returns group data for child group' do
......@@ -96,7 +118,7 @@ RSpec.describe Ci::RunnersHelper do
parent_shared_runners_availability: Namespace::SR_ENABLED
}.merge(runner_constants)
expect(group_shared_runners_settings_data(group)).to eq result
expect(helper.group_shared_runners_settings_data(group)).to eq result
end
end
......@@ -104,7 +126,7 @@ RSpec.describe Ci::RunnersHelper do
let(:group) { create(:group) }
it 'returns group data to render a runner list' do
data = group_runners_data_attributes(group)
data = helper.group_runners_data_attributes(group)
expect(data[:registration_token]).to eq(group.runners_token)
expect(data[:group_id]).to eq(group.id)
......
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