diff --git a/app/components/pipeline/jobs/table/component.js b/app/components/pipeline/jobs/table/component.js index 25ed24e7f..963fa73b8 100644 --- a/app/components/pipeline/jobs/table/component.js +++ b/app/components/pipeline/jobs/table/component.js @@ -1,6 +1,7 @@ import Component from '@glimmer/component'; import { service } from '@ember/service'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { dom } from '@fortawesome/fontawesome-svg-core'; import DataReloader from './dataReloader'; import getDisplayName from './util'; @@ -10,86 +11,145 @@ const INITIAL_PAGE_SIZE = 10; export default class PipelineJobsTableComponent extends Component { @service shuttle; + @service workflowDataReload; + @service('emt-themes/ember-bootstrap-v5') emberModelTableBootstrapTheme; + pipeline; + + userSettings; + + event; + + jobs; + dataReloader; - data = []; - - columns = [ - { - title: 'JOB', - propertyName: 'jobName', - className: 'job-column', - component: 'jobCell', - filteredBy: 'jobName' - }, - { + numBuilds; + + @tracked data; + + columns; + + constructor() { + super(...arguments); + + this.pipeline = this.args.pipeline; + this.event = this.args.event; + this.jobs = this.args.jobs; + this.userSettings = this.args.userSettings; + this.numBuilds = this.args.numBuilds; + this.data = null; + + this.setColumnData(); + } + + setColumnData() { + const historyColumnConfiguration = { title: 'HISTORY', - propertyName: 'history', className: 'history-column', - component: 'historyCell', - filterFunction: async (_, filterVal) => { + component: 'historyCell' + }; + + if (!this.event) { + historyColumnConfiguration.propertyName = 'history'; + historyColumnConfiguration.filterWithSelect = true; + historyColumnConfiguration.predefinedFilterOptions = [ + '5', + '10', + '15', + '20', + '25', + '30' + ]; + historyColumnConfiguration.filterFunction = async (_, filterVal) => { await this.dataReloader.setNumBuilds(filterVal); - }, - filterWithSelect: true, - predefinedFilterOptions: ['5', '10', '15', '20', '25', '30'] - }, - { - title: 'DURATION', - className: 'duration-column', - component: 'durationCell' - }, - { - title: 'START TIME', - className: 'start-time-column', - component: 'startTimeCell' - }, - { - title: 'COVERAGE', - className: 'coverage-column', - component: 'coverageCell' - }, - { - title: 'STAGE', - propertyName: 'stageName', - className: 'stage-column', - component: 'stageCell', - filteredBy: 'stageName' - }, - { - title: 'METRICS', - className: 'metrics-column', - component: 'metricsCell' - }, - { - title: 'ACTIONS', - className: 'actions-column', - component: 'actionsCell' + }; } - ]; - constructor() { - super(...arguments); + this.columns = [ + { + title: 'JOB', + propertyName: 'jobName', + className: 'job-column', + component: 'jobCell', + filteredBy: 'jobName' + }, + historyColumnConfiguration, + { + title: 'DURATION', + className: 'duration-column', + component: 'durationCell' + }, + { + title: 'START TIME', + className: 'start-time-column', + component: 'startTimeCell' + }, + { + title: 'COVERAGE', + className: 'coverage-column', + component: 'coverageCell' + }, + { + title: 'STAGE', + propertyName: 'stageName', + className: 'stage-column', + component: 'stageCell', + filteredBy: 'stageName' + }, + { + title: 'METRICS', + className: 'metrics-column', + component: 'metricsCell' + }, + { + title: 'ACTIONS', + className: 'actions-column', + component: 'actionsCell' + } + ]; + } + + willDestroy() { + super.willDestroy(); - const jobIds = this.args.jobs.map(job => job.id); + this.dataReloader.stop(this.event?.id); + } + + @action + initialize(element) { + dom.i2svg({ node: element }); + this.initializeDataLoader().then(() => {}); + } + + @action + async initializeDataLoader() { + const prNum = this.event?.prNum; + + if (prNum) { + this.jobs = this.workflowDataReload.getJobsForPr(prNum); + } + + const jobIds = this.jobs.map(job => job.id); this.dataReloader = new DataReloader( - this.shuttle, + { shuttle: this.shuttle, workflowDataReload: this.workflowDataReload }, jobIds, INITIAL_PAGE_SIZE, - this.args.numBuilds + this.numBuilds ); - const initialJobIds = this.dataReloader.newJobIds(); - this.args.jobs.forEach(job => { + this.data = []; + + this.jobs.forEach(job => { this.data.push({ job, - jobName: getDisplayName(job), + jobName: getDisplayName(job, prNum), stageName: job?.permutations?.[0]?.stage?.name || 'N/A', - pipeline: this.args.pipeline, - jobs: this.args.jobs, - timestampFormat: this.args.userSettings.timestampFormat, + pipeline: this.pipeline, + jobs: this.jobs, + timestampFormat: this.userSettings.timestampFormat, onCreate: (jobToMonitor, buildsCallback) => { this.dataReloader.addCallbackForJobId( jobToMonitor.id, @@ -102,15 +162,27 @@ export default class PipelineJobsTableComponent extends Component { }); }); - this.dataReloader.fetchBuildsForJobs(initialJobIds).then(() => { - this.dataReloader.start(); - }); + const eventId = this.event?.id; + + if (!eventId) { + const initialJobIds = this.dataReloader.newJobIds(); + + await this.dataReloader.fetchBuildsForJobs(initialJobIds); + } + + this.dataReloader.start(eventId); } - willDestroy() { - super.willDestroy(); + @action + update(element, [event]) { + this.data = []; - this.dataReloader.destroy(); + if (event) { + this.dataReloader.stop(this.event?.id); + this.event = event; + } + + this.initializeDataLoader().then(() => {}); } get theme() { @@ -135,9 +207,4 @@ export default class PipelineJobsTableComponent extends Component { .fetchBuildsForJobs(this.dataReloader.newJobIds()) .then(() => {}); } - - @action - handleDidInsert(element) { - dom.i2svg({ node: element }); - } } diff --git a/app/components/pipeline/jobs/table/dataReloader.js b/app/components/pipeline/jobs/table/dataReloader.js index 33edc59dc..a46694851 100644 --- a/app/components/pipeline/jobs/table/dataReloader.js +++ b/app/components/pipeline/jobs/table/dataReloader.js @@ -1,9 +1,13 @@ import ENV from 'screwdriver-ui/config/environment'; import { setBuildStatus } from 'screwdriver-ui/utils/pipeline/build'; +const QUEUE_NAME = 'jobs-table-data-reloader'; + export default class DataReloader { shuttle; + workflowDataReload; + pageSize; jobIdsMatchingFilter = []; @@ -16,8 +20,11 @@ export default class DataReloader { numBuilds; - constructor(shuttle, jobIds, pageSize, numBuilds) { + constructor(apiFetchers, jobIds, pageSize, numBuilds) { + const { shuttle, workflowDataReload } = apiFetchers; + this.shuttle = shuttle; + this.workflowDataReload = workflowDataReload; this.jobIdsMatchingFilter = jobIds.slice(0, pageSize); jobIds.forEach(jobId => { @@ -68,6 +75,14 @@ export default class DataReloader { this.jobCallbacks[jobId].push(buildsCallback); } + sendBuildsToCallbacks(jobId, builds) { + if (this.jobCallbacks[jobId]) { + this.jobCallbacks[jobId].forEach(callback => { + callback(builds); + }); + } + } + async fetchBuildsForJobs(jobIds) { if (jobIds.length === 0) { return; @@ -87,12 +102,7 @@ export default class DataReloader { const { jobId } = buildsForJob; this.builds[jobId] = buildsForJob.builds; - - if (this.jobCallbacks[jobId]) { - this.jobCallbacks[jobId].forEach(callback => { - callback(buildsForJob.builds); - }); - } + this.sendBuildsToCallbacks(jobId, buildsForJob.builds); }); }); } @@ -105,7 +115,19 @@ export default class DataReloader { await this.fetchBuildsForJobs(this.jobIdsMatchingFilter); } - start() { + start(eventId) { + if (eventId) { + this.workflowDataReload.registerBuildsCallback( + QUEUE_NAME, + eventId, + builds => { + this.parseEventBuilds(builds); + } + ); + + return; + } + this.intervalId = setInterval(() => { if (Object.keys(this.jobCallbacks).length === 0) { return; @@ -115,11 +137,24 @@ export default class DataReloader { }, ENV.APP.BUILD_RELOAD_TIMER); } - destroy() { + stop(eventId) { if (this.intervalId) { clearInterval(this.intervalId); } + if (eventId) { + this.workflowDataReload.removeBuildsCallback(QUEUE_NAME, eventId); + } this.intervalId = null; } + + parseEventBuilds(eventBuilds) { + eventBuilds.forEach(eventBuild => { + const { jobId } = eventBuild; + const builds = [eventBuild]; + + this.builds[jobId] = builds; + this.sendBuildsToCallbacks(jobId, builds); + }); + } } diff --git a/app/components/pipeline/jobs/table/template.hbs b/app/components/pipeline/jobs/table/template.hbs index 86598ae56..d19bc311a 100644 --- a/app/components/pipeline/jobs/table/template.hbs +++ b/app/components/pipeline/jobs/table/template.hbs @@ -1,9 +1,13 @@ - - +
-
+ - +
+ diff --git a/app/components/pipeline/jobs/table/util.js b/app/components/pipeline/jobs/table/util.js index 771942d89..0e3deb475 100644 --- a/app/components/pipeline/jobs/table/util.js +++ b/app/components/pipeline/jobs/table/util.js @@ -1,10 +1,11 @@ /** * Get the display name of the job. Uses the display name configured in the annotation if available. * @param job {Object} Job in the format returned by the API + * @param prNum {Number} PR number * @returns {string} */ -export default function getDisplayName(job) { - const jobName = job.name; +export default function getDisplayName(job, prNum) { + const jobName = prNum ? job.name.slice(`PR-${prNum}:`.length) : job.name; const { annotations } = job?.permutations?.[0] || {}; if (annotations) { diff --git a/app/components/pipeline/workflow/component.js b/app/components/pipeline/workflow/component.js index d48f19849..7cf96c9c6 100644 --- a/app/components/pipeline/workflow/component.js +++ b/app/components/pipeline/workflow/component.js @@ -45,6 +45,8 @@ export default class PipelineWorkflowComponent extends Component { @tracked workflowGraphToDisplay; + @tracked showGraph; + workflowGraph; workflowGraphWithDownstreamTriggers; @@ -107,6 +109,8 @@ export default class PipelineWorkflowComponent extends Component { this.setWorkflowGraphFromEvent(); } } + + this.showGraph = true; } willDestroy() { @@ -180,9 +184,9 @@ export default class PipelineWorkflowComponent extends Component { return getDisplayJobNameLength(this.userSettings); } - get disableDownstreamTriggers() { + get hasDownstreamTriggers() { return ( - this.workflowGraph.nodes.length === + this.workflowGraph.nodes.length !== this.workflowGraphWithDownstreamTriggers.nodes.length ); } @@ -217,4 +221,9 @@ export default class PipelineWorkflowComponent extends Component { this.d3Data = null; } } + + @action + setShowGraph(showGraph) { + this.showGraph = showGraph; + } } diff --git a/app/components/pipeline/workflow/event-rail/styles.scss b/app/components/pipeline/workflow/event-rail/styles.scss index 0b320fca7..6a7867b83 100644 --- a/app/components/pipeline/workflow/event-rail/styles.scss +++ b/app/components/pipeline/workflow/event-rail/styles.scss @@ -1,10 +1,7 @@ @use 'screwdriver-colors' as colors; -@use 'screwdriver-button' as button; @mixin styles { #event-rail { - @include button.styles; - height: 100%; border-top: 1px solid rgba(colors.$sd-text-med, 0.5); border-right: 1px solid rgba(colors.$sd-text-med, 0.5); diff --git a/app/components/pipeline/workflow/styles.scss b/app/components/pipeline/workflow/styles.scss index 6292c962b..08d14e026 100644 --- a/app/components/pipeline/workflow/styles.scss +++ b/app/components/pipeline/workflow/styles.scss @@ -1,4 +1,6 @@ @use 'variables'; +@use 'screwdriver-colors' as colors; +@use 'screwdriver-button' as button; @use 'event-rail/styles' as event-rail; @use 'graph/styles' as graph; @@ -6,6 +8,8 @@ @mixin styles { #pipeline-workflow-container { + @include button.styles; + display: grid; $events-rail-width: 26rem; @@ -19,37 +23,74 @@ grid-area: events; } - #workflow-graph-container { + #workflow-display-container { grid-area: workflow; overflow: auto; $graph-controls-height: 2.5rem; - #workflow-graph-controls { + #workflow-display-controls { display: flex; align-content: center; padding-left: 1rem; height: $graph-controls-height; - .button-container { + #show-workflow-graph-button, + #show-workflow-table-button { + display: flex; + + svg { + margin: auto; + } + + &:disabled { + background: colors.$sd-pale-purple; + } + } + + #show-workflow-graph-button { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + #show-workflow-table-button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + translate: -1px; + } + + .downstream-triggers-container { + margin-left: 1rem; + display: flex; + .x-toggle-component { + &.x-toggle-focused .x-toggle-btn:not(.x-toggle-disabled)::after, + &.x-toggle-focused .x-toggle-btn:not(.x-toggle-disabled)::before { + box-shadow: 0 0 2px 3px colors.$sd-running; + } + + .x-toggle-light.x-toggle-btn { + background: colors.$sd-text-light; + } + + .x-toggle:checked + label > .x-toggle-light.x-toggle-btn { + background: colors.$sd-pale-purple; + } + label { margin-bottom: 0; &.off-label { padding-right: 0; } - - &.on-label { - padding-left: 0.85rem; - } } } } } - #graph-container { + #display-container { position: relative; height: calc(100% - $graph-controls-height); + padding-top: 1rem; overflow: auto; #workflow-graph { diff --git a/app/components/pipeline/workflow/template.hbs b/app/components/pipeline/workflow/template.hbs index 4f1b225f9..9e5d010b6 100644 --- a/app/components/pipeline/workflow/template.hbs +++ b/app/components/pipeline/workflow/template.hbs @@ -12,57 +12,98 @@ /> {{#if this.event}} -
-
-
+
+ - -
-
+ + + + -
- + {{#if (and this.showGraph this.hasDownstreamTriggers)}} +
+ +
+ {{/if}} +
- {{#if this.showTooltip}} - + {{#if this.showGraph}} + - {{/if}} - {{#if this.showStageTooltip}} - + {{/if}} + {{#if this.showStageTooltip}} + + {{/if}} + {{else}} + {{/if}}
diff --git a/tests/integration/components/pipeline/workflow/component-test.js b/tests/integration/components/pipeline/workflow/component-test.js index a4334c940..06b8e7e99 100644 --- a/tests/integration/components/pipeline/workflow/component-test.js +++ b/tests/integration/components/pipeline/workflow/component-test.js @@ -30,7 +30,7 @@ module('Integration | Component | pipeline/workflow', function (hooks) { ); assert.dom('#event-rail').exists({ count: 1 }); - assert.dom('#workflow-graph-container').doesNotExist(); + assert.dom('#workflow-display-container').doesNotExist(); assert.dom('#no-events').exists({ count: 1 }); assert.dom('#no-events').hasText('This pipeline has no events yet'); assert.dom('#invalid-event').doesNotExist(); @@ -59,7 +59,7 @@ module('Integration | Component | pipeline/workflow', function (hooks) { ); assert.dom('#event-rail').exists({ count: 1 }); - assert.dom('#workflow-graph-container').doesNotExist(); + assert.dom('#workflow-display-container').doesNotExist(); assert.dom('#no-events').exists({ count: 1 }); assert .dom('#no-events') @@ -107,7 +107,7 @@ module('Integration | Component | pipeline/workflow', function (hooks) { ); assert.dom('#event-rail').exists({ count: 1 }); - assert.dom('#workflow-graph-container').doesNotExist(); + assert.dom('#workflow-display-container').doesNotExist(); assert.dom('#no-events').doesNotExist(); assert.dom('#invalid-event').exists({ count: 1 }); assert @@ -148,7 +148,7 @@ module('Integration | Component | pipeline/workflow', function (hooks) { ); assert.dom('#event-rail').exists({ count: 1 }); - assert.dom('#workflow-graph-container').doesNotExist(); + assert.dom('#workflow-display-container').doesNotExist(); assert.dom('#no-events').doesNotExist(); assert.dom('#invalid-event').exists({ count: 1 }); assert @@ -204,7 +204,7 @@ module('Integration | Component | pipeline/workflow', function (hooks) { ); assert.dom('#event-rail').exists({ count: 1 }); - assert.dom('#workflow-graph-container').exists({ count: 1 }); + assert.dom('#workflow-display-container').exists({ count: 1 }); assert.dom('#no-events').doesNotExist(); assert.dom('#invalid-event').doesNotExist(); }); @@ -262,7 +262,7 @@ module('Integration | Component | pipeline/workflow', function (hooks) { ); assert.dom('#event-rail').exists({ count: 1 }); - assert.dom('#workflow-graph-container').exists({ count: 1 }); + assert.dom('#workflow-display-container').exists({ count: 1 }); assert.dom('#no-events').doesNotExist(); assert.dom('#invalid-event').doesNotExist(); }); diff --git a/tests/unit/components/pipeline/jobs/table/dataReloader-test.js b/tests/unit/components/pipeline/jobs/table/dataReloader-test.js index 1855b886b..b10d1b1d7 100644 --- a/tests/unit/components/pipeline/jobs/table/dataReloader-test.js +++ b/tests/unit/components/pipeline/jobs/table/dataReloader-test.js @@ -9,7 +9,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { { status: 'SUCCESS', meta: { build: { warning: { message: 'Oops' } } } } ]; - new DataReloader(null, [], 0).setCorrectBuildStatus(builds); + new DataReloader({}, [], 0).setCorrectBuildStatus(builds); assert.equal(builds[0].status, 'SUCCESS'); assert.equal(builds[1].status, 'WARNING'); @@ -17,7 +17,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { test('updateJobsMatchingFilter updates', function (assert) { const jobIds = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; - const dataReloader = new DataReloader(null, jobIds, 3); + const dataReloader = new DataReloader({}, jobIds, 3); assert.equal(dataReloader.jobIdsMatchingFilter.length, 3); assert.equal(dataReloader.jobIdsMatchingFilter[0], 0); @@ -44,7 +44,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { }); test('newJobIds returns new job ids', function (assert) { - const dataReloader = new DataReloader(null, [1, 2, 3, 4, 5], 5); + const dataReloader = new DataReloader({}, [1, 2, 3, 4, 5], 5); dataReloader.jobIdsMatchingFilter = [1, 2, 6, 7, 8]; dataReloader.jobCallbacks = { 1: [], 2: [], 3: [], 4: [], 5: [] }; @@ -57,7 +57,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { }); test('removeCallbacksForJobId removes entry', function (assert) { - const dataReloader = new DataReloader(null, [1, 2], 2); + const dataReloader = new DataReloader({}, [1, 2], 2); dataReloader.jobCallbacks = { 1: [], 2: [] }; assert.equal(Object.entries(dataReloader.jobCallbacks).length, 2); @@ -68,7 +68,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { }); test('addCallbackForJobId adds callback', function (assert) { - const dataReloader = new DataReloader(null, [], 0); + const dataReloader = new DataReloader({}, [], 0); const jobId = 1; const callback = sinon.stub(); @@ -80,7 +80,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { }); test('addCallbackForJobId calls callback if builds exist', function (assert) { - const dataReloader = new DataReloader(null, [], 0); + const dataReloader = new DataReloader({}, [], 0); const jobId = 1; const builds = [{ id: 1 }]; const callback = sinon.stub(); @@ -111,7 +111,7 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { sinon.stub(shuttle, 'fetchFromApi').resolves(buildStatuses); - const dataReloader = new DataReloader(shuttle, [], 0); + const dataReloader = new DataReloader({ shuttle }, [], 0); dataReloader.addCallbackForJobId(jobId, callback); @@ -123,4 +123,21 @@ module('Unit | Component | pipeline/jobs/table/dataReloader', function () { assert.equal(callback.callCount, 1); assert.equal(callback.calledWith(builds), true); }); + + test('parseEventBuilds parses builds and calls callbacks', function (assert) { + const jobId = 1; + const builds = [{ id: 11, status: 'SUCCESS', jobId }]; + const callback = sinon.stub(); + + const dataReloader = new DataReloader({}, [], 0); + + dataReloader.addCallbackForJobId(jobId, callback); + + dataReloader.parseEventBuilds(builds); + + assert.equal(dataReloader.builds[jobId].length, builds.length); + assert.equal(dataReloader.builds[jobId][0].status, 'SUCCESS'); + assert.equal(callback.callCount, 1); + assert.equal(callback.calledWith(builds), true); + }); }); diff --git a/tests/unit/components/pipeline/jobs/table/util-test.js b/tests/unit/components/pipeline/jobs/table/util-test.js index fd2847f7f..beafd8d7e 100644 --- a/tests/unit/components/pipeline/jobs/table/util-test.js +++ b/tests/unit/components/pipeline/jobs/table/util-test.js @@ -19,4 +19,11 @@ module('Unit | Component | pipeline/jobs/table/util', function () { assert.equal(getDisplayName(job), configuredName); }); + + test('getDisplayName removes PR- prefix', function (assert) { + const prNum = 123; + const job = { name: `PR-${prNum}:abc123` }; + + assert.equal(getDisplayName(job, prNum), 'abc123'); + }); });