Commit db6e1bfc authored by Andrew Fontaine's avatar Andrew Fontaine Committed by Jose Ivan Vargas

Show Ongoing Alert for Environment

This shows whether or not there is an ongoing alert for a given
environment on the environments table page.
parent e25d35f7
...@@ -14,6 +14,7 @@ export default { ...@@ -14,6 +14,7 @@ export default {
DeployBoard: () => import('ee_component/environments/components/deploy_board_component.vue'), DeployBoard: () => import('ee_component/environments/components/deploy_board_component.vue'),
CanaryDeploymentCallout: () => CanaryDeploymentCallout: () =>
import('ee_component/environments/components/canary_deployment_callout.vue'), import('ee_component/environments/components/canary_deployment_callout.vue'),
EnvironmentAlert: () => import('ee_component/environments/components/environment_alert.vue'),
}, },
props: { props: {
environments: { environments: {
...@@ -111,6 +112,9 @@ export default { ...@@ -111,6 +112,9 @@ export default {
shouldShowCanaryCallout(env) { shouldShowCanaryCallout(env) {
return env.showCanaryCallout && this.showCanaryDeploymentCallout; return env.showCanaryCallout && this.showCanaryDeploymentCallout;
}, },
shouldRenderAlert(env) {
return env?.has_opened_alert;
},
sortEnvironments(environments) { sortEnvironments(environments) {
/* /*
* The sorting algorithm should sort in the following priorities: * The sorting algorithm should sort in the following priorities:
...@@ -185,6 +189,11 @@ export default { ...@@ -185,6 +189,11 @@ export default {
/> />
</div> </div>
</div> </div>
<environment-alert
v-if="shouldRenderAlert(model)"
:key="`alert-row-${i}`"
:environment="model"
/>
<template v-if="shouldRenderFolderContent(model)"> <template v-if="shouldRenderFolderContent(model)">
<div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`"> <div v-if="model.isLoadingFolderContent" :key="`loading-item-${i}`">
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import canaryCalloutMixin from '../mixins/canary_callout_mixin'; import canaryCalloutMixin from '../mixins/canary_callout_mixin';
import environmentsFolderApp from './environments_folder_view.vue'; import environmentsFolderApp from './environments_folder_view.vue';
import { parseBoolean } from '../../lib/utils/common_utils'; import { parseBoolean } from '../../lib/utils/common_utils';
import Translate from '../../vue_shared/translate'; import Translate from '../../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo);
export default () => const apolloProvider = new VueApollo({
new Vue({ defaultClient: createDefaultClient(),
el: '#environments-folder-list-view', });
export default () => {
const el = document.getElementById('environments-folder-list-view');
return new Vue({
el,
components: { components: {
environmentsFolderApp, environmentsFolderApp,
}, },
mixins: [canaryCalloutMixin], mixins: [canaryCalloutMixin],
apolloProvider,
provide: {
projectPath: el.dataset.projectPath,
},
data() { data() {
const environmentsData = document.querySelector(this.$options.el).dataset; const environmentsData = el.dataset;
return { return {
endpoint: environmentsData.environmentsDataEndpoint, endpoint: environmentsData.environmentsDataEndpoint,
...@@ -35,3 +48,4 @@ export default () => ...@@ -35,3 +48,4 @@ export default () =>
}); });
}, },
}); });
};
...@@ -23,7 +23,8 @@ export default { ...@@ -23,7 +23,8 @@ export default {
}, },
cssContainerClass: { cssContainerClass: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
canReadEnvironment: { canReadEnvironment: {
type: Boolean, type: Boolean,
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import canaryCalloutMixin from './mixins/canary_callout_mixin'; import canaryCalloutMixin from './mixins/canary_callout_mixin';
import environmentsComponent from './components/environments_app.vue'; import environmentsComponent from './components/environments_app.vue';
import { parseBoolean } from '../lib/utils/common_utils'; import { parseBoolean } from '../lib/utils/common_utils';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import createDefaultClient from '~/lib/graphql';
Vue.use(Translate); Vue.use(Translate);
Vue.use(VueApollo);
export default () => const apolloProvider = new VueApollo({
new Vue({ defaultClient: createDefaultClient(),
el: '#environments-list-view', });
export default () => {
const el = document.getElementById('environments-list-view');
return new Vue({
el,
components: { components: {
environmentsComponent, environmentsComponent,
}, },
mixins: [canaryCalloutMixin], mixins: [canaryCalloutMixin],
apolloProvider,
provide: {
projectPath: el.dataset.projectPath,
},
data() { data() {
const environmentsData = document.querySelector(this.$options.el).dataset; const environmentsData = el.dataset;
return { return {
endpoint: environmentsData.environmentsDataEndpoint, endpoint: environmentsData.environmentsDataEndpoint,
...@@ -39,3 +51,4 @@ export default () => ...@@ -39,3 +51,4 @@ export default () =>
}); });
}, },
}); });
};
...@@ -2,7 +2,7 @@ import { parseBoolean } from '~/lib/utils/common_utils'; ...@@ -2,7 +2,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
export default { export default {
data() { data() {
const data = document.querySelector(this.$options.el).dataset; const data = this.$options.el.dataset;
return { return {
canaryDeploymentFeatureId: data.environmentsDataCanaryDeploymentFeatureId, canaryDeploymentFeatureId: data.environmentsDataCanaryDeploymentFeatureId,
......
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
- breadcrumb_title _("Folder/%{name}") % { name: @folder } - breadcrumb_title _("Folder/%{name}") % { name: @folder }
- page_title _("Environments in %{name}") % { name: @folder } - page_title _("Environments in %{name}") % { name: @folder }
#environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data } } #environments-folder-list-view{ data: { environments_data: environments_folder_list_view_data, project_path: @project.full_path } }
...@@ -5,4 +5,5 @@ ...@@ -5,4 +5,5 @@
"can-create-environment" => can?(current_user, :create_environment, @project).to_s, "can-create-environment" => can?(current_user, :create_environment, @project).to_s,
"new-environment-path" => new_project_environment_path(@project), "new-environment-path" => new_project_environment_path(@project),
"help-page-path" => help_page_path("ci/environments/index.md"), "help-page-path" => help_page_path("ci/environments/index.md"),
"deploy-boards-help-path" => help_page_path("user/project/deploy_boards", anchor: "enabling-deploy-boards") } } "deploy-boards-help-path" => help_page_path("user/project/deploy_boards", anchor: "enabling-deploy-boards"),
"project-path" => @project.full_path } }
<script>
import { GlLink, GlSprintf, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import { s__ } from '~/locale';
import TimeagoMixin from '~/vue_shared/mixins/timeago';
import alertQuery from '../graphql/queries/environment.query.graphql';
export default {
components: {
GlLink,
GlSprintf,
SeverityBadge,
},
directives: {
GlTooltip,
},
mixins: [TimeagoMixin],
props: {
environment: {
required: true,
type: Object,
},
},
inject: {
projectPath: {
type: String,
default: '',
},
},
data() {
return { alert: null };
},
apollo: {
alert: {
query: alertQuery,
variables() {
return {
fullPath: this.projectPath,
environmentName: this.environment.name,
};
},
update(data) {
return data?.project?.environment?.latestOpenedMostSevereAlert;
},
},
},
translations: {
alertText: s__(
'EnvironmentsAlert|%{severity} • %{title} %{text}. %{linkStart}View Details%{linkEnd} · %{startedAt} ',
),
},
computed: {
humanizedText() {
return this.alert?.prometheusAlert?.humanizedText;
},
severity() {
return this.alert?.severity || '';
},
},
classes: [
'gl-py-2',
'gl-pl-3',
'gl-text-gray-900',
'gl-bg-gray-10',
'gl-border-t-solid',
'gl-border-gray-100',
'gl-border-1',
],
};
</script>
<template>
<div v-if="alert" :class="$options.classes" data-testid="alert">
<gl-sprintf :message="$options.translations.alertText">
<template #severity>
<severity-badge :severity="severity" class="gl-display-inline" />
</template>
<template #startedAt>
<span v-gl-tooltip :title="tooltipTitle(alert.startedAt)">
{{ timeFormatted(alert.startedAt) }}
</span>
</template>
<template #title>
<span>{{ alert.title }}</span>
</template>
<template #text>
<span>{{ humanizedText }}</span>
</template>
<template #link="{ content }">
<gl-link :href="alert.detailsUrl" data-testid="alert-link">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</template>
query environment($fullPath: ID!, $environmentName: String) {
project(fullPath: $fullPath) {
environment(name: $environmentName) {
latestOpenedMostSevereAlert {
title
severity
detailsUrl
startedAt
prometheusAlert {
humanizedText
}
}
}
}
}
---
title: Show Latest Most Severe Alert on Environment
merge_request: 39743
author:
type: added
import { mount } from '@vue/test-utils';
import EnvironmentAlert from 'ee/environments/components/environment_alert.vue';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import { useFakeDate } from 'helpers/fake_date';
describe('Environment Alert', () => {
let wrapper;
const DEFAULT_PROVIDE = { projectPath: 'test-org/test' };
const DEFAULT_PROPS = { environment: { name: 'staging' } };
useFakeDate();
const factory = (props = {}, provide = {}) => {
wrapper = mount(EnvironmentAlert, {
propsData: {
...DEFAULT_PROPS,
...props,
},
provide: {
...DEFAULT_PROVIDE,
...provide,
},
});
};
beforeEach(() => {
factory();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('has alert', () => {
beforeEach(() => {
wrapper.setData({
alert: {
severity: 'CRITICAL',
title: 'alert title',
prometheusAlert: { humanizedText: '>0.1% jest' },
detailsUrl: '/alert/details',
startedAt: new Date(),
},
});
});
it('should display the alert details', () => {
const text = wrapper.text();
expect(text).toContain('Critical');
expect(text).toContain('alert title >0.1% jest.');
expect(text).toContain('View Details');
expect(text).toContain('just now');
});
it('should link to the details of the alert', () => {
const link = wrapper.find('[data-testid="alert-link"]');
expect(link.text()).toBe('View Details');
expect(link.attributes('href')).toBe('/alert/details');
});
it('should show a severity badge', () => {
expect(wrapper.find(SeverityBadge).props('severity')).toBe('CRITICAL');
});
});
describe('has no alert', () => {
it('should display nothing', () => {
expect(wrapper.find('[data-testid="alert"]').exists()).toBe(false);
});
});
});
import { mount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import EnvironmentAlert from 'ee/environments/components/environment_alert.vue';
import EnvironmentTable from '~/environments/components/environments_table.vue'; import EnvironmentTable from '~/environments/components/environments_table.vue';
import eventHub from '~/environments/event_hub'; import eventHub from '~/environments/event_hub';
import { deployBoardMockData } from './mock_data'; import { deployBoardMockData } from './mock_data';
...@@ -6,21 +7,23 @@ import { deployBoardMockData } from './mock_data'; ...@@ -6,21 +7,23 @@ import { deployBoardMockData } from './mock_data';
describe('Environment table', () => { describe('Environment table', () => {
let wrapper; let wrapper;
const factory = (options = {}) => { const factory = async (options = {}, m = mount) => {
// This destroys any wrappers created before a nested call to factory reassigns it // This destroys any wrappers created before a nested call to factory reassigns it
if (wrapper && wrapper.destroy) { if (wrapper && wrapper.destroy) {
wrapper.destroy(); wrapper.destroy();
} }
wrapper = mount(EnvironmentTable, { wrapper = m(EnvironmentTable, {
...options, ...options,
}); });
await wrapper.vm.$nextTick();
await jest.runOnlyPendingTimers();
}; };
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('Should render a table', () => { it('Should render a table', async () => {
const mockItem = { const mockItem = {
name: 'review', name: 'review',
folderName: 'review', folderName: 'review',
...@@ -29,7 +32,7 @@ describe('Environment table', () => { ...@@ -29,7 +32,7 @@ describe('Environment table', () => {
environment_path: 'url', environment_path: 'url',
}; };
factory({ await factory({
propsData: { propsData: {
environments: [mockItem], environments: [mockItem],
canReadEnvironment: true, canReadEnvironment: true,
...@@ -44,7 +47,7 @@ describe('Environment table', () => { ...@@ -44,7 +47,7 @@ describe('Environment table', () => {
expect(wrapper.classes()).toContain('ci-table'); expect(wrapper.classes()).toContain('ci-table');
}); });
it('should render deploy board container when data is provided', () => { it('should render deploy board container when data is provided', async () => {
const mockItem = { const mockItem = {
name: 'review', name: 'review',
size: 1, size: 1,
...@@ -58,7 +61,7 @@ describe('Environment table', () => { ...@@ -58,7 +61,7 @@ describe('Environment table', () => {
isEmptyDeployBoard: false, isEmptyDeployBoard: false,
}; };
factory({ await factory({
propsData: { propsData: {
environments: [mockItem], environments: [mockItem],
canCreateDeployment: false, canCreateDeployment: false,
...@@ -71,8 +74,8 @@ describe('Environment table', () => { ...@@ -71,8 +74,8 @@ describe('Environment table', () => {
}, },
}); });
expect(wrapper.find('.js-deploy-board-row')).toBeDefined(); expect(wrapper.find('.js-deploy-board-row').exists()).toBe(true);
expect(wrapper.find('.deploy-board-icon')).not.toBeNull(); expect(wrapper.find('.deploy-board-icon').exists()).toBe(true);
}); });
it('should toggle deploy board visibility when arrow is clicked', done => { it('should toggle deploy board visibility when arrow is clicked', done => {
...@@ -112,7 +115,7 @@ describe('Environment table', () => { ...@@ -112,7 +115,7 @@ describe('Environment table', () => {
wrapper.find('.deploy-board-icon').trigger('click'); wrapper.find('.deploy-board-icon').trigger('click');
}); });
it('should render canary callout', () => { it('should render canary callout', async () => {
const mockItem = { const mockItem = {
name: 'review', name: 'review',
folderName: 'review', folderName: 'review',
...@@ -122,7 +125,7 @@ describe('Environment table', () => { ...@@ -122,7 +125,7 @@ describe('Environment table', () => {
showCanaryCallout: true, showCanaryCallout: true,
}; };
factory({ await factory({
propsData: { propsData: {
environments: [mockItem], environments: [mockItem],
canCreateDeployment: false, canCreateDeployment: false,
...@@ -135,6 +138,35 @@ describe('Environment table', () => { ...@@ -135,6 +138,35 @@ describe('Environment table', () => {
}, },
}); });
expect(wrapper.find('.canary-deployment-callout')).not.toBeNull(); expect(wrapper.find('.canary-deployment-callout').exists()).toBe(true);
});
it('should render the alert if there is one', async () => {
const mockItem = {
name: 'review',
size: 1,
environment_path: 'url',
logs_path: 'url',
id: 1,
hasDeployBoard: false,
has_opened_alert: true,
};
await factory(
{
propsData: {
environments: [mockItem],
canReadEnvironment: true,
canaryDeploymentFeatureId: 'canary_deployment',
showCanaryDeploymentCallout: true,
userCalloutsPath: '/callouts',
lockPromotionSvgPath: '/assets/illustrations/lock-promotion.svg',
helpCanaryDeploymentsPath: 'help/canary-deployments',
},
},
shallowMount,
);
expect(wrapper.find(EnvironmentAlert).exists()).toBe(true);
}); });
}); });
...@@ -9536,6 +9536,9 @@ msgstr "" ...@@ -9536,6 +9536,9 @@ msgstr ""
msgid "Environments in %{name}" msgid "Environments in %{name}"
msgstr "" msgstr ""
msgid "EnvironmentsAlert|%{severity} • %{title} %{text}. %{linkStart}View Details%{linkEnd} · %{startedAt} "
msgstr ""
msgid "EnvironmentsDashboard|Add a project to the dashboard" msgid "EnvironmentsDashboard|Add a project to the dashboard"
msgstr "" 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