Commit 8723b76a authored by Jacques Erasmus's avatar Jacques Erasmus Committed by Mike Greiling

Add ability see deployments using a group cluster

Added the ability to view project deployments at group level
parent 43e2b588
......@@ -14,6 +14,8 @@ import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
import setupToggleButtons from '../toggle_buttons';
const Environments = () => import('ee_component/clusters/components/environments.vue');
Vue.use(GlToast);
/**
......@@ -44,6 +46,9 @@ export default class Clusters {
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
clusterId,
} = document.querySelector('.js-edit-cluster-form').dataset;
......@@ -52,7 +57,14 @@ export default class Clusters {
this.clusterBannerDismissedKey = `cluster_${this.clusterId}_banner_dismissed`;
this.store = new ClustersStore();
this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
this.store.setHelpPaths(
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
);
this.store.setManagePrometheusPath(managePrometheusPath);
this.store.updateStatus(clusterStatus);
this.store.updateStatusReason(clusterStatusReason);
......@@ -95,11 +107,12 @@ export default class Clusters {
setupToggleButtons(toggleButtonsContainer);
}
this.initApplications(clusterType);
this.initEnvironments();
this.updateContainer(null, this.store.state.status, this.store.state.statusReason);
this.addListeners();
if (statusPath) {
if (statusPath && !this.environments) {
this.initPolling();
}
}
......@@ -131,6 +144,34 @@ export default class Clusters {
});
}
initEnvironments() {
const { store } = this;
const el = document.querySelector('#js-cluster-environments');
if (!el) {
return;
}
this.environments = new Vue({
el,
data() {
return {
state: store.state,
};
},
render(createElement) {
return createElement(Environments, {
props: {
environments: this.state.environments,
environmentsHelpPath: this.state.environmentsHelpPath,
clustersHelpPath: this.state.clustersHelpPath,
deployBoardsHelpPath: this.state.deployBoardsHelpPath,
},
});
},
});
}
static initDismissableCallout() {
const callout = document.querySelector('.js-cluster-security-warning');
PersistentUserCallout.factory(callout);
......@@ -390,6 +431,10 @@ export default class Clusters {
this.poll.stop();
}
if (this.environments) {
this.environments.$destroy();
}
this.applications.$destroy();
}
}
......@@ -32,6 +32,9 @@ export default class ClusterStore {
this.state = {
helpPath: null,
ingressHelpPath: null,
environmentsHelpPath: null,
clustersHelpPath: null,
deployBoardsHelpPath: null,
status: null,
rbac: false,
statusReason: null,
......@@ -80,13 +83,24 @@ export default class ClusterStore {
updateFailed: false,
},
},
environments: [],
};
}
setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath) {
setHelpPaths(
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
environmentsHelpPath,
clustersHelpPath,
deployBoardsHelpPath,
) {
this.state.helpPath = helpPath;
this.state.ingressHelpPath = ingressHelpPath;
this.state.ingressDnsHelpPath = ingressDnsHelpPath;
this.state.environmentsHelpPath = environmentsHelpPath;
this.state.clustersHelpPath = clustersHelpPath;
this.state.deployBoardsHelpPath = deployBoardsHelpPath;
}
setManagePrometheusPath(managePrometheusPath) {
......@@ -191,4 +205,17 @@ export default class ClusterStore {
}
});
}
updateEnvironments(environments = []) {
this.state.environments = environments.map(environment => ({
name: environment.name,
project: environment.project,
environmentPath: environment.environment_path,
lastDeployment: environment.last_deployment,
rolloutStatus: {
instances: environment.rollout_status ? environment.rollout_status.instances : [],
},
updatedAt: environment.updatedAt,
}));
}
}
......@@ -154,3 +154,12 @@
}
}
}
.cluster-deployments-warning {
color: $orange-600;
}
.badge.pods-badge {
color: $black;
font-weight: $gl-font-weight-bold;
}
%section#cluster-integration
- unless @cluster.status_name.in? %i/scheduled creating/
= render 'form'
- unless @cluster.status_name.in? %i/scheduled creating/
= render_if_exists 'projects/clusters/prometheus_graphs'
.cluster-applications-table#js-cluster-applications
%section.settings#js-cluster-details{ class: ('expanded' if expanded) }
.settings-header
%h4= s_('ClusterIntegration|Kubernetes cluster details')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster')
.settings-content
= render 'clusters/platforms/kubernetes/form', cluster: @cluster, platform: @cluster.platform_kubernetes, update_cluster_url_path: clusterable.cluster_path(@cluster)
%section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
%h4= _('Advanced settings')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration")
.settings-content#advanced-settings-section
= render 'advanced_settings'
......@@ -24,38 +24,19 @@
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'),
environments_help_path: help_page_path('ci/environments', anchor: 'defining-environments'),
clusters_help_path: help_page_path('user/project/clusters/index.md', anchor: 'deploying-to-a-kubernetes-cluster'),
deploy_boards_help_path: help_page_path('user/project/deploy_boards.html', anchor: 'enabling-deploy-boards'),
manage_prometheus_path: manage_prometheus_path,
cluster_id: @cluster.id } }
.js-cluster-application-notice
.flash-container
%section#cluster-integration
%h4= @cluster.name
= render 'banner'
- unless @cluster.status_name.in? %i/scheduled creating/
= render 'form'
= render_if_exists 'clusters/clusters/group_cluster_environments', expanded: expanded
- unless @cluster.status_name.in? %i/scheduled creating/
= render_if_exists 'projects/clusters/prometheus_graphs'
.cluster-applications-table#js-cluster-applications
%section.settings#js-cluster-details{ class: ('expanded' if expanded) }
.settings-header
%h4= s_('ClusterIntegration|Kubernetes cluster details')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p= s_('ClusterIntegration|See and edit the details for your Kubernetes cluster')
.settings-content
= render 'clusters/platforms/kubernetes/form', cluster: @cluster, platform: @cluster.platform_kubernetes, update_cluster_url_path: clusterable.cluster_path(@cluster)
%section.settings.no-animate#js-cluster-advanced-settings{ class: ('expanded' if expanded) }
.settings-header
%h4= _('Advanced settings')
%button.btn.js-settings-toggle{ type: 'button' }
= expanded ? _('Collapse') : _('Expand')
%p= s_("ClusterIntegration|Advanced options on this Kubernetes cluster's integration")
.settings-content#advanced-settings-section
= render 'advanced_settings'
- unless Gitlab.ee?
= render 'configure', expanded: expanded
---
title: Add ability to see project deployments at cluster level (FE)
merge_request: 31575
author:
type: added
<script>
import { GlTable, GlLink, GlEmptyState } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlEmptyState,
GlTable,
GlLink,
Icon,
TimeAgo,
deploymentInstance: () => import('ee_component/vue_shared/components/deployment_instance.vue'),
},
props: {
environments: {
type: Array,
required: true,
},
environmentsHelpPath: {
type: String,
required: true,
},
clustersHelpPath: {
type: String,
required: true,
},
deployBoardsHelpPath: {
type: String,
required: true,
},
},
computed: {
isEmpty() {
return this.environments.length === 0;
},
tableEmptyStateText() {
const text = __(
'Ensure your %{linkStart}environment is part of the deploy stage%{linkEnd} of your CI pipeline to track deployments to your cluster.',
);
const linkStart = `<a href="${this.environmentsHelpPath}">`;
const linkEnd = `</a>`;
return sprintf(text, { linkStart, linkEnd }, false);
},
deploymentsEmptyStateText() {
const text = __(
'Deploy progress not found. To see pods, ensure your environment matches %{linkStart}deploy board criteria%{linkEnd}.',
);
const linkStart = `<a href="${this.deployBoardsHelpPath}">`;
const linkEnd = `</a>`;
return sprintf(text, { linkStart, linkEnd }, false);
},
podsInUseCount() {
let podsInUse = 0;
this.environments.forEach(environment => {
podsInUse += environment.rolloutStatus.instances.length;
});
return podsInUse;
},
},
created() {
this.$options.fields = [
{ key: 'project', label: __('Project'), class: 'pl-0 pr-5 text-nowrap' },
{ key: 'name', label: __('Environment'), class: 'pl-0 pr-5' },
{ key: 'lastDeployment', label: __('Job'), class: 'pl-0 pr-5 text-nowrap' },
{ key: 'rolloutStatus', label: __('Pods in use'), class: 'pl-0 pr-5' },
{
key: 'updatedAt',
label: __('Last updated'),
class: 'pl-0 pr-0 text-md-right text-nowrap',
},
];
},
};
</script>
<template>
<div>
<gl-empty-state
v-if="isEmpty"
:title="__('No deployments found')"
:primary-button-link="clustersHelpPath"
:primary-button-text="__('Learn more about deploying to a cluster')"
>
<div slot="description" v-html="tableEmptyStateText"></div>
</gl-empty-state>
<gl-table v-else :fields="$options.fields" :items="environments" head-variant="white">
<!-- column: Project -->
<template slot="project" slot-scope="data">
<a :href="`/${data.value.path_with_namespace}`">{{ data.value.name }}</a>
</template>
<!-- column: Name -->
<template slot="name" slot-scope="row">
<a :href="`${row.item.environmentPath}`">{{ row.item.name }}</a>
</template>
<!-- column: Job -->
<template slot="lastDeployment" slot-scope="data">
{{ __('deploy') }} #{{ data.value.id }}
</template>
<!-- column: Pods in use -->
<template slot="HEAD_rolloutStatus" slot-scope="data">
{{ data.label }} <span class="badge badge-pill pods-badge bold">{{ podsInUseCount }}</span>
</template>
<template slot="rolloutStatus" slot-scope="row">
<div v-if="row.item.rolloutStatus.instances.length" class="d-flex flex-wrap flex-row">
<template v-for="(instance, i) in row.item.rolloutStatus.instances">
<deployment-instance
:key="i"
:status="instance.status"
:tooltip-text="instance.tooltip"
:pod-name="instance.pod_name"
:stable="instance.stable"
:logs-path="`${row.item.environmentPath}/logs`"
/>
</template>
</div>
<!-- Empty state -->
<div v-else class="deployments-empty d-flex">
<icon
name="warning"
:size="18"
class="cluster-deployments-warning mr-2 align-self-center flex-shrink-0"
/>
<span v-html="deploymentsEmptyStateText"></span>
</div>
</template>
<!-- column: Last updated -->
<template slot="updatedAt" slot-scope="data">
<time-ago :time="data.value" />
</template>
</gl-table>
</div>
</template>
......@@ -14,11 +14,10 @@ import { n__, s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import deployBoardSvg from 'ee_empty_states/icons/_deploy_board.svg';
import { GlLoadingIcon, GlLink } from '@gitlab/ui';
import instanceComponent from './deploy_board_instance_component.vue';
export default {
components: {
instanceComponent,
instanceComponent: () => import('ee_component/vue_shared/components/deployment_instance.vue'),
GlLoadingIcon,
GlLink,
},
......
......@@ -58,10 +58,10 @@ export default {
computed: {
cssClass() {
let cssClassName = `deploy-board-instance-${this.status}`;
let cssClassName = `deployment-instance-${this.status}`;
if (!this.stable) {
cssClassName = `${cssClassName} deploy-board-instance-canary`;
cssClassName = `${cssClassName} deployment-instance-canary`;
}
return cssClassName;
......@@ -79,7 +79,7 @@ export default {
:class="cssClass"
:data-title="tooltipText"
:href="computedLogPath"
class="deploy-board-instance"
class="deployment-instance d-flex justify-content-center align-items-center"
data-placement="top"
>
</a>
......
.deployment-instance {
width: 15px;
height: 15px;
margin: 1px;
border: 1px solid;
border-radius: 3px;
&-running {
background-color: $green-100;
border-color: $green-400;
&:hover {
background-color: $green-300;
border-color: $green-500;
}
}
&-succeeded {
background-color: $green-50;
border-color: $green-400;
}
&-failed,
&-unknown {
background-color: $red-200;
border-color: $red-500;
}
&-pending {
background-color: $white-light;
border-color: $border-color;
}
&.deployment-instance-canary {
&::after {
width: 7px;
height: 7px;
border: 1px solid $white-light;
background-color: $orange-300;
border-radius: 50%;
content: '';
}
}
}
......@@ -70,55 +70,6 @@
}
}
.deploy-board-instance {
width: 15px;
height: 15px;
border-radius: 3px;
border-width: 1px;
border-style: solid;
margin: 1px;
display: flex;
justify-content: center;
align-items: center;
&-running {
background-color: $green-100;
border-color: $green-400;
&:hover {
background-color: $green-300;
border-color: $green-500;
}
}
&-succeeded {
background-color: $green-50;
border-color: $green-400;
}
&-failed,
&-unknown {
background-color: $red-200;
border-color: $red-500;
}
&-pending {
background-color: $white-light;
border-color: $border-color;
}
&.deploy-board-instance-canary {
&::after {
width: 7px;
height: 7px;
border: 1px solid $white-light;
background-color: $orange-300;
border-radius: 50%;
content: '';
}
}
}
.deploy-board-icon {
display: none;
......
- is_configure_active = !params[:tab] || params[:tab] == 'configure'
- is_group_type = @cluster.cluster_type.in? 'group_type'
- is_creating = @cluster.status_name.in? %i/scheduled creating/
- if is_group_type && !is_creating && Feature.enabled?(:view_group_cluster_deployments)
.js-toggle-container
%ul.nav-links.mobile-separator.nav.nav-tabs{ role: 'tablist' }
%li.nav-item{ role: 'presentation' }
%a.nav-link{ class: active_when(is_configure_active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'configure'}), id: 'group-cluster-configure-tab' }
%span= _('Configure')
%li.nav-item{ role: 'presentation' }
%a.nav-link{ class: active_when(!is_configure_active), href: clusterable.cluster_path(@cluster.id, params: {tab: 'environments'}), id: 'group-cluster-environments-tab' }
%span= _('Environments')
.tab-content
- if is_configure_active
.tab-pane.active{ id: 'group-cluster-configure-pane', role: 'tabpanel' }
= render 'configure', expanded: expanded
- else
.tab-pane.active{ id: 'group-cluster-environments-pane', role: 'tabpanel' }
#js-cluster-environments
- else
= render 'configure', expanded: expanded
import { createLocalVue, mount } from '@vue/test-utils';
import Environments from 'ee/clusters/components/environments.vue';
import { GlTable, GlEmptyState } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import environments from './mock_data';
const localVue = createLocalVue();
describe('Environments', () => {
const Component = localVue.extend(Environments);
let wrapper;
let propsData;
beforeEach(() => {
propsData = {
environments: [],
environmentsHelpPath: 'path/to/environments',
clustersHelpPath: 'path/to/clusters',
deployBoardsHelpPath: 'path/to/clusters',
};
wrapper = mount(Component, {
propsData,
localVue,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders an empty state if no deployments are found', () => {
const emptyState = wrapper.find(GlEmptyState);
const emptyStateText =
'No deployments found Ensure your environment is part of the deploy stage of your CI pipeline to track deployments to your cluster. Learn more about deploying to a cluster';
expect(emptyState.exists()).toBe(true);
expect(emptyState.text()).toEqual(emptyStateText);
});
describe('environments table', () => {
let table;
beforeAll(() => {
wrapper = mount(Component, {
propsData: { ...propsData, environments },
localVue,
stubs: { deploymentInstance: '<div class="js-deployment-instance"></div>' },
sync: false,
});
table = wrapper.find(GlTable);
});
it('renders a table component', () => {
expect(table.exists()).toBe(true);
});
it('renders the correct table headers', () => {
const tableHeaders = ['Project', 'Environment', 'Job', 'Pods in use 3', 'Last updated'];
const headers = table.findAll('th');
expect(headers.length).toBe(tableHeaders.length);
tableHeaders.forEach((headerText, i) => expect(headers.at(i).text()).toEqual(headerText));
});
describe('deployment instances', () => {
let tableRows;
beforeAll(() => {
tableRows = table.findAll('tbody tr');
});
it('renders deployment instances', () => {
environments.forEach((environment, i) => {
const { instances } = environment.rolloutStatus;
expect(tableRows.at(i).findAll('.js-deployment-instance').length).toBe(instances.length);
});
});
it('renders an empty state if no deployment instances are found', () => {
const emptyStateText =
'Deploy progress not found. To see pods, ensure your environment matches deploy board criteria.';
environments.forEach((environment, i) => {
const { instances } = environment.rolloutStatus;
if (instances.length === 0) {
const emptyState = tableRows.at(i).find('.deployments-empty');
const emptyStateIcon = emptyState.find(Icon);
expect(emptyState.exists()).toBe(true);
expect(emptyStateIcon.exists()).toBe(true);
expect(emptyState.text()).toEqual(emptyStateText);
expect(emptyStateIcon.props().name).toEqual('warning');
}
});
});
});
});
});
export default [
{
environmentPath: 'some/path',
project: { path_with_namespace: 'some/path', name: 'some project' },
name: 'production',
lastDeployment: { id: '123' },
rolloutStatus: {
instances: [
{ status: 'running', pod_name: 'some pod', tooltip: 'success', track: '1', stable: true },
{ status: 'running', pod_name: 'some pod', tooltip: 'success', track: '2', stable: true },
],
},
updatedAt: '2017-08-13T12:25:24.098Z',
},
{
environmentPath: 'some/other/path',
project: { path_with_namespace: 'some/other/path', name: 'some other project' },
name: 'staging',
lastDeployment: { id: '456' },
rolloutStatus: {
instances: [
{ status: 'running', pod_name: 'some pod', tooltip: 'success', track: '1', stable: true },
],
},
updatedAt: '2019-01-13T12:25:24.098Z',
},
{
environmentPath: 'yet/another/path',
project: { path_with_namespace: 'yet/another/path', name: 'yet another project' },
name: 'development',
lastDeployment: { id: '789' },
rolloutStatus: { instances: [] },
updatedAt: '2019-08-13T12:25:24.098Z',
},
];
......@@ -44,7 +44,7 @@ describe('Deploy Board', () => {
expect(
instances[2].classList.contains(
`deploy-board-instance-${deployBoardMockData.instances[2].status}`,
`deployment-instance-${deployBoardMockData.instances[2].status}`,
),
).toBe(true);
});
......
import Vue from 'vue';
import DeployBoardInstance from 'ee/environments/components/deploy_board_instance_component.vue';
import { folder } from './mock_data';
import DeployBoardInstance from 'ee/vue_shared/components/deployment_instance.vue';
import { folder } from '../../environments/mock_data';
describe('Deploy Board Instance', () => {
let DeployBoardInstanceComponent;
......@@ -18,7 +18,7 @@ describe('Deploy Board Instance', () => {
},
}).$mount();
expect(component.$el.classList.contains('deploy-board-instance-ready')).toBe(true);
expect(component.$el.classList.contains('deployment-instance-ready')).toBe(true);
expect(component.$el.getAttribute('data-title')).toEqual('This is a pod');
});
......@@ -30,7 +30,7 @@ describe('Deploy Board Instance', () => {
},
}).$mount();
expect(component.$el.classList.contains('deploy-board-instance-deploying')).toBe(true);
expect(component.$el.classList.contains('deployment-instance-deploying')).toBe(true);
expect(component.$el.getAttribute('data-title')).toEqual('');
});
......@@ -43,7 +43,7 @@ describe('Deploy Board Instance', () => {
},
}).$mount();
expect(component.$el.classList.contains('deploy-board-instance-canary')).toBe(true);
expect(component.$el.classList.contains('deployment-instance-canary')).toBe(true);
});
it('should have a log path computed with a pod name as a parameter', () => {
......
......@@ -3861,6 +3861,9 @@ msgstr ""
msgid "Confidentiality"
msgstr ""
msgid "Configure"
msgstr ""
msgid "Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}"
msgstr ""
......@@ -4795,6 +4798,9 @@ msgstr ""
msgid "Deploy key was successfully updated."
msgstr ""
msgid "Deploy progress not found. To see pods, ensure your environment matches %{linkStart}deploy board criteria%{linkEnd}."
msgstr ""
msgid "Deploy to..."
msgstr ""
......@@ -5512,6 +5518,9 @@ msgstr ""
msgid "Ensure connectivity is available from the GitLab server to the Prometheus server"
msgstr ""
msgid "Ensure your %{linkStart}environment is part of the deploy stage%{linkEnd} of your CI pipeline to track deployments to your cluster."
msgstr ""
msgid "Enter IP address range"
msgstr ""
......@@ -5557,6 +5566,9 @@ msgstr ""
msgid "EnviornmentDashboard|You are looking at the last updated environment"
msgstr ""
msgid "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 can be masked 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 ""
......@@ -8823,6 +8835,9 @@ msgstr ""
msgid "Learn more about custom project templates"
msgstr ""
msgid "Learn more about deploying to a cluster"
msgstr ""
msgid "Learn more about group-level project templates"
msgstr ""
......@@ -10079,6 +10094,9 @@ msgstr ""
msgid "No data to display"
msgstr ""
msgid "No deployments found"
msgstr ""
msgid "No details available"
msgstr ""
......@@ -11143,6 +11161,9 @@ msgstr ""
msgid "Please wait while we import the repository for you. Refresh at will."
msgstr ""
msgid "Pods in use"
msgstr ""
msgid "Preferences"
msgstr ""
......@@ -18293,6 +18314,9 @@ msgstr[1] ""
msgid "deleted"
msgstr ""
msgid "deploy"
msgstr ""
msgid "design"
msgstr ""
......
......@@ -51,6 +51,9 @@ describe('Clusters Store', () => {
expect(store.state).toEqual({
helpPath: null,
ingressHelpPath: null,
environmentsHelpPath: null,
clustersHelpPath: null,
deployBoardsHelpPath: null,
status: mockResponseData.status,
statusReason: mockResponseData.status_reason,
rbac: false,
......@@ -148,6 +151,7 @@ describe('Clusters Store', () => {
uninstallFailed: false,
},
},
environments: [],
});
});
......
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