+
+
{{ s__('CICD|Jobs') }}
+
+ {{ s__('CICD|Unable to load job data. Please try again.') }}
+
+
+
+
+
+
+
+ {{ value }}
+
+
+ {{ value }}
+
+
+
+
+
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)(