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'; ...@@ -2,6 +2,8 @@ import { DEFAULT_PER_PAGE } from '~/api';
import axios from '../lib/utils/axios_utils'; import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils'; import { buildApiUrl } from './api_utils';
export * from './alert_management_alerts_api';
const PROJECTS_PATH = '/api/:version/projects.json'; const PROJECTS_PATH = '/api/:version/projects.json';
const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id'; const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
......
...@@ -10,23 +10,27 @@ import { ...@@ -10,23 +10,27 @@ import {
GlTab, GlTab,
GlButton, GlButton,
GlSafeHtmlDirective, GlSafeHtmlDirective,
GlFormGroup,
GlFormInput,
GlModal,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { toggleContainerClasses } from '~/lib/utils/dom_utils'; import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { __, s__ } from '~/locale';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.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 { PAGE_CONFIG, SEVERITY_LEVELS } from '../constants';
import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql'; import createIssueMutation from '../graphql/mutations/alert_issue_create.mutation.graphql';
import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql'; import toggleSidebarStatusMutation from '../graphql/mutations/alert_sidebar_status.mutation.graphql';
import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql'; import alertQuery from '../graphql/queries/alert_sidebar_details.query.graphql';
import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.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 AlertSidebar from './alert_sidebar.vue';
import AlertSummaryRow from './alert_summary_row.vue'; import AlertSummaryRow from './alert_summary_row.vue';
import SystemNote from './system_notes/system_note.vue'; import SystemNote from './system_notes/system_note.vue';
...@@ -40,6 +44,15 @@ export default { ...@@ -40,6 +44,15 @@ export default {
), ),
reportedAt: s__('AlertManagement|Reported %{when}'), reportedAt: s__('AlertManagement|Reported %{when}'),
reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), 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: { directives: {
SafeHtml: GlSafeHtmlDirective, SafeHtml: GlSafeHtmlDirective,
...@@ -70,11 +83,14 @@ export default { ...@@ -70,11 +83,14 @@ export default {
GlSprintf, GlSprintf,
GlTab, GlTab,
GlTabs, GlTabs,
GlFormGroup,
GlFormInput,
GlButton, GlButton,
TimeAgoTooltip, TimeAgoTooltip,
AlertSidebar, AlertSidebar,
SystemNote, SystemNote,
AlertMetrics, GlModal,
MetricImagesTab,
}, },
inject: { inject: {
projectPath: { projectPath: {
...@@ -98,6 +114,9 @@ export default { ...@@ -98,6 +114,9 @@ export default {
trackAlertsDetailsViewsOptions: { trackAlertsDetailsViewsOptions: {
default: null, default: null,
}, },
canUpdate: {
default: false,
},
}, },
apollo: { apollo: {
alert: { alert: {
...@@ -130,9 +149,25 @@ export default { ...@@ -130,9 +149,25 @@ export default {
createIncidentError: '', createIncidentError: '',
incidentCreationInProgress: false, incidentCreationInProgress: false,
sidebarErrorMessage: '', sidebarErrorMessage: '',
currentFiles: [],
modalVisible: false,
modalUrl: '',
modalUrlText: '',
}; };
}, },
computed: { computed: {
...mapState(['metricImages', 'isLoadingMetricImages', 'isUploadingImage']),
actionPrimaryProps() {
return {
text: this.$options.i18n.modalUpload,
attributes: {
loading: this.isUploadingImage,
disabled: this.isUploadingImage,
category: 'primary',
variant: 'confirm',
},
};
},
loading() { loading() {
return this.$apollo.queries.alert.loading; return this.$apollo.queries.alert.loading;
}, },
...@@ -179,6 +214,30 @@ export default { ...@@ -179,6 +214,30 @@ export default {
}); });
}, },
methods: { 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() { dismissError() {
this.isErrorDismissed = true; this.isErrorDismissed = true;
this.sidebarErrorMessage = ''; this.sidebarErrorMessage = '';
...@@ -372,13 +431,12 @@ export default { ...@@ -372,13 +431,12 @@ export default {
</alert-summary-row> </alert-summary-row>
<alert-details-table :alert="alert" :loading="loading" :statuses="statuses" /> <alert-details-table :alert="alert" :loading="loading" :statuses="statuses" />
</gl-tab> </gl-tab>
<gl-tab
<metric-images-tab
v-if="!isThreatMonitoringPage" v-if="!isThreatMonitoringPage"
:data-testid="$options.tabsConfig[1].id" data-testid="$options.tabsConfig[1].id"
:title="$options.tabsConfig[1].title" :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"> <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title">
<div v-if="alert.notes.nodes.length > 0" class="issuable-discussion"> <div v-if="alert.notes.nodes.length > 0" class="issuable-discussion">
<ul class="notes main-notes-list timeline"> <ul class="notes main-notes-list timeline">
......
...@@ -3,6 +3,9 @@ import produce from 'immer'; ...@@ -3,6 +3,9 @@ import produce from 'immer';
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; 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 AlertDetails from './components/alert_details.vue';
import { PAGE_CONFIG } from './constants'; import { PAGE_CONFIG } from './constants';
import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql'; import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql';
...@@ -12,7 +15,8 @@ Vue.use(VueApollo); ...@@ -12,7 +15,8 @@ Vue.use(VueApollo);
export default (selector) => { export default (selector) => {
const domEl = document.querySelector(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 router = createRouter();
const resolvers = { const resolvers = {
...@@ -54,7 +58,9 @@ export default (selector) => { ...@@ -54,7 +58,9 @@ export default (selector) => {
page, page,
projectIssuesPath, projectIssuesPath,
projectId, projectId,
iid,
statuses: PAGE_CONFIG[page].STATUSES, statuses: PAGE_CONFIG[page].STATUSES,
canUpdate: parseBoolean(canUpdate),
}; };
if (page === PAGE_CONFIG.OPERATIONS.TITLE) { if (page === PAGE_CONFIG.OPERATIONS.TITLE) {
...@@ -67,6 +73,8 @@ export default (selector) => { ...@@ -67,6 +73,8 @@ export default (selector) => {
provide.isThreatMonitoringPage = true; provide.isThreatMonitoringPage = true;
} }
const store = createStore({}, service);
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el: selector, el: selector,
...@@ -74,6 +82,7 @@ export default (selector) => { ...@@ -74,6 +82,7 @@ export default (selector) => {
components: { components: {
AlertDetails, AlertDetails,
}, },
store,
provide, provide,
apolloProvider, apolloProvider,
router, 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 ...@@ -15,13 +15,14 @@ module Projects::AlertManagementHelper
} }
end end
def alert_management_detail_data(project, alert_id) def alert_management_detail_data(current_user, project, alert_id)
{ {
'alert-id' => alert_id, 'alert-id' => alert_id,
'project-path' => project.full_path, 'project-path' => project.full_path,
'project-id' => project.id, 'project-id' => project.id,
'project-issues-path' => project_issues_path(project), 'project-issues-path' => project_issues_path(project),
'page' => 'OPERATIONS' 'page' => 'OPERATIONS',
'can-update' => can?(current_user, :update_alert_management_alert, project).to_s
} }
end end
......
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
- page_title s_('AlertManagement|Alert detail') - page_title s_('AlertManagement|Alert detail')
- add_page_specific_style 'page_bundles/alert_management_details' - 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 ...@@ -110,15 +110,34 @@ RSpec.describe Projects::AlertManagementHelper do
describe '#alert_management_detail_data' do describe '#alert_management_detail_data' do
let(:alert_id) { 1 } let(:alert_id) { 1 }
let(:issues_path) { project_issues_path(project) } 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 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, 'alert-id' => alert_id,
'project-path' => project_path, 'project-path' => project_path,
'project-id' => project_id, 'project-id' => project_id,
'project-issues-path' => issues_path, 'project-issues-path' => issues_path,
'page' => 'OPERATIONS' 'page' => 'OPERATIONS',
'can-update' => 'true'
) )
end 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
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