diff options
-rw-r--r-- | app/assets/javascripts/jobs/components/job_app.vue | 16 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/components/job_container_item.vue | 19 | ||||
-rw-r--r-- | app/assets/javascripts/jobs/mixins/delayed_job_mixin.js | 50 | ||||
-rw-r--r-- | app/assets/javascripts/pipelines/components/graph/job_item.vue | 21 | ||||
-rw-r--r-- | changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml | 5 | ||||
-rw-r--r-- | lib/gitlab/ci/status/build/scheduled.rb | 13 | ||||
-rw-r--r-- | locale/gitlab.pot | 2 | ||||
-rw-r--r-- | spec/features/projects/jobs_spec.rb | 2 | ||||
-rw-r--r-- | spec/javascripts/fixtures/jobs.rb | 23 | ||||
-rw-r--r-- | spec/javascripts/jobs/components/job_app_spec.js | 31 | ||||
-rw-r--r-- | spec/javascripts/jobs/components/job_container_item_spec.js | 26 | ||||
-rw-r--r-- | spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js | 93 | ||||
-rw-r--r-- | spec/javascripts/pipelines/graph/job_item_spec.js | 27 | ||||
-rw-r--r-- | spec/lib/gitlab/ci/status/build/scheduled_spec.rb | 20 |
14 files changed, 306 insertions, 42 deletions
diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index aff483876f8..35104c80694 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -16,6 +16,8 @@ import Log from './job_log.vue'; import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; import Sidebar from './sidebar.vue'; +import { sprintf } from '~/locale'; +import delayedJobMixin from '../mixins/delayed_job_mixin'; export default { name: 'JobPageApp', @@ -33,6 +35,7 @@ export default { Sidebar, GlLoadingIcon, }, + mixins: [delayedJobMixin], props: { runnerSettingsUrl: { type: String, @@ -92,6 +95,17 @@ export default { shouldRenderContent() { return !this.isLoading && !this.hasError; }, + + emptyStateTitle() { + const { emptyStateIllustration, remainingTime } = this; + const { title } = emptyStateIllustration; + + if (this.isDelayedJob) { + return sprintf(title, { remainingTime }); + } + + return title; + }, }, watch: { // Once the job log is loaded, @@ -272,7 +286,7 @@ export default { class="js-job-empty-state" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" - :title="emptyStateIllustration.title" + :title="emptyStateTitle" :content="emptyStateIllustration.content" :action="emptyStateAction" /> diff --git a/app/assets/javascripts/jobs/components/job_container_item.vue b/app/assets/javascripts/jobs/components/job_container_item.vue index cdac8a391d1..3ddcfd11dca 100644 --- a/app/assets/javascripts/jobs/components/job_container_item.vue +++ b/app/assets/javascripts/jobs/components/job_container_item.vue @@ -1,7 +1,10 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab-org/gitlab-ui'; +import { GlLink } from '@gitlab-org/gitlab-ui'; +import tooltip from '~/vue_shared/directives/tooltip'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import { sprintf } from '~/locale'; export default { components: { @@ -10,8 +13,9 @@ export default { GlLink, }, directives: { - GlTooltip: GlTooltipDirective, + tooltip, }, + mixins: [delayedJobMixin], props: { job: { type: Object, @@ -24,7 +28,14 @@ export default { }, computed: { tooltipText() { - return `${this.job.name} - ${this.job.status.tooltip}`; + const { name, status } = this.job; + const text = `${name} - ${status.tooltip}`; + + if (this.isDelayedJob) { + return sprintf(text, { remainingTime: this.remainingTime }); + } + + return text; }, }, }; @@ -39,7 +50,7 @@ export default { }" > <gl-link - v-gl-tooltip + v-tooltip :href="job.status.details_path" :title="tooltipText" data-boundary="viewport" diff --git a/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js new file mode 100644 index 00000000000..8c7fb785a61 --- /dev/null +++ b/app/assets/javascripts/jobs/mixins/delayed_job_mixin.js @@ -0,0 +1,50 @@ +import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; + +export default { + data() { + return { + remainingTime: formatTime(0), + remainingTimeIntervalId: null, + }; + }, + + mounted() { + this.startRemainingTimeInterval(); + }, + + beforeDestroy() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + }, + + computed: { + isDelayedJob() { + return this.job && this.job.scheduled; + }, + }, + + watch: { + isDelayedJob() { + this.startRemainingTimeInterval(); + }, + }, + + methods: { + startRemainingTimeInterval() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + + if (this.isDelayedJob) { + this.updateRemainingTime(); + this.remainingTimeIntervalId = setInterval(() => this.updateRemainingTime(), 1000); + } + }, + + updateRemainingTime() { + const remainingMilliseconds = calculateRemainingMilliseconds(this.job.scheduled_at); + this.remainingTime = formatTime(remainingMilliseconds); + }, + }, +}; diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index a1504592bbc..7cdde8a53b3 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -2,6 +2,8 @@ import ActionComponent from './action_component.vue'; import JobNameComponent from './job_name_component.vue'; import tooltip from '../../../vue_shared/directives/tooltip'; +import { sprintf } from '~/locale'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -36,6 +38,7 @@ export default { directives: { tooltip, }, + mixins: [delayedJobMixin], props: { job: { type: Object, @@ -52,6 +55,7 @@ export default { default: Infinity, }, }, + computed: { status() { return this.job && this.job.status ? this.job.status : {}; @@ -59,17 +63,23 @@ export default { tooltipText() { const textBuilder = []; + const { name: jobName } = this.job; - if (this.job.name) { - textBuilder.push(this.job.name); + if (jobName) { + textBuilder.push(jobName); } - if (this.job.name && this.status.tooltip) { + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { textBuilder.push('-'); } - if (this.status.tooltip) { - textBuilder.push(this.job.status.tooltip); + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } } return textBuilder.join(' '); @@ -88,6 +98,7 @@ export default { return this.job.status && this.job.status.action && this.job.status.action.path; }, }, + methods: { pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); diff --git a/changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml b/changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml new file mode 100644 index 00000000000..fbedd2796b2 --- /dev/null +++ b/changelogs/unreleased/winh-delayed-jobs-dynamic-timer.yml @@ -0,0 +1,5 @@ +--- +title: Add dynamic timer to delayed jobs +merge_request: 22382 +author: +type: changed diff --git a/lib/gitlab/ci/status/build/scheduled.rb b/lib/gitlab/ci/status/build/scheduled.rb index f443dbee120..b3452eae189 100644 --- a/lib/gitlab/ci/status/build/scheduled.rb +++ b/lib/gitlab/ci/status/build/scheduled.rb @@ -9,7 +9,7 @@ module Gitlab { image: 'illustrations/illustrations_scheduled-job_countdown.svg', size: 'svg-394', - title: _("This is a delayed to run in ") + " #{execute_in}", + title: _("This is a delayed job to run in %{remainingTime}"), content: _("This job will automatically run after it's timer finishes. " \ "Often they are used for incremental roll-out deploys " \ "to production environments. When unscheduled it converts " \ @@ -18,21 +18,12 @@ module Gitlab end def status_tooltip - "delayed manual action (#{execute_in})" + "delayed manual action (%{remainingTime})" end def self.matches?(build, user) build.scheduled? && build.scheduled_at end - - private - - include TimeHelper - - def execute_in - remaining_seconds = [0, subject.scheduled_at - Time.now].max - duration_in_numbers(remaining_seconds) - end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1bfc8a1a9ac..d5a9607f73f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -6197,7 +6197,7 @@ msgstr "" msgid "This is a confidential issue." msgstr "" -msgid "This is a delayed to run in " +msgid "This is a delayed job to run in %{remainingTime}" msgstr "" msgid "This is the author's first Merge Request to this project." diff --git a/spec/features/projects/jobs_spec.rb b/spec/features/projects/jobs_spec.rb index cbb935abd53..a1323699969 100644 --- a/spec/features/projects/jobs_spec.rb +++ b/spec/features/projects/jobs_spec.rb @@ -595,7 +595,7 @@ describe 'Jobs', :clean_gitlab_redis_shared_state do end it 'shows delayed job', :js do - expect(page).to have_content('This is a delayed to run in') + expect(page).to have_content('This is a delayed job to run in') expect(page).to have_content("This job will automatically run after it's timer finishes.") expect(page).to have_link('Unschedule job') end diff --git a/spec/javascripts/fixtures/jobs.rb b/spec/javascripts/fixtures/jobs.rb index 6d5c6d5334f..82d7a5e394e 100644 --- a/spec/javascripts/fixtures/jobs.rb +++ b/spec/javascripts/fixtures/jobs.rb @@ -5,16 +5,24 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do let(:admin) { create(:admin) } let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project_empty_repo, namespace: namespace, path: 'builds-project') } - let(:pipeline) { create(:ci_empty_pipeline, project: project) } + let(:project) { create(:project, :repository, namespace: namespace, path: 'builds-project') } + let(:pipeline) { create(:ci_empty_pipeline, project: project, sha: project.commit.id) } let!(:build_with_artifacts) { create(:ci_build, :success, :artifacts, :trace_artifact, pipeline: pipeline, stage: 'test', artifacts_expire_at: Time.now + 18.months) } let!(:failed_build) { create(:ci_build, :failed, pipeline: pipeline, stage: 'build') } let!(:pending_build) { create(:ci_build, :pending, pipeline: pipeline, stage: 'deploy') } + let!(:delayed_job) do + create(:ci_build, :scheduled, + pipeline: pipeline, + name: 'delayed job', + stage: 'test', + commands: 'test') + end render_views before(:all) do clean_frontend_fixtures('builds/') + clean_frontend_fixtures('jobs/') end before do @@ -34,4 +42,15 @@ describe Projects::JobsController, '(JavaScript fixtures)', type: :controller do expect(response).to be_success store_frontend_fixture(response, example.description) end + + it 'jobs/delayed.json' do |example| + get :show, + namespace_id: project.namespace.to_param, + project_id: project, + id: delayed_job.to_param, + format: :json + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end end diff --git a/spec/javascripts/jobs/components/job_app_spec.js b/spec/javascripts/jobs/components/job_app_spec.js index f8ca43fc150..98c995393b9 100644 --- a/spec/javascripts/jobs/components/job_app_spec.js +++ b/spec/javascripts/jobs/components/job_app_spec.js @@ -8,6 +8,7 @@ import { resetStore } from '../store/helpers'; import job from '../mock_data'; describe('Job App ', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const Component = Vue.extend(jobApp); let store; let vm; @@ -420,6 +421,36 @@ describe('Job App ', () => { done(); }, 0); }); + + it('displays remaining time for a delayed job', done => { + const oneHourInMilliseconds = 3600000; + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - oneHourInMilliseconds, + ); + mock.onGet(props.endpoint).replyOnce(200, { ...delayedJobFixture }); + + vm = mountComponentWithStore(Component, { + props, + store, + }); + + store.subscribeAction(action => { + if (action.type !== 'receiveJobSuccess') { + return; + } + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-job-empty-state')).not.toBeNull(); + + const title = vm.$el.querySelector('.js-job-empty-state-title'); + + expect(title).toContainText('01:00:00'); + done(); + }) + .catch(done.fail); + }); + }); }); }); diff --git a/spec/javascripts/jobs/components/job_container_item_spec.js b/spec/javascripts/jobs/components/job_container_item_spec.js index 8588eda19c8..2d108f1ad7f 100644 --- a/spec/javascripts/jobs/components/job_container_item_spec.js +++ b/spec/javascripts/jobs/components/job_container_item_spec.js @@ -4,6 +4,7 @@ import mountComponent from 'spec/helpers/vue_mount_component_helper'; import job from '../mock_data'; describe('JobContainerItem', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const Component = Vue.extend(JobContainerItem); let vm; @@ -70,4 +71,29 @@ describe('JobContainerItem', () => { expect(vm.$el).toHaveSpriteIcon('retry'); }); }); + + describe('for delayed job', () => { + beforeEach(() => { + const remainingMilliseconds = 1337000; + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingMilliseconds, + ); + }); + + it('displays remaining time in tooltip', done => { + vm = mountComponent(Component, { + job: delayedJobFixture, + isActive: false, + }); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.js-job-link').getAttribute('data-original-title')).toEqual( + 'delayed job - delayed manual action (00:22:17)', + ); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js b/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js new file mode 100644 index 00000000000..48a6b80b365 --- /dev/null +++ b/spec/javascripts/jobs/mixins/delayed_job_mixin_spec.js @@ -0,0 +1,93 @@ +import Vue from 'vue'; +import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('DelayedJobMixin', () => { + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); + const dummyComponent = Vue.extend({ + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + }, + template: '<div>{{ remainingTime }}</div>', + }); + + let vm; + + beforeEach(() => { + jasmine.clock().install(); + }); + + afterEach(() => { + vm.$destroy(); + jasmine.clock().uninstall(); + }); + + describe('if job is empty object', () => { + beforeEach(() => { + vm = mountComponent(dummyComponent, { + job: {}, + }); + }); + + it('sets remaining time to 00:00:00', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + + describe('after mounting', () => { + beforeEach(done => { + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('doe not update remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + }); + }); + + describe('if job is delayed job', () => { + let remainingTimeInMilliseconds = 42000; + + beforeEach(() => { + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - remainingTimeInMilliseconds, + ); + vm = mountComponent(dummyComponent, { + job: delayedJobFixture, + }); + }); + + it('sets remaining time to 00:00:00', () => { + expect(vm.$el.innerText).toBe('00:00:00'); + }); + + describe('after mounting', () => { + beforeEach(done => { + Vue.nextTick() + .then(done) + .catch(done.fail); + }); + + it('sets remaining time', () => { + expect(vm.$el.innerText).toBe('00:00:42'); + }); + + it('updates remaining time', done => { + remainingTimeInMilliseconds = 41000; + jasmine.clock().tick(1000); + + Vue.nextTick() + .then(() => { + expect(vm.$el.innerText).toBe('00:00:41'); + }) + .then(done) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/pipelines/graph/job_item_spec.js b/spec/javascripts/pipelines/graph/job_item_spec.js index 7cbcdc791e7..41b614cc95e 100644 --- a/spec/javascripts/pipelines/graph/job_item_spec.js +++ b/spec/javascripts/pipelines/graph/job_item_spec.js @@ -6,6 +6,7 @@ describe('pipeline graph job item', () => { const JobComponent = Vue.extend(JobItem); let component; + const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const mockJob = { id: 4256, name: 'test', @@ -167,4 +168,30 @@ describe('pipeline graph job item', () => { expect(component.$el.querySelector(tooltipBoundary)).toBeNull(); }); }); + + describe('for delayed job', () => { + beforeEach(() => { + const fifteenMinutesInMilliseconds = 900000; + spyOn(Date, 'now').and.callFake( + () => new Date(delayedJobFixture.scheduled_at).getTime() - fifteenMinutesInMilliseconds, + ); + }); + + it('displays remaining time in tooltip', done => { + component = mountComponent(JobComponent, { + job: delayedJobFixture, + }); + + Vue.nextTick() + .then(() => { + expect( + component.$el + .querySelector('.js-pipeline-graph-job-link') + .getAttribute('data-original-title'), + ).toEqual('delayed job - delayed manual action (00:15:00)'); + }) + .then(done) + .catch(done.fail); + }); + }); }); diff --git a/spec/lib/gitlab/ci/status/build/scheduled_spec.rb b/spec/lib/gitlab/ci/status/build/scheduled_spec.rb index 4a52b3ab8de..68b87fea75d 100644 --- a/spec/lib/gitlab/ci/status/build/scheduled_spec.rb +++ b/spec/lib/gitlab/ci/status/build/scheduled_spec.rb @@ -13,24 +13,10 @@ describe Gitlab::Ci::Status::Build::Scheduled do end describe '#status_tooltip' do - context 'when scheduled_at is not expired' do - let(:build) { create(:ci_build, scheduled_at: 1.minute.since, project: project) } - - it 'shows execute_in of the scheduled job' do - Timecop.freeze(Time.now.change(usec: 0)) do - expect(subject.status_tooltip).to include('00:01:00') - end - end - end - - context 'when scheduled_at is expired' do - let(:build) { create(:ci_build, :expired_scheduled, project: project) } + let(:build) { create(:ci_build, scheduled_at: 1.minute.since, project: project) } - it 'shows 00:00' do - Timecop.freeze do - expect(subject.status_tooltip).to include('00:00') - end - end + it 'has a placeholder for the remaining time' do + expect(subject.status_tooltip).to include('%{remainingTime}') end end |