Commit 3467ff1b authored by Sean Arnold's avatar Sean Arnold

Add metrics image UI for Alerts

Changelog: added
parent fc05549a
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';
......
......@@ -10,23 +10,27 @@ import {
GlTab,
GlButton,
GlSafeHtmlDirective,
GlFormGroup,
GlFormInput,
GlModal,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import * as Sentry from '@sentry/browser';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { fetchPolicies } from '~/lib/graphql';
import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale';
import { __, s__ } from '~/locale';
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';
......@@ -40,6 +44,15 @@ export default {
),
reportedAt: s__('AlertManagement|Reported %{when}'),
reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
modalUpload: __('Upload'),
modalCancel: __('Cancel'),
modalTitle: s__('Incidents|Add image details'),
modalDescription: s__(
"Incidents|Add text or a link to display with your image. If you don't add either, the file name displays instead.",
),
dropDescription: s__(
'Incidents|Drop or %{linkStart}upload%{linkEnd} a metric screenshot to attach it to the alert',
),
},
directives: {
SafeHtml: GlSafeHtmlDirective,
......@@ -70,11 +83,14 @@ export default {
GlSprintf,
GlTab,
GlTabs,
GlFormGroup,
GlFormInput,
GlButton,
TimeAgoTooltip,
AlertSidebar,
SystemNote,
AlertMetrics,
GlModal,
MetricImagesTab,
},
inject: {
projectPath: {
......@@ -98,6 +114,9 @@ export default {
trackAlertsDetailsViewsOptions: {
default: null,
},
canUpdate: {
default: false,
},
},
apollo: {
alert: {
......@@ -130,9 +149,25 @@ export default {
createIncidentError: '',
incidentCreationInProgress: false,
sidebarErrorMessage: '',
currentFiles: [],
modalVisible: false,
modalUrl: '',
modalUrlText: '',
};
},
computed: {
...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']),
actionPrimaryProps() {
return {
text: this.$options.i18n.modalUpload,
attributes: {
loading: this.isUploadingImage,
disabled: this.isUploadingImage,
category: 'primary',
variant: 'confirm',
},
};
},
loading() {
return this.$apollo.queries.alert.loading;
},
......@@ -179,6 +214,30 @@ export default {
});
},
methods: {
clearInputs() {
this.modalVisible = false;
this.modalUrl = '';
this.modalUrlText = '';
this.currentFile = false;
},
openMetricDialog(files) {
this.modalVisible = true;
this.currentFiles = files;
},
async onUpload() {
try {
await this.uploadImage({
files: this.currentFiles,
url: this.modalUrl,
urlText: this.modalUrlText,
});
// Error case handled within action
} catch (error) {
throw Error(error);
} finally {
this.clearInputs();
}
},
dismissError() {
this.isErrorDismissed = true;
this.sidebarErrorMessage = '';
......@@ -372,13 +431,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"
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.js';
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,7 +58,9 @@ export default (selector) => {
page,
projectIssuesPath,
projectId,
iid,
statuses: PAGE_CONFIG[page].STATUSES,
canUpdate: parseBoolean(canUpdate),
};
if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
......@@ -67,6 +73,8 @@ export default (selector) => {
provide.isThreatMonitoringPage = true;
}
const store = createStore({}, service);
// eslint-disable-next-line no-new
new Vue({
el: selector,
......@@ -74,6 +82,7 @@ export default (selector) => {
components: {
AlertDetails,
},
store,
provide,
apolloProvider,
router,
......
import {
fetchAlertMetricImages,
uploadAlertMetricImage,
updateAlertMetricImage,
deleteAlertMetricImage,
} from '~/rest_api';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export const getMetricImages = async (payload) => {
payload = replaceModelIId(payload);
const response = await fetchAlertMetricImages(payload);
return convertObjectPropsToCamelCase(response.data, { deep: true });
};
export const uploadMetricImage = async (payload) => {
payload = replaceModelIId(payload);
const response = await uploadAlertMetricImage(payload);
return convertObjectPropsToCamelCase(response.data);
};
export const updateMetricImage = async (payload) => {
payload = replaceModelIId(payload);
const response = await updateAlertMetricImage(payload);
return convertObjectPropsToCamelCase(response.data);
};
export const deleteMetricImage = async (payload) => {
payload = replaceModelIId(payload);
const response = await deleteAlertMetricImage(payload);
return convertObjectPropsToCamelCase(response.data);
};
function replaceModelIId(payload) {
delete Object.assign(payload, { alertIid: payload.modelIid }).modelIid;
return payload;
}
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) }
......@@ -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