Commit 46b38736 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '340852-alert-metrics-images-ui' into 'master'

Add metrics image UI for Alerts

See merge request gitlab-org/gitlab!84129
parents f0033b79 417dbf8e
import axios from '~/lib/utils/axios_utils';
import { buildApiUrl } from '~/api/api_utils';
import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
const ALERT_METRIC_IMAGES_PATH =
'/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images';
const ALERT_SINGLE_METRIC_IMAGE_PATH =
'/api/:version/projects/:id/alert_management_alerts/:alert_iid/metric_images/:image_id';
export function fetchAlertMetricImages({ alertIid, id }) {
const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH)
.replace(':id', encodeURIComponent(id))
.replace(':alert_iid', encodeURIComponent(alertIid));
return axios.get(metricImagesUrl);
}
export function uploadAlertMetricImage({ alertIid, id, file, url = null, urlText = null }) {
const options = { headers: { ...ContentTypeMultipartFormData } };
const metricImagesUrl = buildApiUrl(ALERT_METRIC_IMAGES_PATH)
.replace(':id', encodeURIComponent(id))
.replace(':alert_iid', encodeURIComponent(alertIid));
// Construct multipart form data
const formData = new FormData();
formData.append('file', file);
if (url) {
formData.append('url', url);
}
if (urlText) {
formData.append('url_text', urlText);
}
return axios.post(metricImagesUrl, formData, options);
}
export function updateAlertMetricImage({ alertIid, id, imageId, url = null, urlText = null }) {
const metricImagesUrl = buildApiUrl(ALERT_SINGLE_METRIC_IMAGE_PATH)
.replace(':id', encodeURIComponent(id))
.replace(':alert_iid', encodeURIComponent(alertIid))
.replace(':image_id', encodeURIComponent(imageId));
// Construct multipart form data
const formData = new FormData();
if (url != null) {
formData.append('url', url);
}
if (urlText != null) {
formData.append('url_text', urlText);
}
return axios.put(metricImagesUrl, formData);
}
export function deleteAlertMetricImage({ alertIid, id, imageId }) {
const individualMetricImageUrl = buildApiUrl(ALERT_SINGLE_METRIC_IMAGE_PATH)
.replace(':id', encodeURIComponent(id))
.replace(':alert_iid', encodeURIComponent(alertIid))
.replace(':image_id', encodeURIComponent(imageId));
return axios.delete(individualMetricImageUrl);
}
......@@ -2,6 +2,8 @@ import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
export * from './alert_management_alerts_api';
const PROJECTS_PATH = '/api/:version/projects.json';
const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
......
......@@ -21,12 +21,12 @@ import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
import { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql';
import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql';
import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql';
import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql';
import AlertMetrics from './alert_metrics.vue';
import AlertSidebar from './alert_sidebar.vue';
import AlertSummaryRow from './alert_summary_row.vue';
import SystemNote from './system_notes/system_note.vue';
......@@ -74,7 +74,7 @@ export default {
TimeAgoTooltip,
AlertSidebar,
SystemNote,
AlertMetrics,
MetricImagesTab,
},
inject: {
projectPath: {
......@@ -372,13 +372,12 @@ export default {
</alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" :statuses="statuses" />
</gl-tab>
<gl-tab
<metric-images-tab
v-if="!isThreatMonitoringPage"
:data-testid="$options.tabsConfig[1].id"
:title="$options.tabsConfig[1].title"
>
<alert-metrics :dashboard-url="alert.metricsDashboardUrl" />
</gl-tab>
/>
<gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title">
<div v-if="alert.notes.nodes.length > 0" class="issuable-discussion">
<ul class="notes main-notes-list timeline">
......
......@@ -3,6 +3,9 @@ import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import createStore from '~/vue_shared/components/metric_images/store';
import service from './service';
import AlertDetails from './components/alert_details.vue';
import { PAGE_CONFIG } from './constants';
import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql';
......@@ -12,7 +15,8 @@ Vue.use(VueApollo);
export default (selector) => {
const domEl = document.querySelector(selector);
const { alertId, projectPath, projectIssuesPath, projectId, page } = domEl.dataset;
const { alertId, projectPath, projectIssuesPath, projectId, page, canUpdate } = domEl.dataset;
const iid = alertId;
const router = createRouter();
const resolvers = {
......@@ -54,15 +58,20 @@ export default (selector) => {
page,
projectIssuesPath,
projectId,
iid,
statuses: PAGE_CONFIG[page].STATUSES,
canUpdate: parseBoolean(canUpdate),
};
const opsProperties = {};
if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
const { TRACK_ALERTS_DETAILS_VIEWS_OPTIONS, TRACK_ALERT_STATUS_UPDATE_OPTIONS } = PAGE_CONFIG[
page
];
provide.trackAlertsDetailsViewsOptions = TRACK_ALERTS_DETAILS_VIEWS_OPTIONS;
provide.trackAlertStatusUpdateOptions = TRACK_ALERT_STATUS_UPDATE_OPTIONS;
opsProperties.store = createStore({}, service);
} else if (page === PAGE_CONFIG.THREAT_MONITORING.TITLE) {
provide.isThreatMonitoringPage = true;
}
......@@ -74,6 +83,7 @@ export default (selector) => {
components: {
AlertDetails,
},
...opsProperties,
provide,
apolloProvider,
router,
......
import {
fetchAlertMetricImages,
uploadAlertMetricImage,
updateAlertMetricImage,
deleteAlertMetricImage,
} from '~/rest_api';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
function replaceModelIId(payload = {}) {
delete Object.assign(payload, { alertIid: payload.modelIid }).modelIid;
return payload;
}
export const getMetricImages = async (payload) => {
const apiPayload = replaceModelIId(payload);
const response = await fetchAlertMetricImages(apiPayload);
return convertObjectPropsToCamelCase(response.data, { deep: true });
};
export const uploadMetricImage = async (payload) => {
const apiPayload = replaceModelIId(payload);
const response = await uploadAlertMetricImage(apiPayload);
return convertObjectPropsToCamelCase(response.data);
};
export const updateMetricImage = async (payload) => {
const apiPayload = replaceModelIId(payload);
const response = await updateAlertMetricImage(apiPayload);
return convertObjectPropsToCamelCase(response.data);
};
export const deleteMetricImage = async (payload) => {
const apiPayload = replaceModelIId(payload);
const response = await deleteAlertMetricImage(apiPayload);
return convertObjectPropsToCamelCase(response.data);
};
export default {
getMetricImages,
uploadMetricImage,
updateMetricImage,
deleteMetricImage,
};
......@@ -15,13 +15,14 @@ module Projects::AlertManagementHelper
}
end
def alert_management_detail_data(project, alert_id)
def alert_management_detail_data(current_user, project, alert_id)
{
'alert-id' => alert_id,
'project-path' => project.full_path,
'project-id' => project.id,
'project-issues-path' => project_issues_path(project),
'page' => 'OPERATIONS'
'page' => 'OPERATIONS',
'can-update' => can?(current_user, :update_alert_management_alert, project).to_s
}
end
......
......@@ -2,4 +2,4 @@
- page_title s_('AlertManagement|Alert detail')
- add_page_specific_style 'page_bundles/alert_management_details'
#js-alert_details{ data: alert_management_detail_data(@project, @alert_id) }
#js-alert_details{ data: alert_management_detail_data(current_user, @project, @alert_id) }
......@@ -86,26 +86,22 @@ The **Alert details** tab has two sections. The top section provides a short lis
### Metrics tab
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217768) in GitLab 13.2.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/217768) in GitLab 13.2.
> - [Changed](https://gitlab.com/gitlab-org/gitlab/-/issues/340852) in GitLab 14.10. In GitLab 14.9 and earlier, this tab shows a metrics chart for alerts coming from Prometheus.
The **Metrics** tab displays a metrics chart for alerts coming from Prometheus. If the alert originated from any other tool, the **Metrics** tab is empty.
For externally-managed Prometheus instances, you must configure your alerting rules to display a chart in the alert. For information about how to configure
your alerting rules, see [Embedding metrics based on alerts in incident issues](../metrics/embed.md#embedding-metrics-based-on-alerts-in-incident-issues). See
[External Prometheus instances](../metrics/alerts.md#external-prometheus-instances) for information about setting up alerts for your self-managed Prometheus
instance.
In many cases, alerts are associated to metrics. You can upload screenshots of metric
charts in the **Metrics** tab.
Prerequisite:
To do so, either:
- You must have at least the Developer role.
- Select **upload** and then select an image from your file browser.
- Drag a file from your file browser and drop it in the drop zone.
To view the metrics for an alert:
When you upload an image, you can add text to the image and link it to the original graph.
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Monitor > Alerts**.
1. Select the alert you want to view.
1. Below the title of the alert, select the **Metrics** tab.
![Text link modal](img/incident_metrics_tab_text_link_modal_v14_9.png)
![Alert Metrics View](img/alert_detail_metrics_v13_2.png)
If you add a link, it is shown above the uploaded image.
#### View an alert's logs
......
import MockAdapter from 'axios-mock-adapter';
import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api';
import axios from '~/lib/utils/axios_utils';
describe('~/api/alert_management_alerts_api.js', () => {
let mock;
let originalGon;
const projectId = 1;
const alertIid = 2;
const imageData = { filePath: 'test', filename: 'hello', id: 5, url: null };
beforeEach(() => {
mock = new MockAdapter(axios);
originalGon = window.gon;
window.gon = { api_version: 'v4' };
});
afterEach(() => {
mock.restore();
window.gon = originalGon;
});
describe('fetchAlertMetricImages', () => {
beforeEach(() => {
jest.spyOn(axios, 'get');
});
it('retrieves metric images from the correct URL and returns them in the response data', () => {
const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`;
const expectedData = [imageData];
const options = { alertIid, id: projectId };
mock.onGet(expectedUrl).reply(200, { data: expectedData });
return alertManagementAlertsApi.fetchAlertMetricImages(options).then(({ data }) => {
expect(axios.get).toHaveBeenCalledWith(expectedUrl);
expect(data.data).toEqual(expectedData);
});
});
});
describe('uploadAlertMetricImage', () => {
beforeEach(() => {
jest.spyOn(axios, 'post');
});
it('uploads a metric image to the correct URL and returns it in the response data', () => {
const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images`;
const expectedData = [imageData];
const file = new File(['zip contents'], 'hello');
const url = 'https://www.example.com';
const urlText = 'Example website';
const expectedFormData = new FormData();
expectedFormData.append('file', file);
expectedFormData.append('url', url);
expectedFormData.append('url_text', urlText);
mock.onPost(expectedUrl).reply(201, { data: expectedData });
return alertManagementAlertsApi
.uploadAlertMetricImage({
alertIid,
id: projectId,
file,
url,
urlText,
})
.then(({ data }) => {
expect(data).toEqual({ data: expectedData });
expect(axios.post).toHaveBeenCalledWith(expectedUrl, expectedFormData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
});
});
});
describe('updateAlertMetricImage', () => {
beforeEach(() => {
jest.spyOn(axios, 'put');
});
it('updates a metric image to the correct URL and returns it in the response data', () => {
const imageIid = 3;
const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`;
const expectedData = [imageData];
const url = 'https://www.example.com';
const urlText = 'Example website';
const expectedFormData = new FormData();
expectedFormData.append('url', url);
expectedFormData.append('url_text', urlText);
mock.onPut(expectedUrl).reply(200, { data: expectedData });
return alertManagementAlertsApi
.updateAlertMetricImage({
alertIid,
id: projectId,
imageId: imageIid,
url,
urlText,
})
.then(({ data }) => {
expect(data).toEqual({ data: expectedData });
expect(axios.put).toHaveBeenCalledWith(expectedUrl, expectedFormData);
});
});
});
describe('deleteAlertMetricImage', () => {
beforeEach(() => {
jest.spyOn(axios, 'delete');
});
it('deletes a metric image to the correct URL and returns it in the response data', () => {
const imageIid = 3;
const expectedUrl = `/api/v4/projects/${projectId}/alert_management_alerts/${alertIid}/metric_images/${imageIid}`;
const expectedData = [imageData];
mock.onDelete(expectedUrl).reply(204, { data: expectedData });
return alertManagementAlertsApi
.deleteAlertMetricImage({
alertIid,
id: projectId,
imageId: imageIid,
})
.then(({ data }) => {
expect(data).toEqual({ data: expectedData });
expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
});
});
});
});
......@@ -12,12 +12,17 @@ import AlertSummaryRow from '~/vue_shared/alert_details/components/alert_summary
import { PAGE_CONFIG, SEVERITY_LEVELS } from '~/vue_shared/alert_details/constants';
import createIssueMutation from '~/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import MetricImagesTab from '~/vue_shared/components/metric_images/metric_images_tab.vue';
import createStore from '~/vue_shared/components/metric_images/store/';
import service from '~/vue_shared/alert_details/service';
import mockAlerts from './mocks/alerts.json';
const mockAlert = mockAlerts[0];
const environmentName = 'Production';
const environmentPath = '/fake/path';
jest.mock('~/vue_shared/alert_details/service');
describe('AlertDetails', () => {
let environmentData = { name: environmentName, path: environmentPath };
let mock;
......@@ -67,9 +72,11 @@ describe('AlertDetails', () => {
$route: { params: {} },
},
stubs: {
...stubs,
AlertSummaryRow,
'metric-images-tab': true,
...stubs,
},
store: createStore({}, service),
}),
);
}
......@@ -91,7 +98,7 @@ describe('AlertDetails', () => {
const findEnvironmentName = () => wrapper.findByTestId('environmentName');
const findEnvironmentPath = () => wrapper.findByTestId('environmentPath');
const findDetailsTable = () => wrapper.findComponent(AlertDetailsTable);
const findMetricsTab = () => wrapper.findByTestId('metrics');
const findMetricsTab = () => wrapper.findComponent(MetricImagesTab);
describe('Alert details', () => {
describe('when alert is null', () => {
......@@ -129,8 +136,21 @@ describe('AlertDetails', () => {
expect(wrapper.findByTestId('startTimeItem').exists()).toBe(true);
expect(wrapper.findByTestId('startTimeItem').props('time')).toBe(mockAlert.startedAt);
});
});
describe('Metrics tab', () => {
it('should mount without errors', () => {
mountComponent({
mountMethod: mount,
provide: {
canUpdate: true,
iid: '1',
},
stubs: {
MetricImagesTab,
},
});
it('renders the metrics tab', () => {
expect(findMetricsTab().exists()).toBe(true);
});
});
......@@ -312,7 +332,9 @@ describe('AlertDetails', () => {
describe('header', () => {
const findHeader = () => wrapper.findByTestId('alert-header');
const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } };
const stubs = {
TimeAgoTooltip: { template: '<span>now</span>' },
};
describe('individual header fields', () => {
describe.each`
......
import { fileList, fileListRaw } from 'jest/vue_shared/components/metric_images/mock_data';
import {
getMetricImages,
uploadMetricImage,
updateMetricImage,
deleteMetricImage,
} from '~/vue_shared/alert_details/service';
import * as alertManagementAlertsApi from '~/api/alert_management_alerts_api';
jest.mock('~/api/alert_management_alerts_api');
describe('Alert details service', () => {
it('fetches metric images', async () => {
alertManagementAlertsApi.fetchAlertMetricImages.mockResolvedValue({ data: fileListRaw });
const result = await getMetricImages();
expect(alertManagementAlertsApi.fetchAlertMetricImages).toHaveBeenCalled();
expect(result).toEqual(fileList);
});
it('uploads a metric image', async () => {
alertManagementAlertsApi.uploadAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] });
const result = await uploadMetricImage();
expect(alertManagementAlertsApi.uploadAlertMetricImage).toHaveBeenCalled();
expect(result).toEqual(fileList[0]);
});
it('updates a metric image', async () => {
alertManagementAlertsApi.updateAlertMetricImage.mockResolvedValue({ data: fileListRaw[0] });
const result = await updateMetricImage();
expect(alertManagementAlertsApi.updateAlertMetricImage).toHaveBeenCalled();
expect(result).toEqual(fileList[0]);
});
it('deletes a metric image', async () => {
alertManagementAlertsApi.deleteAlertMetricImage.mockResolvedValue({ data: '' });
const result = await deleteMetricImage();
expect(alertManagementAlertsApi.deleteAlertMetricImage).toHaveBeenCalled();
expect(result).toEqual({});
});
});
......@@ -110,15 +110,34 @@ RSpec.describe Projects::AlertManagementHelper do
describe '#alert_management_detail_data' do
let(:alert_id) { 1 }
let(:issues_path) { project_issues_path(project) }
let(:can_update_alert) { true }
before do
allow(helper)
.to receive(:can?)
.with(current_user, :update_alert_management_alert, project)
.and_return(can_update_alert)
end
it 'returns detail page configuration' do
expect(helper.alert_management_detail_data(project, alert_id)).to eq(
expect(helper.alert_management_detail_data(current_user, project, alert_id)).to eq(
'alert-id' => alert_id,
'project-path' => project_path,
'project-id' => project_id,
'project-issues-path' => issues_path,
'page' => 'OPERATIONS'
'page' => 'OPERATIONS',
'can-update' => 'true'
)
end
context 'when user cannot update alert' do
let(:can_update_alert) { false }
it 'shows error tracking enablement as disabled' do
expect(helper.alert_management_detail_data(current_user, project, alert_id)).to include(
'can-update' => 'false'
)
end
end
end
end
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