Commit a9a0f4f4 authored by Angelo Gulina's avatar Angelo Gulina Committed by Shinya Maeda

Expose environment path for the Alert details section

The feature is behind a feature flag
GraphQL Schema is updated and so the relevant documentation
parent 0ef3cf20
......@@ -30,6 +30,7 @@ import AlertSidebar from './alert_sidebar.vue';
import AlertMetrics from './alert_metrics.vue';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertSummaryRow from './alert_summary_row.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
......@@ -76,6 +77,7 @@ export default {
SystemNote,
AlertMetrics,
},
mixins: [glFeatureFlagsMixin()],
inject: {
projectPath: {
default: '',
......@@ -147,6 +149,15 @@ export default {
this.$router.replace({ name: 'tab', params: { tabId } });
},
},
environmentName() {
return this.shouldDisplayEnvironment && this.alert?.environment?.name;
},
environmentPath() {
return this.shouldDisplayEnvironment && this.alert?.environment?.path;
},
shouldDisplayEnvironment() {
return this.glFeatures.exposeEnvironmentPathInAlertDetails;
},
},
mounted() {
this.trackPageViews();
......@@ -299,19 +310,18 @@ export default {
</span>
</alert-summary-row>
<alert-summary-row
v-if="alert.environment"
v-if="environmentName"
:label="`${s__('AlertManagement|Environment')}:`"
>
<gl-link
v-if="alert.environmentUrl"
v-if="environmentPath"
class="gl-display-inline-block"
data-testid="environmentUrl"
:href="alert.environmentUrl"
target="_blank"
data-testid="environmentPath"
:href="environmentPath"
>
{{ alert.environment }}
{{ environmentName }}
</gl-link>
<span v-else data-testid="environment">{{ alert.environment }}</span>
<span v-else data-testid="environmentName">{{ environmentName }}</span>
</alert-summary-row>
<alert-summary-row
v-if="alert.startedAt"
......
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import produce from 'immer';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import createRouter from './router';
import AlertDetails from './components/alert_details.vue';
import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql';
import createRouter from './router';
Vue.use(VueApollo);
......
......@@ -11,6 +11,10 @@ fragment AlertDetailItem on AlertManagementAlert {
updatedAt
endedAt
hosts
environment {
name
path
}
details
runbook
todos {
......
......@@ -7,9 +7,11 @@ import {
convertToSentenceCase,
splitCamelCase,
} from '~/lib/utils/text_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
const allowedFields = [
'iid',
'title',
......@@ -22,17 +24,15 @@ const allowedFields = [
'description',
'endedAt',
'details',
'environment',
'hosts',
];
const isAllowed = fieldName => allowedFields.includes(fieldName);
export default {
components: {
GlLoadingIcon,
GlTable,
},
mixins: [glFeatureFlagsMixin()],
props: {
alert: {
type: Object,
......@@ -60,14 +60,23 @@ export default {
},
],
computed: {
flaggedAllowedFields() {
return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields;
},
items() {
if (!this.alert) {
return [];
}
return reduce(
this.alert,
(allowedItems, value, fieldName) => {
if (isAllowed(fieldName)) {
(allowedItems, fieldValue, fieldName) => {
if (this.isAllowed(fieldName)) {
let value;
if (fieldName === 'environment') {
value = fieldValue?.name;
} else {
value = fieldValue;
}
return [...allowedItems, { fieldName, value }];
}
return allowedItems;
......@@ -75,6 +84,14 @@ export default {
[],
);
},
shouldDisplayEnvironment() {
return this.glFeatures.exposeEnvironmentPathInAlertDetails;
},
},
methods: {
isAllowed(fieldName) {
return this.flaggedAllowedFields.includes(fieldName);
},
},
};
</script>
......
......@@ -10,5 +10,6 @@ class Projects::AlertManagementController < Projects::ApplicationController
def details
@alert_id = params[:id]
push_frontend_feature_flag(:expose_environment_path_in_alert_details, @project)
end
end
......@@ -68,6 +68,11 @@ module Types
null: true,
description: 'Timestamp the alert ended'
field :environment,
Types::EnvironmentType,
null: true,
description: 'Environment for the alert'
field :event_count,
GraphQL::INT_TYPE,
null: true,
......
......@@ -5,6 +5,8 @@ module Types
graphql_name 'Environment'
description 'Describes where code is deployed for a project'
present_using ::EnvironmentPresenter
authorize :read_environment
field :name, GraphQL::STRING_TYPE, null: false,
......@@ -16,6 +18,10 @@ module Types
field :state, GraphQL::STRING_TYPE, null: false,
description: 'State of the environment, for example: available/stopped'
field :path, GraphQL::STRING_TYPE, null: true,
description: 'The path to the environment. Will always return null ' \
'if `expose_environment_path_in_alert_details` feature flag is disabled'
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment',
resolver: Resolvers::Metrics::DashboardResolver
......@@ -23,6 +29,6 @@ module Types
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,
description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned'
end
end
......@@ -4,6 +4,7 @@ class Environment < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
include FastDestroyAll::Helpers
include Presentable
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
......
# frozen_string_literal: true
class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
presents :environment
def path
if Feature.enabled?(:expose_environment_path_in_alert_details, project)
project_environment_path(project, self)
end
end
end
---
name: expose_environment_path_in_alert_details
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43414
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258638
type: development
group: group::progressive delivery
default_enabled: false
......@@ -244,6 +244,11 @@ type AlertManagementAlert implements Noteable {
"""
endedAt: Time
"""
Environment for the alert
"""
environment: Environment
"""
Number of events of this alert
"""
......@@ -5773,7 +5778,7 @@ type Environment {
id: ID!
"""
The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.
The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned
"""
latestOpenedMostSevereAlert: AlertManagementAlert
......@@ -5792,6 +5797,12 @@ type Environment {
"""
name: String!
"""
The path to the environment. Will always return null if
`expose_environment_path_in_alert_details` feature flag is disabled
"""
path: String
"""
State of the environment, for example: available/stopped
"""
......
......@@ -666,6 +666,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environment",
"description": "Environment for the alert",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Environment",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "eventCount",
"description": "Number of events of this alert",
......@@ -15990,7 +16004,7 @@
},
{
"name": "latestOpenedMostSevereAlert",
"description": "The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.",
"description": "The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned",
"args": [
],
......@@ -16047,6 +16061,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "path",
"description": "The path to the environment. Will always return null if `expose_environment_path_in_alert_details` feature flag is disabled",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "state",
"description": "State of the environment, for example: available/stopped",
......@@ -77,6 +77,7 @@ Describes an alert from the project's Alert Management.
| `details` | JSON | Alert details |
| `detailsUrl` | String! | The URL of the alert detail page |
| `endedAt` | Time | Timestamp the alert ended |
| `environment` | Environment | Environment for the alert |
| `eventCount` | Int | Number of events of this alert |
| `hosts` | String! => Array | List of hosts the alert came from |
| `iid` | ID! | Internal ID of the alert |
......@@ -943,9 +944,10 @@ Describes where code is deployed for a project.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | ID! | ID of the environment |
| `latestOpenedMostSevereAlert` | AlertManagementAlert | The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned. |
| `latestOpenedMostSevereAlert` | AlertManagementAlert | The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned |
| `metricsDashboard` | MetricsDashboard | Metrics dashboard schema for the environment |
| `name` | String! | Human-readable name of the environment |
| `path` | String | The path to the environment. Will always return null if `expose_environment_path_in_alert_details` feature flag is disabled |
| `state` | String! | State of the environment, for example: available/stopped |
### Epic
......
......@@ -308,3 +308,49 @@ Viewing logs from a metrics panel can be useful if you're triaging an
application incident and need to [explore logs](../metrics/dashboards/index.md#chart-context-menu)
from across your application. These logs help you understand what's affecting
your application's performance and how to resolve any problems.
## View the environment that generated the alert
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/232492) in GitLab 13.5.
> - It's [deployed behind a feature flag](../../user/feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-environment-link-in-alert-details). **(CORE ONLY)**
CAUTION: **Warning:**
This feature might not be available to you. Check the **version history** note above for details.
The environment information and the link are displayed in the Alert Details tab[#alert-details-tab].
### Enable or disable Environment Link in Alert Details **(CORE ONLY)**
Viewing the environment is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../administration/feature_flags.md)
can enable it.
To enable it:
```ruby
Feature.enable(:expose_environment_path_in_alert_details)
```
To enable for just a particular project:
```ruby
project = Project.find_by_full_path('your-group/your-project')
Feature.enable(:expose_environment_path_in_alert_details, project)
```
To disable it:
```ruby
Feature.disable(:expose_environment_path_in_alert_details)
```
To disable for just a particular project:
```ruby
project = Project.find_by_full_path('your-group/your-project')
Feature.disable(:expose_environment_path_in_alert_details, project)
```
......@@ -42,7 +42,7 @@ RSpec.describe Projects::AlertManagementController do
let(:role) { :reporter }
it 'shows 404' do
get :index, params: { namespace_id: project.namespace, project_id: project }
get :details, params: { namespace_id: project.namespace, project_id: project, id: id }
expect(response).to have_gitlab_http_status(:not_found)
end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Alert management', :js do
let_it_be(:project) { create(:project) }
let_it_be(:developer) { create(:user) }
before_all do
project.add_developer(developer)
end
context 'when visiting the alert details page' do
let!(:alert) { create(:alert_management_alert, :resolved, :with_fingerprint, title: 'dos-test', project: project, **options) }
let(:options) { {} }
before do
sign_in(user)
end
context 'when actor has permission to see the alert' do
let(:user) { developer }
it 'shows the alert details' do
visit(details_project_alert_management_path(project, alert))
within('.alert-management-details-table') do
expect(page).to have_content(alert.title)
end
end
context 'when alert belongs to an environment' do
let(:options) { { environment: environment } }
let!(:environment) { create(:environment, name: 'production', project: project) }
it 'shows the environment name' do
visit(details_project_alert_management_path(project, alert))
expect(page).to have_link(environment.name, href: project_environment_path(project, environment))
within('.alert-management-details-table') do
expect(page).to have_content(environment.name)
end
end
context 'when expose_environment_path_in_alert_details feature flag is disabled' do
before do
stub_feature_flags(expose_environment_path_in_alert_details: false)
end
it 'does not show the environment name' do
visit(details_project_alert_management_path(project, alert))
within('.alert-management-details-table') do
expect(page).to have_content(alert.title)
expect(page).not_to have_content(environment.name)
end
end
end
end
end
end
end
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertDetails from '~/alert_management/components/alert_details.vue';
import AlertSummaryRow from '~/alert_management/components/alert_summary_row.vue';
import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import {
trackAlertsDetailsViewsOptions,
ALERTS_SEVERITY_LABELS,
trackAlertsDetailsViewsOptions,
} from '~/alert_management/constants';
import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql';
import { joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import mockAlerts from '../mocks/alerts.json';
const mockAlert = mockAlerts[0];
const environmentName = 'Production';
const environmentPath = '/fake/path';
describe('AlertDetails', () => {
let wrapper;
let environmentData = {
name: environmentName,
path: environmentPath,
};
let glFeatures = { exposeEnvironmentPathInAlertDetails: false };
let mock;
let wrapper;
const projectPath = 'root/alerts';
const projectIssuesPath = 'root/alerts/-/issues';
const projectId = '1';
......@@ -33,9 +40,17 @@ describe('AlertDetails', () => {
projectPath,
projectIssuesPath,
projectId,
glFeatures,
},
data() {
return { alert: { ...mockAlert }, sidebarStatus: false, ...data };
return {
alert: {
...mockAlert,
environment: environmentData,
},
sidebarStatus: false,
...data,
};
},
mocks: {
$apollo: {
......@@ -72,7 +87,8 @@ describe('AlertDetails', () => {
const findCreateIncidentBtn = () => wrapper.findByTestId('createIncidentBtn');
const findViewIncidentBtn = () => wrapper.findByTestId('viewIncidentBtn');
const findIncidentCreationAlert = () => wrapper.findByTestId('incidentCreationError');
const findEnvironmentLink = () => wrapper.findByTestId('environmentUrl');
const findEnvironmentName = () => wrapper.findByTestId('environmentName');
const findEnvironmentPath = () => wrapper.findByTestId('environmentPath');
const findDetailsTable = () => wrapper.find(AlertDetailsTable);
describe('Alert details', () => {
......@@ -120,8 +136,6 @@ describe('AlertDetails', () => {
field | data | isShown
${'eventCount'} | ${1} | ${true}
${'eventCount'} | ${undefined} | ${false}
${'environment'} | ${undefined} | ${false}
${'environment'} | ${'Production'} | ${true}
${'monitoringTool'} | ${'New Relic'} | ${true}
${'monitoringTool'} | ${undefined} | ${false}
${'service'} | ${'Prometheus'} | ${true}
......@@ -144,16 +158,34 @@ describe('AlertDetails', () => {
});
});
describe('environment URL fields', () => {
it('should show the environment URL when available', () => {
const environment = 'Production';
const environmentUrl = 'fake/url';
mountComponent({
data: { alert: { ...mockAlert, environment, environmentUrl } },
describe('environment fields', () => {
describe('when exposeEnvironmentPathInAlertDetails is disabled', () => {
beforeEach(mountComponent);
it('should not show the environment', () => {
expect(findEnvironmentName().exists()).toBe(false);
expect(findEnvironmentPath().exists()).toBe(false);
});
});
expect(findEnvironmentLink().text()).toBe(environment);
expect(findEnvironmentLink().attributes('href')).toBe(environmentUrl);
describe('when exposeEnvironmentPathInAlertDetails is enabled', () => {
beforeEach(() => {
glFeatures = { exposeEnvironmentPathInAlertDetails: true };
mountComponent();
});
it('should show the environment name with link to path', () => {
expect(findEnvironmentName().exists()).toBe(false);
expect(findEnvironmentPath().text()).toBe(environmentName);
expect(findEnvironmentPath().attributes('href')).toBe(environmentPath);
});
it('should only show the environment name if the path is not provided', () => {
environmentData = { name: environmentName, path: null };
mountComponent();
expect(findEnvironmentPath().exists()).toBe(false);
expect(findEnvironmentName().text()).toBe(environmentName);
});
});
});
......
......@@ -18,13 +18,24 @@ const mockAlert = {
__typename: 'AlertManagementAlert',
};
const environmentName = 'Production';
const environmentPath = '/fake/path';
describe('AlertDetails', () => {
let environmentData = { name: environmentName, path: environmentPath };
let glFeatures = { exposeEnvironmentPathInAlertDetails: false };
let wrapper;
function mountComponent(propsData = {}) {
wrapper = mount(AlertDetailsTable, {
provide: {
glFeatures,
},
propsData: {
alert: mockAlert,
alert: {
...mockAlert,
environment: environmentData,
},
loading: false,
...propsData,
},
......@@ -38,6 +49,12 @@ describe('AlertDetails', () => {
const findTableComponent = () => wrapper.find(GlTable);
const findTableKeys = () => findTableComponent().findAll('tbody td:first-child');
const findTableFieldValueByKey = fieldKey =>
findTableComponent()
.findAll('tbody tr')
.filter(row => row.text().includes(fieldKey))
.at(0)
.find('td:nth-child(2)');
const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName);
describe('Alert details', () => {
......@@ -62,11 +79,7 @@ describe('AlertDetails', () => {
});
describe('with table data', () => {
const environment = 'myEnvironment';
const environmentUrl = 'fake/url';
beforeEach(() => {
mountComponent({ alert: { ...mockAlert, environment, environmentUrl } });
});
beforeEach(mountComponent);
it('renders a table', () => {
expect(findTableComponent().exists()).toBe(true);
......@@ -83,18 +96,43 @@ describe('AlertDetails', () => {
expect(findTableField(fields, 'Title').exists()).toBe(true);
expect(findTableField(fields, 'Severity').exists()).toBe(true);
expect(findTableField(fields, 'Status').exists()).toBe(true);
expect(findTableField(fields, 'Environment').exists()).toBe(true);
expect(findTableField(fields, 'Hosts').exists()).toBe(true);
expect(findTableField(fields, 'Environment').exists()).toBe(false);
});
it('should not show disallowed alert fields', () => {
it('should not show disallowed and flaggedAllowed alert fields', () => {
const fields = findTableKeys();
expect(findTableField(fields, 'Typename').exists()).toBe(false);
expect(findTableField(fields, 'Todos').exists()).toBe(false);
expect(findTableField(fields, 'Notes').exists()).toBe(false);
expect(findTableField(fields, 'Assignees').exists()).toBe(false);
expect(findTableField(fields, 'EnvironmentUrl').exists()).toBe(false);
expect(findTableField(fields, 'Environment').exists()).toBe(false);
});
});
describe('when exposeEnvironmentPathInAlertDetails is enabled', () => {
beforeEach(() => {
glFeatures = { exposeEnvironmentPathInAlertDetails: true };
mountComponent();
});
it('should show flaggedAllowed alert fields', () => {
const fields = findTableKeys();
expect(findTableField(fields, 'Environment').exists()).toBe(true);
});
it('should display only the name for the environment', () => {
expect(findTableFieldValueByKey('Iid').text()).toBe('1527542');
expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName);
});
it('should not display the environment row if there is not data', () => {
environmentData = { name: null, path: null };
mountComponent();
expect(findTableFieldValueByKey('Environment').text()).toBeFalsy();
});
});
});
......
......@@ -32,6 +32,7 @@ RSpec.describe GitlabSchema.types['AlertManagementAlert'] do
todos
details_url
prometheus_alert
environment
]
expect(described_class).to have_graphql_fields(*expected_fields)
......
......@@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['Environment'] do
it 'has the expected fields' do
expected_fields = %w[
name id state metrics_dashboard latest_opened_most_severe_alert
name id state metrics_dashboard latest_opened_most_severe_alert path
]
expect(described_class).to have_graphql_fields(*expected_fields)
......@@ -28,6 +28,7 @@ RSpec.describe GitlabSchema.types['Environment'] do
project(fullPath: "#{project.full_path}") {
environment(name: "#{environment.name}") {
name
path
state
}
}
......@@ -43,6 +44,18 @@ RSpec.describe GitlabSchema.types['Environment'] do
expect(subject['data']['project']['environment']['name']).to eq(environment.name)
end
it 'returns the path when the feature is enabled' do
expect(subject['data']['project']['environment']['path']).to eq(
Gitlab::Routing.url_helpers.project_environment_path(project, environment)
)
end
it 'does not return the path when the feature is disabled' do
stub_feature_flags(expose_environment_path_in_alert_details: false)
expect(subject['data']['project']['environment']['path']).to be_nil
end
context 'when query alert data for the environment' do
let_it_be(:query) do
%(
......
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