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 d36d329cce4ec43ce587efc79a77b2be98a37673..ba08a7864fb204128a72284e240177dc92a8c442 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 @@ -19,6 +19,11 @@ export default { RunnerActiveList, RunnerWaitTimes, }, + inject: { + clickhouseCiAnalyticsAvailable: { + default: false, + }, + }, props: { adminRunnersPath: { type: String, @@ -49,23 +54,22 @@ export default { {{ s__('Runners|Use the dashboard to view performance statistics of your runner fleet.') }} </p> - <div> - <div class="gl-sm-display-flex gl-gap-4 gl-justify-content-space-between"> - <runner-dashboard-stat-online - class="runners-dashboard-third-gap-4 gl-flex-grow-1 gl-mb-4" - /> - <runner-dashboard-stat-offline - class="runners-dashboard-third-gap-4 gl-flex-grow-1 gl-mb-4" - /> - <runner-usage class="runners-dashboard-third-gap-4 gl-flex-grow-1 gl-mb-4" /> - </div> + <div class="gl-sm-display-flex gl-column-gap-4 gl-justify-content-space-between"> + <div class="gl-sm-display-flex gl-column-gap-4 gl-justify-content-space-between gl-w-full"> + <div + class="runners-dashboard-two-thirds-gap-4 gl-display-flex gl-gap-4 gl-justify-content-space-between gl-mb-4 gl-flex-wrap" + > + <runner-dashboard-stat-online class="runners-dashboard-half-gap-4" /> + <runner-dashboard-stat-offline class="runners-dashboard-half-gap-4" /> - <div class="gl-md-display-flex gl-gap-4 gl-justify-content-space-between"> - <runner-job-failures class="runners-dashboard-two-thirds-gap-4 gl-flex-grow-1 gl-mb-4" /> - <runner-active-list class="runners-dashboard-third-gap-4 gl-flex-grow-1 gl-mb-4" /> - </div> + <!-- we use job failures as fallback, when clickhouse is not available --> + <runner-usage v-if="clickhouseCiAnalyticsAvailable" class="gl-flex-basis-full" /> + <runner-job-failures v-else class="gl-flex-basis-full" /> + </div> - <runner-wait-times class="runners-dashboard-wait-times" /> + <runner-active-list class="runners-dashboard-third-gap-4 gl-mb-4" /> + </div> </div> + <runner-wait-times class="gl-mb-4" /> </div> </template> diff --git a/ee/app/assets/javascripts/ci/runner/components/runner_usage.vue b/ee/app/assets/javascripts/ci/runner/components/runner_usage.vue index 21569555e84c0e84d4b813d0b89a6ea401f9f669..ac619cbf4477ab5d55a72f3736294e60f22fedf8 100644 --- a/ee/app/assets/javascripts/ci/runner/components/runner_usage.vue +++ b/ee/app/assets/javascripts/ci/runner/components/runner_usage.vue @@ -1,29 +1,59 @@ <script> -import { GlButton } from '@gitlab/ui'; +import { GlAvatar, GlButton, GlLink, GlTableLite } from '@gitlab/ui'; import { createAlert } from '~/alert'; -import { s__ } from '~/locale'; +import { s__, formatNumber } from '~/locale'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { INSTANCE_TYPE } from '~/ci/runner/constants'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import RunnerUsageQuery from '../graphql/performance/runner_usage.query.graphql'; +import RunnerUsageByProjectQuery from '../graphql/performance/runner_usage_by_project.query.graphql'; import RunnerUsageExportMutation from '../graphql/performance/runner_usage_export.mutation.graphql'; +const thClass = ['gl-font-sm!', 'gl-text-secondary!']; + export default { name: 'RunnerUsage', components: { + GlAvatar, GlButton, - }, - inject: { - clickhouseCiAnalyticsAvailable: { - default: false, - }, + GlLink, + GlTableLite, }, data() { return { loading: false, }; }, + apollo: { + topProjects: { + query: RunnerUsageByProjectQuery, + update(data) { + return data.runnerUsageByProject; + }, + }, + topRunners: { + query: RunnerUsageQuery, + update(data) { + return data.runnerUsage; + }, + }, + }, methods: { + formatNumber, + runnerName(runner) { + const { id: graphqlId, shortSha, description } = runner; + const id = getIdFromGraphQLId(graphqlId); + + if (description) { + return `#${id} (${shortSha}) - ${description}`; + } + return `#${id} (${shortSha})`; + }, + findClosestTd(el) { + return el.closest('td'); + }, async onClick() { const confirmed = await confirmAction( s__( @@ -76,16 +106,79 @@ export default { } }, }, + topRunnersFields: [ + { + key: 'runner', + label: s__('Runners|Most used instance runners'), + thClass: [...thClass, 'gl-width-full'], + }, + { + key: 'ciMinutesUsed', + label: s__('Runners|Usage (min)'), + thClass: [...thClass, 'gl-text-right'], + tdClass: 'gl-text-right', + }, + ], + topProjectsFields: [ + { + key: 'project', + label: s__('Runners|Top projects consuming runners'), + thClass: [...thClass, 'gl-width-full'], + }, + { + key: 'ciMinutesUsed', + label: s__('Runners|Usage (min)'), + thClass: [...thClass, 'gl-text-right'], + tdClass: 'gl-text-right', + }, + ], }; </script> <template> - <div v-if="clickhouseCiAnalyticsAvailable" class="gl-border gl-rounded-base gl-p-5"> - <div class="gl-display-flex gl-align-items-center"> - <h2 class="gl-font-lg gl-m-0 gl-flex-grow-1">{{ s__('Runners|Runner Usage') }}</h2> - + <div class="gl-border gl-rounded-base gl-p-5"> + <div class="gl-display-flex gl-align-items-center gl-mb-4"> + <h2 class="gl-font-lg gl-flex-grow-1 gl-m-0"> + {{ s__('Runners|Runner Usage (previous month)') }} + </h2> <gl-button :loading="loading" size="small" @click="onClick"> {{ s__('Runners|Export as CSV') }} </gl-button> </div> + + <div + class="gl-md-display-flex gl-justify-content-space-between gl-align-items-flex-start gl-gap-4" + > + <gl-table-lite + :fields="$options.topProjectsFields" + :items="topProjects" + class="runners-top-result-table runners-dashboard-half-gap-4" + data-testid="top-projects-table" + > + <template #cell(project)="{ value }"> + <gl-avatar + :label="value.name" + :src="value.avatarUrl" + shape="rect" + :size="16" + :entity-name="value.name" + /> + <gl-link :href="value.webUrl" class="gl-text-body!">{{ value.name }}</gl-link> + </template> + + <template #cell(ciMinutesUsed)="{ value }">{{ formatNumber(value) }}</template> + </gl-table-lite> + + <gl-table-lite + :fields="$options.topRunnersFields" + :items="topRunners" + class="runners-top-result-table runners-dashboard-half-gap-4" + data-testid="top-runners-table" + > + <template #cell(runner)="{ value }"> + <gl-link :href="value.adminUrl" class="gl-text-body!">{{ runnerName(value) }}</gl-link> + </template> + <template #cell(ciMinutesUsed)="{ value }">{{ formatNumber(value) }}</template> + </gl-table-lite> + </div> </div> </template> diff --git a/ee/app/assets/javascripts/ci/runner/graphql/performance/runner_usage.query.graphql b/ee/app/assets/javascripts/ci/runner/graphql/performance/runner_usage.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..84edf3403c6de831f87bc203f3d093ab83cf826c --- /dev/null +++ b/ee/app/assets/javascripts/ci/runner/graphql/performance/runner_usage.query.graphql @@ -0,0 +1,11 @@ +query getRunnerUsage { + runnerUsage(runnerType: INSTANCE_TYPE, runnersLimit: 5) { + runner { + id + shortSha + description + adminUrl + } + ciMinutesUsed + } +} diff --git a/ee/app/assets/javascripts/ci/runner/graphql/performance/runner_usage_by_project.query.graphql b/ee/app/assets/javascripts/ci/runner/graphql/performance/runner_usage_by_project.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..fb7f5ec2f5017e52d1254f8daace43a35dce1a68 --- /dev/null +++ b/ee/app/assets/javascripts/ci/runner/graphql/performance/runner_usage_by_project.query.graphql @@ -0,0 +1,11 @@ +query getRunnerUsageByProject { + runnerUsageByProject(runnerType: INSTANCE_TYPE, projectsLimit: 5) { + project { + id + name + avatarUrl + webUrl + } + ciMinutesUsed + } +} diff --git a/ee/app/assets/stylesheets/page_bundles/runners.scss b/ee/app/assets/stylesheets/page_bundles/runners.scss index ce52f67432a69c1bda4d7c18fa347beef1689baf..a1838f4c4dc87df5f03cf0d0d9af3870c436b795 100644 --- a/ee/app/assets/stylesheets/page_bundles/runners.scss +++ b/ee/app/assets/stylesheets/page_bundles/runners.scss @@ -1,13 +1,21 @@ @import 'page_bundles/mixins_and_variables_and_functions'; +.runners-dashboard-half-gap-4 { + // Subtract the length of gl-gap-4 to show a correct gap + flex-basis: calc(50% - #{$gl-spacing-scale-4}); + flex-grow: 1; +} + .runners-dashboard-third-gap-4 { // Subtract the length of gl-gap-4 to show a correct gap flex-basis: calc(33.33% - #{$gl-spacing-scale-4}); + flex-grow: 1; } .runners-dashboard-two-thirds-gap-4 { // Subtract half of the length of gl-gap-4 to show a correct gap flex-basis: calc(66.66% - #{$gl-spacing-scale-4 / 2}); + flex-grow: 1; } .runner-active-list-table tr:first-child th { @@ -17,3 +25,19 @@ .runner-active-list-table tr:last-child td { border-bottom: 0; } + + +.runners-top-result-table { + td { + white-space: nowrap; + text-overflow:ellipsis; + overflow: hidden; + max-width: 1px; + padding-top: $gl-spacing-scale-3 !important; + padding-bottom: $gl-spacing-scale-3 !important; + } + + tr:last-child td { + border-bottom: 0; + } +} 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 51e60c3878b8948057ba815f6100c70a4210a535..fb1532039ec50e4bccda44642917895bb7c1b433 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 @@ -16,12 +16,13 @@ const mockNewRunnerPath = '/runners/new'; describe('AdminRunnersDashboardApp', () => { let wrapper; - const createComponent = () => { + const createComponent = (options) => { wrapper = shallowMountExtended(AdminRunnersDashboardApp, { propsData: { adminRunnersPath: mockAdminRunnersPath, newRunnerPath: mockNewRunnerPath, }, + ...options, }); }; @@ -42,9 +43,39 @@ describe('AdminRunnersDashboardApp', () => { it('shows dashboard panels', () => { expect(wrapper.findComponent(RunnerDashboardStatOnline).exists()).toBe(true); expect(wrapper.findComponent(RunnerDashboardStatOffline).exists()).toBe(true); - expect(wrapper.findComponent(RunnerUsage).exists()).toBe(true); - expect(wrapper.findComponent(RunnerJobFailures).exists()).toBe(true); expect(wrapper.findComponent(RunnerActiveList).exists()).toBe(true); expect(wrapper.findComponent(RunnerWaitTimes).exists()).toBe(true); }); + + describe('when clickhouse is available', () => { + beforeEach(() => { + createComponent({ + provide: { clickhouseCiAnalyticsAvailable: true }, + }); + }); + + it('shows runner usage', () => { + expect(wrapper.findComponent(RunnerUsage).exists()).toBe(true); + }); + + it('does not show job failures', () => { + expect(wrapper.findComponent(RunnerJobFailures).exists()).toBe(false); + }); + }); + + describe('when clickhouse is not available', () => { + beforeEach(() => { + createComponent({ + provide: { clickhouseCiAnalyticsAvailable: false }, + }); + }); + + it('does not runner usage', () => { + expect(wrapper.findComponent(RunnerUsage).exists()).toBe(false); + }); + + it('shows job failures', () => { + expect(wrapper.findComponent(RunnerJobFailures).exists()).toBe(true); + }); + }); }); diff --git a/ee/spec/frontend/ci/runner/components/runner_usage_spec.js b/ee/spec/frontend/ci/runner/components/runner_usage_spec.js index 3cb68a397c35ef3ea3dc29bc535770bf32daf389..8e012c269897973ae07783a45c78050847a5787a 100644 --- a/ee/spec/frontend/ci/runner/components/runner_usage_spec.js +++ b/ee/spec/frontend/ci/runner/components/runner_usage_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlButton } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import { createAlert } from '~/alert'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; @@ -9,6 +9,9 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import { INSTANCE_TYPE } from '~/ci/runner/constants'; + +import RunnerUsageQuery from 'ee/ci/runner/graphql/performance/runner_usage.query.graphql'; +import RunnerUsageByProjectQuery from 'ee/ci/runner/graphql/performance/runner_usage_by_project.query.graphql'; import RunnerUsageExportMutation from 'ee/ci/runner/graphql/performance/runner_usage_export.mutation.graphql'; import RunnerUsage from 'ee/ci/runner/components/runner_usage.vue'; @@ -19,36 +22,102 @@ jest.mock('~/alert'); jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); jest.mock('~/sentry/sentry_browser_wrapper'); +const mockRunnerUsage = [ + { + runner: { + id: 'gid://gitlab/Ci::Runner/1', + shortSha: 'sha1', + description: 'Runner 1', + adminUrl: '/admin/runners/1', + __typename: 'CiRunner', + }, + ciMinutesUsed: 1001, + __typename: 'CiRunnerUsage', + }, + { + runner: { + id: 'gid://gitlab/Ci::Runner/2', + shortSha: 'sha2', + description: 'Runner 2', + adminUrl: '/admin/runners/2', + __typename: 'CiRunner', + }, + ciMinutesUsed: 11, + __typename: 'CiRunnerUsage', + }, +]; + +const mockRunnerUsageByProject = [ + { + project: { + id: 'gid://gitlab/Project/1', + name: 'Project1', + avatarUrl: '/project1.png', + webUrl: '/group1/project1', + __typename: 'Project', + }, + ciMinutesUsed: 1002, + __typename: 'CiRunnerUsageByProject', + }, + { + project: { + id: 'gid://gitlab/Project/22', + name: 'Project2', + avatarUrl: '/project2.png', + webUrl: '/group1/project2', + __typename: 'Project', + }, + ciMinutesUsed: 12, + __typename: 'CiRunnerUsageByProject', + }, +]; + describe('RunnerUsage', () => { let wrapper; let mockToast; - let runnerUsageExportHandler; + + const runnerUsageHandler = jest.fn(); + const runnerUsageByProjectHandler = jest.fn(); + const runnerUsageExportHandler = jest.fn(); const findButton = () => wrapper.findComponent(GlButton); + const findTopRunners = () => wrapper.findByTestId('top-runners-table').findAll('tr'); + const findTopProjects = () => wrapper.findByTestId('top-projects-table').findAll('tr'); + const clickButton = async () => { findButton().vm.$emit('click'); await waitForPromises(); }; - const createWrapper = ({ provide } = {}) => { + const createWrapper = ({ mountFn = shallowMountExtended } = {}) => { confirmAction.mockResolvedValue(true); - runnerUsageExportHandler = jest.fn(); mockToast = jest.fn(); - wrapper = shallowMount(RunnerUsage, { - apolloProvider: createMockApollo([[RunnerUsageExportMutation, runnerUsageExportHandler]]), + runnerUsageHandler.mockResolvedValue({ + data: { runnerUsage: mockRunnerUsage }, + }); + runnerUsageByProjectHandler.mockResolvedValue({ + data: { runnerUsageByProject: mockRunnerUsageByProject }, + }); + + wrapper = mountFn(RunnerUsage, { + apolloProvider: createMockApollo([ + [RunnerUsageQuery, runnerUsageHandler], + [RunnerUsageByProjectQuery, runnerUsageByProjectHandler], + [RunnerUsageExportMutation, runnerUsageExportHandler], + ]), mocks: { $toast: { show: mockToast }, }, - provide: { - clickhouseCiAnalyticsAvailable: true, - ...provide, - }, }); }; beforeEach(() => { + runnerUsageHandler.mockReset(); + runnerUsageByProjectHandler.mockReset(); + runnerUsageExportHandler.mockReset(); + createWrapper(); }); @@ -56,12 +125,48 @@ describe('RunnerUsage', () => { expect(findButton().text()).toBe('Export as CSV'); }); - it('does not render when clickhouseCiAnalytics is disabled', () => { - createWrapper({ - provide: { clickhouseCiAnalyticsAvailable: false }, + it('loads top projects', async () => { + createWrapper({ mountFn: mountExtended }); + + await waitForPromises(); + + const [header, row1, row2] = findTopProjects().wrappers; + + expect(header.text()).toContain('Top projects consuming runners'); + expect(header.text()).toContain('Usage (min)'); + + expect(row1.findComponent(GlAvatar).attributes()).toMatchObject({ + label: 'Project1', + src: '/project1.png', + }); + expect(row1.text()).toContain('Project1'); + expect(row1.text()).toContain('1,002'); + + expect(row2.findComponent(GlAvatar).attributes()).toMatchObject({ + label: 'Project2', + src: '/project2.png', }); + expect(row2.text()).toContain('Project2'); + expect(row2.text()).toContain('12'); + }); + + it('loads top runners', async () => { + createWrapper({ mountFn: mountExtended }); + + await waitForPromises(); + + const [header, row1, row2] = findTopRunners().wrappers.map((w) => w.text()); + + expect(header).toContain('Most used instance runners'); + expect(header).toContain('Usage (min)'); + + expect(row1).toContain('#1 (sha1)'); + expect(row1).toContain('Runner 1'); + expect(row1).toContain('1,001'); - expect(wrapper.html()).toBe(''); + expect(row2).toContain('#2 (sha2)'); + expect(row2).toContain('Runner 2'); + expect(row2).toContain('11'); }); it('calls mutation on button click', async () => { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8d4ff6a7c9a38edc36066c8dae5b3c642f97b6e6..cc5c4eff54a257bff621cf9e3c1589ef3090e411 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -42579,6 +42579,9 @@ msgstr "" msgid "Runners|Most recent failures" msgstr "" +msgid "Runners|Most used instance runners" +msgstr "" + msgid "Runners|Name" msgstr "" @@ -42760,7 +42763,7 @@ msgstr "" msgid "Runners|Runner Registration token" msgstr "" -msgid "Runners|Runner Usage" +msgid "Runners|Runner Usage (previous month)" msgstr "" msgid "Runners|Runner assigned to project." @@ -43031,6 +43034,9 @@ msgstr "" msgid "Runners|Token expiry" msgstr "" +msgid "Runners|Top projects consuming runners" +msgstr "" + msgid "Runners|UTC Time" msgstr "" @@ -43052,6 +43058,9 @@ msgstr "" msgid "Runners|Upgrade recommended" msgstr "" +msgid "Runners|Usage (min)" +msgstr "" + msgid "Runners|Use Group runners when you want all projects in a group to have access to a set of runners." msgstr ""