Commit d502e117 authored by Miguel Rincon's avatar Miguel Rincon

Display projects related to a runner

This change renders the projects which are linked to a given runner
in the runner details page.
parent 9870ee7a
......@@ -279,7 +279,11 @@ export default {
</gl-link>
</template>
</runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
<runner-pagination
v-model="search.pagination"
class="gl-mt-3"
:page-info="runners.pageInfo"
/>
</template>
</div>
</template>
......@@ -34,8 +34,6 @@ export default {
<gl-avatar shape="rect" :entity-name="name" :alt="name" :src="avatarUrl" :size="48" />
</gl-link>
<gl-link :href="href" class="gl-font-lg gl-font-weight-bold gl-text-gray-900!">{{
fullName
}}</gl-link>
<gl-link :href="href" class="gl-font-weight-bold gl-text-gray-900!">{{ fullName }}</gl-link>
</div>
</template>
......@@ -3,9 +3,10 @@ import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE } from '../constants';
import { ACCESS_LEVEL_REF_PROTECTED, GROUP_TYPE, PROJECT_TYPE } from '../constants';
import RunnerDetail from './runner_detail.vue';
import RunnerDetailGroups from './runner_detail_groups.vue';
import RunnerGroups from './runner_groups.vue';
import RunnerProjects from './runner_projects.vue';
import RunnerTags from './runner_tags.vue';
export default {
......@@ -14,7 +15,8 @@ export default {
GlTab,
GlIntersperse,
RunnerDetail,
RunnerDetailGroups,
RunnerGroups,
RunnerProjects,
RunnerTags,
TimeAgo,
},
......@@ -48,6 +50,9 @@ export default {
isGroupRunner() {
return this.runner?.runnerType === GROUP_TYPE;
},
isProjectRunner() {
return this.runner?.runnerType === PROJECT_TYPE;
},
},
ACCESS_LEVEL_REF_PROTECTED,
};
......@@ -94,7 +99,8 @@ export default {
</dl>
</div>
<runner-detail-groups v-if="isGroupRunner" :runner="runner" />
<runner-groups v-if="isGroupRunner" :runner="runner" />
<runner-projects v-if="isProjectRunner" :runner="runner" />
</template>
</gl-tab>
</gl-tabs>
......
......@@ -29,7 +29,14 @@ export default {
},
methods: {
handlePageChange(page) {
if (page > this.value.page) {
if (page === 1) {
// Small optimization for first page
// If we have loaded using "first",
// page is already cached.
this.$emit('input', {
page,
});
} else if (page > this.value.page) {
this.$emit('input', {
page,
after: this.pageInfo.endCursor,
......@@ -47,11 +54,12 @@ export default {
<template>
<gl-pagination
v-bind="$attrs"
:value="value.page"
:prev-page="prevPage"
:next-page="nextPage"
align="center"
class="gl-pagination gl-mt-3"
class="gl-pagination"
@input="handlePageChange"
/>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { sprintf, formatNumber } from '~/locale';
import { createAlert } from '~/flash';
import getRunnerProjectsQuery from '../graphql/get_runner_projects.query.graphql';
import {
I18N_ASSIGNED_PROJECTS,
I18N_NONE,
I18N_FETCH_ERROR,
RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
} from '../constants';
import { captureException } from '../sentry_utils';
import RunnerAssignedItem from './runner_assigned_item.vue';
import RunnerPagination from './runner_pagination.vue';
export default {
name: 'RunnerProjects',
components: {
GlSkeletonLoading,
RunnerAssignedItem,
RunnerPagination,
},
props: {
runner: {
type: Object,
required: true,
},
},
data() {
return {
projects: {
items: [],
pageInfo: {},
count: 0,
},
pagination: {
page: 1,
},
};
},
apollo: {
projects: {
query: getRunnerProjectsQuery,
variables() {
return this.variables;
},
update(data) {
const { runner } = data;
return {
count: runner?.projectCount || 0,
items: runner?.projects?.nodes || [],
pageInfo: runner?.projects?.pageInfo || {},
};
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
},
},
},
computed: {
variables() {
const { id } = this.runner;
const { before, after } = this.pagination;
if (before) {
return {
id,
before,
last: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
};
}
return {
id,
after,
first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
};
},
loading() {
return this.$apollo.queries.projects.loading;
},
heading() {
return sprintf(I18N_ASSIGNED_PROJECTS, {
projectCount: formatNumber(this.projects.count),
});
},
},
methods: {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
I18N_NONE,
};
</script>
<template>
<div class="gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid">
<h3 class="gl-font-lg gl-mt-5 gl-mb-0">
{{ heading }}
</h3>
<gl-skeleton-loading v-if="loading" class="gl-py-5" />
<template v-else-if="projects.items.length">
<runner-assigned-item
v-for="(project, i) in projects.items"
:key="project.id"
:class="{ 'gl-border-t-gray-100 gl-border-t-1 gl-border-t-solid': i !== 0 }"
:href="project.webUrl"
:name="project.name"
:full-name="project.nameWithNamespace"
:avatar-url="project.avatarUrl"
/>
</template>
<span v-else class="gl-text-gray-500">{{ $options.I18N_NONE }}</span>
<runner-pagination v-model="pagination" :disabled="loading" :page-info="projects.pageInfo" />
</div>
</template>
......@@ -3,6 +3,8 @@ import { __, s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
......@@ -39,6 +41,13 @@ export const I18N_RESUME = __('Resume');
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
// Runner details
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_NONE = __('None');
// Styles
export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getRunnerProjects(
$id: CiRunnerID!
$first: Int
$last: Int
$before: String
$after: String
) {
runner(id: $id) {
id
projectCount
projects(first: $first, last: $last, before: $before, after: $after) {
nodes {
id
avatarUrl
name
nameWithNamespace
webUrl
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -19,6 +19,9 @@ fragment RunnerDetailsShared on CiRunner {
deleteRunner
}
groups {
# Only a single group can be loaded here, while projects
# are loaded separately using the query with pagination
# parameters `get_runner_projects.query.graphql`.
nodes {
id
avatarUrl
......
......@@ -31159,6 +31159,9 @@ msgstr ""
msgid "Runners|Assigned Group"
msgstr ""
msgid "Runners|Assigned Projects (%{projectCount})"
msgstr ""
msgid "Runners|Associated with one or more projects"
msgstr ""
......
......@@ -11,11 +11,12 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
let_it_be(:admin) { create(:admin) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:project_2) { create(:project, :repository, :public) }
let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') }
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') }
let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') }
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
query_path = 'runner/graphql/'
fixtures_path = 'graphql/runner/'
......@@ -87,6 +88,22 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
end
describe GraphQL::Query, type: :request do
get_runner_projects_query_name = 'get_runner_projects.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runner_projects_query_name}")
end
it "#{fixtures_path}#{get_runner_projects_query_name}.json" do
post_graphql(query, current_user: admin, variables: {
id: project_runner.to_global_id.to_s
})
expect_graphql_errors_to_be_empty
end
end
end
describe do
......
......@@ -7,7 +7,7 @@ import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner
import RunnerDetails from '~/runner/components/runner_details.vue';
import RunnerDetail from '~/runner/components/runner_detail.vue';
import RunnerDetailGroups from '~/runner/components/runner_detail_groups.vue';
import RunnerGroups from '~/runner/components/runner_groups.vue';
import { runnerData, runnerWithGroupData } from '../mock_data';
......@@ -35,7 +35,7 @@ describe('RunnerDetails', () => {
return ErrorWrapper(dtLabel);
};
const findDetailGroups = () => wrapper.findComponent(RunnerDetailGroups);
const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetails, {
......
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import RunnerDetailGroups from '~/runner/components/runner_detail_groups.vue';
import RunnerGroups from '~/runner/components/runner_groups.vue';
import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
import { runnerData, runnerWithGroupData } from '../mock_data';
......@@ -9,14 +9,14 @@ const mockInstanceRunner = runnerData.data.runner;
const mockGroupRunner = runnerWithGroupData.data.runner;
const mockGroup = mockGroupRunner.groups.nodes[0];
describe('RunnerDetailGroups', () => {
describe('RunnerGroups', () => {
let wrapper;
const findHeading = () => wrapper.find('h3');
const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem);
const createComponent = ({ runner = mockGroupRunner, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetailGroups, {
wrapper = mountFn(RunnerGroups, {
propsData: {
runner,
},
......
......@@ -104,7 +104,6 @@ describe('RunnerPagination', () => {
expect(wrapper.emitted('input')[0]).toEqual([
{
before: mockStartCursor,
page: 1,
},
]);
......
import { GlSkeletonLoading } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createAlert } from '~/flash';
import { sprintf } from '~/locale';
import {
I18N_ASSIGNED_PROJECTS,
I18N_NONE,
RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
} from '~/runner/constants';
import RunnerProjects from '~/runner/components/runner_projects.vue';
import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import { captureException } from '~/runner/sentry_utils';
import getRunnerProjectsQuery from '~/runner/graphql/get_runner_projects.query.graphql';
import { runnerData, runnerProjectsData } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunner = runnerData.data.runner;
const mockRunnerWithProjects = runnerProjectsData.data.runner;
const mockProjects = mockRunnerWithProjects.projects.nodes;
Vue.use(VueApollo);
describe('RunnerProjects', () => {
let wrapper;
let mockRunnerProjectsQuery;
const findHeading = () => wrapper.find('h3');
const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem);
const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerProjects, {
apolloProvider: createMockApollo([[getRunnerProjectsQuery, mockRunnerProjectsQuery]]),
propsData: {
runner: mockRunner,
},
});
};
beforeEach(() => {
mockRunnerProjectsQuery = jest.fn();
});
afterEach(() => {
mockRunnerProjectsQuery.mockReset();
wrapper.destroy();
});
it('Requests runner projects', async () => {
createComponent();
await waitForPromises();
expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1);
expect(mockRunnerProjectsQuery).toHaveBeenCalledWith({
id: mockRunner.id,
first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
});
});
describe('When there are projects assigned', () => {
beforeEach(async () => {
mockRunnerProjectsQuery.mockResolvedValueOnce(runnerProjectsData);
createComponent();
await waitForPromises();
});
it('Shows a heading', async () => {
const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length });
expect(findHeading().text()).toBe(expected);
});
it('Shows projects', () => {
expect(findRunnerAssignedItems().length).toBe(mockProjects.length);
});
it('Shows a project', () => {
const item = findRunnerAssignedItems().at(0);
const { webUrl, name, nameWithNamespace, avatarUrl } = mockProjects[0];
expect(item.props()).toMatchObject({
href: webUrl,
name,
fullName: nameWithNamespace,
avatarUrl,
});
});
describe('When "Next" page is clicked', () => {
beforeEach(async () => {
findRunnerPagination().vm.$emit('input', { page: 3, after: 'AFTER_CURSOR' });
await waitForPromises();
});
it('A new page is requested', () => {
expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(2);
expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({
id: mockRunner.id,
first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
after: 'AFTER_CURSOR',
});
});
it('When "Prev" page is clicked, the previous page is requested', async () => {
findRunnerPagination().vm.$emit('input', { page: 2, before: 'BEFORE_CURSOR' });
await waitForPromises();
expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(3);
expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({
id: mockRunner.id,
last: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
before: 'BEFORE_CURSOR',
});
});
});
});
describe('When loading', () => {
it('shows loading indicator and no other content', () => {
createComponent();
expect(findGlSkeletonLoading().exists()).toBe(true);
expect(wrapper.findByText(I18N_NONE).exists()).toBe(false);
expect(findRunnerAssignedItems().length).toBe(0);
expect(findRunnerPagination().attributes('disabled')).toBe('true');
});
});
describe('When there are no projects', () => {
beforeEach(async () => {
mockRunnerProjectsQuery.mockResolvedValueOnce({
data: {
runner: {
id: mockRunner.id,
projectCount: 0,
projects: {
nodes: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
},
},
},
});
createComponent();
await waitForPromises();
});
it('Shows a "None" label', () => {
expect(wrapper.findByText(I18N_NONE).exists()).toBe(true);
});
});
describe('When an error occurs', () => {
beforeEach(async () => {
mockRunnerProjectsQuery.mockRejectedValue(new Error('Error!'));
createComponent();
await waitForPromises();
});
it('shows an error', () => {
expect(createAlert).toHaveBeenCalled();
});
it('reports an error', () => {
expect(captureException).toHaveBeenCalledWith({
component: 'RunnerProjects',
error: expect.any(Error),
});
});
});
});
......@@ -130,8 +130,8 @@ describe('RunnerUpdateForm', () => {
editAdminUrl,
contactedAt,
userPermissions,
groups,
version,
groups,
...submitted
} = mockRunner;
......
......@@ -6,6 +6,7 @@ import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.que
import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json';
import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json';
import runnerWithGroupData from 'test_fixtures/graphql/runner/get_runner.query.graphql.with_group.json';
import runnerProjectsData from 'test_fixtures/graphql/runner/get_runner_projects.query.graphql.json';
// Group queries
import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json';
......@@ -18,6 +19,7 @@ export {
runnersDataPaginated,
runnerData,
runnerWithGroupData,
runnerProjectsData,
groupRunnersData,
groupRunnersCountData,
groupRunnersDataPaginated,
......
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