Commit f6b040a0 authored by Andrew Fontaine's avatar Andrew Fontaine

Add Environments Dashboard

The environments dashboard exists to show a list of all environments in
a project, as well as their status, any available alerts, and the status
of the last pipeline ran for that environment.
parent 4d336ab5
......@@ -11,7 +11,7 @@
}
&-body {
height: 120px;
min-height: 120px;
&-warning {
background-color: $orange-50;
......@@ -22,10 +22,8 @@
}
}
&-time-ago {
&-icon {
color: $gray-500;
}
&-icon {
color: $gray-500;
}
&-footer {
......
<script>
import _ from 'underscore';
import {
GlLoadingIcon,
GlModal,
GlModalDirective,
GlButton,
GlDashboardSkeleton,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import ProjectHeader from './project_header.vue';
import Environment from './environment.vue';
export default {
addProjectsModalHeader: s__('EnvironmentsDashboard|Add projects'),
addProjectsModalSubmit: s__('EnvironmentsDashboard|Add projects'),
dashboardHeader: s__('EnvironmentsDashboard|Environments Dashboard'),
addProjectsButton: s__('EnvironmentsDashboard|Add projects'),
emptyDashboardHeader: s__('EnvironmentsDashboard|Add a project to the dashboard'),
emptyDashboardDocs: s__(
"EnvironmentsDashboard|The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses.",
),
viewDocumentationButton: s__('View documentation'),
components: {
GlModal,
GlDashboardSkeleton,
GlLoadingIcon,
GlButton,
ProjectSelector,
Environment,
ProjectHeader,
},
directives: {
'gl-modal': GlModalDirective,
},
modalId: 'add-projects-modal',
props: {
addPath: {
type: String,
required: true,
},
listPath: {
type: String,
required: true,
},
emptyDashboardSvgPath: {
type: String,
required: true,
},
emptyDashboardHelpPath: {
type: String,
required: true,
},
},
data() {
return {
projects: [],
projectTokens: '',
isLoadingProjects: false,
selectedProjects: [],
projectSearchResults: [],
searchCount: 0,
searchQuery: '',
messages: {},
};
},
computed: {
isSearchingProjects() {
return this.searchCount > 0;
},
okDisabled() {
return _.isEmpty(this.selectedProjects);
},
},
created() {
this.setProjectEndpoints({
list: this.listPath,
add: this.addPath,
});
this.fetchProjects();
},
methods: {
fetchSearchResults() {},
addProjectsToDashboard() {},
fetchProjects() {},
setProjectEndpoints() {},
clearSearchResults() {},
toggleSelectedProject() {},
setSearchQuery() {},
addProjects() {
this.addProjectsToDashboard();
},
onModalShown() {
this.$refs.projectSelector.focusSearchInput();
},
onModalHidden() {
this.clearSearchResults();
},
onOk() {
this.addProjectsToDashboard();
},
searched(query) {
this.setSearchQuery(query);
this.fetchSearchResults();
},
projectClicked(project) {
this.toggleSelectedProject(project);
},
},
};
</script>
<template>
<div class="operations-dashboard">
<gl-modal
:modal-id="$options.modalId"
:title="$options.addProjectsModalHeader"
:ok-title="$options.addProjectsModalSubmit"
:ok-disabled="okDisabled"
ok-variant="success"
@shown="onModalShown"
@hidden="onModalHidden"
@ok="onOk"
>
<project-selector
ref="projectSelector"
:project-search-results="projectSearchResults"
:selected-projects="selectedProjects"
:show-no-results-message="messages.noResults"
:show-loading-indicator="isSearchingProjects"
:show-minimum-search-query-message="messages.minimumQuery"
:show-search-error-message="messages.searchError"
@searched="searched"
@projectClicked="projectClicked"
/>
</gl-modal>
<div class="page-title-holder flex-fill d-flex align-items-center">
<h1 class="js-dashboard-title page-title text-nowrap flex-fill">
{{ $options.dashboardHeader }}
</h1>
<gl-button v-gl-modal="$options.modalId" class="js-add-projects-button btn btn-success">
{{ $options.addProjectsButton }}
</gl-button>
</div>
<div class="prepend-top-default">
<div v-if="projects.length" class="dashboard-cards">
<div v-for="project in projects" :key="project.id" class="column prepend-top-default">
<project-header :project="project" />
<div class="row">
<environment
v-for="environment in project.environments"
:key="environment.id"
:environment="environment"
class="col-12 col-md-6 col-xl-4 px-2 prepend-top-default"
/>
</div>
</div>
</div>
<div v-else-if="!isLoadingProjects" class="row prepend-top-20 text-center">
<div class="col-12 d-flex justify-content-center svg-content">
<img :src="emptyDashboardSvgPath" class="js-empty-state-svg col-12 prepend-top-20" />
</div>
<h4 class="js-title col-12 prepend-top-20">
{{ $options.emptyDashboardHeader }}
</h4>
<div class="col-12 d-flex justify-content-center">
<span class="js-sub-title mw-460 text-tertiary text-left">
{{ $options.emptyDashboardDocs }}
</span>
</div>
<div class="col-12">
<a
:href="emptyDashboardHelpPath"
class="js-documentation-link btn btn-primary prepend-top-default append-bottom-default"
>
{{ $options.viewDocumentationButton }}
</a>
</div>
</div>
<gl-dashboard-skeleton v-else />
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import Commit from '~/vue_shared/components/commit.vue';
import Alerts from 'ee/vue_shared/dashboards/components/alerts.vue';
import TimeAgo from 'ee/vue_shared/dashboards/components/time_ago.vue';
import { STATUS_FAILED, STATUS_RUNNING } from 'ee/vue_shared/dashboards/constants';
import ProjectPipeline from 'ee/vue_shared/dashboards/components/project_pipeline.vue';
import EnvironmentHeader from './environment_header.vue';
export default {
components: {
EnvironmentHeader,
UserAvatarLink,
GlLink,
Commit,
Alerts,
ProjectPipeline,
TimeAgo,
Icon,
},
directives: {
'gl-tooltip': GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
environment: {
type: Object,
required: true,
},
},
tooltips: {
timeAgo: __('Finished'),
triggerer: __('Triggerer'),
job: s__('EnvironmentsDashboard|Job: %{job}'),
},
noDeploymentMessage: __('This environment has no deployments yet.'),
computed: {
hasPipelineFailed() {
return (
this.lastPipeline &&
this.lastPipeline.details &&
this.lastPipeline.details.status &&
this.lastPipeline.details.status.group === STATUS_FAILED
);
},
hasPipelineErrors() {
return this.environment.alert_count > 0;
},
cardClasses() {
return {
'dashboard-card-body-warning': !this.hasPipelineFailed && this.hasPipelineErrors,
'dashboard-card-body-failed': this.hasPipelineFailed,
'bg-secondary': !this.hasPipelineFailed && !this.hasPipelineErrors,
};
},
user() {
return this.lastDeployment && !_.isEmpty(this.lastDeployment.user)
? this.lastDeployment.user
: null;
},
lastPipeline() {
return !_.isEmpty(this.environment.last_pipeline) ? this.environment.last_pipeline : null;
},
lastDeployment() {
return !_.isEmpty(this.environment.last_deployment) ? this.environment.last_deployment : null;
},
deployable() {
return !_.isEmpty(this.lastDeployment.deployable) ? this.lastDeployment.deployable : null;
},
commit() {
return !_.isEmpty(this.lastDeployment.commit) ? this.lastDeployment.commit : {};
},
jobTooltip() {
return sprintf(this.$options.tooltips.job, { job: this.buildName });
},
commitRef() {
return this.lastDeployment && !_.isEmpty(this.lastDeployment.commit)
? {
...this.lastDeployment.commit,
...this.lastDeployment.ref,
ref_url: this.lastDeployment.ref.ref_path,
}
: {};
},
finishedTime() {
return this.deployable.updated_at;
},
finishedTimeTitle() {
return this.tooltipTitle(this.finishedTime);
},
shouldShowTimeAgo() {
return (
this.deployable &&
this.deployable.status &&
this.deployable.status.group !== STATUS_RUNNING &&
this.finishedTime
);
},
buildName() {
if (
this.environment &&
this.environment.last_deployment &&
this.environment.last_deployment.deployable
) {
const { deployable } = this.environment.last_deployment;
return `${deployable.name} #${deployable.id}`;
}
return '';
},
},
};
</script>
<template>
<div class="dashboard-card card border-0">
<environment-header
:environment="environment"
:has-pipeline-failed="hasPipelineFailed"
:has-errors="hasPipelineErrors"
/>
<div :class="cardClasses" class="dashboard-card-body card-body">
<div v-if="lastDeployment" class="row">
<div class="col-1 align-self-center">
<user-avatar-link
v-if="user"
:link-href="user.path"
:img-src="user.avatar_url"
:tooltip-text="user.name"
:img-size="32"
/>
</div>
<div class="col-10 col-sm-6 pr-0 pl-5 align-self-center align-middle ci-table">
<div class="branch-commit">
<icon name="work" />
<gl-link v-gl-tooltip="jobTooltip" :href="deployable.build_path" class="str-truncated">
{{ buildName }}
</gl-link>
</div>
<commit
:tag="commitRef.tag"
:commit-ref="commitRef"
:short-sha="commit.short_id"
:commit-url="commit.commit_url"
:title="commit.title"
:author="commit.author"
:show-branch="true"
/>
</div>
<div class="col-sm-5 pl-0 text-right align-self-center d-none d-sm-block">
<time-ago
v-if="shouldShowTimeAgo"
:time="finishedTime"
:tooltip-text="$options.tooltips.timeAgo"
/>
<alerts :count="environment.alert_count" :last-alert="environment.last_alert" />
</div>
<div v-if="lastPipeline" class="col-12">
<project-pipeline
:project-name="environment.name_with_namespace"
:last-pipeline="lastPipeline"
:has-pipeline-failed="hasPipelineFailed"
/>
</div>
</div>
<div v-else class="h-100 d-flex justify-content-center align-items-center">
<div class="text-plain text-metric text-center bold w-75">
{{ $options.noDeploymentMessage }}
</div>
</div>
</div>
</div>
</template>
<script>
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { GlButton, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
Icon,
ProjectAvatar,
ReviewAppLink,
GlButton,
GlBadge,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
environment: {
type: Object,
required: true,
},
hasPipelineFailed: {
type: Boolean,
required: false,
default: false,
},
hasErrors: {
type: Boolean,
required: false,
default: false,
},
},
tooltips: {
information: s__('EnviornmentDashboard|You are looking at the last updated environment'),
},
computed: {
headerClasses() {
return {
'dashboard-card-header-warning': this.hasErrors,
'dashboard-card-header-failed': this.hasPipelineFailed,
'bg-light': !this.hasErrors && !this.hasPipelineFailed,
};
},
},
};
</script>
<template>
<div :class="headerClasses" class="card-header border-0 py-2 d-flex align-items-center">
<div class="flex-grow-1 block-truncated">
<gl-link
v-gl-tooltip
class="js-environment-link cgray"
:href="environment.environment_path"
:title="environment.name"
>
<span class="js-environment-name bold"> {{ environment.name }}</span>
</gl-link>
<gl-badge v-if="environment.children" :pill="true" class="dashboard-card-icon">{{
environment.children.length
}}</gl-badge>
</div>
<icon
v-if="environment.children"
v-gl-tooltip
:title="$options.tooltips.information"
name="information"
css-classes="dashboard-card-icon"
/>
<review-app-link
v-else-if="environment.external_url"
:link="environment.external_url"
css-class="btn btn-default btn-sm"
/>
</div>
</template>
<script>
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui';
export default {
components: {
Icon,
ProjectAvatar,
GlLink,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
project: {
type: Object,
required: true,
},
},
methods: {
onRemove() {
this.$emit('remove', this.project.remove_path);
},
},
removeProjectText: s__('EnvironmentsDashboard|Remove'),
moreActionsText: s__('EnvironmentsDashboard|More actions'),
};
</script>
<template>
<div
class="d-flex align-items-center page-title-holder text-secondary justify-content-between pb-2"
>
<div class="d-flex align-items-center">
<project-avatar :project="project.namespace" :size="20" class="flex-shrink-0" />
<gl-link class="js-namespace-link text-secondary" :href="`/${project.namespace.full_path}`">
<span class="js-namespace append-right-8"> {{ project.namespace.name }} </span>
</gl-link>
<span class="append-right-8">&gt;</span>
<project-avatar :project="project" :size="20" class="flex-shrink-0" />
<gl-link class="js-project-link text-secondary" :href="project.web_url">
<span class="js-name append-right-8"> {{ project.name }} </span>
</gl-link>
</div>
<div class="dropdown js-more-actions">
<button
v-gl-tooltip
class="js-more-actions-toggle d-flex align-items-center ml-2 btn btn-transparent"
type="button"
data-toggle="dropdown"
:title="$options.moreActionsText"
>
<icon name="ellipsis_v" class="text-secondary" />
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
<gl-button class="js-remove-button" @click="onRemove()">
<span class="text-danger"> {{ $options.removeProjectText }} </span>
</gl-button>
</li>
</ul>
</div>
</div>
</template>
<script>
import { __, n__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
count: {
type: Number,
required: false,
default: 0,
},
},
computed: {
alertClasses() {
return {
'text-tertiary': this.count <= 0,
'text-warning': this.count > 0,
};
},
alertCount() {
return sprintf(__('%{count} %{alerts}'), {
count: this.count,
alerts: this.pluralizedAlerts,
});
},
pluralizedAlerts() {
return n__('Alert', 'Alerts', this.count);
},
},
};
</script>
<template>
<div class="dashboard-card-alert row">
<div class="col-12">
<icon
:class="alertClasses"
class="align-text-bottom js-dashboard-alerts-icon"
name="warning"
/>
<span class="js-alert-count text-secondary prepend-left-4"> {{ alertCount }} </span>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { mapState, mapActions } from 'vuex';
import { GlDashboardSkeleton, GlModal, GlModalDirective, GlButton } from '@gitlab/ui';
import {
GlLoadingIcon,
GlModal,
GlModalDirective,
GlButton,
GlDashboardSkeleton,
} from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import DashboardProject from './project.vue';
......@@ -10,6 +16,7 @@ export default {
DashboardProject,
GlModal,
GlDashboardSkeleton,
GlLoadingIcon,
GlButton,
ProjectSelector,
},
......
export const STATUS_FAILED = 'failed';
export const STATUS_RUNNING = 'running';
export default {
data() {
return {
isInputFocused: false,
};
},
methods: {
onFocus() {
this.isInputFocused = true;
this.$emit('focus');
},
onBlur() {
this.isInputFocused = false;
this.$emit('blur');
},
},
};
import Vue from 'vue';
import EnvironmentDashboardComponent from 'ee/environments_dashboard/components/dashboard/dashboard.vue';
document.addEventListener(
'DOMContentLoaded',
() =>
new Vue({
el: '#js-environments',
components: {
EnvironmentDashboardComponent,
},
render(createElement) {
return createElement(EnvironmentDashboardComponent, {
props: {
listPath: this.$el.dataset.listPath,
addPath: this.$el.dataset.addPath,
emptyDashboardSvgPath: this.$el.dataset.emptyDashboardSvgPath,
emptyDashboardHelpPath: this.$el.dataset.emptyDashboardHelpPath,
},
});
},
}),
);
<script>
import _ from 'underscore';
import { __, n__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
......@@ -12,6 +13,11 @@ export default {
required: false,
default: 0,
},
lastAlert: {
type: Object,
required: false,
default: null,
},
},
computed: {
alertClasses() {
......@@ -21,7 +27,8 @@ export default {
};
},
alertCount() {
return sprintf(__('%{count} %{alerts}'), {
const text = this.lastAlert ? '%{count} %{alerts}:' : '%{count} %{alerts}';
return sprintf(__(text), {
count: this.count,
alerts: this.pluralizedAlerts,
});
......@@ -29,6 +36,17 @@ export default {
pluralizedAlerts() {
return n__('Alert', 'Alerts', this.count);
},
alertText() {
return sprintf(
__('%{title} %{operator} %{threshold}'),
{
title: _.escape(this.lastAlert.title),
threshold: `${_.escape(this.lastAlert.threshold)}%`,
operator: this.lastAlert.operator,
},
false,
);
},
},
};
</script>
......@@ -42,6 +60,7 @@ export default {
name="warning"
/>
<span class="js-alert-count text-secondary prepend-left-4"> {{ alertCount }} </span>
<span v-if="lastAlert" class="text-secondary">{{ alertText }}</span>
</div>
</div>
</template>
......@@ -35,7 +35,7 @@ export default {
<div class="text-secondary">
<icon
name="clock"
class="dashboard-card-time-ago-icon align-text-bottom js-dashboard-project-clock-icon"
class="dashboard-card-icon align-text-bottom js-dashboard-project-clock-icon"
/>
<time ref="timeAgo" class="js-dashboard-project-time-ago">
......
......@@ -13,7 +13,6 @@ class OperationsController < ApplicationController
end
def environments
render :index
end
def dashboard_feature_flag
......
- page_title _('Environments Dashboard')
- @hide_breadcrumbs = true
#js-environments{ data: operations_data }
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`dashboard should match the snapshot 1`] = `
<div
class="operations-dashboard"
>
<div>
<!---->
</div>
<div
class="page-title-holder flex-fill d-flex align-items-center"
>
<h1
class="js-dashboard-title page-title text-nowrap flex-fill"
>
Environments Dashboard
</h1>
<button
class="btn js-add-projects-button btn btn-success btn-secondary"
type="button"
>
Add projects
</button>
</div>
<div
class="prepend-top-default"
>
<div
class="row prepend-top-20 text-center"
>
<div
class="col-12 d-flex justify-content-center svg-content"
>
<img
class="js-empty-state-svg col-12 prepend-top-20"
src="/assets/illustrations/operations-dashboard_empty.svg"
/>
</div>
<h4
class="js-title col-12 prepend-top-20"
>
Add a project to the dashboard
</h4>
<div
class="col-12 d-flex justify-content-center"
>
<span
class="js-sub-title mw-460 text-tertiary text-left"
>
The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses.
</span>
</div>
<div
class="col-12"
>
<a
class="js-documentation-link btn btn-primary prepend-top-default append-bottom-default"
href="/help/user/operations_dashboard/index.html"
>
View documentation
</a>
</div>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Environment Header has a failed pipeline matches the snapshot 1`] = `
<div
class="card-header border-0 py-2 d-flex align-items-center dashboard-card-header-failed"
>
<div
class="flex-grow-1 block-truncated"
>
<gllink-stub
class="js-environment-link cgray"
data-original-title="staging"
href="/enivronment/1"
title=""
>
<span
class="js-environment-name bold"
>
staging
</span>
</gllink-stub>
<!---->
</div>
<reviewapplink-stub
cssclass="btn btn-default btn-sm"
link="http://example.com"
/>
</div>
`;
exports[`Environment Header has errors matches the snapshot 1`] = `
<div
class="card-header border-0 py-2 d-flex align-items-center dashboard-card-header-warning"
>
<div
class="flex-grow-1 block-truncated"
>
<gllink-stub
class="js-environment-link cgray"
data-original-title="staging"
href="/enivronment/1"
title=""
>
<span
class="js-environment-name bold"
>
staging
</span>
</gllink-stub>
<!---->
</div>
<reviewapplink-stub
cssclass="btn btn-default btn-sm"
link="http://example.com"
/>
</div>
`;
exports[`Environment Header renders name and link to app matches the snapshot 1`] = `
<div
class="card-header border-0 py-2 d-flex align-items-center bg-light"
>
<div
class="flex-grow-1 block-truncated"
>
<gllink-stub
class="js-environment-link cgray"
data-original-title="staging"
href="/enivronment/1"
title=""
>
<span
class="js-environment-name bold"
>
staging
</span>
</gllink-stub>
<!---->
</div>
<reviewapplink-stub
cssclass="btn btn-default btn-sm"
link="http://example.com"
/>
</div>
`;
exports[`Environment Header with children matches the snapshot 1`] = `
<div
class="card-header border-0 py-2 d-flex align-items-center bg-light"
>
<div
class="flex-grow-1 block-truncated"
>
<gllink-stub
class="js-environment-link cgray"
data-original-title="review/testing"
href="/enivronment/1"
title=""
>
<span
class="js-environment-name bold"
>
review/testing
</span>
</gllink-stub>
<glbadge-stub
class="dashboard-card-icon"
pill="true"
>
5
</glbadge-stub>
</div>
<icon-stub
cssclasses="dashboard-card-icon"
data-original-title="You are looking at the last updated environment"
name="information"
size="16"
title=""
/>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Environment matchs the snapshot 1`] = `
<div
class="dashboard-card card border-0"
>
<environmentheader-stub
environment="[object Object]"
haserrors="true"
/>
<div
class="dashboard-card-body card-body dashboard-card-body-warning"
>
<div
class="row"
>
<div
class="col-1 align-self-center"
>
<useravatarlink-stub
imgalt=""
imgcssclasses=""
imgsize="32"
imgsrc="https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon"
linkhref="/root"
tooltipplacement="top"
tooltiptext="Administrator"
username=""
/>
</div>
<div
class="col-10 col-sm-6 pr-0 pl-5 align-self-center align-middle ci-table"
>
<div
class="branch-commit"
>
<icon-stub
cssclasses=""
name="work"
size="16"
/>
<gllink-stub
class="str-truncated"
data-original-title=""
href="/root/minimal-ruby-app/-/jobs/1097"
title=""
>
production #1097
</gllink-stub>
</div>
<commit-stub
commitref="[object Object]"
commiturl="https://22878.qa-tunnel.gitlab.info/root/minimal-ruby-app/commit/63492726c2264a0277141d6a6573c3d22ecd7de3"
shortsha="63492726"
show-branch="true"
showrefinfo="true"
title="Improve code quality"
/>
</div>
<div
class="col-sm-5 pl-0 text-right align-self-center d-none d-sm-block"
>
<timeago-stub
time="2019-02-20T16:15:40.122Z"
tooltiptext="Finished"
/>
<alerts-stub
count="1"
lastalert="[object Object]"
/>
</div>
<!---->
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Project Header matches the snapshot 1`] = `
<div
class="d-flex align-items-center page-title-holder text-secondary justify-content-between pb-2"
>
<div
class="d-flex align-items-center"
>
<projectavatar-stub
class="flex-shrink-0"
project="[object Object]"
size="20"
/>
<gllink-stub
class="js-namespace-link text-secondary"
href="/hello"
>
<span
class="js-namespace append-right-8"
>
hello
</span>
</gllink-stub>
<span
class="append-right-8"
>
&gt;
</span>
<projectavatar-stub
class="flex-shrink-0"
project="[object Object]"
size="20"
/>
<gllink-stub
class="js-project-link text-secondary"
>
<span
class="js-name append-right-8"
>
world
</span>
</gllink-stub>
</div>
<div
class="dropdown js-more-actions"
>
<button
class="js-more-actions-toggle d-flex align-items-center ml-2 btn btn-transparent"
data-original-title="More actions"
data-toggle="dropdown"
title=""
type="button"
>
<icon-stub
class="text-secondary"
cssclasses=""
name="ellipsis_v"
size="16"
/>
</button>
<ul
class="dropdown-menu dropdown-menu-right"
>
<li>
<glbutton-stub
class="js-remove-button"
>
<span
class="text-danger"
>
Remove
</span>
</glbutton-stub>
</li>
</ul>
</div>
</div>
`;
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlButton } from '@gitlab/ui';
import ProjectSelector from '~/vue_shared/components/project_selector/project_selector.vue';
import component from 'ee/environments_dashboard/components/dashboard/dashboard.vue';
import ProjectHeader from 'ee/environments_dashboard/components/dashboard/project_header.vue';
import Environment from 'ee/environments_dashboard/components/dashboard/environment.vue';
import environment from './mock_environment.json';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('dashboard', () => {
const Component = localVue.extend(component);
let wrapper;
let propsData;
beforeEach(() => {
propsData = {
addPath: 'mock-addPath',
listPath: 'mock-listPath',
emptyDashboardSvgPath: '/assets/illustrations/operations-dashboard_empty.svg',
emptyDashboardHelpPath: '/help/user/operations_dashboard/index.html',
};
wrapper = mount(Component, {
propsData,
localVue,
methods: {
fetchProjects: () => {},
},
sync: false,
});
});
it('should match the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders the dashboard title', () => {
expect(wrapper.find('.js-dashboard-title').text()).toBe('Environments Dashboard');
});
describe('add projects button', () => {
let button;
beforeEach(() => {
button = wrapper.find(GlButton);
});
it('is labelled correctly', () => {
expect(button.text()).toBe('Add projects');
});
it('should show the modal on click', done => {
button.trigger('click');
wrapper.vm.$nextTick(() => {
expect(wrapper.find(ProjectSelector)).toExist();
done();
});
});
});
describe('wrapped components', () => {
beforeEach(done => {
wrapper.vm.projects = [
{
id: 0,
name: 'test',
namespace: { name: 'test', id: 0 },
environments: [{ ...environment, id: 0 }, environment],
},
{ id: 1, name: 'test', namespace: { name: 'test', id: 0 }, environments: [environment] },
];
wrapper.vm.$nextTick(() => done());
});
describe('project header', () => {
it('should have one project header per project', () => {
const headers = wrapper.findAll(ProjectHeader);
expect(headers.length).toBe(2);
});
});
describe('environment component', () => {
it('should have one environment component per environment', () => {
const environments = wrapper.findAll(Environment);
expect(environments.length).toBe(3);
});
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlLink, GlBadge } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue';
import component from 'ee/environments_dashboard/components/dashboard/environment_header.vue';
const localVue = createLocalVue();
describe('Environment Header', () => {
const Component = localVue.extend(component);
let wrapper;
let propsData;
beforeEach(() => {
propsData = {
environment: {
environment_path: '/enivronment/1',
name: 'staging',
external_url: 'http://example.com',
},
};
});
afterEach(() => {
wrapper.destroy();
});
describe('renders name and link to app', () => {
beforeEach(() => {
wrapper = shallowMount(Component, {
propsData,
localVue,
});
});
it('renders the environment name', () => {
expect(wrapper.find('.js-environment-name').text()).toBe(propsData.environment.name);
});
it('renders a link to the enivironment page', () => {
expect(wrapper.find(GlLink).attributes('href')).toBe(propsData.environment.environment_path);
});
it('renders a link to the external app', () => {
expect(wrapper.find(ReviewAppLink).attributes('link')).toBe(
propsData.environment.external_url,
);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('with children', () => {
beforeEach(() => {
propsData.environment.children = [{}, {}, {}, {}, {}];
propsData.environment.name = 'review/testing';
wrapper = shallowMount(Component, {
propsData,
localVue,
});
});
it('shows a badge with the number of other environments in the folder', () => {
const expected = propsData.environment.children.length.toString();
expect(wrapper.find(GlBadge).text()).toBe(expected);
});
it('shows an icon stating the environment is one of many in a folder', () => {
expect(wrapper.find(Icon).attributes('name')).toBe('information');
expect(wrapper.find(Icon).attributes('data-original-title')).toMatch(
/last updated environment/,
);
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('has errors', () => {
beforeEach(() => {
propsData.hasErrors = true;
wrapper = shallowMount(Component, {
propsData,
localVue,
});
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('has a failed pipeline', () => {
beforeEach(() => {
propsData.hasPipelineFailed = true;
wrapper = shallowMount(Component, {
propsData,
localVue,
});
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Commit from '~/vue_shared/components/commit.vue';
import component from 'ee/environments_dashboard/components/dashboard/environment.vue';
import EnvironmentHeader from 'ee/environments_dashboard/components/dashboard/environment_header.vue';
import Alert from 'ee/vue_shared/dashboards/components/alerts.vue';
import environment from './mock_environment.json';
const localVue = createLocalVue();
describe('Environment', () => {
const Component = localVue.extend(component);
let wrapper;
let propsData;
beforeEach(() => {
propsData = {
environment,
};
wrapper = shallowMount(Component, {
localVue,
propsData,
});
});
afterEach(() => {
wrapper.destroy();
});
it('matchs the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('wrapped components', () => {
describe('environment header', () => {
it('binds environment', () => {
expect(wrapper.find(EnvironmentHeader).props('environment')).toBe(environment);
});
});
describe('alerts', () => {
let alert;
beforeEach(() => {
alert = wrapper.find(Alert);
});
it('binds alert count to count', () => {
expect(alert.props('count')).toBe(environment.alert_count);
});
it('binds last alert', () => {
expect(alert.props('lastAlert')).toBe(environment.last_alert);
});
});
describe('commit', () => {
let commit;
beforeEach(() => {
commit = wrapper.find(Commit);
});
it('binds commitRef', () => {
expect(commit.props('commitRef')).toBe(wrapper.vm.commitRef);
});
it('binds short_id to shortSha', () => {
expect(commit.props('shortSha')).toBe(environment.last_deployment.commit.short_id);
});
it('binds commitUrl', () => {
expect(commit.props('commitUrl')).toBe(environment.last_deployment.commit.commit_url);
});
it('binds title', () => {
expect(commit.props('title')).toBe(environment.last_deployment.commit.title);
});
it('binds author', () => {
expect(commit.props('author')).toBe(environment.last_deployment.commit.author);
});
it('binds tag', () => {
expect(commit.props('tag')).toBe(environment.last_deployment.ref.tag);
});
});
});
});
{
"id": 27,
"name": "production",
"state": "available",
"external_url": "http://root-minimal-ruby-app.35.188.122.130.xip.io",
"environment_type": null,
"name_without_type": "production",
"last_deployment": {
"id": 227,
"iid": 50,
"sha": "63492726c2264a0277141d6a6573c3d22ecd7de3",
"ref": {
"name": "master",
"ref_path": "/root/minimal-ruby-app/tree/master",
"tag": false
},
"created_at": "2019-02-20T16:09:12.213Z",
"tag": false,
"last?": true,
"user": {
"id": 1,
"name": "Administrator",
"username": "root",
"state": "active",
"avatar_url": "https://secure.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon",
"web_url": "https://22878.qa-tunnel.gitlab.info/root",
"status_tooltip_html": null,
"path": "/root"
},
"commit": {
"id": "63492726c2264a0277141d6a6573c3d22ecd7de3",
"short_id": "63492726",
"created_at": "2017-09-07T13:45:32.000Z",
"parent_ids": ["3fc05cdfcdc813ffe7d82af7bac9df5663f3bc17"],
"title": "Improve code quality",
"message": "Improve code quality",
"author_name": "Alessio Caiazza",
"author_email": "acaiazza@gitlab.com",
"authored_date": "2017-09-07T13:45:32.000Z",
"committer_name": "Alessio Caiazza",
"committer_email": "acaiazza@gitlab.com",
"committed_date": "2017-09-07T13:45:32.000Z",
"author": null,
"author_gravatar_url": "https://secure.gravatar.com/avatar/9c90b0ce0129300552700dc0fcd871fc?s=80\u0026d=identicon",
"commit_url": "https://22878.qa-tunnel.gitlab.info/root/minimal-ruby-app/commit/63492726c2264a0277141d6a6573c3d22ecd7de3",
"commit_path": "/root/minimal-ruby-app/commit/63492726c2264a0277141d6a6573c3d22ecd7de3"
},
"deployable": {
"id": 1097,
"name": "production",
"started": "2019-02-20T16:15:09.799Z",
"archived": false,
"build_path": "/root/minimal-ruby-app/-/jobs/1097",
"retry_path": "/root/minimal-ruby-app/-/jobs/1097/retry",
"playable": false,
"scheduled": false,
"created_at": "2019-02-20T16:09:12.194Z",
"updated_at": "2019-02-20T16:15:40.122Z",
"status": {
"icon": "status_success",
"text": "passed",
"label": "passed",
"group": "success",
"tooltip": "passed",
"has_details": true,
"details_path": "/root/minimal-ruby-app/-/jobs/1097",
"illustration": {
"image": "/assets/illustrations/skipped-job_empty-8b877955fbf175e42ae65b6cb95346e15282c6fc5b682756c329af3a0055225e.svg",
"size": "svg-430",
"title": "This job does not have a trace."
},
"favicon": "/assets/ci_favicons/favicon_status_success-8451333011eee8ce9f2ab25dc487fe24a8758c694827a582f17f42b0a90446a2.png",
"action": {
"icon": "retry",
"title": "Retry",
"path": "/root/minimal-ruby-app/-/jobs/1097/retry",
"method": "post",
"button_title": "Retry this job"
}
}
},
"manual_actions": [],
"scheduled_actions": []
},
"has_stop_action": false,
"environment_path": "/root/minimal-ruby-app/environments/27",
"stop_path": "/root/minimal-ruby-app/environments/27/stop",
"cluster_type": "project_type",
"terminal_path": "/root/minimal-ruby-app/environments/27/terminal",
"folder_path": "/root/minimal-ruby-app/environments/folders/production",
"created_at": "2019-02-19T09:12:37.400Z",
"updated_at": "2019-02-19T09:32:30.109Z",
"can_stop": true,
"rollout_status": {
"status": "loading"
},
"logs_path": "/root/minimal-ruby-app/environments/27/logs",
"alert_count": 1,
"alert_path": "/root/minimal-ruby-app/environments/27/metrics",
"last_alert": {
"id": 1,
"title": "my metric",
"query": "avg(metric)",
"threshold": 1.0,
"operator": "\u003e",
"alert_path": "/root/minimal-ruby-app/prometheus/alerts/21.json?environment_id=27"
}
}
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import component from 'ee/environments_dashboard/components/dashboard/project_header.vue';
const localVue = createLocalVue();
describe('Project Header', () => {
const Component = localVue.extend(component);
let wrapper;
let propsData;
beforeEach(() => {
propsData = {
project: {
namespace: {
name: 'hello',
full_path: 'hello',
},
name: 'world',
remove_path: '/hello/world/remove',
},
};
});
beforeEach(() => {
wrapper = shallowMount(Component, {
propsData,
localVue,
});
});
afterEach(() => {
wrapper.destroy();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('renders project namespace, name, and avatars', () => {
it('shows the project namespace avatar', () => {
const projectNamespaceAvatar = wrapper.findAll(ProjectAvatar).at(0);
expect(projectNamespaceAvatar.props('project')).toEqual(propsData.project.namespace);
});
it('shows the project namespace', () => {
expect(wrapper.find('.js-namespace').text()).toBe(propsData.project.namespace.name);
});
it('links to the project namespace', () => {
const expectedUrl = `/${propsData.project.namespace.full_path}`;
expect(wrapper.find('.js-namespace-link').attributes('href')).toBe(expectedUrl);
});
it('shows the project avatar', () => {
const projectAvatar = wrapper.findAll(ProjectAvatar).at(1);
expect(projectAvatar.props('project')).toEqual(propsData.project);
});
it('shows the project name', () => {
expect(wrapper.find('.js-name').text()).toBe(propsData.project.name);
});
it('links to the project', () => {
expect(wrapper.find('.js-project-link').attributes('href')).toBe(propsData.project.web_url);
});
});
describe('more actions', () => {
it('should list "remove" as an action', () => {
const removeLink = wrapper
.find('.dropdown-menu')
.findAll('li')
.filter(w => w.text() === 'Remove');
expect(removeLink.exists()).toBe(true);
});
it('should emit a "remove" event when "remove" is clicked', () => {
const removeLink = wrapper
.find('.dropdown-menu')
.findAll('li')
.filter(w => w.text() === 'Remove');
removeLink
.at(0)
.find(GlButton)
.vm.$emit('click');
expect(wrapper.emitted('remove')).toContainEqual([propsData.project.remove_path]);
});
});
});
......@@ -135,9 +135,6 @@ msgstr ""
msgid "%{counter_storage} (%{counter_repositories} repositories, %{counter_build_artifacts} build artifacts, %{counter_lfs_objects} LFS)"
msgstr ""
msgid "%{count} %{alerts}"
msgstr ""
msgid "%{count} approval required from %{name}"
msgid_plural "%{count} approvals required from %{name}"
msgstr[0] ""
......@@ -263,6 +260,9 @@ msgstr[1] ""
msgid "%{text} is available"
msgstr ""
msgid "%{title} %{operator} %{threshold}"
msgstr ""
msgid "%{title} changes"
msgstr ""
......@@ -4264,6 +4264,9 @@ msgstr ""
msgid "Enter the merge request title"
msgstr ""
msgid "EnviornmentDashboard|You are looking at the last updated environment"
msgstr ""
msgid "Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they will be masked by default so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want."
msgstr ""
......@@ -4276,9 +4279,33 @@ msgstr ""
msgid "Environments"
msgstr ""
msgid "Environments Dashboard"
msgstr ""
msgid "Environments allow you to track deployments of your application %{link_to_read_more}."
msgstr ""
msgid "EnvironmentsDashboard|Add a project to the dashboard"
msgstr ""
msgid "EnvironmentsDashboard|Add projects"
msgstr ""
msgid "EnvironmentsDashboard|Environments Dashboard"
msgstr ""
msgid "EnvironmentsDashboard|Job: %{job}"
msgstr ""
msgid "EnvironmentsDashboard|More actions"
msgstr ""
msgid "EnvironmentsDashboard|Remove"
msgstr ""
msgid "EnvironmentsDashboard|The environments dashboard provides a summary of each project's environments' status, including pipeline and alert statuses."
msgstr ""
msgid "Environments|An error occurred while fetching the environments."
msgstr ""
......@@ -11961,6 +11988,9 @@ msgstr ""
msgid "This domain is not verified. You will need to verify ownership before access is enabled."
msgstr ""
msgid "This environment has no deployments yet."
msgstr ""
msgid "This field is required."
msgstr ""
......@@ -13072,6 +13102,9 @@ msgstr ""
msgid "View details: %{details_url}"
msgstr ""
msgid "View documentation"
msgstr ""
msgid "View eligible approvers"
msgstr ""
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment