Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/vue_shared/alert_details')
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue392
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue56
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue86
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue125
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue18
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue38
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue299
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue41
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue120
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue149
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue48
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/constants.js31
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql30
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql8
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql25
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_sidebar_status.mutation.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql10
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql11
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_status.query.graphql3
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/index.js83
-rw-r--r--app/assets/javascripts/vue_shared/alert_details/router.js13
21 files changed, 1589 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
new file mode 100644
index 00000000000..bcea7ca654e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue
@@ -0,0 +1,392 @@
+<script>
+import {
+ GlAlert,
+ GlBadge,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTabs,
+ GlTab,
+ GlButton,
+ GlSafeHtmlDirective,
+} from '@gitlab/ui';
+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 * as Sentry from '~/sentry/wrapper';
+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 { 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_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';
+
+const containerEl = document.querySelector('.page-with-contextual-sidebar');
+
+export default {
+ i18n: {
+ errorMsg: s__(
+ 'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.',
+ ),
+ reportedAt: s__('AlertManagement|Reported %{when}'),
+ reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'),
+ },
+ directives: {
+ SafeHtml: GlSafeHtmlDirective,
+ },
+ severityLabels: SEVERITY_LEVELS,
+ tabsConfig: [
+ {
+ id: 'overview',
+ title: s__('AlertManagement|Alert details'),
+ },
+ {
+ id: 'metrics',
+ title: s__('AlertManagement|Metrics'),
+ },
+ {
+ id: 'activity',
+ title: s__('AlertManagement|Activity feed'),
+ },
+ ],
+ components: {
+ AlertDetailsTable,
+ AlertSummaryRow,
+ GlBadge,
+ GlAlert,
+ GlIcon,
+ GlLink,
+ GlLoadingIcon,
+ GlSprintf,
+ GlTab,
+ GlTabs,
+ GlButton,
+ TimeAgoTooltip,
+ AlertSidebar,
+ SystemNote,
+ AlertMetrics,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ alertId: {
+ default: '',
+ },
+ isThreatMonitoringPage: {
+ default: false,
+ },
+ projectId: {
+ default: '',
+ },
+ projectIssuesPath: {
+ default: '',
+ },
+ trackAlertsDetailsViewsOptions: {
+ default: null,
+ },
+ },
+ apollo: {
+ alert: {
+ fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
+ query: alertQuery,
+ variables() {
+ return {
+ fullPath: this.projectPath,
+ alertId: this.alertId,
+ };
+ },
+ update(data) {
+ return data?.project?.alertManagementAlerts?.nodes?.[0] ?? null;
+ },
+ error(error) {
+ this.errored = true;
+ Sentry.captureException(error);
+ },
+ },
+ sidebarStatus: {
+ query: sidebarStatusQuery,
+ },
+ },
+ data() {
+ return {
+ alert: null,
+ errored: false,
+ sidebarStatus: false,
+ isErrorDismissed: false,
+ createIncidentError: '',
+ incidentCreationInProgress: false,
+ sidebarErrorMessage: '',
+ };
+ },
+ computed: {
+ loading() {
+ return this.$apollo.queries.alert.loading;
+ },
+ reportedAtMessage() {
+ return this.alert?.monitoringTool
+ ? this.$options.i18n.reportedAtWithTool
+ : this.$options.i18n.reportedAt;
+ },
+ showErrorMsg() {
+ return this.errored && !this.isErrorDismissed;
+ },
+ activeTab() {
+ return this.$route.params.tabId || this.$options.tabsConfig[0].id;
+ },
+ currentTabIndex: {
+ get() {
+ return this.$options.tabsConfig.findIndex((tab) => tab.id === this.activeTab);
+ },
+ set(tabIdx) {
+ const tabId = this.$options.tabsConfig[tabIdx].id;
+ this.$router.replace({ name: 'tab', params: { tabId } });
+ },
+ },
+ environmentName() {
+ return this.alert?.environment?.name;
+ },
+ environmentPath() {
+ return this.alert?.environment?.path;
+ },
+ },
+ mounted() {
+ if (this.trackAlertsDetailsViewsOptions) {
+ this.trackPageViews();
+ }
+ toggleContainerClasses(containerEl, {
+ 'issuable-bulk-update-sidebar': true,
+ 'right-sidebar-expanded': true,
+ });
+ },
+ updated() {
+ this.$nextTick(() => {
+ highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
+ initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
+ });
+ },
+ methods: {
+ dismissError() {
+ this.isErrorDismissed = true;
+ this.sidebarErrorMessage = '';
+ },
+ toggleSidebar() {
+ this.$apollo.mutate({ mutation: toggleSidebarStatusMutation });
+ toggleContainerClasses(containerEl, {
+ 'right-sidebar-collapsed': !this.sidebarStatus,
+ 'right-sidebar-expanded': this.sidebarStatus,
+ });
+ },
+ handleAlertSidebarError(errorMessage) {
+ this.errored = true;
+ this.sidebarErrorMessage = errorMessage;
+ },
+ createIncident() {
+ this.incidentCreationInProgress = true;
+
+ this.$apollo
+ .mutate({
+ mutation: createIssueMutation,
+ variables: {
+ iid: this.alert.iid,
+ projectPath: this.projectPath,
+ },
+ })
+ .then(
+ ({
+ data: {
+ createAlertIssue: { errors, issue },
+ },
+ }) => {
+ if (errors?.length) {
+ [this.createIncidentError] = errors;
+ this.incidentCreationInProgress = false;
+ } else if (issue) {
+ visitUrl(this.incidentPath(issue.iid));
+ }
+ },
+ )
+ .catch((error) => {
+ this.createIncidentError = error;
+ this.incidentCreationInProgress = false;
+ });
+ },
+ incidentPath(issueId) {
+ return joinPaths(this.projectIssuesPath, issueId);
+ },
+ trackPageViews() {
+ const { category, action } = this.trackAlertsDetailsViewsOptions;
+ Tracking.event(category, action);
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
+ <p v-safe-html="sidebarErrorMessage || $options.i18n.errorMsg"></p>
+ </gl-alert>
+ <gl-alert
+ v-if="createIncidentError"
+ variant="danger"
+ data-testid="incidentCreationError"
+ @dismiss="createIncidentError = null"
+ >
+ {{ createIncidentError }}
+ </gl-alert>
+ <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div>
+ <div
+ v-if="alert"
+ class="alert-management-details gl-relative"
+ :class="{ 'pr-sm-8': sidebarStatus }"
+ >
+ <div
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-flex-direction-column gl-sm-flex-direction-row"
+ >
+ <div data-testid="alert-header">
+ <gl-badge class="gl-mr-3">
+ <strong>{{ s__('AlertManagement|Alert') }}</strong>
+ </gl-badge>
+ <span>
+ <gl-sprintf :message="reportedAtMessage">
+ <template #when>
+ <time-ago-tooltip :time="alert.createdAt" />
+ </template>
+ <template #tool>{{ alert.monitoringTool }}</template>
+ </gl-sprintf>
+ </span>
+ </div>
+ <gl-button
+ v-if="alert.issueIid"
+ class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button"
+ data-testid="viewIncidentBtn"
+ :href="incidentPath(alert.issueIid)"
+ category="primary"
+ variant="success"
+ >
+ {{ s__('AlertManagement|View incident') }}
+ </gl-button>
+ <gl-button
+ v-else
+ class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button"
+ data-testid="createIncidentBtn"
+ :loading="incidentCreationInProgress"
+ category="primary"
+ variant="success"
+ @click="createIncident()"
+ >
+ {{ s__('AlertManagement|Create incident') }}
+ </gl-button>
+ <gl-button
+ :aria-label="__('Toggle sidebar')"
+ category="primary"
+ variant="default"
+ class="d-sm-none gl-absolute toggle-sidebar-mobile-button"
+ type="button"
+ icon="chevron-double-lg-left"
+ @click="toggleSidebar"
+ />
+ </div>
+ <div
+ v-if="alert"
+ class="gl-display-flex gl-justify-content-space-between gl-align-items-center"
+ >
+ <h2 data-testid="title">{{ alert.title }}</h2>
+ </div>
+ <gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs">
+ <gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title">
+ <alert-summary-row v-if="alert.severity" :label="`${s__('AlertManagement|Severity')}:`">
+ <span data-testid="severity">
+ <gl-icon
+ class="gl-vertical-align-middle"
+ :size="12"
+ :name="`severity-${alert.severity.toLowerCase()}`"
+ :class="`icon-${alert.severity.toLowerCase()}`"
+ />
+ {{ $options.severityLabels[alert.severity] }}
+ </span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="environmentName"
+ :label="`${s__('AlertManagement|Environment')}:`"
+ >
+ <gl-link
+ v-if="environmentPath"
+ class="gl-display-inline-block"
+ data-testid="environmentPath"
+ :href="environmentPath"
+ >
+ {{ environmentName }}
+ </gl-link>
+ <span v-else data-testid="environmentName">{{ environmentName }}</span>
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.startedAt"
+ :label="`${s__('AlertManagement|Start time')}:`"
+ >
+ <time-ago-tooltip data-testid="startTimeItem" :time="alert.startedAt" />
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.eventCount"
+ :label="`${s__('AlertManagement|Events')}:`"
+ data-testid="eventCount"
+ >
+ {{ alert.eventCount }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.monitoringTool"
+ :label="`${s__('AlertManagement|Tool')}:`"
+ data-testid="monitoringTool"
+ >
+ {{ alert.monitoringTool }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.service"
+ :label="`${s__('AlertManagement|Service')}:`"
+ data-testid="service"
+ >
+ {{ alert.service }}
+ </alert-summary-row>
+ <alert-summary-row
+ v-if="alert.runbook"
+ :label="`${s__('AlertManagement|Runbook')}:`"
+ data-testid="runbook"
+ >
+ {{ alert.runbook }}
+ </alert-summary-row>
+ <alert-details-table :alert="alert" :loading="loading" />
+ </gl-tab>
+ <gl-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">
+ <system-note v-for="note in alert.notes.nodes" :key="note.id" :note="note" />
+ </ul>
+ </div>
+ </gl-tab>
+ </gl-tabs>
+ <alert-sidebar
+ :alert="alert"
+ @toggle-sidebar="toggleSidebar"
+ @alert-error="handleAlertSidebarError"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
new file mode 100644
index 00000000000..dd4faa03c00
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_metrics.vue
@@ -0,0 +1,56 @@
+<script>
+import Vue from 'vue';
+import Vuex from 'vuex';
+import * as Sentry from '~/sentry/wrapper';
+
+Vue.use(Vuex);
+
+export default {
+ props: {
+ dashboardUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ metricEmbedComponent: null,
+ namespace: 'alertMetrics',
+ };
+ },
+ mounted() {
+ if (this.dashboardUrl) {
+ Promise.all([
+ import('~/monitoring/components/embeds/metric_embed.vue'),
+ import('~/monitoring/stores'),
+ ])
+ .then(([{ default: MetricEmbed }, { monitoringDashboard }]) => {
+ this.$store = new Vuex.Store({
+ modules: {
+ [this.namespace]: monitoringDashboard,
+ },
+ });
+ this.metricEmbedComponent = MetricEmbed;
+ })
+ .catch((e) => Sentry.captureException(e));
+ }
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-3">
+ <div v-if="dashboardUrl" ref="metricsChart">
+ <component
+ :is="metricEmbedComponent"
+ v-if="metricEmbedComponent"
+ :dashboard-url="dashboardUrl"
+ :namespace="namespace"
+ />
+ </div>
+ <div v-else ref="emptyState">
+ {{ s__("AlertManagement|Metrics weren't available in the alerts payload.") }}
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
new file mode 100644
index 00000000000..a01bd462196
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_sidebar.vue
@@ -0,0 +1,86 @@
+<script>
+import sidebarStatusQuery from '../graphql/queries/alert_sidebar_status.query.graphql';
+import SidebarAssignees from './sidebar/sidebar_assignees.vue';
+import SidebarHeader from './sidebar/sidebar_header.vue';
+import SidebarStatus from './sidebar/sidebar_status.vue';
+import SidebarTodo from './sidebar/sidebar_todo.vue';
+
+export default {
+ components: {
+ SidebarAssignees,
+ SidebarHeader,
+ SidebarTodo,
+ SidebarStatus,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ projectId: {
+ default: '',
+ },
+ // TODO remove this limitation in https://gitlab.com/gitlab-org/gitlab/-/issues/296717
+ isThreatMonitoringPage: {
+ default: false,
+ },
+ },
+ props: {
+ alert: {
+ type: Object,
+ required: true,
+ },
+ },
+ apollo: {
+ sidebarStatus: {
+ query: sidebarStatusQuery,
+ },
+ },
+ data() {
+ return {
+ sidebarStatus: false,
+ };
+ },
+ computed: {
+ sidebarCollapsedClass() {
+ return this.sidebarStatus ? 'right-sidebar-collapsed' : 'right-sidebar-expanded';
+ },
+ },
+};
+</script>
+
+<template>
+ <aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar">
+ <div class="issuable-sidebar js-issuable-update">
+ <sidebar-header
+ :sidebar-collapsed="sidebarStatus"
+ :project-path="projectPath"
+ :alert="alert"
+ @toggle-sidebar="$emit('toggle-sidebar')"
+ @alert-error="$emit('alert-error', $event)"
+ />
+ <sidebar-todo
+ v-if="sidebarStatus"
+ :project-path="projectPath"
+ :alert="alert"
+ :sidebar-collapsed="sidebarStatus"
+ @alert-error="$emit('alert-error', $event)"
+ />
+ <sidebar-status
+ v-if="!isThreatMonitoringPage"
+ :project-path="projectPath"
+ :alert="alert"
+ @toggle-sidebar="$emit('toggle-sidebar')"
+ @alert-error="$emit('alert-error', $event)"
+ />
+ <sidebar-assignees
+ :project-path="projectPath"
+ :project-id="projectId"
+ :alert="alert"
+ :sidebar-collapsed="sidebarStatus"
+ @toggle-sidebar="$emit('toggle-sidebar')"
+ @alert-error="$emit('alert-error', $event)"
+ />
+ <div class="block"></div>
+ </div>
+ </aside>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
new file mode 100644
index 00000000000..8d5eb24ed1d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_status.vue
@@ -0,0 +1,125 @@
+<script>
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql';
+import { s__ } from '~/locale';
+import Tracking from '~/tracking';
+
+export default {
+ i18n: {
+ UPDATE_ALERT_STATUS_ERROR: s__(
+ 'AlertManagement|There was an error while updating the status of the alert.',
+ ),
+ UPDATE_ALERT_STATUS_INSTRUCTION: s__('AlertManagement|Please try again.'),
+ },
+ statuses: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ },
+ inject: {
+ trackAlertStatusUpdateOptions: {
+ default: null,
+ },
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ isDropdownShowing: {
+ type: Boolean,
+ required: false,
+ },
+ isSidebar: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ dropdownClass() {
+ // eslint-disable-next-line no-nested-ternary
+ return this.isSidebar ? (this.isDropdownShowing ? 'show' : 'gl-display-none') : '';
+ },
+ },
+ methods: {
+ updateAlertStatus(status) {
+ this.$emit('handle-updating', true);
+ this.$apollo
+ .mutate({
+ mutation: updateAlertStatusMutation,
+ variables: {
+ iid: this.alert.iid,
+ status: status.toUpperCase(),
+ projectPath: this.projectPath,
+ },
+ })
+ .then((resp) => {
+ if (this.trackAlertStatusUpdateOptions) {
+ this.trackStatusUpdate(status);
+ }
+ const errors = resp.data?.updateAlertStatus?.errors || [];
+
+ if (errors[0]) {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${errors[0]}`,
+ );
+ }
+
+ this.$emit('hide-dropdown');
+ })
+ .catch(() => {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_STATUS_ERROR} ${this.$options.i18n.UPDATE_ALERT_STATUS_INSTRUCTION}`,
+ );
+ })
+ .finally(() => {
+ this.$emit('handle-updating', false);
+ });
+ },
+ trackStatusUpdate(status) {
+ const { category, action, label } = this.trackAlertStatusUpdateOptions;
+ Tracking.event(category, action, { label, property: status });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
+ <gl-dropdown
+ ref="dropdown"
+ right
+ :text="$options.statuses[alert.status]"
+ class="w-100"
+ toggle-class="dropdown-menu-toggle"
+ @keydown.esc.native="$emit('hide-dropdown')"
+ @hide="$emit('hide-dropdown')"
+ >
+ <p v-if="isSidebar" class="gl-new-dropdown-header-top" data-testid="dropdown-header">
+ {{ s__('AlertManagement|Assign status') }}
+ </p>
+ <div class="dropdown-content dropdown-body">
+ <gl-dropdown-item
+ v-for="(label, field) in $options.statuses"
+ :key="field"
+ data-testid="statusDropdownItem"
+ :active="label.toUpperCase() === alert.status"
+ :active-class="'is-active'"
+ @click="updateAlertStatus(label)"
+ >
+ {{ label }}
+ </gl-dropdown-item>
+ </div>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue
new file mode 100644
index 00000000000..13835b7e2fa
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_summary_row.vue
@@ -0,0 +1,18 @@
+<script>
+export default {
+ props: {
+ label: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-my-5 gl-display-flex">
+ <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3">{{ label }}</div>
+ <div class="gl-pl-2">
+ <slot></slot>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue
new file mode 100644
index 00000000000..c39a72a45b9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlDropdownItem } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlDropdownItem,
+ },
+ props: {
+ user: {
+ type: Object,
+ required: true,
+ },
+ active: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ methods: {
+ isActive(name) {
+ return this.alert.assignees.nodes.some(({ username }) => username === name);
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-dropdown-item
+ :key="user.username"
+ data-testid="assigneeDropdownItem"
+ :active="active"
+ active-class="is-active"
+ :avatar-url="user.avatar_url"
+ :secondary-text="`@${user.username}`"
+ @click="$emit('update-alert-assignees', user.username)"
+ >
+ {{ user.name }}
+ </gl-dropdown-item>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
new file mode 100644
index 00000000000..2a999b908f9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue
@@ -0,0 +1,299 @@
+<script>
+import {
+ GlIcon,
+ GlDropdown,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlTooltip,
+ GlButton,
+ GlSprintf,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import { s__, __ } from '~/locale';
+import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql';
+import SidebarAssignee from './sidebar_assignee.vue';
+
+const DATA_REFETCH_DELAY = 250;
+
+export default {
+ i18n: {
+ FETCH_USERS_ERROR: s__(
+ 'AlertManagement|There was an error while updating the assignee(s) list. Please try again.',
+ ),
+ UPDATE_ALERT_ASSIGNEES_ERROR: s__(
+ 'AlertManagement|There was an error while updating the assignee(s) of the alert. Please try again.',
+ ),
+ UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR: s__(
+ 'AlertManagement|This assignee cannot be assigned to this alert.',
+ ),
+ ASSIGNEES_BLOCK: s__('AlertManagement|Alert assignee(s): %{assignees}'),
+ },
+ components: {
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ GlDropdownDivider,
+ GlDropdownSectionHeader,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlTooltip,
+ GlButton,
+ GlSprintf,
+ SidebarAssignee,
+ },
+ props: {
+ projectId: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ isEditable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ sidebarCollapsed: {
+ type: Boolean,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ isDropdownShowing: false,
+ isDropdownSearching: false,
+ isUpdating: false,
+ search: '',
+ users: [],
+ };
+ },
+ computed: {
+ currentUser() {
+ return gon?.current_username;
+ },
+ userName() {
+ return this.alert?.assignees?.nodes[0]?.username;
+ },
+ userFullName() {
+ return this.alert?.assignees?.nodes[0]?.name;
+ },
+ userImg() {
+ return this.alert?.assignees?.nodes[0]?.avatarUrl;
+ },
+ sortedUsers() {
+ return this.users
+ .map((user) => ({ ...user, active: this.isActive(user.username) }))
+ .sort((a, b) => (a.active === b.active ? 0 : a.active ? -1 : 1)); // eslint-disable-line no-nested-ternary
+ },
+ dropdownClass() {
+ return this.isDropdownShowing ? 'dropdown-menu-selectable show' : 'gl-display-none';
+ },
+ dropDownTitle() {
+ return this.userName ?? __('Select assignee');
+ },
+ userListValid() {
+ return !this.isDropdownSearching && this.users.length > 0;
+ },
+ userListEmpty() {
+ return !this.isDropdownSearching && this.users.length === 0;
+ },
+ },
+ watch: {
+ search: debounce(function debouncedUserSearch() {
+ this.updateAssigneesDropdown();
+ }, DATA_REFETCH_DELAY),
+ },
+ mounted() {
+ this.updateAssigneesDropdown();
+ },
+ methods: {
+ hideDropdown() {
+ this.isDropdownShowing = false;
+ },
+ toggleFormDropdown() {
+ this.isDropdownShowing = !this.isDropdownShowing;
+ const { dropdown } = this.$refs.dropdown.$refs;
+ if (dropdown && this.isDropdownShowing) {
+ dropdown.show();
+ }
+ },
+ isActive(name) {
+ return this.alert.assignees.nodes.some(({ username }) => username === name);
+ },
+ buildUrl(urlRoot, url) {
+ let newUrl;
+ if (urlRoot != null) {
+ newUrl = urlRoot.replace(/\/$/, '') + url;
+ }
+ return newUrl;
+ },
+ updateAssigneesDropdown() {
+ this.isDropdownSearching = true;
+ return axios
+ .get(this.buildUrl(gon.relative_url_root, '/-/autocomplete/users.json'), {
+ params: {
+ search: this.search,
+ per_page: 20,
+ active: true,
+ current_user: true,
+ project_id: this.projectId,
+ },
+ })
+ .then(({ data }) => {
+ this.users = data;
+ })
+ .catch(() => {
+ this.$emit('alert-error', this.$options.i18n.FETCH_USERS_ERROR);
+ })
+ .finally(() => {
+ this.isDropdownSearching = false;
+ });
+ },
+ updateAlertAssignees(assignees) {
+ this.isUpdating = true;
+ this.$apollo
+ .mutate({
+ mutation: alertSetAssignees,
+ variables: {
+ iid: this.alert.iid,
+ assigneeUsernames: [this.isActive(assignees) ? '' : assignees],
+ projectPath: this.projectPath,
+ },
+ })
+ .then(({ data: { alertSetAssignees: { errors } = [] } = {} } = {}) => {
+ this.hideDropdown();
+
+ if (errors[0]) {
+ this.$emit(
+ 'alert-error',
+ `${this.$options.i18n.UPDATE_ALERT_ASSIGNEES_GRAPHQL_ERROR} ${errors[0]}.`,
+ );
+ }
+ })
+ .catch(() => {
+ this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_ASSIGNEES_ERROR);
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block alert-assignees">
+ <div ref="assignees" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
+ <gl-icon name="user" :size="14" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.assignees" boundary="viewport" placement="left">
+ <gl-sprintf :message="$options.i18n.ASSIGNEES_BLOCK">
+ <template #assignees>
+ {{ userName }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+
+ <div class="hide-collapsed">
+ <p class="title gl-display-flex gl-justify-content-space-between">
+ {{ __('Assignee') }}
+ <a
+ v-if="isEditable"
+ ref="editButton"
+ class="btn-link"
+ href="#"
+ @click="toggleFormDropdown"
+ @keydown.esc="hideDropdown"
+ >
+ {{ __('Edit') }}
+ </a>
+ </p>
+
+ <gl-dropdown
+ ref="dropdown"
+ :text="dropDownTitle"
+ class="gl-w-full"
+ :class="dropdownClass"
+ toggle-class="dropdown-menu-toggle"
+ @keydown.esc.native="hideDropdown"
+ @hide="hideDropdown"
+ >
+ <p class="gl-new-dropdown-header-top">
+ {{ __('Assign To') }}
+ </p>
+ <gl-search-box-by-type v-model.trim="search" :placeholder="__('Search users')" />
+ <div class="dropdown-content dropdown-body">
+ <template v-if="userListValid">
+ <gl-dropdown-item
+ :active="!userName"
+ active-class="is-active"
+ @click="updateAlertAssignees('')"
+ >
+ {{ __('Unassigned') }}
+ </gl-dropdown-item>
+ <gl-dropdown-divider />
+
+ <gl-dropdown-section-header>
+ {{ __('Assignee') }}
+ </gl-dropdown-section-header>
+ <sidebar-assignee
+ v-for="user in sortedUsers"
+ :key="user.username"
+ :user="user"
+ :active="user.active"
+ @update-alert-assignees="updateAlertAssignees"
+ />
+ </template>
+ <p v-else-if="userListEmpty" class="gl-mx-5 gl-my-4">
+ {{ __('No Matching Results') }}
+ </p>
+ <gl-loading-icon v-else />
+ </div>
+ </gl-dropdown>
+ </div>
+
+ <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <div v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }">
+ <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users">
+ <span class="gl-relative gl-mr-4">
+ <img
+ :alt="userName"
+ :src="userImg"
+ :width="32"
+ class="avatar avatar-inline gl-m-0 s32"
+ data-qa-selector="avatar_image"
+ />
+ </span>
+ <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">
+ <strong class="dropdown-menu-user-full-name">
+ {{ userFullName }}
+ </strong>
+ <span class="dropdown-menu-user-username">@{{ userName }}</span>
+ </span>
+ </div>
+ <span v-else class="gl-display-flex gl-align-items-center gl-line-height-normal">
+ {{ __('None') }} -
+ <gl-button
+ class="gl-ml-2"
+ href="#"
+ variant="link"
+ data-testid="unassigned-users"
+ @click="updateAlertAssignees(currentUser)"
+ >
+ {{ __('assign yourself') }}
+ </gl-button>
+ </span>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
new file mode 100644
index 00000000000..fd40b5d9f65
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_header.vue
@@ -0,0 +1,41 @@
+<script>
+import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
+import SidebarTodo from './sidebar_todo.vue';
+
+export default {
+ components: {
+ ToggleSidebar,
+ SidebarTodo,
+ },
+ props: {
+ alert: {
+ type: Object,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ sidebarCollapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block gl-display-flex gl-justify-content-space-between">
+ <span class="issuable-header-text hide-collapsed">
+ {{ __('To Do') }}
+ </span>
+ <sidebar-todo
+ v-if="!sidebarCollapsed"
+ :project-path="projectPath"
+ :alert="alert"
+ :sidebar-collapsed="sidebarCollapsed"
+ @alert-error="$emit('alert-error', $event)"
+ />
+ <toggle-sidebar :collapsed="sidebarCollapsed" @toggle="$emit('toggle-sidebar')" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
new file mode 100644
index 00000000000..0a2bad5510b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_status.vue
@@ -0,0 +1,120 @@
+<script>
+import { GlIcon, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import AlertStatus from '../alert_status.vue';
+
+export default {
+ statuses: {
+ TRIGGERED: s__('AlertManagement|Triggered'),
+ ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
+ RESOLVED: s__('AlertManagement|Resolved'),
+ },
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlTooltip,
+ GlSprintf,
+ AlertStatus,
+ },
+ props: {
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ alert: {
+ type: Object,
+ required: true,
+ },
+ isEditable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ isDropdownShowing: false,
+ isUpdating: false,
+ };
+ },
+ computed: {
+ dropdownClass() {
+ return this.isDropdownShowing ? 'show' : 'gl-display-none';
+ },
+ },
+ methods: {
+ hideDropdown() {
+ this.isDropdownShowing = false;
+ },
+ toggleFormDropdown() {
+ this.isDropdownShowing = !this.isDropdownShowing;
+ const { dropdown } = this.$children[2].$refs.dropdown.$refs;
+ if (dropdown && this.isDropdownShowing) {
+ dropdown.show();
+ }
+ },
+ handleUpdating(updating) {
+ this.isUpdating = updating;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="block alert-status">
+ <div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
+ <gl-icon name="status" :size="14" />
+ <gl-loading-icon v-if="isUpdating" />
+ </div>
+ <gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
+ <gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
+ <template #status>
+ {{ alert.status.toLowerCase() }}
+ </template>
+ </gl-sprintf>
+ </gl-tooltip>
+
+ <div class="hide-collapsed">
+ <p class="title gl-display-flex justify-content-between">
+ {{ s__('AlertManagement|Status') }}
+ <a
+ v-if="isEditable"
+ ref="editButton"
+ class="btn-link"
+ href="#"
+ @click="toggleFormDropdown"
+ @keydown.esc="hideDropdown"
+ >
+ {{ s__('AlertManagement|Edit') }}
+ </a>
+ </p>
+
+ <alert-status
+ :alert="alert"
+ :project-path="projectPath"
+ :is-dropdown-showing="isDropdownShowing"
+ :is-sidebar="true"
+ @alert-error="$emit('alert-error', $event)"
+ @hide-dropdown="hideDropdown"
+ @handle-updating="handleUpdating"
+ />
+
+ <gl-loading-icon v-if="isUpdating" :inline="true" />
+ <p
+ v-else-if="!isDropdownShowing"
+ class="value gl-m-0"
+ :class="{ 'no-value': !$options.statuses[alert.status] }"
+ >
+ <span
+ v-if="$options.statuses[alert.status]"
+ class="gl-text-gray-500"
+ data-testid="status"
+ >{{ $options.statuses[alert.status] }}</span
+ >
+ <span v-else>
+ {{ s__('AlertManagement|None') }}
+ </span>
+ </p>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
new file mode 100644
index 00000000000..39ac6c7feca
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_todo.vue
@@ -0,0 +1,149 @@
+<script>
+import produce from 'immer';
+import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql';
+import { s__ } from '~/locale';
+import Todo from '~/sidebar/components/todo_toggle/todo.vue';
+import createAlertTodoMutation from '../../graphql/mutations/alert_todo_create.mutation.graphql';
+import alertQuery from '../../graphql/queries/alert_details.query.graphql';
+
+export default {
+ i18n: {
+ UPDATE_ALERT_TODO_ERROR: s__(
+ 'AlertManagement|There was an error while updating the to-do item of the alert.',
+ ),
+ },
+ components: {
+ Todo,
+ },
+ props: {
+ alert: {
+ type: Object,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ },
+ sidebarCollapsed: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isUpdating: false,
+ };
+ },
+ computed: {
+ alertID() {
+ return parseInt(this.alert.iid, 10);
+ },
+ firstToDoId() {
+ return this.alert?.todos?.nodes[0]?.id;
+ },
+ hasPendingTodos() {
+ return this.alert?.todos?.nodes.length > 0;
+ },
+ getAlertQueryVariables() {
+ return {
+ fullPath: this.projectPath,
+ alertId: this.alert.iid,
+ };
+ },
+ },
+ methods: {
+ updateToDoCount(add) {
+ const oldCount = parseInt(document.querySelector('.js-todos-count').innerText, 10);
+ const count = add ? oldCount + 1 : oldCount - 1;
+ const headerTodoEvent = new CustomEvent('todo:toggle', {
+ detail: {
+ count,
+ },
+ });
+
+ return document.dispatchEvent(headerTodoEvent);
+ },
+ addToDo() {
+ this.isUpdating = true;
+ return this.$apollo
+ .mutate({
+ mutation: createAlertTodoMutation,
+ variables: {
+ iid: this.alert.iid,
+ projectPath: this.projectPath,
+ },
+ })
+ .then(({ data: { errors = [] } }) => {
+ if (errors[0]) {
+ return this.throwError(errors[0]);
+ }
+ return this.updateToDoCount(true);
+ })
+ .catch(() => {
+ this.throwError();
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ markAsDone() {
+ this.isUpdating = true;
+ return this.$apollo
+ .mutate({
+ mutation: todoMarkDoneMutation,
+ variables: {
+ id: this.firstToDoId,
+ },
+ update: this.updateCache,
+ })
+ .then(({ data: { errors = [] } }) => {
+ if (errors[0]) {
+ return this.throwError(errors[0]);
+ }
+ return this.updateToDoCount(false);
+ })
+ .catch(() => {
+ this.throwError();
+ })
+ .finally(() => {
+ this.isUpdating = false;
+ });
+ },
+ updateCache(store) {
+ const sourceData = store.readQuery({
+ query: alertQuery,
+ variables: this.getAlertQueryVariables,
+ });
+
+ const data = produce(sourceData, (draftData) => {
+ // eslint-disable-next-line no-param-reassign
+ draftData.project.alertManagementAlerts.nodes[0].todos.nodes = [];
+ });
+
+ store.writeQuery({
+ query: alertQuery,
+ variables: this.getAlertQueryVariables,
+ data,
+ });
+ },
+ throwError(err = '') {
+ const error = err || s__('AlertManagement|Please try again.');
+ this.$emit('alert-error', `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${error}`);
+ },
+ },
+};
+</script>
+
+<template>
+ <div :class="{ 'block todo': sidebarCollapsed, 'gl-ml-auto': !sidebarCollapsed }">
+ <todo
+ data-testid="alert-todo-button"
+ :collapsed="sidebarCollapsed"
+ :issuable-id="alertID"
+ :is-todo="hasPendingTodos"
+ :is-action-active="isUpdating"
+ issuable-type="alert"
+ @toggleTodo="hasPendingTodos ? markAsDone() : addToDo()"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
new file mode 100644
index 00000000000..3705e36a579
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/components/system_notes/system_note.vue
@@ -0,0 +1,48 @@
+<script>
+/* eslint-disable vue/no-v-html */
+import { GlIcon } from '@gitlab/ui';
+import NoteHeader from '~/notes/components/note_header.vue';
+
+export default {
+ components: {
+ NoteHeader,
+ GlIcon,
+ },
+ props: {
+ note: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ noteAnchorId() {
+ return `note_${this.note?.id?.split('/').pop()}`;
+ },
+ noteAuthor() {
+ const {
+ author,
+ author: { id },
+ } = this.note;
+ return { ...author, id: id?.split('/').pop() };
+ },
+ },
+};
+</script>
+
+<template>
+ <li :id="noteAnchorId" class="timeline-entry note system-note note-wrapper gl-p-0!">
+ <div class="gl-display-inline-flex gl-align-items-center">
+ <div
+ class="gl-display-inline gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-box-sizing-content-box gl-p-3 gl-mt-n2 gl-mr-6"
+ >
+ <gl-icon :name="note.systemNoteIconName" />
+ </div>
+
+ <div class="note-header">
+ <note-header :author="noteAuthor" :created-at="note.createdAt" :note-id="note.id">
+ <span v-html="note.bodyHtml"></span>
+ </note-header>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/vue_shared/alert_details/constants.js b/app/assets/javascripts/vue_shared/alert_details/constants.js
new file mode 100644
index 00000000000..2ab5160534c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/constants.js
@@ -0,0 +1,31 @@
+import { s__ } from '~/locale';
+
+export const SEVERITY_LEVELS = {
+ CRITICAL: s__('severity|Critical'),
+ HIGH: s__('severity|High'),
+ MEDIUM: s__('severity|Medium'),
+ LOW: s__('severity|Low'),
+ INFO: s__('severity|Info'),
+ UNKNOWN: s__('severity|Unknown'),
+};
+
+/* eslint-disable @gitlab/require-i18n-strings */
+export const PAGE_CONFIG = {
+ OPERATIONS: {
+ TITLE: 'OPERATIONS',
+ // Tracks snowplow event when user views alert details
+ TRACK_ALERTS_DETAILS_VIEWS_OPTIONS: {
+ category: 'Alert Management',
+ action: 'view_alert_details',
+ },
+ // Tracks snowplow event when alert status is updated
+ TRACK_ALERT_STATUS_UPDATE_OPTIONS: {
+ category: 'Alert Management',
+ action: 'update_alert_status',
+ label: 'Status',
+ },
+ },
+ THREAT_MONITORING: {
+ TITLE: 'THREAT_MONITORING',
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql
new file mode 100644
index 00000000000..9a9ae369519
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/fragments/alert_detail_item.fragment.graphql
@@ -0,0 +1,30 @@
+#import "~/graphql_shared/fragments/alert.fragment.graphql"
+#import "~/graphql_shared/fragments/alert_note.fragment.graphql"
+
+fragment AlertDetailItem on AlertManagementAlert {
+ ...AlertListItem
+ createdAt
+ monitoringTool
+ metricsDashboardUrl
+ service
+ description
+ updatedAt
+ endedAt
+ hosts
+ environment {
+ name
+ path
+ }
+ details
+ runbook
+ todos {
+ nodes {
+ id
+ }
+ }
+ notes {
+ nodes {
+ ...AlertNote
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
new file mode 100644
index 00000000000..bc4d91a51d1
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_issue_create.mutation.graphql
@@ -0,0 +1,8 @@
+mutation createAlertIssue($projectPath: ID!, $iid: String!) {
+ createAlertIssue(input: { iid: $iid, projectPath: $projectPath }) {
+ errors
+ issue {
+ iid
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql
new file mode 100644
index 00000000000..63d952a4857
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql
@@ -0,0 +1,25 @@
+#import "~/graphql_shared/fragments/alert_note.fragment.graphql"
+
+mutation alertSetAssignees($projectPath: ID!, $assigneeUsernames: [String!]!, $iid: String!) {
+ alertSetAssignees(
+ input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
+ ) {
+ errors
+ alert {
+ iid
+ assignees {
+ nodes {
+ username
+ name
+ avatarUrl
+ webUrl
+ }
+ }
+ notes {
+ nodes {
+ ...AlertNote
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_sidebar_status.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_sidebar_status.mutation.graphql
new file mode 100644
index 00000000000..f666fcd6782
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_sidebar_status.mutation.graphql
@@ -0,0 +1,3 @@
+mutation toggleSidebarStatus {
+ toggleSidebarStatus @client
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql
new file mode 100644
index 00000000000..dc961b5eb90
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/mutations/alert_todo_create.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/alert_detail_item.fragment.graphql"
+
+mutation alertTodoCreate($projectPath: ID!, $iid: String!) {
+ alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) {
+ errors
+ alert {
+ ...AlertDetailItem
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql
new file mode 100644
index 00000000000..5ee2cf7ca44
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_details.query.graphql
@@ -0,0 +1,11 @@
+#import "../fragments/alert_detail_item.fragment.graphql"
+
+query alertDetails($fullPath: ID!, $alertId: String) {
+ project(fullPath: $fullPath) {
+ alertManagementAlerts(iid: $alertId) {
+ nodes {
+ ...AlertDetailItem
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_status.query.graphql b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_status.query.graphql
new file mode 100644
index 00000000000..61c570c5cd0
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/graphql/queries/alert_sidebar_status.query.graphql
@@ -0,0 +1,3 @@
+query sidebarStatus {
+ sidebarStatus @client
+}
diff --git a/app/assets/javascripts/vue_shared/alert_details/index.js b/app/assets/javascripts/vue_shared/alert_details/index.js
new file mode 100644
index 00000000000..3ea43d7a843
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/index.js
@@ -0,0 +1,83 @@
+import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
+import produce from 'immer';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import AlertDetails from './components/alert_details.vue';
+import { PAGE_CONFIG } from './constants';
+import sidebarStatusQuery from './graphql/queries/alert_sidebar_status.query.graphql';
+import createRouter from './router';
+
+Vue.use(VueApollo);
+
+export default (selector) => {
+ const domEl = document.querySelector(selector);
+ const { alertId, projectPath, projectIssuesPath, projectId, page } = domEl.dataset;
+ const router = createRouter();
+
+ const resolvers = {
+ Mutation: {
+ toggleSidebarStatus: (_, __, { cache }) => {
+ const sourceData = cache.readQuery({ query: sidebarStatusQuery });
+ const data = produce(sourceData, (draftData) => {
+ // eslint-disable-next-line no-param-reassign
+ draftData.sidebarStatus = !draftData.sidebarStatus;
+ });
+ cache.writeQuery({ query: sidebarStatusQuery, data });
+ },
+ },
+ };
+
+ const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(resolvers, {
+ cacheConfig: {
+ dataIdFromObject: (object) => {
+ // eslint-disable-next-line no-underscore-dangle
+ if (object.__typename === 'AlertManagementAlert') {
+ return object.iid;
+ }
+ return defaultDataIdFromObject(object);
+ },
+ },
+ assumeImmutableResults: true,
+ }),
+ });
+
+ apolloProvider.clients.defaultClient.cache.writeData({
+ data: {
+ sidebarStatus: false,
+ },
+ });
+
+ const provide = {
+ projectPath,
+ alertId,
+ page,
+ projectIssuesPath,
+ projectId,
+ };
+
+ 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;
+ } else if (page === PAGE_CONFIG.THREAT_MONITORING.TITLE) {
+ provide.isThreatMonitoringPage = true;
+ }
+
+ // eslint-disable-next-line no-new
+ new Vue({
+ el: selector,
+ components: {
+ AlertDetails,
+ },
+ provide,
+ apolloProvider,
+ router,
+ render(createElement) {
+ return createElement('alert-details', {});
+ },
+ });
+};
diff --git a/app/assets/javascripts/vue_shared/alert_details/router.js b/app/assets/javascripts/vue_shared/alert_details/router.js
new file mode 100644
index 00000000000..5687fe4e0f5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/alert_details/router.js
@@ -0,0 +1,13 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { joinPaths } from '~/lib/utils/url_utility';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base) {
+ return new VueRouter({
+ mode: 'hash',
+ base: joinPaths(gon.relative_url_root || '', base),
+ routes: [{ path: '/:tabId', name: 'tab' }],
+ });
+}