Commit 6fd16ebc authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '347856-runner-jobs' into 'master'

Add runner jobs tab

See merge request gitlab-org/gitlab!80380
parents 8b3dcdd8 ccd1a1ab
<script>
import { GlLink } from '@gitlab/ui';
export default {
props: {
href: {
type: String,
required: false,
default: null,
},
},
computed: {
component() {
if (this.href) {
return GlLink;
}
return 'span';
},
},
};
</script>
<template>
<component :is="component" :href="href" v-bind="$attrs" v-on="$listeners">
<slot></slot>
</component>
</template>
<script>
import { GlTabs, GlTab, GlIntersperse } from '@gitlab/ui';
import { GlBadge, 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, PROJECT_TYPE } from '../constants';
import { formatJobCount } from '../utils';
import RunnerDetail from './runner_detail.vue';
import RunnerGroups from './runner_groups.vue';
import RunnerProjects from './runner_projects.vue';
import RunnerJobs from './runner_jobs.vue';
import RunnerTags from './runner_tags.vue';
export default {
components: {
GlBadge,
GlTabs,
GlTab,
GlIntersperse,
RunnerDetail,
RunnerGroups,
RunnerProjects,
RunnerJobs,
RunnerTags,
TimeAgo,
},
......@@ -53,6 +57,9 @@ export default {
isProjectRunner() {
return this.runner?.runnerType === PROJECT_TYPE;
},
jobCount() {
return formatJobCount(this.runner?.jobCount);
},
},
ACCESS_LEVEL_REF_PROTECTED,
};
......@@ -65,7 +72,7 @@ export default {
<template v-if="runner">
<div class="gl-pt-4">
<dl class="gl-mb-0">
<dl class="gl-mb-0" data-testid="runner-details-list">
<runner-detail :label="s__('Runners|Description')" :value="runner.description" />
<runner-detail
:label="s__('Runners|Last contact')"
......@@ -103,5 +110,15 @@ export default {
<runner-projects v-if="isProjectRunner" :runner="runner" />
</template>
</gl-tab>
<gl-tab>
<template #title>
{{ s__('Runners|Jobs') }}
<gl-badge v-if="jobCount" data-testid="job-count-badge" class="gl-ml-1" size="sm">
{{ jobCount }}
</gl-badge>
</template>
<runner-jobs v-if="runner" :runner="runner" />
</gl-tab>
</gl-tabs>
</template>
<script>
import { GlSkeletonLoading } from '@gitlab/ui';
import { createAlert } from '~/flash';
import getRunnerJobsQuery from '../graphql/get_runner_jobs.query.graphql';
import { I18N_FETCH_ERROR, I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '../constants';
import { captureException } from '../sentry_utils';
import { getPaginationVariables } from '../utils';
import RunnerJobsTable from './runner_jobs_table.vue';
import RunnerPagination from './runner_pagination.vue';
export default {
name: 'RunnerJobs',
components: {
GlSkeletonLoading,
RunnerJobsTable,
RunnerPagination,
},
props: {
runner: {
type: Object,
required: true,
},
},
data() {
return {
jobs: {
items: [],
pageInfo: {},
},
pagination: {
page: 1,
},
};
},
apollo: {
jobs: {
query: getRunnerJobsQuery,
variables() {
return this.variables;
},
update({ runner }) {
return {
items: runner?.jobs?.nodes || [],
pageInfo: runner?.jobs?.pageInfo || {},
};
},
error(error) {
createAlert({ message: I18N_FETCH_ERROR });
this.reportToSentry(error);
},
},
},
computed: {
variables() {
const { id } = this.runner;
return {
id,
...getPaginationVariables(this.pagination, RUNNER_DETAILS_JOBS_PAGE_SIZE),
};
},
loading() {
return this.$apollo.queries.jobs.loading;
},
},
methods: {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
I18N_NO_JOBS_FOUND,
};
</script>
<template>
<div class="gl-pt-3">
<gl-skeleton-loading v-if="loading" class="gl-py-5" />
<runner-jobs-table v-else-if="jobs.items.length" :jobs="jobs.items" />
<p v-else>{{ $options.I18N_NO_JOBS_FOUND }}</p>
<runner-pagination v-model="pagination" :disabled="loading" :page-info="jobs.pageInfo" />
</div>
</template>
<script>
import { GlTableLite } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
import RunnerTags from '~/runner/components/runner_tags.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { tableField } from '../utils';
import LinkCell from './cells/link_cell.vue';
export default {
components: {
CiBadge,
GlTableLite,
LinkCell,
RunnerTags,
TimeAgo,
},
props: {
jobs: {
type: Array,
required: true,
},
},
methods: {
trAttr(job) {
if (job?.id) {
return { 'data-testid': `job-row-${getIdFromGraphQLId(job.id)}` };
}
return {};
},
jobId(job) {
return getIdFromGraphQLId(job.id);
},
jobPath(job) {
return job.detailedStatus?.detailsPath;
},
projectName(job) {
return job.pipeline?.project?.name;
},
projectWebUrl(job) {
return job.pipeline?.project?.webUrl;
},
commitShortSha(job) {
return job.shortSha;
},
commitPath(job) {
return job.commitPath;
},
},
fields: [
tableField({ key: 'status', label: s__('Job|Status') }),
tableField({ key: 'job', label: __('Job') }),
tableField({ key: 'project', label: __('Project') }),
tableField({ key: 'commit', label: __('Commit') }),
tableField({ key: 'finished_at', label: s__('Job|Finished at') }),
tableField({ key: 'tags', label: s__('Runners|Tags') }),
],
};
</script>
<template>
<gl-table-lite
:items="jobs"
:fields="$options.fields"
:tbody-tr-attr="trAttr"
primary-key="id"
stacked="md"
fixed
>
<template #cell(status)="{ item = {} }">
<ci-badge v-if="item.detailedStatus" :status="item.detailedStatus" />
</template>
<template #cell(job)="{ item = {} }">
<link-cell :href="jobPath(item)"> #{{ jobId(item) }} </link-cell>
</template>
<template #cell(project)="{ item = {} }">
<link-cell :href="projectWebUrl(item)">{{ projectName(item) }}</link-cell>
</template>
<template #cell(commit)="{ item = {} }">
<link-cell :href="commitPath(item)"> {{ commitShortSha(item) }}</link-cell>
</template>
<template #cell(tags)="{ item = {} }">
<runner-tags :tag-list="item.tags" />
</template>
<template #cell(finished_at)="{ item = {} }">
<time-ago v-if="item.finishedAt" :time="item.finishedAt" />
</template>
</gl-table-lite>
</template>
......@@ -4,6 +4,7 @@ export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
export const RUNNER_DETAILS_JOBS_PAGE_SIZE = 30;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
......@@ -45,6 +46,7 @@ export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run
export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
export const I18N_NONE = __('None');
export const I18N_NO_JOBS_FOUND = s__('Runner|This runner has not run any jobs.');
// Styles
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, $after: String) {
runner(id: $id) {
id
projectCount
jobs(before: $before, after: $after, first: $first, last: $last) {
nodes {
id
detailedStatus {
# fields for `<ci-badge>`
id
detailsPath
group
icon
text
}
pipeline {
id
project {
id
name
webUrl
}
}
shortSha
commitPath
tags
finishedAt
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -8,6 +8,7 @@ fragment RunnerDetailsShared on CiRunner {
ipAddress
description
maximumTimeout
jobCount
tagList
createdAt
status(legacyMode: null)
......
......@@ -20878,6 +20878,9 @@ msgstr ""
msgid "Job|Erase job log and artifacts"
msgstr ""
msgid "Job|Finished at"
msgstr ""
msgid "Job|Job artifacts"
msgstr ""
......@@ -20902,6 +20905,9 @@ msgstr ""
msgid "Job|Show complete raw"
msgstr ""
msgid "Job|Status"
msgstr ""
msgid "Job|The artifacts were removed"
msgstr ""
......@@ -31321,6 +31327,9 @@ msgstr ""
msgid "Runners|Instance"
msgstr ""
msgid "Runners|Jobs"
msgstr ""
msgid "Runners|Last contact"
msgstr ""
......@@ -31561,6 +31570,9 @@ msgstr ""
msgid "Runners|stale"
msgstr ""
msgid "Runner|This runner has not run any jobs."
msgstr ""
msgid "Running"
msgstr ""
......
......@@ -17,6 +17,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
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, project_2], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
let_it_be(:build) { create(:ci_build, runner: instance_runner) }
query_path = 'runner/graphql/'
fixtures_path = 'graphql/runner/'
......@@ -104,6 +105,22 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
end
describe GraphQL::Query, type: :request do
get_runner_jobs_query_name = 'get_runner_jobs.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runner_jobs_query_name}")
end
it "#{fixtures_path}#{get_runner_jobs_query_name}.json" do
post_graphql(query, current_user: admin, variables: {
id: instance_runner.to_global_id.to_s
})
expect_graphql_errors_to_be_empty
end
end
end
describe do
......
import { GlLink } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import LinkCell from '~/runner/components/cells/link_cell.vue';
describe('LinkCell', () => {
let wrapper;
const findGlLink = () => wrapper.find(GlLink);
const findSpan = () => wrapper.find('span');
const createComponent = ({ props = {}, ...options } = {}) => {
wrapper = shallowMountExtended(LinkCell, {
propsData: {
...props,
},
...options,
});
};
it('when an href is provided, renders a link', () => {
createComponent({ props: { href: '/url' } });
expect(findGlLink().exists()).toBe(true);
});
it('when an href is not provided, renders no link', () => {
createComponent();
expect(findGlLink().exists()).toBe(false);
});
describe.each`
href | findContent
${null} | ${findSpan}
${'/url'} | ${findGlLink}
`('When href is $href', ({ href, findContent }) => {
const content = 'My Text';
const attrs = { foo: 'bar' };
const listeners = {
click: jest.fn(),
};
beforeEach(() => {
createComponent({
props: { href },
slots: {
default: content,
},
attrs,
listeners,
});
});
afterAll(() => {
listeners.click.mockReset();
});
it('Renders content', () => {
expect(findContent().text()).toBe(content);
});
it('Passes attributes', () => {
expect(findContent().attributes()).toMatchObject(attrs);
});
it('Passes event listeners', () => {
expect(listeners.click).toHaveBeenCalledTimes(0);
findContent().vm.$emit('click');
expect(listeners.click).toHaveBeenCalledTimes(1);
});
});
});
import { GlSprintf, GlIntersperse } from '@gitlab/ui';
import { GlSprintf, GlIntersperse, GlTab } from '@gitlab/ui';
import { createWrapper, ErrorWrapper } from '@vue/test-utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
......@@ -8,6 +8,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 RunnerGroups from '~/runner/components/runner_groups.vue';
import RunnersJobs from '~/runner/components/runner_jobs.vue';
import RunnerTags from '~/runner/components/runner_tags.vue';
import RunnerTag from '~/runner/components/runner_tag.vue';
......@@ -38,6 +39,8 @@ describe('RunnerDetails', () => {
};
const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
const findRunnersJobs = () => wrapper.findComponent(RunnersJobs);
const findJobCountBadge = () => wrapper.findByTestId('job-count-badge');
const createComponent = ({ props = {}, mountFn = shallowMountExtended, stubs } = {}) => {
wrapper = mountFn(RunnerDetails, {
......@@ -146,4 +149,41 @@ describe('RunnerDetails', () => {
});
});
});
describe('Jobs tab', () => {
const stubs = { GlTab };
it('without a runner, shows no jobs', () => {
createComponent({
props: { runner: null },
stubs,
});
expect(findJobCountBadge().exists()).toBe(false);
expect(findRunnersJobs().exists()).toBe(false);
});
it('without a job count, shows no jobs count', () => {
createComponent({
props: {
runner: { ...mockRunner, jobCount: undefined },
},
stubs,
});
expect(findJobCountBadge().exists()).toBe(false);
});
it('with a job count, shows jobs count', () => {
const runner = { ...mockRunner, jobCount: 3 };
createComponent({
props: { runner },
stubs,
});
expect(findJobCountBadge().text()).toBe('3');
expect(findRunnersJobs().props('runner')).toBe(runner);
});
});
});
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 RunnerJobs from '~/runner/components/runner_jobs.vue';
import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import { captureException } from '~/runner/sentry_utils';
import { I18N_NO_JOBS_FOUND, RUNNER_DETAILS_JOBS_PAGE_SIZE } from '~/runner/constants';
import getRunnerJobsQuery from '~/runner/graphql/get_runner_jobs.query.graphql';
import { runnerData, runnerJobsData } from '../mock_data';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
const mockRunner = runnerData.data.runner;
const mockRunnerWithJobs = runnerJobsData.data.runner;
const mockJobs = mockRunnerWithJobs.jobs.nodes;
Vue.use(VueApollo);
describe('RunnerJobs', () => {
let wrapper;
let mockRunnerJobsQuery;
const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
const findRunnerJobsTable = () => wrapper.findComponent(RunnerJobsTable);
const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerJobs, {
apolloProvider: createMockApollo([[getRunnerJobsQuery, mockRunnerJobsQuery]]),
propsData: {
runner: mockRunner,
},
});
};
beforeEach(() => {
mockRunnerJobsQuery = jest.fn();
});
afterEach(() => {
mockRunnerJobsQuery.mockReset();
wrapper.destroy();
});
it('Requests runner jobs', async () => {
createComponent();
await waitForPromises();
expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(1);
expect(mockRunnerJobsQuery).toHaveBeenCalledWith({
id: mockRunner.id,
first: RUNNER_DETAILS_JOBS_PAGE_SIZE,
});
});
describe('When there are jobs assigned', () => {
beforeEach(async () => {
mockRunnerJobsQuery.mockResolvedValueOnce(runnerJobsData);
createComponent();
await waitForPromises();
});
it('Shows jobs', () => {
const jobs = findRunnerJobsTable().props('jobs');
expect(jobs).toHaveLength(mockJobs.length);
expect(jobs[0]).toMatchObject(mockJobs[0]);
});
describe('When "Next" page is clicked', () => {
beforeEach(async () => {
findRunnerPagination().vm.$emit('input', { page: 2, after: 'AFTER_CURSOR' });
await waitForPromises();
});
it('A new page is requested', () => {
expect(mockRunnerJobsQuery).toHaveBeenCalledTimes(2);
expect(mockRunnerJobsQuery).toHaveBeenLastCalledWith({
id: mockRunner.id,
first: RUNNER_DETAILS_JOBS_PAGE_SIZE,
after: 'AFTER_CURSOR',
});
});
});
});
describe('When loading', () => {
it('shows loading indicator and no other content', () => {
createComponent();
expect(findGlSkeletonLoading().exists()).toBe(true);
expect(findRunnerJobsTable().exists()).toBe(false);
expect(findRunnerPagination().attributes('disabled')).toBe('true');
});
});
describe('When there are no jobs', () => {
beforeEach(async () => {
mockRunnerJobsQuery.mockResolvedValueOnce({
data: {
runner: {
id: mockRunner.id,
projectCount: 0,
jobs: {
nodes: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: '',
endCursor: '',
},
},
},
},
});
createComponent();
await waitForPromises();
});
it('Shows a "None" label', () => {
expect(wrapper.text()).toBe(I18N_NO_JOBS_FOUND);
});
});
describe('When an error occurs', () => {
beforeEach(async () => {
mockRunnerJobsQuery.mockRejectedValue(new Error('Error!'));
createComponent();
await waitForPromises();
});
it('shows an error', () => {
expect(createAlert).toHaveBeenCalled();
});
it('reports an error', () => {
expect(captureException).toHaveBeenCalledWith({
component: 'RunnerJobs',
error: expect.any(Error),
});
});
});
});
import { GlTableLite } from '@gitlab/ui';
import {
extendedWrapper,
shallowMountExtended,
mountExtended,
} from 'helpers/vue_test_utils_helper';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerJobsTable from '~/runner/components/runner_jobs_table.vue';
import { useFakeDate } from 'helpers/fake_date';
import { runnerJobsData } from '../mock_data';
const mockJobs = runnerJobsData.data.runner.jobs.nodes;
describe('RunnerJobsTable', () => {
let wrapper;
const mockNow = '2021-01-15T12:00:00Z';
const mockOneHourAgo = '2021-01-15T11:00:00Z';
useFakeDate(mockNow);
const findTable = () => wrapper.findComponent(GlTableLite);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="job-row-"]');
const findCell = ({ field }) =>
extendedWrapper(findRows().at(0).find(`[data-testid="td-${field}"]`));
const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(RunnerJobsTable, {
propsData: {
jobs: mockJobs,
...props,
},
stubs: {
GlTableLite,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('Sets job id as a row key', () => {
createComponent();
expect(findTable().attributes('primarykey')).toBe('id');
});
describe('Table data', () => {
beforeEach(() => {
createComponent({}, mountExtended);
});
it('Displays headers', () => {
const headerLabels = findHeaders().wrappers.map((w) => w.text());
expect(headerLabels).toEqual([
s__('Job|Status'),
__('Job'),
__('Project'),
__('Commit'),
s__('Job|Finished at'),
s__('Runners|Tags'),
]);
});
it('Displays a list of jobs', () => {
expect(findRows()).toHaveLength(1);
});
it('Displays details of a job', () => {
const { id, detailedStatus, pipeline, shortSha, commitPath } = mockJobs[0];
expect(findCell({ field: 'status' }).text()).toMatchInterpolatedText(detailedStatus.text);
expect(findCell({ field: 'job' }).text()).toContain(`#${getIdFromGraphQLId(id)}`);
expect(findCell({ field: 'job' }).find('a').attributes('href')).toBe(
detailedStatus.detailsPath,
);
expect(findCell({ field: 'project' }).text()).toBe(pipeline.project.name);
expect(findCell({ field: 'project' }).find('a').attributes('href')).toBe(
pipeline.project.webUrl,
);
expect(findCell({ field: 'commit' }).text()).toBe(shortSha);
expect(findCell({ field: 'commit' }).find('a').attributes('href')).toBe(commitPath);
});
});
describe('Table data formatting', () => {
let mockJobsCopy;
beforeEach(() => {
mockJobsCopy = [
{
...mockJobs[0],
},
];
});
it('Formats finishedAt time', () => {
mockJobsCopy[0].finishedAt = mockOneHourAgo;
createComponent({ props: { jobs: mockJobsCopy } }, mountExtended);
expect(findCell({ field: 'finished_at' }).text()).toBe('1 hour ago');
});
it('Formats tags', () => {
mockJobsCopy[0].tags = ['tag-1', 'tag-2'];
createComponent({ props: { jobs: mockJobsCopy } }, mountExtended);
expect(findCell({ field: 'tags' }).text()).toMatchInterpolatedText('tag-1 tag-2');
});
});
});
......@@ -123,6 +123,7 @@ describe('RunnerUpdateForm', () => {
// Some read-only fields are not submitted
const {
__typename,
ipAddress,
runnerType,
createdAt,
......@@ -132,7 +133,7 @@ describe('RunnerUpdateForm', () => {
userPermissions,
version,
groups,
__typename,
jobCount,
...submitted
} = mockRunner;
......
......@@ -7,6 +7,7 @@ import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query
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';
import runnerJobsData from 'test_fixtures/graphql/runner/get_runner_jobs.query.graphql.json';
// Group queries
import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json';
......@@ -20,6 +21,7 @@ export {
runnerData,
runnerWithGroupData,
runnerProjectsData,
runnerJobsData,
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