diff --git a/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_active_list.vue b/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_active_list.vue new file mode 100644 index 0000000000000000000000000000000000000000..f33d845210254fbcbb70caf55c37da92cb259549 --- /dev/null +++ b/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_active_list.vue @@ -0,0 +1,65 @@ +<script> +import mostActiveRunnersQuery from 'ee/ci/runner/graphql/performance/most_active_runners.query.graphql'; +import RunnerActiveList from 'ee/ci/runner/components/runner_active_list.vue'; + +import { captureException } from '~/ci/runner/sentry_utils'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/alert'; +import { I18N_FETCH_ERROR, JOBS_ROUTE_PATH } from '~/ci/runner/constants'; + +export default { + name: 'AdminRunnerActiveList', + components: { + RunnerActiveList, + }, + data() { + return { + activeRunners: [], + }; + }, + apollo: { + activeRunners: { + query: mostActiveRunnersQuery, + fetchPolicy: fetchPolicies.NETWORK_ONLY, + update({ runners }) { + const items = runners?.nodes || []; + return ( + items + // The backend does not filter out inactive runners, but + // showing them can be confusing for users. Ignore runners + // with no active jobs. + .filter((item) => item.runningJobCount > 0) + .map((item) => { + const { adminUrl, ...runner } = item; + return { + ...runner, + jobsUrl: this.jobsUrl(adminUrl), + }; + }) + ); + }, + error(error) { + createAlert({ message: I18N_FETCH_ERROR }); + + captureException({ error, component: this.$options.name }); + }, + }, + }, + computed: { + loading() { + return this.$apollo.queries.activeRunners.loading; + }, + }, + methods: { + jobsUrl(adminUrl) { + const url = new URL(adminUrl); + url.hash = JOBS_ROUTE_PATH; + + return url.href; + }, + }, +}; +</script> +<template> + <runner-active-list :active-runners="activeRunners" :loading="loading" /> +</template> diff --git a/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app.vue b/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app.vue index ba08a7864fb204128a72284e240177dc92a8c442..00b755e56f4a3ee8dedfce7230331c8308bd4778 100644 --- a/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app.vue +++ b/ee/app/assets/javascripts/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app.vue @@ -5,18 +5,19 @@ import RunnerDashboardStatOnline from '../components/runner_dashboard_stat_onlin import RunnerDashboardStatOffline from '../components/runner_dashboard_stat_offline.vue'; import RunnerUsage from '../components/runner_usage.vue'; import RunnerJobFailures from '../components/runner_job_failures.vue'; -import RunnerActiveList from '../components/runner_active_list.vue'; import RunnerWaitTimes from '../components/runner_wait_times.vue'; +import AdminRunnerActiveList from './admin_runners_active_list.vue'; + export default { components: { GlButton, + AdminRunnerActiveList, RunnerListHeader, RunnerDashboardStatOnline, RunnerDashboardStatOffline, RunnerUsage, RunnerJobFailures, - RunnerActiveList, RunnerWaitTimes, }, inject: { @@ -67,7 +68,7 @@ export default { <runner-job-failures v-else class="gl-flex-basis-full" /> </div> - <runner-active-list class="runners-dashboard-third-gap-4 gl-mb-4" /> + <admin-runner-active-list class="runners-dashboard-third-gap-4 gl-mb-4" /> </div> </div> <runner-wait-times class="gl-mb-4" /> diff --git a/ee/app/assets/javascripts/ci/runner/components/runner_active_list.vue b/ee/app/assets/javascripts/ci/runner/components/runner_active_list.vue index 57bac5a90c20148f91ab9bc254da446cde3edd52..3f8bfd2f2ffb458c4c2fc0922c1dfb570aa599b4 100644 --- a/ee/app/assets/javascripts/ci/runner/components/runner_active_list.vue +++ b/ee/app/assets/javascripts/ci/runner/components/runner_active_list.vue @@ -1,14 +1,7 @@ <script> -import EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; import { formatNumber, s__ } from '~/locale'; -import mostActiveRunnersQuery from 'ee/ci/runner/graphql/performance/most_active_runners.graphql'; - -import { captureException } from '~/ci/runner/sentry_utils'; -import { fetchPolicies } from '~/lib/graphql'; -import { createAlert } from '~/alert'; -import { I18N_FETCH_ERROR, JOBS_ROUTE_PATH } from '~/ci/runner/constants'; import { tableField } from '~/ci/runner/utils'; import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue'; @@ -23,41 +16,19 @@ export default { GlSkeletonLoader, RunnerFullName, }, - data() { - return { - activeRunners: [], - }; - }, - apollo: { + props: { activeRunners: { - query: mostActiveRunnersQuery, - fetchPolicy: fetchPolicies.NETWORK_ONLY, - update({ runners }) { - // The backend does not filter out inactive runners, but - // showing them can be confusing for users. Ignore runners - // with no active jobs. - const items = runners?.nodes || []; - return items.filter((item) => item.runningJobCount > 0); - }, - error(error) { - createAlert({ message: I18N_FETCH_ERROR }); - - captureException({ error, component: this.$options.name }); - }, + type: Array, + default: () => [], + required: false, }, - }, - computed: { - loading() { - return this.$apollo.queries.activeRunners.loading; + loading: { + type: Boolean, + default: false, + required: false, }, }, methods: { - jobsUrl({ adminUrl }) { - const url = new URL(adminUrl); - url.hash = JOBS_ROUTE_PATH; - - return url.href; - }, formatNumber, }, fields: [ @@ -70,7 +41,6 @@ export default { }), ], CI_ICON_STATUS: { group: 'running', icon: 'status_running' }, - EMPTY_STATE_SVG_URL, }; </script> <template> @@ -94,7 +64,7 @@ export default { <runner-full-name :runner="item" /> </template> <template #cell(runningJobCount)="{ item = {}, value }"> - <gl-link :href="jobsUrl(item)"> + <gl-link :href="item.jobsUrl"> <ci-icon :status="$options.CI_ICON_STATUS" /> {{ formatNumber(value) }} </gl-link> diff --git a/ee/app/assets/javascripts/ci/runner/graphql/performance/most_active_runners.graphql b/ee/app/assets/javascripts/ci/runner/graphql/performance/most_active_runners.query.graphql similarity index 100% rename from ee/app/assets/javascripts/ci/runner/graphql/performance/most_active_runners.graphql rename to ee/app/assets/javascripts/ci/runner/graphql/performance/most_active_runners.query.graphql diff --git a/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_active_list_spec.js b/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_active_list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..dfea760a25a21c368de7021e35873db1d81b6bcc --- /dev/null +++ b/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_active_list_spec.js @@ -0,0 +1,123 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import RunnerActiveList from 'ee/ci/runner/components/runner_active_list.vue'; + +import AdminRunnersActiveList from 'ee/ci/runner/admin_runners_dashboard/admin_runners_active_list.vue'; +import mostActiveRunnersQuery from 'ee/ci/runner/graphql/performance/most_active_runners.query.graphql'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import { captureException } from '~/ci/runner/sentry_utils'; +import { JOBS_ROUTE_PATH } from '~/ci/runner/constants'; + +import { mostActiveRunnersData } from '../mock_data'; + +jest.mock('~/alert'); +jest.mock('~/ci/runner/sentry_utils'); + +const mostActiveRunners = mostActiveRunnersData.data.runners.nodes; +const [mockRunner, mockRunner2] = mostActiveRunners; + +Vue.use(VueApollo); + +describe('AdminRunnersActiveList', () => { + let wrapper; + let mostActiveRunnersHandler; + + const findRunnersActiveList = () => wrapper.findComponent(RunnerActiveList); + + const createComponent = () => { + wrapper = shallowMountExtended(AdminRunnersActiveList, { + apolloProvider: createMockApollo([[mostActiveRunnersQuery, mostActiveRunnersHandler]]), + }); + }; + + beforeEach(() => { + mostActiveRunnersHandler = jest.fn(); + }); + + it('Requests most active runners', () => { + createComponent(); + + expect(mostActiveRunnersHandler).toHaveBeenCalledTimes(1); + }); + + describe('When loading data', () => { + it('should show a loading skeleton', () => { + createComponent(); + + expect(findRunnersActiveList().props('loading')).toBe(true); + }); + }); + + describe('When there are active runners', () => { + beforeEach(async () => { + mostActiveRunnersHandler.mockResolvedValue(mostActiveRunnersData); + + createComponent(); + await waitForPromises(); + }); + + it('shows results', () => { + expect(findRunnersActiveList().props('loading')).toBe(false); + expect(findRunnersActiveList().props('activeRunners')).toHaveLength(2); + }); + + it('shows runner jobs url', () => { + const { adminUrl, ...runner } = mockRunner; + expect(findRunnersActiveList().props('activeRunners')[0]).toMatchObject(runner); + expect(findRunnersActiveList().props('activeRunners')[0].jobsUrl).toEqual( + `${adminUrl}#${JOBS_ROUTE_PATH}`, + ); + }); + }); + + describe('When there are active runners with no active jobs', () => { + beforeEach(async () => { + mostActiveRunnersHandler.mockResolvedValue({ + data: { + runners: { + nodes: [ + mockRunner, + { + ...mockRunner2, + runningJobCount: 0, + }, + ], + }, + }, + }); + + createComponent(); + await waitForPromises(); + }); + + it('ignores runners with no active jobs', () => { + expect(findRunnersActiveList().props('activeRunners')).toHaveLength(1); + expect(findRunnersActiveList().props('activeRunners')[0].id).toBe(mockRunner.id); + }); + }); + + describe('When an error occurs', () => { + beforeEach(async () => { + mostActiveRunnersHandler.mockRejectedValue(new Error('Error!')); + + createComponent(); + await waitForPromises(); + }); + + it('shows an error', () => { + expect(createAlert).toHaveBeenCalled(); + }); + + it('reports an error', () => { + expect(captureException).toHaveBeenCalledWith({ + component: 'AdminRunnerActiveList', + error: expect.any(Error), + }); + }); + }); +}); diff --git a/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app_spec.js b/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app_spec.js index fb1532039ec50e4bccda44642917895bb7c1b433..8e2de94c51ab3379a543a3870e7690c8384ddda4 100644 --- a/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app_spec.js +++ b/ee/spec/frontend/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app_spec.js @@ -1,13 +1,14 @@ import { GlButton } from '@gitlab/ui'; import AdminRunnersDashboardApp from 'ee/ci/runner/admin_runners_dashboard/admin_runners_dashboard_app.vue'; +import AdminRunnerActiveList from 'ee/ci/runner/admin_runners_dashboard/admin_runners_active_list.vue'; + import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RunnerDashboardStatOnline from 'ee/ci/runner/components/runner_dashboard_stat_online.vue'; import RunnerDashboardStatOffline from 'ee/ci/runner/components/runner_dashboard_stat_offline.vue'; import RunnerUsage from 'ee/ci/runner/components/runner_usage.vue'; import RunnerJobFailures from 'ee/ci/runner/components/runner_job_failures.vue'; -import RunnerActiveList from 'ee/ci/runner/components/runner_active_list.vue'; import RunnerWaitTimes from 'ee/ci/runner/components/runner_wait_times.vue'; const mockAdminRunnersPath = '/runners/list'; @@ -43,7 +44,7 @@ describe('AdminRunnersDashboardApp', () => { it('shows dashboard panels', () => { expect(wrapper.findComponent(RunnerDashboardStatOnline).exists()).toBe(true); expect(wrapper.findComponent(RunnerDashboardStatOffline).exists()).toBe(true); - expect(wrapper.findComponent(RunnerActiveList).exists()).toBe(true); + expect(wrapper.findComponent(AdminRunnerActiveList).exists()).toBe(true); expect(wrapper.findComponent(RunnerWaitTimes).exists()).toBe(true); }); diff --git a/ee/spec/frontend/ci/runner/components/runner_active_list_spec.js b/ee/spec/frontend/ci/runner/components/runner_active_list_spec.js index 69df9e5a1896788db792a0c69c272c9008ff25d0..e6ad9af52237c42a60c79b0bc8f8e30e2512835e 100644 --- a/ee/spec/frontend/ci/runner/components/runner_active_list_spec.js +++ b/ee/spec/frontend/ci/runner/components/runner_active_list_spec.js @@ -1,37 +1,24 @@ import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui'; -import Vue from 'vue'; -import VueApollo from 'vue-apollo'; -import { - extendedWrapper, - shallowMountExtended, - mountExtended, -} from 'helpers/vue_test_utils_helper'; -import { stubComponent } from 'helpers/stub_component'; +import { extendedWrapper, mountExtended } from 'helpers/vue_test_utils_helper'; import RunnerActiveList from 'ee/ci/runner/components/runner_active_list.vue'; -import mostActiveRunnersQuery from 'ee/ci/runner/graphql/performance/most_active_runners.graphql'; - -import createMockApollo from 'helpers/mock_apollo_helper'; -import waitForPromises from 'helpers/wait_for_promises'; -import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { createAlert } from '~/alert'; -import { captureException } from '~/ci/runner/sentry_utils'; import { JOBS_ROUTE_PATH } from '~/ci/runner/constants'; - import { mostActiveRunnersData } from '../mock_data'; jest.mock('~/alert'); jest.mock('~/ci/runner/sentry_utils'); const mostActiveRunners = mostActiveRunnersData.data.runners.nodes; -const [mockRunner, mockRunner2] = mostActiveRunners; +const [{ adminUrl, ...mockRunner }, { adminUrl2, ...mockRunner2 }] = mostActiveRunners; -Vue.use(VueApollo); +mockRunner.jobsUrl = `${adminUrl}#${JOBS_ROUTE_PATH}`; +mockRunner2.jobsUrl = `${adminUrl2}#${JOBS_ROUTE_PATH}`; + +// Vue.use(VueApollo); describe('RunnerActiveList', () => { let wrapper; - let mostActiveRunnersHandler; const findTable = () => wrapper.findComponent(GlTable); const findHeaders = () => wrapper.findAll('thead th'); @@ -39,41 +26,31 @@ describe('RunnerActiveList', () => { const findCell = (row = 0, fieldKey) => extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); - const createComponent = ({ mountFn = shallowMountExtended, ...options } = {}) => { - wrapper = mountFn(RunnerActiveList, { - apolloProvider: createMockApollo([[mostActiveRunnersQuery, mostActiveRunnersHandler]]), + const createComponent = ({ props = {}, ...options } = {}) => { + wrapper = mountExtended(RunnerActiveList, { + propsData: { + ...props, + }, ...options, }); }; - beforeEach(() => { - mostActiveRunnersHandler = jest.fn(); - }); - - it('Requests most active runners', () => { - createComponent({ - stubs: { - GlTable: stubComponent(GlTable), - }, - }); - - expect(mostActiveRunnersHandler).toHaveBeenCalledTimes(1); - }); - describe('When loading data', () => { it('should show a loading skeleton', () => { - createComponent({ mountFn: mountExtended }); + createComponent({ props: { loading: true }, mountFn: mountExtended }); expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(true); }); }); describe('When there are active runners', () => { - beforeEach(async () => { - mostActiveRunnersHandler.mockResolvedValue(mostActiveRunnersData); - - createComponent({ mountFn: mountExtended }); - await waitForPromises(); + beforeEach(() => { + createComponent({ + props: { + activeRunners: [mockRunner, mockRunner2], + }, + mountFn: mountExtended, + }); }); it('shows table', () => { @@ -82,7 +59,7 @@ describe('RunnerActiveList', () => { it('shows headers', () => { const headers = findHeaders().wrappers.map((w) => w.text()); - expect(headers).toEqual(['', s__('Runners|Runner'), s__('Runners|Running Jobs')]); + expect(headers).toEqual(['', 'Runner', 'Running Jobs']); }); it('shows runners', () => { @@ -105,60 +82,15 @@ describe('RunnerActiveList', () => { expect(findCell(1, 'runningJobCount').text()).toBe('1'); }); - it('shows jobs link', async () => { - createComponent({ mountFn: mountExtended }); - await waitForPromises(); - + it('shows jobs link', () => { const url = findCell(0, 'runningJobCount').findComponent(GlLink).attributes('href'); - expect(url).toBe(`${mockRunner.adminUrl}#${JOBS_ROUTE_PATH}`); - }); - }); - - describe('When there are active runners with no active jobs', () => { - beforeEach(async () => { - mostActiveRunnersHandler.mockResolvedValue({ - data: { - runners: { - nodes: [ - mockRunner, - { - ...mockRunner2, - runningJobCount: 0, - }, - ], - }, - }, - }); - - createComponent({ mountFn: mountExtended }); - await waitForPromises(); - }); - - it('ignores runners with no active jobs', () => { - expect(findRows()).toHaveLength(1); - - // Row 1 - const runner = `#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha}) - ${ - mockRunner.description - }`; - expect(findCell(0, 'index').text()).toBe('1'); - expect(findCell(0, 'runner').text()).toBe(runner); - expect(findCell(0, 'runningJobCount').text()).toBe('2'); + expect(url).toBe(mockRunner.jobsUrl); }); }); describe('When there are no runners', () => { - beforeEach(async () => { - mostActiveRunnersHandler.mockResolvedValueOnce({ - data: { - runners: { - nodes: [], - }, - }, - }); - + beforeEach(() => { createComponent({ mountFn: mountExtended }); - await waitForPromises(); }); it('should render no runners', () => { @@ -167,28 +99,4 @@ describe('RunnerActiveList', () => { expect(wrapper.text()).toContain('no runners'); }); }); - - describe('When an error occurs', () => { - beforeEach(async () => { - mostActiveRunnersHandler.mockRejectedValue(new Error('Error!')); - - createComponent({ - stubs: { - GlTable: stubComponent(GlTable), - }, - }); - await waitForPromises(); - }); - - it('shows an error', () => { - expect(createAlert).toHaveBeenCalled(); - }); - - it('reports an error', () => { - expect(captureException).toHaveBeenCalledWith({ - component: 'RunnerActiveList', - error: expect.any(Error), - }); - }); - }); }); diff --git a/ee/spec/frontend/ci/runner/mock_data.js b/ee/spec/frontend/ci/runner/mock_data.js index e9af32b92ccbc90a356306fe26c727d0968930b6..40518e13f549baff37908e17d275059326944650 100644 --- a/ee/spec/frontend/ci/runner/mock_data.js +++ b/ee/spec/frontend/ci/runner/mock_data.js @@ -4,7 +4,7 @@ import allRunnersUpgradeStatusData from 'test_fixtures/graphql/ci/runner/list/all_runners.query.graphql.upgrade_status.json'; // Dashboard queries -import mostActiveRunnersData from 'test_fixtures/graphql/ci/runner/performance/most_active_runners.graphql.json'; +import mostActiveRunnersData from 'test_fixtures/graphql/ci/runner/performance/most_active_runners.query.graphql.json'; import runnerFailedJobsData from 'test_fixtures/graphql/ci/runner/performance/runner_failed_jobs.graphql.json'; export const runnersWaitTimes = { diff --git a/ee/spec/frontend/fixtures/runner.rb b/ee/spec/frontend/fixtures/runner.rb index 5894c61fdeaebfcbfc70f28487c7e6671534a67f..7ebeb2596f8ff80676914c89d76cda4634497261 100644 --- a/ee/spec/frontend/fixtures/runner.rb +++ b/ee/spec/frontend/fixtures/runner.rb @@ -50,7 +50,7 @@ end describe 'most_active_runners.query.graphql', type: :request do - runner_jobs_query = 'performance/most_active_runners.graphql' + runner_jobs_query = 'performance/most_active_runners.query.graphql' let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{runner_jobs_query}", ee: true) end