Commit a83f2072 authored by Miguel Rincon's avatar Miguel Rincon

Add runner counts to group runners page

This change improves the group runners page with total counts for
each tab and removal of the "Instance" tab.
parent 3736bc11
<script> <script>
import { GlTabs, GlTab } from '@gitlab/ui'; import { GlTabs, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import { searchValidator } from '~/runner/runner_search_utils'; import { searchValidator } from '~/runner/runner_search_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants'; import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
I18N_ALL_TYPES,
I18N_INSTANCE_TYPE,
I18N_GROUP_TYPE,
I18N_PROJECT_TYPE,
} from '../constants';
const tabs = [ const I18N_TAB_TITLES = {
{ [INSTANCE_TYPE]: I18N_INSTANCE_TYPE,
title: s__('Runners|All'), [GROUP_TYPE]: I18N_GROUP_TYPE,
runnerType: null, [PROJECT_TYPE]: I18N_PROJECT_TYPE,
}, };
{
title: s__('Runners|Instance'),
runnerType: INSTANCE_TYPE,
},
{
title: s__('Runners|Group'),
runnerType: GROUP_TYPE,
},
{
title: s__('Runners|Project'),
runnerType: PROJECT_TYPE,
},
];
export default { export default {
components: { components: {
...@@ -29,12 +23,34 @@ export default { ...@@ -29,12 +23,34 @@ export default {
GlTab, GlTab,
}, },
props: { props: {
runnerTypes: {
type: Array,
required: false,
default: () => [INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE],
},
value: { value: {
type: Object, type: Object,
required: true, required: true,
validator: searchValidator, validator: searchValidator,
}, },
}, },
computed: {
tabs() {
const tabs = this.runnerTypes.map((runnerType) => ({
title: I18N_TAB_TITLES[runnerType],
runnerType,
}));
// Always add a "All" tab that resets filters
return [
{
title: I18N_ALL_TYPES,
runnerType: null,
},
...tabs,
];
},
},
methods: { methods: {
onTabSelected({ runnerType }) { onTabSelected({ runnerType }) {
this.$emit('input', { this.$emit('input', {
...@@ -47,13 +63,12 @@ export default { ...@@ -47,13 +63,12 @@ export default {
return runnerType === this.value.runnerType; return runnerType === this.value.runnerType;
}, },
}, },
tabs,
}; };
</script> </script>
<template> <template>
<gl-tabs v-bind="$attrs" data-testid="runner-type-tabs"> <gl-tabs v-bind="$attrs" data-testid="runner-type-tabs">
<gl-tab <gl-tab
v-for="tab in $options.tabs" v-for="tab in tabs"
:key="`${tab.runnerType}`" :key="`${tab.runnerType}`"
:active="isTabActive(tab)" :active="isTabActive(tab)"
@click="onTabSelected(tab)" @click="onTabSelected(tab)"
......
...@@ -2,12 +2,16 @@ import { s__ } from '~/locale'; ...@@ -2,12 +2,16 @@ import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20; export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000; export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
// Type // Type
export const I18N_ALL_TYPES = s__('Runners|All');
export const I18N_INSTANCE_TYPE = s__('Runners|Instance');
export const I18N_GROUP_TYPE = s__('Runners|Group');
export const I18N_PROJECT_TYPE = s__('Runners|Project');
export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects'); export const I18N_INSTANCE_RUNNER_DESCRIPTION = s__('Runners|Available to all projects');
export const I18N_GROUP_RUNNER_DESCRIPTION = s__( export const I18N_GROUP_RUNNER_DESCRIPTION = s__(
'Runners|Available to all projects and subgroups in the group', 'Runners|Available to all projects and subgroups in the group',
......
<script> <script>
import { GlLink } from '@gitlab/ui'; import { GlBadge, GlLink } from '@gitlab/ui';
import { createAlert } from '~/flash'; import { createAlert } from '~/flash';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility'; import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale'; import { formatNumber } from '~/locale';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
...@@ -18,7 +18,7 @@ import { ...@@ -18,7 +18,7 @@ import {
I18N_FETCH_ERROR, I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE, GROUP_TYPE,
GROUP_RUNNER_COUNT_LIMIT, PROJECT_TYPE,
STATUS_ONLINE, STATUS_ONLINE,
STATUS_OFFLINE, STATUS_OFFLINE,
STATUS_STALE, STATUS_STALE,
...@@ -46,6 +46,7 @@ const runnersCountSmartQuery = { ...@@ -46,6 +46,7 @@ const runnersCountSmartQuery = {
export default { export default {
name: 'GroupRunnersApp', name: 'GroupRunnersApp',
components: { components: {
GlBadge,
GlLink, GlLink,
RegistrationDropdown, RegistrationDropdown,
RunnerFilteredSearchBar, RunnerFilteredSearchBar,
...@@ -131,6 +132,33 @@ export default { ...@@ -131,6 +132,33 @@ export default {
}; };
}, },
}, },
allRunnersCount: {
...runnersCountSmartQuery,
variables() {
return {
...this.countVariables,
type: null,
};
},
},
groupRunnersCount: {
...runnersCountSmartQuery,
variables() {
return {
...this.countVariables,
type: GROUP_TYPE,
};
},
},
projectRunnersCount: {
...runnersCountSmartQuery,
variables() {
return {
...this.countVariables,
type: PROJECT_TYPE,
};
},
},
}, },
computed: { computed: {
variables() { variables() {
...@@ -139,23 +167,17 @@ export default { ...@@ -139,23 +167,17 @@ export default {
groupFullPath: this.groupFullPath, groupFullPath: this.groupFullPath,
}; };
}, },
countVariables() {
// Exclude pagination variables, leave only filters variables
const { sort, before, last, after, first, ...countVariables } = this.variables;
return countVariables;
},
runnersLoading() { runnersLoading() {
return this.$apollo.queries.runners.loading; return this.$apollo.queries.runners.loading;
}, },
noRunnersFound() { noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length; return !this.runnersLoading && !this.runners.items.length;
}, },
groupRunnersCount() {
if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) {
return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`;
}
return formatNumber(this.groupRunnersLimitedCount);
},
runnerCountMessage() {
return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), {
groupRunnersCount: this.groupRunnersCount,
});
},
searchTokens() { searchTokens() {
return [statusTokenConfig]; return [statusTokenConfig];
}, },
...@@ -179,10 +201,31 @@ export default { ...@@ -179,10 +201,31 @@ export default {
this.reportToSentry(error); this.reportToSentry(error);
}, },
methods: { methods: {
tabCount({ runnerType }) {
let count;
switch (runnerType) {
case null:
count = this.allRunnersCount;
break;
case GROUP_TYPE:
count = this.groupRunnersCount;
break;
case PROJECT_TYPE:
count = this.projectRunnersCount;
break;
default:
return null;
}
if (typeof count === 'number') {
return formatNumber(count);
}
return null;
},
reportToSentry(error) { reportToSentry(error) {
captureException({ error, component: this.$options.name }); captureException({ error, component: this.$options.name });
}, },
}, },
TABS_RUNNER_TYPES: [GROUP_TYPE, PROJECT_TYPE],
GROUP_TYPE, GROUP_TYPE,
}; };
</script> </script>
...@@ -198,9 +241,17 @@ export default { ...@@ -198,9 +241,17 @@ export default {
<div class="gl-display-flex gl-align-items-center"> <div class="gl-display-flex gl-align-items-center">
<runner-type-tabs <runner-type-tabs
v-model="search" v-model="search"
:runner-types="$options.TABS_RUNNER_TYPES"
content-class="gl-display-none" content-class="gl-display-none"
nav-class="gl-border-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 <registration-dropdown
class="gl-ml-auto" class="gl-ml-auto"
......
- page_title s_('Runners|Runners') - page_title s_('Runners|Runners')
%h2.page-title
= s_('Runners|Group Runners')
#js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count } ) } #js-group-runners{ data: group_runners_data_attributes(@group).merge( { group_runners_limited_count: @group_runners_limited_count } ) }
...@@ -30810,9 +30810,6 @@ msgstr "" ...@@ -30810,9 +30810,6 @@ msgstr ""
msgid "Runners|Group" msgid "Runners|Group"
msgstr "" msgstr ""
msgid "Runners|Group Runners"
msgstr ""
msgid "Runners|IP Address" msgid "Runners|IP Address"
msgstr "" msgstr ""
...@@ -30945,9 +30942,6 @@ msgstr "" ...@@ -30945,9 +30942,6 @@ msgstr ""
msgid "Runners|Runners" msgid "Runners|Runners"
msgstr "" msgstr ""
msgid "Runners|Runners in this group: %{groupRunnersCount}"
msgstr ""
msgid "Runners|Runs untagged jobs" msgid "Runners|Runs untagged jobs"
msgstr "" msgstr ""
......
import { GlTab } from '@gitlab/ui'; import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants'; import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }; const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' };
...@@ -13,6 +13,7 @@ describe('RunnerTypeTabs', () => { ...@@ -13,6 +13,7 @@ describe('RunnerTypeTabs', () => {
findTabs() findTabs()
.filter((tab) => tab.attributes('active') === 'true') .filter((tab) => tab.attributes('active') === 'true')
.at(0); .at(0);
const getTabsTitles = () => findTabs().wrappers.map((tab) => tab.text());
const createComponent = ({ props, ...options } = {}) => { const createComponent = ({ props, ...options } = {}) => {
wrapper = shallowMount(RunnerTypeTabs, { wrapper = shallowMount(RunnerTypeTabs, {
...@@ -35,13 +36,18 @@ describe('RunnerTypeTabs', () => { ...@@ -35,13 +36,18 @@ describe('RunnerTypeTabs', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('Renders options to filter runners', () => { it('Renders all options to filter runners by default', () => {
expect(findTabs().wrappers.map((tab) => tab.text())).toEqual([ expect(getTabsTitles()).toEqual(['All', 'Instance', 'Group', 'Project']);
'All', });
'Instance',
'Group', it('Renders fewer options to filter runners', () => {
'Project', createComponent({
]); props: {
runnerTypes: [GROUP_TYPE, PROJECT_TYPE],
},
});
expect(getTabsTitles()).toEqual(['All', 'Group', 'Project']);
}); });
it('"All" is selected by default', () => { it('"All" is selected by default', () => {
......
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import { shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import {
extendedWrapper,
shallowMountExtended,
mountExtended,
} from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash'; import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility'; import { updateHistory } from '~/lib/utils/url_utility';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue'; import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue';
...@@ -23,6 +26,7 @@ import { ...@@ -23,6 +26,7 @@ import {
DEFAULT_SORT, DEFAULT_SORT,
INSTANCE_TYPE, INSTANCE_TYPE,
GROUP_TYPE, GROUP_TYPE,
PROJECT_TYPE,
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
STATUS_ACTIVE, STATUS_ACTIVE,
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
...@@ -54,6 +58,7 @@ describe('GroupRunnersApp', () => { ...@@ -54,6 +58,7 @@ describe('GroupRunnersApp', () => {
const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () => const findRunnerPaginationPrev = () =>
...@@ -62,7 +67,12 @@ describe('GroupRunnersApp', () => { ...@@ -62,7 +67,12 @@ describe('GroupRunnersApp', () => {
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { const mockCountQueryResult = (count) =>
Promise.resolve({
data: { group: { id: groupRunnersCountData.data.group.id, runners: { count } } },
});
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const handlers = [ const handlers = [
[getGroupRunnersQuery, mockGroupRunnersQuery], [getGroupRunnersQuery, mockGroupRunnersQuery],
[getGroupRunnersCountQuery, mockGroupRunnersCountQuery], [getGroupRunnersCountQuery, mockGroupRunnersCountQuery],
...@@ -90,7 +100,7 @@ describe('GroupRunnersApp', () => { ...@@ -90,7 +100,7 @@ describe('GroupRunnersApp', () => {
}); });
it('shows total runner counts', async () => { it('shows total runner counts', async () => {
createComponent({ mountFn: mount }); createComponent({ mountFn: mountExtended });
await waitForPromises(); await waitForPromises();
...@@ -101,6 +111,44 @@ describe('GroupRunnersApp', () => { ...@@ -101,6 +111,44 @@ describe('GroupRunnersApp', () => {
expect(stats).toMatch('Stale runners 2'); expect(stats).toMatch('Stale runners 2');
}); });
it('shows the runner tabs with a runner count for each type', async () => {
mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
switch (type) {
case GROUP_TYPE:
return mockCountQueryResult(2);
case PROJECT_TYPE:
return mockCountQueryResult(1);
default:
return mockCountQueryResult(4);
}
});
createComponent({ mountFn: mountExtended });
await waitForPromises();
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText('All 4 Group 2 Project 1');
});
it('shows the runner tabs with a formatted runner count', async () => {
mockGroupRunnersCountQuery.mockImplementation(({ type }) => {
switch (type) {
case GROUP_TYPE:
return mockCountQueryResult(2000);
case PROJECT_TYPE:
return mockCountQueryResult(1000);
default:
return mockCountQueryResult(3000);
}
});
createComponent({ mountFn: mountExtended });
await waitForPromises();
expect(findRunnerTypeTabs().text()).toMatchInterpolatedText(
'All 3,000 Group 2,000 Project 1,000',
);
});
it('shows the runner setup instructions', () => { it('shows the runner setup instructions', () => {
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken); expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE); expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
...@@ -115,7 +163,7 @@ describe('GroupRunnersApp', () => { ...@@ -115,7 +163,7 @@ describe('GroupRunnersApp', () => {
const { webUrl, node } = groupRunnersData.data.group.runners.edges[0]; const { webUrl, node } = groupRunnersData.data.group.runners.edges[0];
const { id, shortSha } = node; const { id, shortSha } = node;
createComponent({ mountFn: mount }); createComponent({ mountFn: mountExtended });
await waitForPromises(); await waitForPromises();
...@@ -135,7 +183,7 @@ describe('GroupRunnersApp', () => { ...@@ -135,7 +183,7 @@ describe('GroupRunnersApp', () => {
}); });
it('sets tokens in the filtered search', () => { it('sets tokens in the filtered search', () => {
createComponent({ mountFn: mount }); createComponent({ mountFn: mountExtended });
const tokens = findFilteredSearch().props('tokens'); const tokens = findFilteredSearch().props('tokens');
...@@ -250,7 +298,7 @@ describe('GroupRunnersApp', () => { ...@@ -250,7 +298,7 @@ describe('GroupRunnersApp', () => {
beforeEach(() => { beforeEach(() => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated); mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
createComponent({ mountFn: mount }); createComponent({ mountFn: mountExtended });
}); });
it('more pages can be selected', () => { it('more pages can be selected', () => {
......
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