diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue index 499d7716fa74b06142ecc49b6714b694c5aa66a6..5befd100c9163a285ef19c61c5500ce7b7b5b00d 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules.vue @@ -12,20 +12,25 @@ import { } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, sprintf } from '~/locale'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; import { queryToObject } from '~/lib/utils/url_utility'; import { reportToSentry } from '~/ci/utils'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import deletePipelineScheduleMutation from '../graphql/mutations/delete_pipeline_schedule.mutation.graphql'; import playPipelineScheduleMutation from '../graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '../graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '../graphql/queries/get_pipeline_schedules.query.graphql'; +import pipelineScheduleStatusUpdatedSubscription from '../graphql/subscriptions/ci_pipeline_schedule_status_updated.subscription.graphql'; import { ALL_SCOPE, SCHEDULES_PER_PAGE, DEFAULT_SORT_VALUE, TABLE_SORT_STORAGE_KEY, } from '../constants'; +import { updateScheduleNodes } from '../utils'; import PipelineSchedulesTable from './table/pipeline_schedules_table.vue'; import TakeOwnershipModal from './take_ownership_modal.vue'; import DeletePipelineScheduleModal from './delete_pipeline_schedule_modal.vue'; @@ -80,6 +85,7 @@ export default { TakeOwnershipModal, PipelineScheduleEmptyState, }, + mixins: [glFeatureFlagMixin()], inject: { projectPath: { default: '', @@ -90,6 +96,9 @@ export default { newSchedulePath: { default: '', }, + projectId: { + default: '', + }, }, apollo: { schedules: { @@ -125,6 +134,55 @@ export default { error(error) { this.reportError(this.$options.i18n.schedulesFetchError, error); }, + result({ data }) { + // we use a manual subscribeToMore call due to issues with + // the skip hook not working correctly for the subscription + // and previousData object being an empty {} on init + if ( + data?.project?.pipelineSchedules?.nodes?.length > 0 && + this.shouldUseRealtimeStatus && + !this.isSubscribed + ) { + // Prevent duplicate subscriptions on refetch + this.isSubscribed = true; + + this.$apollo.queries.schedules.subscribeToMore({ + document: pipelineScheduleStatusUpdatedSubscription, + variables: { + projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), + }, + updateQuery( + previousData, + { + subscriptionData: { + data: { ciPipelineScheduleStatusUpdated }, + }, + }, + ) { + if (ciPipelineScheduleStatusUpdated) { + const schedules = previousData?.project?.pipelineSchedules?.nodes || []; + + const updatedNodes = updateScheduleNodes( + schedules, + ciPipelineScheduleStatusUpdated, + ); + + return { + ...previousData, + project: { + ...previousData.project, + pipelineSchedules: { + ...previousData.project.pipelineSchedules, + nodes: updatedNodes, + }, + }, + }; + } + return previousData; + }, + }); + } + }, }, }, data() { @@ -148,6 +206,7 @@ export default { pagination: { ...defaultPagination, }, + isSubscribed: false, }; }, computed: { @@ -225,6 +284,9 @@ export default { this.sortDesc = values.sortDesc; }, }, + shouldUseRealtimeStatus() { + return this.glFeatures?.ciPipelineSchedulesStatusRealtime; + }, }, watch: { // this watcher ensures that the count on the all tab diff --git a/app/assets/javascripts/ci/pipeline_schedules/graphql/subscriptions/ci_pipeline_schedule_status_updated.subscription.graphql b/app/assets/javascripts/ci/pipeline_schedules/graphql/subscriptions/ci_pipeline_schedule_status_updated.subscription.graphql new file mode 100644 index 0000000000000000000000000000000000000000..8ae9d07208f5d0dfc6e24c1c51e21feabbaf79d0 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/graphql/subscriptions/ci_pipeline_schedule_status_updated.subscription.graphql @@ -0,0 +1,16 @@ +subscription pipelineScheduleStatusUpdated($projectId: ProjectID!) { + ciPipelineScheduleStatusUpdated(projectId: $projectId) { + id + lastPipeline { + id + detailedStatus { + id + group + icon + label + text + detailsPath + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js index c1ce478ea3a059093151578a65dacc2995c22fe7..2026b7da1864a5faf43ed6a1104db574978f683a 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js +++ b/app/assets/javascripts/ci/pipeline_schedules/mount_pipeline_schedules_app.js @@ -18,7 +18,8 @@ export default () => { return false; } - const { projectPath, pipelinesPath, newSchedulePath, schedulesPath } = containerEl.dataset; + const { projectPath, pipelinesPath, newSchedulePath, schedulesPath, projectId } = + containerEl.dataset; return new Vue({ el: containerEl, @@ -29,6 +30,7 @@ export default () => { pipelinesPath, newSchedulePath, schedulesPath, + projectId, }, render(createElement) { return createElement(PipelineSchedules); diff --git a/app/assets/javascripts/ci/pipeline_schedules/utils.js b/app/assets/javascripts/ci/pipeline_schedules/utils.js new file mode 100644 index 0000000000000000000000000000000000000000..6fed1d96fdebcef91c97fd56ab644669f0496922 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_schedules/utils.js @@ -0,0 +1,12 @@ +export const updateScheduleNodes = (schedules = [], updatedSchedule = {}) => { + return schedules.map((schedule) => { + if (schedule.id === updatedSchedule.id) { + return { + ...schedule, + lastPipeline: updatedSchedule.lastPipeline, + }; + } + + return schedule; + }); +}; diff --git a/app/controllers/projects/pipeline_schedules_controller.rb b/app/controllers/projects/pipeline_schedules_controller.rb index f50be1957505dd09c0a86e89d185473ad32be67a..80ed7760974eddf4ff2d5da49a858e25c43c47a1 100644 --- a/app/controllers/projects/pipeline_schedules_controller.rb +++ b/app/controllers/projects/pipeline_schedules_controller.rb @@ -11,6 +11,7 @@ class Projects::PipelineSchedulesController < Projects::ApplicationController before_action :authorize_create_pipeline_schedule!, only: [:new, :create] before_action :authorize_update_pipeline_schedule!, only: [:edit, :update] before_action :authorize_admin_pipeline_schedule!, only: [:take_ownership, :destroy] + before_action :push_realtime_schedule_feature_flag, only: [:index] feature_category :continuous_integration urgency :low @@ -120,4 +121,8 @@ def authorize_update_pipeline_schedule! def authorize_admin_pipeline_schedule! access_denied! unless can?(current_user, :admin_pipeline_schedule, schedule) end + + def push_realtime_schedule_feature_flag + push_frontend_feature_flag(:ci_pipeline_schedules_status_realtime, @project) + end end diff --git a/app/views/projects/pipeline_schedules/index.html.haml b/app/views/projects/pipeline_schedules/index.html.haml index 502e4b73c7217121d05763d907992c92b2a4469f..d32c55e496ec736725d88c7d1b14c8967feac587 100644 --- a/app/views/projects/pipeline_schedules/index.html.haml +++ b/app/views/projects/pipeline_schedules/index.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title _("Schedules") - page_title _("Pipeline Schedules") -#pipeline-schedules-app{ data: { project_path: @project.full_path, pipelines_path: project_pipelines_path(@project), new_schedule_path: new_project_pipeline_schedule_path(@project) } } +#pipeline-schedules-app{ data: { project_path: @project.full_path, pipelines_path: project_pipelines_path(@project), new_schedule_path: new_project_pipeline_schedule_path(@project), project_id: @project.id } } diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js index afc5526b811eee3601547e4e6babd6eaa890d20f..747a9e75338ce60dbd658b883bb4c0ab95391a8e 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_spec.js @@ -15,6 +15,7 @@ import deletePipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/muta import playPipelineScheduleMutation from '~/ci/pipeline_schedules/graphql/mutations/play_pipeline_schedule.mutation.graphql'; import takeOwnershipMutation from '~/ci/pipeline_schedules/graphql/mutations/take_ownership.mutation.graphql'; import getPipelineSchedulesQuery from '~/ci/pipeline_schedules/graphql/queries/get_pipeline_schedules.query.graphql'; +import pipelineScheduleStatusUpdatedSubscription from '~/ci/pipeline_schedules/graphql/subscriptions/ci_pipeline_schedule_status_updated.subscription.graphql'; import { SCHEDULES_PER_PAGE, TABLE_SORT_STORAGE_KEY } from '~/ci/pipeline_schedules/constants'; import { mockGetPipelineSchedulesGraphQLResponse, @@ -28,6 +29,7 @@ import { mockPipelineSchedulesResponsePlanLimitReached, mockPipelineSchedulesResponseUnlimited, noPlanLimitResponse, + mockScheduleUpdateResponse, } from '../mock_data'; Vue.use(VueApollo); @@ -52,6 +54,7 @@ describe('Pipeline schedules app', () => { .fn() .mockResolvedValue(mockPipelineSchedulesResponseUnlimited); const failedHandler = jest.fn().mockRejectedValue(new Error('GraphQL error')); + const subscriptionHandler = jest.fn().mockResolvedValue(mockScheduleUpdateResponse); const deleteMutationHandlerSuccess = jest.fn().mockResolvedValue(deleteMutationResponse); const deleteMutationHandlerFailed = jest.fn().mockRejectedValue(new Error('GraphQL error')); @@ -65,16 +68,23 @@ describe('Pipeline schedules app', () => { .mockRejectedValue(new Error('GraphQL error')); const createMockApolloProvider = ( - requestHandlers = [[getPipelineSchedulesQuery, successHandler]], + requestHandlers = [ + [getPipelineSchedulesQuery, successHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ], ) => { return createMockApollo(requestHandlers); }; - const createComponent = (requestHandlers) => { + const createComponent = (requestHandlers, realtimeEnabled = true) => { wrapper = mountExtended(PipelineSchedules, { provide: { projectPath: 'gitlab-org/gitlab', newSchedulePath: '/root/ci-project/-/pipeline_schedules/new', + projectId: '23', + glFeatures: { + ciPipelineSchedulesStatusRealtime: realtimeEnabled, + }, }, mocks: { $toast, @@ -152,7 +162,10 @@ describe('Pipeline schedules app', () => { }); it('shows query error alert', async () => { - createComponent([[getPipelineSchedulesQuery, failedHandler]]); + createComponent([ + [getPipelineSchedulesQuery, failedHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); @@ -165,6 +178,7 @@ describe('Pipeline schedules app', () => { createComponent([ [getPipelineSchedulesQuery, successHandler], [deletePipelineScheduleMutation, deleteMutationHandlerFailed], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], ]); await waitForPromises(); @@ -180,6 +194,7 @@ describe('Pipeline schedules app', () => { createComponent([ [getPipelineSchedulesQuery, successHandler], [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], ]); await waitForPromises(); @@ -228,6 +243,7 @@ describe('Pipeline schedules app', () => { createComponent([ [getPipelineSchedulesQuery, successHandler], [playPipelineScheduleMutation, playMutationHandlerFailed], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], ]); await waitForPromises(); @@ -243,6 +259,7 @@ describe('Pipeline schedules app', () => { createComponent([ [getPipelineSchedulesQuery, successHandler], [playPipelineScheduleMutation, playMutationHandlerSuccess], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], ]); await waitForPromises(); @@ -267,6 +284,7 @@ describe('Pipeline schedules app', () => { createComponent([ [getPipelineSchedulesQuery, successHandler], [takeOwnershipMutation, takeOwnershipMutationHandlerFailed], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], ]); await waitForPromises(); @@ -284,6 +302,7 @@ describe('Pipeline schedules app', () => { createComponent([ [getPipelineSchedulesQuery, successHandler], [takeOwnershipMutation, takeOwnershipMutationHandlerSuccess], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], ]); await waitForPromises(); @@ -329,7 +348,10 @@ describe('Pipeline schedules app', () => { describe('pipeline schedule tabs', () => { beforeEach(async () => { - createComponent([[getPipelineSchedulesQuery, successHandler]]); + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); }); @@ -374,7 +396,10 @@ describe('Pipeline schedules app', () => { describe('Empty pipeline schedules response', () => { it('should show an empty state', async () => { - createComponent([[getPipelineSchedulesQuery, successEmptyHandler]]); + createComponent([ + [getPipelineSchedulesQuery, successEmptyHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); @@ -397,7 +422,10 @@ describe('Pipeline schedules app', () => { }); it('should not show empty state', async () => { - createComponent([[getPipelineSchedulesQuery, successEmptyHandler]]); + createComponent([ + [getPipelineSchedulesQuery, successEmptyHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); @@ -410,7 +438,10 @@ describe('Pipeline schedules app', () => { const { pageInfo } = mockPipelineSchedulesResponseWithPagination.data.project.pipelineSchedules; beforeEach(async () => { - createComponent([[getPipelineSchedulesQuery, successHandlerWithPagination]]); + createComponent([ + [getPipelineSchedulesQuery, successHandlerWithPagination], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); }); @@ -471,7 +502,10 @@ describe('Pipeline schedules app', () => { sortDesc: true, }), ); - createComponent([[getPipelineSchedulesQuery, successHandler]]); + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); }); @@ -504,7 +538,10 @@ describe('Pipeline schedules app', () => { const newSort = 'DESCRIPTION_ASC'; beforeEach(async () => { - createComponent([[getPipelineSchedulesQuery, successHandler]]); + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); await findTable().vm.$emit('update-sorting', newSort, 'description', false); @@ -534,7 +571,10 @@ describe('Pipeline schedules app', () => { describe('when update-sorting event is emitted', () => { beforeEach(async () => { - createComponent([[getPipelineSchedulesQuery, successHandlerWithPagination]]); + createComponent([ + [getPipelineSchedulesQuery, successHandlerWithPagination], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); }); @@ -560,7 +600,10 @@ describe('Pipeline schedules app', () => { `( 'Alert should show: $alertExists and button should be disabled: $buttonDisabled when plan limit: $description', async ({ handler, buttonDisabled, alertExists }) => { - createComponent([[getPipelineSchedulesQuery, handler]]); + createComponent([ + [getPipelineSchedulesQuery, handler], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); await waitForPromises(); @@ -568,4 +611,44 @@ describe('Pipeline schedules app', () => { expect(findPlanLimitReachedAlert().exists()).toBe(alertExists); }, ); + + describe('subscription', () => { + describe('with feature flag enabled', () => { + it('calls subscription with correct variables', async () => { + createComponent(); + + await waitForPromises(); + + expect(subscriptionHandler).toHaveBeenCalledWith({ projectId: 'gid://gitlab/Project/23' }); + }); + + it('does not make redundant subscription calls for refetches', async () => { + createComponent([ + [getPipelineSchedulesQuery, successHandler], + [deletePipelineScheduleMutation, deleteMutationHandlerSuccess], + [pipelineScheduleStatusUpdatedSubscription, subscriptionHandler], + ]); + + await waitForPromises(); + + findTable().vm.$emit('showDeleteModal', mockPipelineScheduleNodes[0].id); + + findDeleteModal().vm.$emit('deleteSchedule'); + + await waitForPromises(); + + expect(subscriptionHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('with feature flag disabled', () => { + it('does not call subscription', async () => { + createComponent([[getPipelineSchedulesQuery, successHandler]], false); + + await waitForPromises(); + + expect(subscriptionHandler).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/ci/pipeline_schedules/mock_data.js b/spec/frontend/ci/pipeline_schedules/mock_data.js index de580c86f19659c41df1952e3a67fe61c8546812..ad9d2ca450b264e10169670a2a3d6342a0e0cba4 100644 --- a/spec/frontend/ci/pipeline_schedules/mock_data.js +++ b/spec/frontend/ci/pipeline_schedules/mock_data.js @@ -227,4 +227,127 @@ export const updateScheduleMutationResponse = { }, }; +export const mockScheduleUpdateResponse = { + data: { + ciPipelineScheduleStatusUpdated: { + id: 'gid://gitlab/Ci::PipelineSchedule/4', + lastPipeline: { + id: 'gid://gitlab/Ci::Pipeline/631', + detailedStatus: { + id: 'success-631-631', + group: 'success', + icon: 'status_success', + label: 'passed', + text: 'Passed', + detailsPath: '/root/long-running-pipeline/-/pipelines/631', + __typename: 'DetailedStatus', + }, + __typename: 'Pipeline', + }, + __typename: 'PipelineSchedule', + }, + }, +}; + +// does not use fixture to get a consistent ID +// for testing utility function +export const mockSchedules = [ + { + __typename: 'PipelineSchedule', + id: 'gid://gitlab/Ci::PipelineSchedule/3', + description: 'Schedule Two', + cron: '20 16 1 * *', + cronTimezone: 'America/New_York', + ref: 'main', + forTag: false, + editPath: '/root/ci-project/-/pipeline_schedules/3/edit', + refPath: '/root/ci-project/-/commits/main', + refForDisplay: 'main', + lastPipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/617', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'success-617-617', + group: 'success-with-warnings', + icon: 'status_warning', + label: 'passed with warnings', + text: 'Warning', + detailsPath: '/root/ci-project/-/pipelines/617', + }, + }, + active: true, + nextRunAt: '2025-09-01T21:19:00Z', + realNextRun: '2025-09-01T21:19:00Z', + owner: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + username: 'root', + avatarUrl: + 'https://www.gravatar.com/avatar/3699a2727a92a410332ca568fef4353e3ae40c0b0c1fd5043585ceec77dc0e05?s=80&d=identicon', + name: 'Administrator', + webPath: '/root', + }, + inputs: { + __typename: 'CiInputsFieldConnection', + nodes: [], + }, + variables: { + __typename: 'PipelineScheduleVariableConnection', + nodes: [], + }, + userPermissions: { + __typename: 'PipelineSchedulePermissions', + playPipelineSchedule: true, + updatePipelineSchedule: true, + adminPipelineSchedule: true, + }, + }, + { + __typename: 'PipelineSchedule', + id: 'gid://gitlab/Ci::PipelineSchedule/4', + description: 'Schedule One', + cron: '51 17 * * 0', + cronTimezone: 'America/New_York', + ref: 'main', + forTag: false, + editPath: '/root/long-running-pipeline/-/pipeline_schedules/4/edit', + refPath: '/root/long-running-pipeline/-/commits/main', + refForDisplay: 'main', + lastPipeline: { + __typename: 'Pipeline', + id: 'gid://gitlab/Ci::Pipeline/631', + detailedStatus: { + __typename: 'DetailedStatus', + id: 'running-631-631', + group: 'running', + icon: 'status_running', + label: 'running', + text: 'Running', + detailsPath: '/root/long-running-pipeline/-/pipelines/631', + }, + }, + active: true, + nextRunAt: '2025-08-24T22:19:00Z', + realNextRun: '2025-08-24T22:19:00Z', + owner: { + __typename: 'UserCore', + id: 'gid://gitlab/User/1', + username: 'root', + avatarUrl: + 'https://www.gravatar.com/avatar/3699a2727a92a410332ca568fef4353e3ae40c0b0c1fd5043585ceec77dc0e05?s=80&d=identicon', + name: 'Administrator', + webPath: '/root', + }, + inputs: { __typename: 'CiInputsFieldConnection', nodes: [] }, + variables: { __typename: 'PipelineScheduleVariableConnection', nodes: [] }, + userPermissions: { + __typename: 'PipelineSchedulePermissions', + playPipelineSchedule: true, + updatePipelineSchedule: true, + adminPipelineSchedule: true, + }, + }, +]; + export { mockGetPipelineSchedulesGraphQLResponse }; diff --git a/spec/frontend/ci/pipeline_schedules/utils_spec.js b/spec/frontend/ci/pipeline_schedules/utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..25baab3d645b4a2658ff852ab257c542d86ba21c --- /dev/null +++ b/spec/frontend/ci/pipeline_schedules/utils_spec.js @@ -0,0 +1,20 @@ +import { updateScheduleNodes } from '~/ci/pipeline_schedules/utils'; +import { mockScheduleUpdateResponse, mockSchedules } from './mock_data'; + +describe('Pipeline schedule utility functions', () => { + describe('updateScheduleNodes', () => { + it('updates the schedule pipeline status from running to passed', () => { + const unchangedSchedule = mockSchedules[0]; + + expect(mockSchedules[1].lastPipeline.detailedStatus.group).toBe('running'); + + const updatedSchedules = updateScheduleNodes( + mockSchedules, + mockScheduleUpdateResponse.data.ciPipelineScheduleStatusUpdated, + ); + + expect(updatedSchedules[0]).toEqual(unchangedSchedule); + expect(updatedSchedules[1].lastPipeline.detailedStatus.group).toBe('success'); + }); + }); +});