diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_dashboard_clickhouse.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_dashboard_clickhouse.vue index 08776c26c106521a1d2319440387649a181a15ac..7b57ee9f22b629b08865b6a5a09105b9ed4fd795 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipelines_dashboard_clickhouse.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipelines_dashboard_clickhouse.vue @@ -20,6 +20,8 @@ export default { PipelinesStats, PipelineDurationChart, PipelineStatusChart, + JobAnalyticsTable: () => + import('ee_component/projects/pipelines/charts/components/job_analytics_table.vue'), }, inject: { defaultBranch: { @@ -40,6 +42,7 @@ export default { source: null, branch: this.defaultBranch, dateRange: DATE_RANGE_DEFAULT, + jobName: '', }; return { @@ -90,6 +93,7 @@ export default { branch: (this.params.branch === BRANCH_ANY ? null : this.params.branch) || null, fromTime: getDateInPast(today, DATE_RANGES_AS_DAYS[this.params.dateRange] || 7), toTime: today, + jobName: this.params.jobName || null, }; }, }, @@ -104,7 +108,7 @@ export default { this.params = paramsFromQuery(window.location.search, this.defaultParams); }, onFiltersInput(params) { - this.params = params; + this.params = { ...this.params, ...params }; updateQueryHistory(this.params, this.defaultParams); }, @@ -127,6 +131,7 @@ export default { + diff --git a/app/assets/javascripts/projects/pipelines/charts/url_utils.js b/app/assets/javascripts/projects/pipelines/charts/url_utils.js index dbf91970f03d12d6b35c42ba8123bbee467653a3..86b50d64eca1e7d578dd687ad810e1cb080b839d 100644 --- a/app/assets/javascripts/projects/pipelines/charts/url_utils.js +++ b/app/assets/javascripts/projects/pipelines/charts/url_utils.js @@ -3,6 +3,7 @@ import { queryToObject, updateHistory, mergeUrlParams } from '~/lib/utils/url_ut const PARAM_KEY_SOURCE = 'source'; const PARAM_KEY_BRANCH = 'branch'; const PARAM_KEY_DATE_RANGE = 'time'; +const PARAM_KEY_JOB_NAME = 'job'; /** * Returns an object that represents parameters in the URL @@ -16,6 +17,7 @@ export const paramsFromQuery = (searchString = window.location.search, defaultPa source: query[PARAM_KEY_SOURCE] || defaultParams.source, branch: query[PARAM_KEY_BRANCH] || defaultParams.branch, dateRange: query[PARAM_KEY_DATE_RANGE] || defaultParams.dateRange, + jobName: query[PARAM_KEY_JOB_NAME] || defaultParams.jobName, }; }; @@ -26,11 +28,12 @@ export const paramsFromQuery = (searchString = window.location.search, defaultPa * @param {Object} params - Default values, so URL is not updated with redundant values */ export const updateQueryHistory = (params, defaultParams = {}) => { - const { source, branch, dateRange } = params; + const { source, branch, dateRange, jobName } = params; const query = { [PARAM_KEY_SOURCE]: source === defaultParams.source ? null : source, [PARAM_KEY_BRANCH]: branch === defaultParams.branch ? null : branch, [PARAM_KEY_DATE_RANGE]: dateRange === defaultParams.dateRange ? null : dateRange, + [PARAM_KEY_JOB_NAME]: jobName === defaultParams.jobName ? null : jobName, }; updateHistory({ url: mergeUrlParams(query, window.location.href, { sort: true }), diff --git a/ee/app/assets/javascripts/projects/pipelines/charts/components/job_analytics_table.vue b/ee/app/assets/javascripts/projects/pipelines/charts/components/job_analytics_table.vue new file mode 100644 index 0000000000000000000000000000000000000000..fd3591092470c84d02f7663ee5e9eb196ee925ba --- /dev/null +++ b/ee/app/assets/javascripts/projects/pipelines/charts/components/job_analytics_table.vue @@ -0,0 +1,247 @@ + + + diff --git a/ee/app/assets/javascripts/projects/pipelines/charts/components/table_utils.js b/ee/app/assets/javascripts/projects/pipelines/charts/components/table_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..b2d0d93b2b4cf322b82b87ff740647ffd8843992 --- /dev/null +++ b/ee/app/assets/javascripts/projects/pipelines/charts/components/table_utils.js @@ -0,0 +1,19 @@ +import { formatNumber } from '~/locale'; +import { formatPipelineDuration } from '~/projects/pipelines/charts/format_utils'; + +export const numericField = () => ({ + thClass: 'gl-text-right', + tdClass: 'gl-text-right', + thAlignRight: true, + sortable: true, + formatter: (n) => + formatNumber(n, { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }), +}); + +export const durationField = () => ({ + ...numericField(), + formatter: formatPipelineDuration, +}); diff --git a/ee/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_job_analytics.query.graphql b/ee/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_job_analytics.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..52fcec697961f37561db0d8382f492273c47ee4c --- /dev/null +++ b/ee/app/assets/javascripts/projects/pipelines/charts/graphql/queries/get_job_analytics.query.graphql @@ -0,0 +1,49 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getJobAnalytics( + $fullPath: ID! + $fromTime: Time! + $toTime: Time! + $source: CiPipelineSources + $branch: String + $jobName: String + $before: String + $after: String + $first: Int + $last: Int + $sort: CiJobAnalyticsSort +) { + project(fullPath: $fullPath) { + id + jobAnalytics( + fromTime: $fromTime + toTime: $toTime + source: $source + ref: $branch + nameSearch: $jobName + before: $before + after: $after + first: $first + last: $last + sort: $sort + ) { + nodes { + name + statistics { + successRate: rate(status: SUCCESS) + failedRate: rate(status: FAILED) + successCount: count(status: SUCCESS) + failedCount: count(status: FAILED) + count + durationStatistics { + meanDuration: mean + p95Duration: p95 + } + } + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/ee/app/controllers/ee/projects/pipelines_controller.rb b/ee/app/controllers/ee/projects/pipelines_controller.rb index e7ea1dedc6f8c84a98d3784f3f297cf190759d81..34596b1510f83311d3a72b69eb04e3f7a094cead 100644 --- a/ee/app/controllers/ee/projects/pipelines_controller.rb +++ b/ee/app/controllers/ee/projects/pipelines_controller.rb @@ -11,6 +11,10 @@ module PipelinesController before_action :authorize_read_licenses!, only: [:licenses, :license_count] + before_action only: [:charts] do + push_licensed_feature(:ci_job_analytics_for_projects, project) + end + before_action only: [:show, :security] do if ::Feature.enabled?(:pipeline_security_ai_vr, project) push_frontend_ability( diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index 4647229372814f6bcb3aea212875aa479d4d020d..74cd985adad1e75ba67f882a55fcd01d952b253d 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -97,8 +97,9 @@ class Features blocking_merge_requests board_assignee_lists board_milestone_lists - ci_secrets_management + ci_job_analytics_for_projects ci_pipeline_cancellation_restrictions + ci_secrets_management cluster_agents_ci_impersonation cluster_agents_user_impersonation cluster_deployments diff --git a/ee/spec/frontend/pipelines/charts/components/job_analytics_table_spec.js b/ee/spec/frontend/pipelines/charts/components/job_analytics_table_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..7c94090206ec4ed1d448b166bf22955790d2905d --- /dev/null +++ b/ee/spec/frontend/pipelines/charts/components/job_analytics_table_spec.js @@ -0,0 +1,568 @@ +import { GlLoadingIcon, GlTable, GlSearchBoxByClick, GlEmptyState } from '@gitlab/ui'; +import CHART_EMPTY_STATE_SVG_URL from '@gitlab/svgs/dist/illustrations/empty-state/empty-pipeline-md.svg?url'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import JobAnalyticsTable from 'ee/projects/pipelines/charts/components/job_analytics_table.vue'; +import getJobAnalytics from 'ee/projects/pipelines/charts/graphql/queries/get_job_analytics.query.graphql'; +import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import { formatPipelineDuration } from '~/projects/pipelines/charts/format_utils'; +import { stubComponent } from 'helpers/stub_component'; + +Vue.use(VueApollo); + +describe('JobAnalyticsTable', () => { + let wrapper; + let mockJobAnalyticsHandler; + + const mockJobAnalyticsData = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + jobAnalytics: { + __typename: 'CiJobAnalyticsConnection', + nodes: [ + { + name: 'test-job', + statistics: { + successRate: 90.0, + failedRate: 0.0, + successCount: '9', + failedCount: '1', + count: '10', + durationStatistics: { + meanDuration: 60, + p95Duration: 90, + __typename: 'CiDurationStatistics', + }, + __typename: 'CiJobAnalyticsStatistics', + }, + __typename: 'CiJobAnalytics', + }, + { + name: 'build-job', + statistics: { + successRate: 100.0, + failedRate: 0.0, + successCount: '10', + failedCount: '0', + count: '10', + durationStatistics: { + meanDuration: 61, + p95Duration: 91, + __typename: 'CiDurationStatistics', + }, + __typename: 'CiJobAnalyticsStatistics', + }, + __typename: 'CiJobAnalytics', + }, + ], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'start-cursor', + endCursor: 'end-cursor', + }, + }, + }, + }, + }; + + const mockJobAnalyticsDataPage2 = { + data: { + project: { + __typename: 'Project', + id: 'gid://gitlab/Project/1', + jobAnalytics: { + __typename: 'CiJobAnalyticsConnection', + nodes: [ + { + name: 'deploy-job', + statistics: { + successRate: 95.0, + failedRate: 5.0, + successCount: '95', + failedCount: '5', + count: '100', + durationStatistics: { + meanDuration: 62, + p95Duration: 92, + __typename: 'CiDurationStatistics', + }, + __typename: 'CiJobAnalyticsStatistics', + }, + __typename: 'CiJobAnalytics', + }, + ], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: true, + startCursor: 'start-cursor-2', + endCursor: 'end-cursor-2', + }, + }, + }, + }, + }; + + const mockJobAnalyticsDataEmpty = { + data: { + project: { + id: 'gid://gitlab/Project/3', + jobAnalytics: { + nodes: [], + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + }, + }, + }, + }, + }; + + const mockVariables = { + fullPath: 'gitlab-org/gitlab', + fromTime: '2024-01-01T00:00:00Z', + toTime: '2024-01-31T23:59:59Z', + source: null, + branch: null, + jobName: null, + }; + + const createMockApolloProvider = (handler) => { + mockJobAnalyticsHandler = handler; + return createMockApollo([[getJobAnalytics, handler]]); + }; + + const createComponent = ({ + variables = mockVariables, + handler = jest.fn().mockResolvedValue(mockJobAnalyticsData), + provide = {}, + } = {}) => { + wrapper = shallowMount(JobAnalyticsTable, { + apolloProvider: createMockApolloProvider(handler), + propsData: { + variables, + }, + provide: { + glLicensedFeatures: { + ciJobAnalyticsForProjects: true, + }, + ...provide, + }, + stubs: { + GlTable: stubComponent(GlTable, { + props: { + items: {}, + noLocalSorting: {}, + ...GlTable.props, + }, + }), + }, + }); + }; + + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findTable = () => wrapper.findComponent(GlTable); + const findSearchBox = () => wrapper.findComponent(GlSearchBoxByClick); + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + const findPagination = () => wrapper.findComponent(RunnerPagination); + + describe('when feature is not available', () => { + beforeEach(() => { + createComponent({ + provide: { + glLicensedFeatures: { + ciJobAnalyticsForProjects: false, + }, + }, + }); + }); + + it('does not render the component', () => { + expect(wrapper.text()).toBe(''); + }); + + it('does not query for job analytics', () => { + expect(mockJobAnalyticsHandler).not.toHaveBeenCalled(); + }); + }); + + describe('when feature is available', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders the component with heading', () => { + expect(wrapper.html()).not.toBe(''); + expect(wrapper.find('h3').text()).toBe('Jobs'); + }); + + it('renders the search box', () => { + expect(findSearchBox().exists()).toBe(true); + expect(findSearchBox().props('placeholder')).toBe('Search by job name'); + }); + + describe('loading state', () => { + beforeEach(() => { + createComponent({ + handler: jest.fn().mockReturnValue(new Promise(() => {})), + }); + }); + + it('shows loading icon while loading', () => { + expect(findLoadingIcon().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); + }); + + it('disables pagination when loading', () => { + expect(findPagination().attributes('disabled')).toBeDefined(); + }); + }); + + describe('with data', () => { + beforeEach(async () => { + await waitForPromises(); + }); + + it('renders the table', () => { + expect(findTable().exists()).toBe(true); + expect(findLoadingIcon().exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); + }); + + it('passes correct fields to the table', () => { + const fields = findTable().props('fields'); + + expect(fields).toHaveLength(5); + expect(fields[0]).toMatchObject({ key: 'name', label: 'Job' }); + expect(fields[1]).toMatchObject({ + key: 'meanDuration', + label: 'Mean duration (s)', + }); + expect(fields[2]).toMatchObject({ key: 'p95Duration', label: 'P95 duration (s)' }); + expect(fields[3]).toMatchObject({ key: 'failedRate', label: 'Failure rate (%)' }); + expect(fields[4]).toMatchObject({ key: 'successRate', label: 'Success rate (%)' }); + }); + + it('passes correct items to the table', () => { + expect(findTable().props('items')).toEqual([ + { + __typename: 'CiDurationStatistics', + name: 'test-job', + successRate: 90, + failedRate: 0, + successCount: '9', + failedCount: '1', + count: '10', + meanDuration: 60, + p95Duration: 90, + }, + { + __typename: 'CiDurationStatistics', + name: 'build-job', + successRate: 100, + failedRate: 0, + successCount: '10', + failedCount: '0', + count: '10', + meanDuration: 61, + p95Duration: 91, + }, + ]); + }); + + it('configures table for custom sorting', () => { + expect(findTable().props('noLocalSorting')).toBe(true); + }); + + it('sets default sort', () => { + expect(findTable().props('sortBy')).toBe('meanDuration'); + expect(findTable().props('sortDesc')).toBe(true); + }); + + it('renders pagination', () => { + expect(findPagination().props('pageInfo')).toEqual( + mockJobAnalyticsData.data.project.jobAnalytics.pageInfo, + ); + }); + }); + + describe('empty state', () => { + beforeEach(async () => { + createComponent({ + handler: jest.fn().mockResolvedValue(mockJobAnalyticsDataEmpty), + }); + await waitForPromises(); + }); + + it('shows empty state when no data', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + expect(findLoadingIcon().exists()).toBe(false); + }); + + it('shows correct empty state', () => { + expect(findEmptyState().props('description')).toBe('No job data found.'); + expect(findEmptyState().props('svgPath')).toEqual(CHART_EMPTY_STATE_SVG_URL); + }); + }); + + describe('empty state with search filter', () => { + beforeEach(async () => { + createComponent({ + variables: { ...mockVariables, jobName: 'non-existent' }, + handler: jest.fn().mockResolvedValue(mockJobAnalyticsDataEmpty), + }); + await waitForPromises(); + }); + + it('shows filtered empty state message', () => { + expect(findEmptyState().props('description')).toBe( + 'No job data found for the current filter.', + ); + }); + }); + }); + + describe('GraphQL query', () => { + it('queries with correct variables', async () => { + createComponent(); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith({ + ...mockVariables, + sort: 'MEAN_DURATION_DESC', + first: 5, + }); + }); + + it('includes jobName in query when provided', async () => { + const variables = { ...mockVariables, jobName: 'test-job' }; + createComponent({ variables }); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith({ + ...mockVariables, + sort: 'MEAN_DURATION_DESC', + jobName: 'test-job', + first: 5, + }); + }); + }); + + describe('search functionality', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('emits input event when search is submitted', async () => { + findSearchBox().vm.$emit('submit', 'new-job-name'); + await waitForPromises(); + + expect(wrapper.emitted('filters-input')).toHaveLength(1); + expect(wrapper.emitted('filters-input')[0][0]).toEqual({ + ...mockVariables, + jobName: 'new-job-name', + }); + + expect(mockJobAnalyticsHandler).toHaveBeenCalledTimes(1); + + findSearchBox().vm.$emit('submit', 'new-job-name'); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenCalledTimes(2); + }); + + it('resets pagination when search is submitted', async () => { + mockJobAnalyticsHandler.mockResolvedValueOnce(mockJobAnalyticsDataPage2); + + findSearchBox().vm.$emit('submit', 'new-job-name'); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith({ + ...mockVariables, + sort: 'MEAN_DURATION_DESC', + first: 5, + }); + }); + }); + + describe('clears search', () => { + beforeEach(async () => { + createComponent({ + variables: { + ...mockVariables, + jobName: 'predefined-job-name', + }, + }); + await waitForPromises(); + }); + + it('emits input event when search is cleared', async () => { + findSearchBox().vm.$emit('clear'); + await waitForPromises(); + + expect(wrapper.emitted('filters-input')).toHaveLength(1); + expect(wrapper.emitted('filters-input')[0][0]).toEqual({ + ...mockVariables, + jobName: '', + }); + }); + }); + + describe('sorting', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it.each` + sortBy | sortDesc | expectedSort + ${'name'} | ${true} | ${'NAME_DESC'} + ${'name'} | ${false} | ${'NAME_ASC'} + ${'meanDuration'} | ${false} | ${'MEAN_DURATION_ASC'} + ${'p95Duration'} | ${true} | ${'P95_DURATION_DESC'} + ${'p95Duration'} | ${false} | ${'P95_DURATION_ASC'} + ${'failedRate'} | ${true} | ${'FAILED_RATE_DESC'} + ${'failedRate'} | ${false} | ${'FAILED_RATE_ASC'} + ${'successRate'} | ${true} | ${'SUCCESS_RATE_DESC'} + ${'successRate'} | ${false} | ${'SUCCESS_RATE_ASC'} + `( + 'queries with $expectedSort when sorting by $sortBy with desc=$sortDesc', + async ({ sortBy, sortDesc, expectedSort }) => { + mockJobAnalyticsHandler.mockClear(); + + findTable().vm.$emit('sort-changed', { sortBy, sortDesc }); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + sort: expectedSort, + }), + ); + }, + ); + + it('resets pagination when sort changes', async () => { + findPagination().vm.$emit('input', { after: 'some-cursor' }); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith({ + ...mockVariables, + first: 5, + sort: 'MEAN_DURATION_DESC', + after: 'some-cursor', + }); + + findTable().vm.$emit('sort-changed', { sortBy: 'failedRate', sortDesc: false }); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith({ + ...mockVariables, + first: 5, + sort: 'FAILED_RATE_ASC', + // no after cursor is present! + }); + }); + }); + + describe('pagination', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('updates pagination state when pagination input is emitted', async () => { + mockJobAnalyticsHandler.mockResolvedValueOnce(mockJobAnalyticsDataPage2); + + findPagination().vm.$emit('input', { after: 'my-new-cursor' }); + await waitForPromises(); + + expect(findPagination().props('pageInfo')).toEqual( + mockJobAnalyticsDataPage2.data.project.jobAnalytics.pageInfo, + ); + }); + + it('queries with pagination variables', async () => { + mockJobAnalyticsHandler.mockClear(); + + findPagination().vm.$emit('input', { after: 'end-cursor' }); + await waitForPromises(); + + expect(mockJobAnalyticsHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + after: 'end-cursor', + first: 5, + }), + ); + }); + }); + + describe('field formatters', () => { + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('formats duration fields correctly', () => { + const fields = findTable().props('fields'); + const meanDurationField = fields.find((f) => f.key === 'meanDuration'); + const p95DurationField = fields.find((f) => f.key === 'p95Duration'); + + expect(meanDurationField.formatter).toBe(formatPipelineDuration); + expect(p95DurationField.formatter).toBe(formatPipelineDuration); + }); + + it('formats numeric fields with correct alignment', () => { + const fields = findTable().props('fields'); + const numericFields = fields.filter((f) => + ['meanDuration', 'p95Duration', 'failedRate', 'successRate'].includes(f.key), + ); + + numericFields.forEach((field) => { + expect(field.thClass).toBe('gl-text-right'); + expect(field.tdClass).toBe('gl-text-right'); + expect(field.thAlignRight).toBe(true); + expect(field.sortable).toBe(true); + }); + }); + }); + + describe('error handling', () => { + beforeEach(async () => { + createComponent({ + handler: jest.fn().mockRejectedValue(new Error('GraphQL error')), + }); + await waitForPromises(); + }); + + it('shows empty state on error', () => { + expect(findEmptyState().exists()).toBe(true); + expect(findTable().exists()).toBe(false); + }); + }); + + describe('initial jobName from variables', () => { + it('sets jobName from variables prop', () => { + createComponent({ + variables: { ...mockVariables, jobName: 'initial-job' }, + }); + + expect(findSearchBox().props('value')).toBe('initial-job'); + }); + + it('defaults to empty string when jobName is not provided', () => { + createComponent(); + + expect(findSearchBox().props('value')).toBe(''); + }); + }); +}); diff --git a/ee/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js b/ee/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d989dce212599a0c40dce35f6d0444acc8787453 --- /dev/null +++ b/ee/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMount } from '@vue/test-utils'; +import PipelinesDashboardClickhouse from '~/projects/pipelines/charts/components/pipelines_dashboard_clickhouse.vue'; +import JobAnalyticsTable from 'ee_component/projects/pipelines/charts/components/job_analytics_table.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { useFakeDate } from 'helpers/fake_date'; + +Vue.use(VueApollo); + +describe('PipelinesDashboardClickhouse', () => { + useFakeDate('2022-02-15T08:30'); // a date with a time + + let wrapper; + + const findJobAnalyticsTable = () => wrapper.findComponent(JobAnalyticsTable); + + const createComponent = () => { + wrapper = shallowMount(PipelinesDashboardClickhouse, { + apolloProvider: createMockApollo(), + stubs: { + JobAnalyticsTable, + }, + }); + }; + + beforeEach(async () => { + createComponent(); + await waitForPromises(); + }); + + it('renders the job analytics table', () => { + expect(findJobAnalyticsTable().props('variables')).toEqual({ + branch: null, + fromTime: new Date('2022-02-08'), + toTime: new Date('2022-02-15'), + fullPath: '', + jobName: null, + source: null, + }); + }); + + it('filters according to the job analytics table', async () => { + findJobAnalyticsTable().vm.$emit('filters-input', { jobName: 'a-job-name' }); + await waitForPromises(); + + expect(findJobAnalyticsTable().props('variables')).toMatchObject({ + jobName: 'a-job-name', + }); + }); +}); diff --git a/ee/spec/frontend/projects/pipelines/charts/components/table_utils_spec.js b/ee/spec/frontend/projects/pipelines/charts/components/table_utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e7371c8f99075549e51d1274b5290d8f4c5da9f4 --- /dev/null +++ b/ee/spec/frontend/projects/pipelines/charts/components/table_utils_spec.js @@ -0,0 +1,50 @@ +import { numericField, durationField } from 'ee/projects/pipelines/charts/components/table_utils'; + +describe('table_utils', () => { + describe('numericField', () => { + let field; + + beforeEach(() => { + field = numericField(); + }); + + it('returns an object with correct styling classes', () => { + expect(field.thClass).toBe('gl-text-right'); + expect(field.tdClass).toBe('gl-text-right'); + expect(field.thAlignRight).toBe(true); + }); + + it('sets sortable to true', () => { + expect(field.sortable).toBe(true); + }); + + it('calls formatNumber with correct options', () => { + expect(field.formatter(1111.45)).toBe('1,111'); + expect(field.formatter(1111.99)).toBe('1,112'); + }); + }); + + describe('durationField', () => { + let field; + + beforeEach(() => { + field = durationField(); + }); + + it('returns an object with correct styling classes', () => { + expect(field.thClass).toBe('gl-text-right'); + expect(field.tdClass).toBe('gl-text-right'); + expect(field.thAlignRight).toBe(true); + }); + + it('sets sortable to true', () => { + expect(field.sortable).toBe(true); + }); + + it('calls formatNumber with correct options', () => { + expect(field.formatter(60)).toBe('1m'); + expect(field.formatter(3600)).toBe('1h'); + expect(field.formatter(3600 * 24 * 7)).toBe('1w'); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 75994af249ee298a4fc212ffbe63bfe8291ce0a3..078972f183168ad2c993b1fd232511485f27d8a0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -13277,6 +13277,12 @@ msgstr "" msgid "CICD|No authentication events to display." msgstr "" +msgid "CICD|No job data found for the current filter." +msgstr "" + +msgid "CICD|No job data found." +msgstr "" + msgid "CICD|No resources selected (minimal access only)" msgstr "" @@ -13304,6 +13310,9 @@ msgstr "" msgid "CICD|Run pipelines triggered by AI agents automatically" msgstr "" +msgid "CICD|Search by job name" +msgstr "" + msgid "CICD|Select the groups and projects authorized to use a CI/CD job token to authenticate requests to this project. %{linkStart}Learn more%{linkEnd}." msgstr "" @@ -13331,6 +13340,9 @@ msgstr "" msgid "CICD|Turn on incremental logging" msgstr "" +msgid "CICD|Unable to load job data. Please try again." +msgstr "" + msgid "CICD|Unprotected branches will not have access to the cache from protected branches." msgstr "" @@ -38519,6 +38531,9 @@ msgstr "" msgid "Job|Failed" msgstr "" +msgid "Job|Failure rate (%{percentSymbol})" +msgstr "" + msgid "Job|Finished at" msgstr "" @@ -38558,12 +38573,18 @@ msgstr "" msgid "Job|Manual" msgstr "" +msgid "Job|Mean duration (s)" +msgstr "" + msgid "Job|No job log" msgstr "" msgid "Job|No search results found" msgstr "" +msgid "Job|P95 duration (s)" +msgstr "" + msgid "Job|Passed" msgstr "" @@ -38627,6 +38648,9 @@ msgstr "" msgid "Job|Status" msgstr "" +msgid "Job|Success rate (%{percentSymbol})" +msgstr "" + msgid "Job|The artifacts were removed" msgstr "" diff --git a/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js b/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js index 9d15b6feebbc1f9b2d55b6cf5f1ed1c609aa4a21..1443bd89738876f895bddf2d6be079d971e20f71 100644 --- a/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/pipelines_dashboard_clickhouse_spec.js @@ -95,6 +95,7 @@ describe('PipelinesDashboardClickhouse', () => { source: null, branch: defaultBranch, dateRange: DATE_RANGE_DEFAULT, + jobName: '', }, }); }); @@ -109,6 +110,7 @@ describe('PipelinesDashboardClickhouse', () => { branch: defaultBranch, fromTime: new Date('2022-02-08'), toTime: new Date('2022-02-15'), + jobName: null, }); }); }); @@ -121,6 +123,7 @@ describe('PipelinesDashboardClickhouse', () => { source: null, dateRange: DATE_RANGE_DEFAULT, branch: defaultBranch, + jobName: '', }, variables: { source: null, @@ -128,6 +131,7 @@ describe('PipelinesDashboardClickhouse', () => { branch: defaultBranch, fromTime: new Date('2022-02-08'), toTime: new Date('2022-02-15'), + jobName: null, }, query: '', }, @@ -137,6 +141,7 @@ describe('PipelinesDashboardClickhouse', () => { source: null, dateRange: DATE_RANGE_30_DAYS, branch: BRANCH_ANY, + jobName: '', }, variables: { source: null, @@ -144,6 +149,7 @@ describe('PipelinesDashboardClickhouse', () => { branch: null, fromTime: new Date('2022-01-16'), toTime: new Date('2022-02-15'), + jobName: null, }, query: '?branch=~any&time=30d', }, @@ -153,6 +159,7 @@ describe('PipelinesDashboardClickhouse', () => { source: SOURCE_PUSH, dateRange: DATE_RANGE_180_DAYS, branch: 'feature-branch', + jobName: '', }, variables: { source: SOURCE_PUSH, @@ -160,9 +167,28 @@ describe('PipelinesDashboardClickhouse', () => { branch: 'feature-branch', fromTime: new Date('2021-08-19'), toTime: new Date('2022-02-15'), + jobName: null, }, query: '?branch=feature-branch&source=PUSH&time=180d', }, + { + name: 'feature branch pushes in the last 180 days filtering by "test" job', + input: { + source: SOURCE_PUSH, + dateRange: DATE_RANGE_180_DAYS, + branch: 'feature-branch', + jobName: 'test', + }, + variables: { + source: SOURCE_PUSH, + fullPath: projectPath, + branch: 'feature-branch', + fromTime: new Date('2021-08-19'), + toTime: new Date('2022-02-15'), + jobName: 'test', + }, + query: '?branch=feature-branch&job=test&source=PUSH&time=180d', + }, ]; it.each(tests)(