diff options
Diffstat (limited to 'app/assets/javascripts/pipelines')
25 files changed, 1724 insertions, 0 deletions
diff --git a/app/assets/javascripts/pipelines/components/async_button.vue b/app/assets/javascripts/pipelines/components/async_button.vue new file mode 100644 index 00000000000..37a6f02d8fd --- /dev/null +++ b/app/assets/javascripts/pipelines/components/async_button.vue @@ -0,0 +1,104 @@ +<script> +/* eslint-disable no-new, no-alert */ +/* global Flash */ +import '~/flash'; +import eventHub from '../event_hub'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + endpoint: { + type: String, + required: true, + }, + + service: { + type: Object, + required: true, + }, + + title: { + type: String, + required: true, + }, + + icon: { + type: String, + required: true, + }, + + cssClass: { + type: String, + required: true, + }, + + confirmActionMessage: { + type: String, + required: false, + }, + }, + + components: { + loadingIcon, + }, + + data() { + return { + isLoading: false, + }; + }, + + computed: { + iconClass() { + return `fa fa-${this.icon}`; + }, + + buttonClass() { + return `btn has-tooltip ${this.cssClass}`; + }, + }, + + methods: { + onClick() { + if (this.confirmActionMessage && confirm(this.confirmActionMessage)) { + this.makeRequest(); + } else if (!this.confirmActionMessage) { + this.makeRequest(); + } + }, + + makeRequest() { + this.isLoading = true; + + $(this.$el).tooltip('destroy'); + + this.service.postAction(this.endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, + }, +}; +</script> + +<template> + <button + type="button" + @click="onClick" + :class="buttonClass" + :title="title" + :aria-label="title" + data-container="body" + data-placement="top" + :disabled="isLoading"> + <i + :class="iconClass" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> + </button> +</template> diff --git a/app/assets/javascripts/pipelines/components/empty_state.vue b/app/assets/javascripts/pipelines/components/empty_state.vue new file mode 100644 index 00000000000..3db64339a62 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/empty_state.vue @@ -0,0 +1,34 @@ +<script> +import pipelinesEmptyStateSVG from 'empty_states/icons/_pipelines_empty.svg'; + +export default { + props: { + helpPagePath: { + type: String, + required: true, + }, + }, + data: () => ({ pipelinesEmptyStateSVG }), +}; +</script> + +<template> + <div class="row empty-state js-empty-state"> + <div class="col-xs-12"> + <div class="svg-content" v-html="pipelinesEmptyStateSVG" /> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>Build with confidence</h4> + <p> + Continous Integration can help catch bugs by running your tests automatically, + while Continuous Deployment can help you deliver code to your product environment. + </p> + <a :href="helpPagePath" class="btn btn-info"> + Get started with Pipelines + </a> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/error_state.vue b/app/assets/javascripts/pipelines/components/error_state.vue new file mode 100644 index 00000000000..90cee68163e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/error_state.vue @@ -0,0 +1,21 @@ +<script> +import pipelinesErrorStateSVG from 'empty_states/icons/_pipelines_failed.svg'; + +export default { + data: () => ({ pipelinesErrorStateSVG }), +}; +</script> + +<template> + <div class="row empty-state js-pipelines-error-state"> + <div class="col-xs-12"> + <div class="svg-content" v-html="pipelinesErrorStateSVG" /> + </div> + + <div class="col-xs-12 text-center"> + <div class="text-content"> + <h4>The API failed to fetch the pipelines.</h4> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue new file mode 100644 index 00000000000..1f9e3d39779 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -0,0 +1,64 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + + cssClass() { + return `js-${gl.text.dasherize(this.actionIcon)}`; + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + class="ci-action-icon-container" + data-toggle="tooltip" + data-container="body"> + + <i + class="ci-action-icon-wrapper" + :class="cssClass" + v-html="actionIconSvg" + aria-hidden="true" + /> + </a> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue new file mode 100644 index 00000000000..19cafff4e1c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_action_component.vue @@ -0,0 +1,56 @@ +<script> + import getActionIcon from '../../../vue_shared/ci_action_icons'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders either a cancel, retry or play icon pointing to the given path. + * TODO: Remove UJS from here and use an async request instead. + */ + export default { + props: { + tooltipText: { + type: String, + required: true, + }, + + link: { + type: String, + required: true, + }, + + actionMethod: { + type: String, + required: true, + }, + + actionIcon: { + type: String, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + actionIconSvg() { + return getActionIcon(this.actionIcon); + }, + }, + }; +</script> +<template> + <a + :data-method="actionMethod" + :title="tooltipText" + :href="link" + ref="tooltip" + rel="nofollow" + class="ci-action-icon-wrapper js-ci-status-icon" + data-toggle="tooltip" + data-container="body" + v-html="actionIconSvg" + aria-label="Job's action"> + </a> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue new file mode 100644 index 00000000000..d597af8dfb5 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/dropdown_job_component.vue @@ -0,0 +1,86 @@ +<script> + import jobNameComponent from './job_name_component.vue'; + import jobComponent from './job_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the dropdown for the pipeline graph. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "icon_action_retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + export default { + props: { + job: { + type: Object, + required: true, + }, + }, + + mixins: [ + tooltipMixin, + ], + + components: { + jobComponent, + jobNameComponent, + }, + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + }, + }; +</script> +<template> + <div> + <button + type="button" + data-toggle="dropdown" + data-container="body" + class="dropdown-menu-toggle build-content" + :title="tooltipText" + ref="tooltip"> + + <job-name-component + :name="job.name" + :status="job.status" /> + + <span class="dropdown-counter-badge"> + {{job.size}} + </span> + </button> + + <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown"> + <li class="scrollable-menu"> + <ul> + <li v-for="item in job.jobs"> + <job-component + :job="item" + :is-dropdown="true" + css-class-job-name="mini-pipeline-graph-dropdown-item" + /> + </li> + </ul> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue new file mode 100644 index 00000000000..14c98847d93 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -0,0 +1,113 @@ +<script> + /* global Flash */ + import Visibility from 'visibilityjs'; + import Poll from '../../../lib/utils/poll'; + import PipelineService from '../../services/pipeline_service'; + import PipelineStore from '../../stores/pipeline_store'; + import stageColumnComponent from './stage_column_component.vue'; + import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; + import '../../../flash'; + + export default { + components: { + stageColumnComponent, + loadingIcon, + }, + + data() { + const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset; + const store = new PipelineStore(); + + return { + isLoading: false, + endpoint: DOMdata.endpoint, + store, + state: store.state, + }; + }, + + created() { + this.service = new PipelineService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'getPipeline', + successCallback: this.successCallback, + errorCallback: this.errorCallback, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + }, + + methods: { + successCallback(response) { + const data = response.json(); + + this.isLoading = false; + this.store.storeGraph(data.details.stages); + }, + + errorCallback() { + this.isLoading = false; + return new Flash('An error occurred while fetching the pipeline.'); + }, + + capitalizeStageName(name) { + return name.charAt(0).toUpperCase() + name.slice(1); + }, + + isFirstColumn(index) { + return index === 0; + }, + + stageConnectorClass(index, stage) { + let className; + + // If it's the first stage column and only has one job + if (index === 0 && stage.groups.length === 1) { + className = 'no-margin'; + } else if (index > 0) { + // If it is not the first column + className = 'left-margin'; + } + + return className; + }, + }, + }; +</script> +<template> + <div class="build-content middle-block js-pipeline-graph"> + <div class="pipeline-visualization pipeline-graph"> + <div class="text-center"> + <loading-icon + v-if="isLoading" + size="3" + /> + </div> + + <ul + v-if="!isLoading" + class="stage-column-list"> + <stage-column-component + v-for="(stage, index) in state.graph" + :title="capitalizeStageName(stage.name)" + :jobs="stage.groups" + :key="stage.name" + :stage-connector-class="stageConnectorClass(index, stage)" + :is-first-column="isFirstColumn(index)"/> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_component.vue b/app/assets/javascripts/pipelines/components/graph/job_component.vue new file mode 100644 index 00000000000..b39c936101e --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_component.vue @@ -0,0 +1,124 @@ +<script> + import actionComponent from './action_component.vue'; + import dropdownActionComponent from './dropdown_action_component.vue'; + import jobNameComponent from './job_name_component.vue'; + import tooltipMixin from '../../../vue_shared/mixins/tooltip'; + + /** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "icon_status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "icon_action_retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + + export default { + props: { + job: { + type: Object, + required: true, + }, + + cssClassJobName: { + type: String, + required: false, + default: '', + }, + + isDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + components: { + actionComponent, + dropdownActionComponent, + jobNameComponent, + }, + + mixins: [ + tooltipMixin, + ], + + computed: { + tooltipText() { + return `${this.job.name} - ${this.job.status.label}`; + }, + + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + }, + }; +</script> +<template> + <div> + <a + v-if="job.status.details_path" + :href="job.status.details_path" + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </a> + + <div + v-else + :title="tooltipText" + :class="cssClassJobName" + ref="tooltip" + data-toggle="tooltip" + data-container="body"> + + <job-name-component + :name="job.name" + :status="job.status" + /> + </div> + + <action-component + v-if="hasAction && !isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + + <dropdown-action-component + v-if="hasAction && isDropdown" + :tooltip-text="job.status.action.title" + :link="job.status.action.path" + :action-icon="job.status.action.icon" + :action-method="job.status.action.method" + /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue new file mode 100644 index 00000000000..d8856e10668 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/job_name_component.vue @@ -0,0 +1,37 @@ +<script> + import ciIcon from '../../../vue_shared/components/ci_icon.vue'; + + /** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ + export default { + props: { + name: { + type: String, + required: true, + }, + + status: { + type: Object, + required: true, + }, + }, + + components: { + ciIcon, + }, + }; +</script> +<template> + <span> + <ci-icon + :status="status" /> + + <span class="ci-status-text"> + {{name}} + </span> + </span> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue new file mode 100644 index 00000000000..9b1bbb0906f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -0,0 +1,83 @@ +<script> +import jobComponent from './job_component.vue'; +import dropdownJobComponent from './dropdown_job_component.vue'; + +export default { + props: { + title: { + type: String, + required: true, + }, + + jobs: { + type: Array, + required: true, + }, + + isFirstColumn: { + type: Boolean, + required: false, + default: false, + }, + + stageConnectorClass: { + type: String, + required: false, + default: '', + }, + }, + + components: { + jobComponent, + dropdownJobComponent, + }, + + methods: { + firstJob(list) { + return list[0]; + }, + + jobId(job) { + return `ci-badge-${job.name}`; + }, + + buildConnnectorClass(index) { + return index === 0 && !this.isFirstColumn ? 'left-connector' : ''; + }, + }, +}; +</script> +<template> + <li + class="stage-column" + :class="stageConnectorClass"> + <div class="stage-name"> + {{title}} + </div> + <div class="builds-container"> + <ul> + <li + v-for="(job, index) in jobs" + :key="job.id" + class="build" + :class="buildConnnectorClass(index)" + :id="jobId(job)"> + + <div class="curve"></div> + + <job-component + v-if="job.size === 1" + :job="job" + css-class-job-name="build-content" + /> + + <dropdown-job-component + v-if="job.size > 1" + :job="job" + /> + + </li> + </ul> + </div> + </li> +</template> diff --git a/app/assets/javascripts/pipelines/components/nav_controls.js b/app/assets/javascripts/pipelines/components/nav_controls.js new file mode 100644 index 00000000000..6aa10531034 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/nav_controls.js @@ -0,0 +1,52 @@ +export default { + props: { + newPipelinePath: { + type: String, + required: true, + }, + + hasCiEnabled: { + type: Boolean, + required: true, + }, + + helpPagePath: { + type: String, + required: true, + }, + + ciLintPath: { + type: String, + required: true, + }, + + canCreatePipeline: { + type: Boolean, + required: true, + }, + }, + + template: ` + <div class="nav-controls"> + <a + v-if="canCreatePipeline" + :href="newPipelinePath" + class="btn btn-create"> + Run Pipeline + </a> + + <a + v-if="!hasCiEnabled" + :href="helpPagePath" + class="btn btn-info"> + Get started with Pipelines + </a> + + <a + :href="ciLintPath" + class="btn btn-default"> + CI Lint + </a> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.js b/app/assets/javascripts/pipelines/components/navigation_tabs.js new file mode 100644 index 00000000000..1626ae17a30 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/navigation_tabs.js @@ -0,0 +1,72 @@ +export default { + props: { + scope: { + type: String, + required: true, + }, + + count: { + type: Object, + required: true, + }, + + paths: { + type: Object, + required: true, + }, + }, + + mounted() { + $(document).trigger('init.scrolling-tabs'); + }, + + template: ` + <ul class="nav-links scrolling-tabs"> + <li + class="js-pipelines-tab-all" + :class="{ 'active': scope === 'all'}"> + <a :href="paths.allPath"> + All + <span class="badge js-totalbuilds-count"> + {{count.all}} + </span> + </a> + </li> + <li class="js-pipelines-tab-pending" + :class="{ 'active': scope === 'pending'}"> + <a :href="paths.pendingPath"> + Pending + <span class="badge"> + {{count.pending}} + </span> + </a> + </li> + <li class="js-pipelines-tab-running" + :class="{ 'active': scope === 'running'}"> + <a :href="paths.runningPath"> + Running + <span class="badge"> + {{count.running}} + </span> + </a> + </li> + <li class="js-pipelines-tab-finished" + :class="{ 'active': scope === 'finished'}"> + <a :href="paths.finishedPath"> + Finished + <span class="badge"> + {{count.finished}} + </span> + </a> + </li> + <li class="js-pipelines-tab-branches" + :class="{ 'active': scope === 'branches'}"> + <a :href="paths.branchesPath">Branches</a> + </li> + <li class="js-pipelines-tab-tags" + :class="{ 'active': scope === 'tags'}"> + <a :href="paths.tagsPath">Tags</a> + </li> + </ul> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.js b/app/assets/javascripts/pipelines/components/pipeline_url.js new file mode 100644 index 00000000000..7cd2e0f9366 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipeline_url.js @@ -0,0 +1,56 @@ +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; + +export default { + props: [ + 'pipeline', + ], + computed: { + user() { + return !!this.pipeline.user; + }, + }, + components: { + userAvatarLink, + }, + template: ` + <td> + <a + :href="pipeline.path" + class="js-pipeline-url-link"> + <span class="pipeline-id">#{{pipeline.id}}</span> + </a> + <span>by</span> + <user-avatar-link + v-if="user" + class="js-pipeline-url-user" + :link-href="pipeline.user.web_url" + :img-src="pipeline.user.avatar_url" + :tooltip-text="pipeline.user.name" + /> + <span + v-if="!user" + class="js-pipeline-url-api api"> + API + </span> + <span + v-if="pipeline.flags.latest" + class="js-pipeline-url-lastest label label-success has-tooltip" + title="Latest pipeline for this branch" + data-original-title="Latest pipeline for this branch"> + latest + </span> + <span + v-if="pipeline.flags.yaml_errors" + class="js-pipeline-url-yaml label label-danger has-tooltip" + :title="pipeline.yaml_errors" + :data-original-title="pipeline.yaml_errors"> + yaml invalid + </span> + <span + v-if="pipeline.flags.stuck" + class="js-pipeline-url-stuck label label-warning"> + stuck + </span> + </td> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_actions.js b/app/assets/javascripts/pipelines/components/pipelines_actions.js new file mode 100644 index 00000000000..b9e066c5db1 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_actions.js @@ -0,0 +1,91 @@ +/* eslint-disable no-new */ +/* global Flash */ +import '~/flash'; +import playIconSvg from 'icons/_icon_play.svg'; +import eventHub from '../event_hub'; +import loadingIconComponent from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + actions: { + type: Array, + required: true, + }, + + service: { + type: Object, + required: true, + }, + }, + + components: { + loadingIconComponent, + }, + + data() { + return { + playIconSvg, + isLoading: false, + }; + }, + + methods: { + onClickAction(endpoint) { + this.isLoading = true; + + $(this.$refs.tooltip).tooltip('destroy'); + + this.service.postAction(endpoint) + .then(() => { + this.isLoading = false; + eventHub.$emit('refreshPipelines'); + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occured while making the request.'); + }); + }, + + isActionDisabled(action) { + if (action.playable === undefined) { + return false; + } + + return !action.playable; + }, + }, + + template: ` + <div class="btn-group" v-if="actions"> + <button + type="button" + class="dropdown-toggle btn btn-default has-tooltip js-pipeline-dropdown-manual-actions" + title="Manual job" + data-toggle="dropdown" + data-placement="top" + aria-label="Manual job" + ref="tooltip" + :disabled="isLoading"> + ${playIconSvg} + <i + class="fa fa-caret-down" + aria-hidden="true" /> + <loading-icon v-if="isLoading" /> + </button> + + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="action in actions"> + <button + type="button" + class="js-pipeline-action-link no-btn btn" + @click="onClickAction(action.path)" + :class="{ 'disabled': isActionDisabled(action) }" + :disabled="isActionDisabled(action)"> + ${playIconSvg} + <span>{{action.name}}</span> + </button> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.js b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js new file mode 100644 index 00000000000..f18e2dfadaf --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.js @@ -0,0 +1,33 @@ +export default { + props: { + artifacts: { + type: Array, + required: true, + }, + }, + + template: ` + <div class="btn-group" role="group"> + <button + class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" + title="Artifacts" + data-placement="top" + data-toggle="dropdown" + aria-label="Artifacts"> + <i class="fa fa-download" aria-hidden="true"></i> + <i class="fa fa-caret-down" aria-hidden="true"></i> + </button> + <ul class="dropdown-menu dropdown-menu-align-right"> + <li v-for="artifact in artifacts"> + <a + rel="nofollow" + download + :href="artifact.path"> + <i class="fa fa-download" aria-hidden="true"></i> + <span>Download {{artifact.name}} artifacts</span> + </a> + </li> + </ul> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/components/stage.vue b/app/assets/javascripts/pipelines/components/stage.vue new file mode 100644 index 00000000000..7fc19fce1ff --- /dev/null +++ b/app/assets/javascripts/pipelines/components/stage.vue @@ -0,0 +1,170 @@ +<script> + +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +/* global Flash */ +import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons'; +import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + +export default { + props: { + stage: { + type: Object, + required: true, + }, + + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + }, + + data() { + return { + isLoading: false, + dropdownContent: '', + endpoint: this.stage.dropdown_path, + }; + }, + + components: { + loadingIcon, + }, + + updated() { + if (this.dropdownContent.length > 0) { + this.stopDropdownClickPropagation(); + } + }, + + watch: { + updateDropdown() { + if (this.updateDropdown && + this.isDropdownOpen() && + !this.isLoading) { + this.fetchJobs(); + } + }, + }, + + methods: { + onClickStage() { + if (!this.isDropdownOpen()) { + this.isLoading = true; + this.fetchJobs(); + } + }, + + fetchJobs() { + this.$http.get(this.endpoint) + .then((response) => { + this.dropdownContent = response.json().html; + this.isLoading = false; + }) + .catch(() => { + this.closeDropdown(); + this.isLoading = false; + + const flash = new Flash('Something went wrong on our end.'); + return flash; + }); + }, + + /** + * When the user right clicks or cmd/ctrl + click in the job name + * the dropdown should not be closed and the link should open in another tab, + * so we stop propagation of the click event inside the dropdown. + * + * Since this component is rendered multiple times per page we need to guarantee we only + * target the click event of this component. + */ + stopDropdownClickPropagation() { + $(this.$el.querySelectorAll('.js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item')) + .on('click', (e) => { + e.stopPropagation(); + }); + }, + + closeDropdown() { + if (this.isDropdownOpen()) { + $(this.$refs.dropdown).dropdown('toggle'); + } + }, + + isDropdownOpen() { + return this.$el.classList.contains('open'); + }, + }, + + computed: { + dropdownClass() { + return this.dropdownContent.length > 0 ? 'js-builds-dropdown-container' : 'js-builds-dropdown-loading'; + }, + + triggerButtonClass() { + return `ci-status-icon-${this.stage.status.group}`; + }, + + svgIcon() { + return borderlessStatusIconEntityMap[this.stage.status.icon]; + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <button + :class="triggerButtonClass" + @click="onClickStage" + class="mini-pipeline-graph-dropdown-toggle has-tooltip js-builds-dropdown-button" + :title="stage.title" + data-placement="top" + data-toggle="dropdown" + type="button" + id="stageDropdown" + aria-haspopup="true" + aria-expanded="false"> + + <span + v-html="svgIcon" + aria-hidden="true" + :aria-label="stage.title"> + </span> + + <i + class="fa fa-caret-down" + aria-hidden="true"> + </i> + </button> + + <ul + class="dropdown-menu mini-pipeline-graph-dropdown-menu js-builds-dropdown-container" + aria-labelledby="stageDropdown"> + + <li + :class="dropdownClass" + class="js-builds-dropdown-list scrollable-menu"> + + <loading-icon v-if="isLoading"/> + + <ul + v-else + v-html="dropdownContent"> + </ul> + </li> + </ul> + </div> +</script> diff --git a/app/assets/javascripts/pipelines/components/time_ago.js b/app/assets/javascripts/pipelines/components/time_ago.js new file mode 100644 index 00000000000..188f74cc705 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/time_ago.js @@ -0,0 +1,98 @@ +import iconTimerSvg from 'icons/_icon_timer.svg'; +import '../../lib/utils/datetime_utility'; + +export default { + props: { + finishedTime: { + type: String, + required: true, + }, + + duration: { + type: Number, + required: true, + }, + }, + + data() { + return { + iconTimerSvg, + }; + }, + + updated() { + $(this.$refs.tooltip).tooltip('fixTitle'); + }, + + computed: { + hasDuration() { + return this.duration > 0; + }, + + hasFinishedTime() { + return this.finishedTime !== ''; + }, + + localTimeFinished() { + return gl.utils.formatDate(this.finishedTime); + }, + + durationFormated() { + const date = new Date(this.duration * 1000); + + let hh = date.getUTCHours(); + let mm = date.getUTCMinutes(); + let ss = date.getSeconds(); + + // left pad + if (hh < 10) { + hh = `0${hh}`; + } + if (mm < 10) { + mm = `0${mm}`; + } + if (ss < 10) { + ss = `0${ss}`; + } + + return `${hh}:${mm}:${ss}`; + }, + + finishedTimeFormated() { + const timeAgo = gl.utils.getTimeago(); + + return timeAgo.format(this.finishedTime); + }, + }, + + template: ` + <td class="pipelines-time-ago"> + <p + class="duration" + v-if="hasDuration"> + <span + v-html="iconTimerSvg"> + </span> + {{durationFormated}} + </p> + + <p + class="finished-at" + v-if="hasFinishedTime"> + + <i + class="fa fa-calendar" + aria-hidden="true" /> + + <time + ref="tooltip" + data-toggle="tooltip" + data-placement="top" + data-container="body" + :title="localTimeFinished"> + {{finishedTimeFormated}} + </time> + </p> + </td> + `, +}; diff --git a/app/assets/javascripts/pipelines/event_hub.js b/app/assets/javascripts/pipelines/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/pipelines/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/pipelines/graph_bundle.js b/app/assets/javascripts/pipelines/graph_bundle.js new file mode 100644 index 00000000000..b7a6b5d8479 --- /dev/null +++ b/app/assets/javascripts/pipelines/graph_bundle.js @@ -0,0 +1,10 @@ +import Vue from 'vue'; +import pipelineGraph from './components/graph/graph_component.vue'; + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-pipeline-graph-vue', + components: { + pipelineGraph, + }, + render: createElement => createElement('pipeline-graph'), +})); diff --git a/app/assets/javascripts/pipelines/index.js b/app/assets/javascripts/pipelines/index.js new file mode 100644 index 00000000000..48f9181a8d9 --- /dev/null +++ b/app/assets/javascripts/pipelines/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import PipelinesStore from './stores/pipelines_store'; +import PipelinesComponent from './pipelines'; +import '../vue_shared/vue_resource_interceptor'; + +$(() => new Vue({ + el: document.querySelector('#pipelines-list-vue'), + + data() { + const store = new PipelinesStore(); + + return { + store, + }; + }, + components: { + 'vue-pipelines': PipelinesComponent, + }, + template: ` + <vue-pipelines :store="store" /> + `, +})); diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js new file mode 100644 index 00000000000..d6952d1ee5f --- /dev/null +++ b/app/assets/javascripts/pipelines/pipelines.js @@ -0,0 +1,295 @@ +import Visibility from 'visibilityjs'; +import PipelinesService from './services/pipelines_service'; +import eventHub from './event_hub'; +import pipelinesTableComponent from '../vue_shared/components/pipelines_table'; +import tablePagination from '../vue_shared/components/table_pagination.vue'; +import emptyState from './components/empty_state.vue'; +import errorState from './components/error_state.vue'; +import navigationTabs from './components/navigation_tabs'; +import navigationControls from './components/nav_controls'; +import loadingIcon from '../vue_shared/components/loading_icon.vue'; +import Poll from '../lib/utils/poll'; + +export default { + props: { + store: { + type: Object, + required: true, + }, + }, + + components: { + tablePagination, + pipelinesTableComponent, + emptyState, + errorState, + navigationTabs, + navigationControls, + loadingIcon, + }, + + data() { + const pipelinesData = document.querySelector('#pipelines-list-vue').dataset; + + return { + endpoint: pipelinesData.endpoint, + cssClass: pipelinesData.cssClass, + helpPagePath: pipelinesData.helpPagePath, + newPipelinePath: pipelinesData.newPipelinePath, + canCreatePipeline: pipelinesData.canCreatePipeline, + allPath: pipelinesData.allPath, + pendingPath: pipelinesData.pendingPath, + runningPath: pipelinesData.runningPath, + finishedPath: pipelinesData.finishedPath, + branchesPath: pipelinesData.branchesPath, + tagsPath: pipelinesData.tagsPath, + hasCi: pipelinesData.hasCi, + ciLintPath: pipelinesData.ciLintPath, + state: this.store.state, + apiScope: 'all', + pagenum: 1, + isLoading: false, + hasError: false, + isMakingRequest: false, + updateGraphDropdown: false, + hasMadeRequest: false, + }; + }, + + computed: { + canCreatePipelineParsed() { + return gl.utils.convertPermissionToBoolean(this.canCreatePipeline); + }, + + scope() { + const scope = gl.utils.getParameterByName('scope'); + return scope === null ? 'all' : scope; + }, + + shouldRenderErrorState() { + return this.hasError && !this.isLoading; + }, + + /** + * The empty state should only be rendered when the request is made to fetch all pipelines + * and none is returned. + * + * @return {Boolean} + */ + shouldRenderEmptyState() { + return !this.isLoading && + !this.hasError && + this.hasMadeRequest && + !this.state.pipelines.length && + (this.scope === 'all' || this.scope === null); + }, + + /** + * When a specific scope does not have pipelines we render a message. + * + * @return {Boolean} + */ + shouldRenderNoPipelinesMessage() { + return !this.isLoading && + !this.hasError && + !this.state.pipelines.length && + this.scope !== 'all' && + this.scope !== null; + }, + + shouldRenderTable() { + return !this.hasError && + !this.isLoading && this.state.pipelines.length; + }, + + /** + * Pagination should only be rendered when there is more than one page. + * + * @return {Boolean} + */ + shouldRenderPagination() { + return !this.isLoading && + this.state.pipelines.length && + this.state.pageInfo.total > this.state.pageInfo.perPage; + }, + + hasCiEnabled() { + return this.hasCi !== undefined; + }, + + paths() { + return { + allPath: this.allPath, + pendingPath: this.pendingPath, + finishedPath: this.finishedPath, + runningPath: this.runningPath, + branchesPath: this.branchesPath, + tagsPath: this.tagsPath, + }; + }, + + pageParameter() { + return gl.utils.getParameterByName('page') || this.pagenum; + }, + + scopeParameter() { + return gl.utils.getParameterByName('scope') || this.apiScope; + }, + }, + + created() { + this.service = new PipelinesService(this.endpoint); + + const poll = new Poll({ + resource: this.service, + method: 'getPipelines', + data: { page: this.pageParameter, scope: this.scopeParameter }, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: this.setIsMakingRequest, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + poll.restart(); + } else { + poll.stop(); + } + }); + + eventHub.$on('refreshPipelines', this.fetchPipelines); + }, + + beforeDestroyed() { + eventHub.$off('refreshPipelines'); + }, + + methods: { + /** + * Will change the page number and update the URL. + * + * @param {Number} pageNumber desired page to go to. + */ + change(pageNumber) { + const param = gl.utils.setParamInURL('page', pageNumber); + + gl.utils.visitUrl(param); + return param; + }, + + fetchPipelines() { + if (!this.isMakingRequest) { + this.isLoading = true; + + this.service.getPipelines({ scope: this.scopeParameter, page: this.pageParameter }) + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + } + }, + + successCallback(resp) { + const response = { + headers: resp.headers, + body: resp.json(), + }; + + this.store.storeCount(response.body.count); + this.store.storePipelines(response.body.pipelines); + this.store.storePagination(response.headers); + + this.isLoading = false; + this.updateGraphDropdown = true; + this.hasMadeRequest = true; + }, + + errorCallback() { + this.hasError = true; + this.isLoading = false; + this.updateGraphDropdown = false; + }, + + setIsMakingRequest(isMakingRequest) { + this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } + }, + }, + + template: ` + <div :class="cssClass"> + + <div + class="top-area scrolling-tabs-container inner-page-scroll-tabs" + v-if="!isLoading && !shouldRenderEmptyState"> + <div class="fade-left"> + <i class="fa fa-angle-left" aria-hidden="true"></i> + </div> + <div class="fade-right"> + <i class="fa fa-angle-right" aria-hidden="true"></i> + </div> + <navigation-tabs + :scope="scope" + :count="state.count" + :paths="paths" /> + + <navigation-controls + :new-pipeline-path="newPipelinePath" + :has-ci-enabled="hasCiEnabled" + :help-page-path="helpPagePath" + :ciLintPath="ciLintPath" + :can-create-pipeline="canCreatePipelineParsed " /> + </div> + + <div class="content-list pipelines"> + + <loading-icon + label="Loading Pipelines" + size="3" + v-if="isLoading" + /> + + <empty-state + v-if="shouldRenderEmptyState" + :help-page-path="helpPagePath" /> + + <error-state v-if="shouldRenderErrorState" /> + + <div + class="blank-state blank-state-no-icon" + v-if="shouldRenderNoPipelinesMessage"> + <h2 class="blank-state-title js-blank-state-title">No pipelines to show.</h2> + </div> + + <div + class="table-holder" + v-if="shouldRenderTable"> + + <pipelines-table-component + :pipelines="state.pipelines" + :service="service" + :update-graph-dropdown="updateGraphDropdown" + /> + </div> + + <table-pagination + v-if="shouldRenderPagination" + :pagenum="pagenum" + :change="change" + :count="state.count.all" + :pageInfo="state.pageInfo" + /> + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js new file mode 100644 index 00000000000..f1cc60c1ee0 --- /dev/null +++ b/app/assets/javascripts/pipelines/services/pipeline_service.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class PipelineService { + constructor(endpoint) { + this.pipeline = Vue.resource(endpoint); + } + + getPipeline() { + return this.pipeline.get(); + } +} diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js new file mode 100644 index 00000000000..b21f84b4545 --- /dev/null +++ b/app/assets/javascripts/pipelines/services/pipelines_service.js @@ -0,0 +1,45 @@ +/* eslint-disable class-methods-use-this */ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class PipelinesService { + + /** + * Commits and merge request endpoints need to be requested with `.json`. + * + * The url provided to request the pipelines in the new merge request + * page already has `.json`. + * + * @param {String} root + */ + constructor(root) { + let endpoint; + + if (root.indexOf('.json') === -1) { + endpoint = `${root}.json`; + } else { + endpoint = root; + } + + this.pipelines = Vue.resource(endpoint); + } + + getPipelines(data = {}) { + const { scope, page } = data; + return this.pipelines.get({ scope, page }); + } + + /** + * Post request for all pipelines actions. + * Endpoint content type needs to be: + * `Content-Type:application/x-www-form-urlencoded` + * + * @param {String} endpoint + * @return {Promise} + */ + postAction(endpoint) { + return Vue.http.post(`${endpoint}.json`); + } +} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js new file mode 100644 index 00000000000..86ab50d8f1e --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -0,0 +1,11 @@ +export default class PipelineStore { + constructor() { + this.state = {}; + + this.state.graph = []; + } + + storeGraph(graph = []) { + this.state.graph = graph; + } +} diff --git a/app/assets/javascripts/pipelines/stores/pipelines_store.js b/app/assets/javascripts/pipelines/stores/pipelines_store.js new file mode 100644 index 00000000000..ffefe0192f2 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/pipelines_store.js @@ -0,0 +1,30 @@ +export default class PipelinesStore { + constructor() { + this.state = {}; + + this.state.pipelines = []; + this.state.count = {}; + this.state.pageInfo = {}; + } + + storePipelines(pipelines = []) { + this.state.pipelines = pipelines; + } + + storeCount(count = {}) { + this.state.count = count; + } + + storePagination(pagination = {}) { + let paginationInfo; + + if (Object.keys(pagination).length) { + const normalizedHeaders = gl.utils.normalizeHeaders(pagination); + paginationInfo = gl.utils.parseIntPagination(normalizedHeaders); + } else { + paginationInfo = pagination; + } + + this.state.pageInfo = paginationInfo; + } +} |