diff options
Diffstat (limited to 'app/assets/javascripts/monitoring')
38 files changed, 1409 insertions, 439 deletions
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue index 5562981fe1c..909ae2980d2 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -1,12 +1,12 @@ <script> import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { values, get } from 'lodash'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import AlertWidgetForm from './alert_widget_form.vue'; import AlertsService from '../services/alerts_service'; import { alertsValidator, queriesValidator } from '../validators'; import { OPERATORS } from '../constants'; -import { values, get } from 'lodash'; export default { components: { @@ -174,8 +174,8 @@ export default { handleSetApiAction(apiAction) { this.apiAction = apiAction; }, - handleCreate({ operator, threshold, prometheus_metric_id }) { - const newAlert = { operator, threshold, prometheus_metric_id }; + handleCreate({ operator, threshold, prometheus_metric_id, runbookUrl }) { + const newAlert = { operator, threshold, prometheus_metric_id, runbookUrl }; this.isLoading = true; this.service .createAlert(newAlert) @@ -189,8 +189,8 @@ export default { this.isLoading = false; }); }, - handleUpdate({ alert, operator, threshold }) { - const updatedAlert = { operator, threshold }; + handleUpdate({ alert, operator, threshold, runbookUrl }) { + const updatedAlert = { operator, threshold, runbookUrl }; this.isLoading = true; this.service .updateAlert(alert, updatedAlert) diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue index b2d7ca0c4e0..5fa0da53a04 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -7,8 +7,8 @@ import { GlButtonGroup, GlFormGroup, GlFormInput, - GlDropdown, - GlDropdownItem, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, GlModal, GlTooltipDirective, } from '@gitlab/ui'; @@ -88,6 +88,7 @@ export default { operator: null, threshold: null, prometheusMetricId: null, + runbookUrl: null, selectedAlert: {}, alertQuery: '', }; @@ -116,7 +117,8 @@ export default { this.operator && this.threshold === Number(this.threshold) && (this.operator !== this.selectedAlert.operator || - this.threshold !== this.selectedAlert.threshold) + this.threshold !== this.selectedAlert.threshold || + this.runbookUrl !== this.selectedAlert.runbookUrl) ); }, submitAction() { @@ -153,13 +155,17 @@ export default { const existingAlert = this.alertsToManage[existingAlertPath]; if (existingAlert) { + const { operator, threshold, runbookUrl } = existingAlert; + this.selectedAlert = existingAlert; - this.operator = existingAlert.operator; - this.threshold = existingAlert.threshold; + this.operator = operator; + this.threshold = threshold; + this.runbookUrl = runbookUrl; } else { this.selectedAlert = {}; this.operator = this.operators.greaterThan; this.threshold = null; + this.runbookUrl = null; } this.prometheusMetricId = queryId; @@ -168,13 +174,13 @@ export default { this.resetAlertData(); this.$emit('cancel'); }, - handleSubmit(e) { - e.preventDefault(); + handleSubmit() { this.$emit(this.submitAction, { alert: this.selectedAlert.alert_path, operator: this.operator, threshold: this.threshold, prometheus_metric_id: this.prometheusMetricId, + runbookUrl: this.runbookUrl, }); }, handleShown() { @@ -189,6 +195,7 @@ export default { this.threshold = null; this.prometheusMetricId = null; this.selectedAlert = {}; + this.runbookUrl = null; }, getAlertFormActionTrackingOption() { const label = `${this.submitAction}_alert`; @@ -217,7 +224,7 @@ export default { :modal-id="modalId" :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" :ok-disabled="formDisabled" - @ok="handleSubmit" + @ok.prevent="handleSubmit" @hidden="handleHidden" @shown="handleShown" > @@ -247,7 +254,7 @@ export default { <gl-dropdown id="alert-query-dropdown" :text="queryDropdownLabel" - toggle-class="dropdown-menu-toggle qa-alert-query-dropdown" + toggle-class="dropdown-menu-toggle gl-border-1! qa-alert-query-dropdown" > <gl-dropdown-item v-for="query in relevantQueries" @@ -259,7 +266,7 @@ export default { </gl-dropdown-item> </gl-dropdown> </gl-form-group> - <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> + <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')"> <gl-deprecated-button :class="{ active: operator === operators.greaterThan }" :disabled="formDisabled" @@ -294,6 +301,19 @@ export default { data-qa-selector="alert_threshold_field" /> </gl-form-group> + <gl-form-group + :label="s__('PrometheusAlerts|Runbook URL (optional)')" + label-for="alert-runbook" + > + <gl-form-input + id="alert-runbook" + v-model="runbookUrl" + :disabled="formDisabled" + data-testid="alertRunbookField" + type="text" + :placeholder="s__('PrometheusAlerts|https://gitlab.com/gitlab-com/runbooks')" + /> + </gl-form-group> </div> <template #modal-ok> <gl-link diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue new file mode 100644 index 00000000000..63fa60bbdf0 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/gauge.vue @@ -0,0 +1,122 @@ +<script> +import { GlResizeObserverDirective } from '@gitlab/ui'; +import { GlGaugeChart } from '@gitlab/ui/dist/charts'; +import { isFinite, isArray, isInteger } from 'lodash'; +import { graphDataValidatorForValues } from '../../utils'; +import { getValidThresholds } from './options'; +import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; + +export default { + components: { + GlGaugeChart, + }, + directives: { + GlResizeObserverDirective, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, true), + }, + }, + data() { + return { + width: 0, + }; + }, + computed: { + rangeValues() { + let min = 0; + let max = 100; + + const { minValue, maxValue } = this.graphData; + + const isValidMinMax = () => { + return isFinite(minValue) && isFinite(maxValue) && minValue < maxValue; + }; + + if (isValidMinMax()) { + min = minValue; + max = maxValue; + } + + return { + min, + max, + }; + }, + validThresholds() { + const { mode, values } = this.graphData?.thresholds || {}; + const range = this.rangeValues; + + if (!isArray(values)) { + return []; + } + + return getValidThresholds({ mode, range, values }); + }, + queryResult() { + return this.graphData?.metrics[0]?.result[0]?.value[1]; + }, + splitValue() { + const { split } = this.graphData; + const defaultValue = 10; + + return isInteger(split) && split > 0 ? split : defaultValue; + }, + textValue() { + const formatFromPanel = this.graphData.format; + const defaultFormat = SUPPORTED_FORMATS.engineering; + const format = SUPPORTED_FORMATS[formatFromPanel] ?? defaultFormat; + const { queryResult } = this; + + const formatter = getFormatter(format); + + return isFinite(queryResult) ? formatter(queryResult) : '--'; + }, + thresholdsValue() { + /** + * If there are no valid thresholds, a default threshold + * will be set at 90% of the gauge arcs' max value + */ + const { min, max } = this.rangeValues; + + const defaultThresholdValue = [(max - min) * 0.95]; + return this.validThresholds.length ? this.validThresholds : defaultThresholdValue; + }, + value() { + /** + * The gauge chart gitlab-ui component expects a value + * of type number. + * + * So, if the query result is undefined, + * we pass the gauge chart a value of NaN. + */ + return this.queryResult || NaN; + }, + }, + methods: { + onResize() { + if (!this.$refs.gaugeChart) return; + const { width } = this.$refs.gaugeChart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> +<template> + <div v-gl-resize-observer-directive="onResize"> + <gl-gauge-chart + ref="gaugeChart" + v-bind="$attrs" + :value="value" + :min="rangeValues.min" + :max="rangeValues.max" + :thresholds="thresholdsValue" + :text="textValue" + :split-number="splitValue" + :width="width" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index ddb44f7b1be..7003e2d37cf 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -36,7 +36,7 @@ export default { ); }, xAxisName() { - return this.graphData.x_label || ''; + return this.graphData.xLabel || ''; }, yAxisName() { return this.graphData.y_label || ''; diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index 42252dd5897..0cd4a02311c 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -1,6 +1,8 @@ +import { isFinite, uniq, sortBy, includes } from 'lodash'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { __, s__ } from '~/locale'; import { formatDate, timezones, formats } from '../../format_date'; +import { thresholdModeTypes } from '../../constants'; const yAxisBoundaryGap = [0.1, 0.1]; /** @@ -109,3 +111,65 @@ export const getTooltipFormatter = ({ const formatter = getFormatter(format); return num => formatter(num, precision); }; + +// Thresholds + +/** + * + * Used to find valid thresholds for the gauge chart + * + * An array of thresholds values is + * - duplicate values are removed; + * - filtered for invalid values; + * - sorted in ascending order; + * - only first two values are used. + */ +export const getValidThresholds = ({ mode, range = {}, values = [] } = {}) => { + const supportedModes = [thresholdModeTypes.ABSOLUTE, thresholdModeTypes.PERCENTAGE]; + const { min, max } = range; + + /** + * return early if min and max have invalid values + * or mode has invalid value + */ + if (!isFinite(min) || !isFinite(max) || min >= max || !includes(supportedModes, mode)) { + return []; + } + + const uniqueThresholds = uniq(values); + + const numberThresholds = uniqueThresholds.filter(threshold => isFinite(threshold)); + + const validThresholds = numberThresholds.filter(threshold => { + let isValid; + + if (mode === thresholdModeTypes.PERCENTAGE) { + isValid = threshold > 0 && threshold < 100; + } else if (mode === thresholdModeTypes.ABSOLUTE) { + isValid = threshold > min && threshold < max; + } + + return isValid; + }); + + const transformedThresholds = validThresholds.map(threshold => { + let transformedThreshold; + + if (mode === 'percentage') { + transformedThreshold = (threshold / 100) * (max - min); + } else { + transformedThreshold = threshold; + } + + return transformedThreshold; + }); + + const sortedThresholds = sortBy(transformedThresholds); + + const reducedThresholdsArray = + sortedThresholds.length > 2 + ? [sortedThresholds[0], sortedThresholds[1]] + : [...sortedThresholds]; + + return reducedThresholdsArray; +}; diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index 106c76a97dc..a8ab41ebf26 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -50,7 +50,7 @@ export default { } formatter = getFormatter(SUPPORTED_FORMATS.number); - return `${formatter(this.queryResult, defaultPrecision)}${this.queryInfo.unit}`; + return `${formatter(this.queryResult, defaultPrecision)}${this.queryInfo.unit ?? ''}`; }, graphTitle() { return this.queryInfo.label; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index f2add429a80..054111c203e 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,6 +1,6 @@ <script> -import { omit, throttle } from 'lodash'; -import { GlLink, GlDeprecatedButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; +import { isEmpty, omit, throttle } from 'lodash'; +import { GlLink, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { s__ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; @@ -25,7 +25,6 @@ export default { GlAreaChart, GlLineChart, GlTooltip, - GlDeprecatedButton, GlChartSeriesLabel, GlLink, Icon, @@ -45,6 +44,11 @@ export default { required: false, default: () => ({}), }, + timeRange: { + type: Object, + required: false, + default: () => ({}), + }, seriesConfig: { type: Object, required: false, @@ -174,10 +178,17 @@ export default { chartOptions() { const { yAxis, xAxis } = this.option; const option = omit(this.option, ['series', 'yAxis', 'xAxis']); + const xAxisBounds = isEmpty(this.timeRange) + ? {} + : { + min: this.timeRange.start, + max: this.timeRange.end, + }; const timeXAxis = { ...getTimeAxisOptions({ timezone: this.timezone }), ...xAxis, + ...xAxisBounds, }; const dataYAxis = { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index bde62275797..24aa7b3f504 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -2,12 +2,12 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import Mousetrap from 'mousetrap'; -import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import DashboardHeader from './dashboard_header.vue'; import DashboardPanel from './dashboard_panel.vue'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; -import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -34,7 +34,6 @@ export default { DashboardHeader, DashboardPanel, Icon, - GlIcon, GlButton, GraphGroup, EmptyState, @@ -48,11 +47,6 @@ export default { TrackEvent: TrackEventDirective, }, props: { - externalDashboardUrl: { - type: String, - required: false, - default: '', - }, hasMetrics: { type: Boolean, required: false, @@ -72,10 +66,6 @@ export default { type: String, required: true, }, - addDashboardDocumentationPath: { - type: String, - required: true, - }, settingsPath: { type: String, required: true, @@ -320,7 +310,7 @@ export default { }, onKeyup(event) { const { key } = event; - if (key === ESC_KEY || key === ESC_KEY_IE11) { + if (key === ESC_KEY) { this.clearExpandedPanel(); } }, @@ -398,7 +388,8 @@ export default { }, }, i18n: { - goBackLabel: s__('Metrics|Go back (Esc)'), + collapsePanelLabel: s__('Metrics|Collapse panel'), + collapsePanelTooltip: s__('Metrics|Collapse panel (Esc)'), }, }; </script> @@ -409,14 +400,11 @@ export default { v-if="showHeader" ref="prometheusGraphsHeader" class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" - :add-dashboard-documentation-path="addDashboardDocumentationPath" :default-branch="defaultBranch" :rearrange-panels-available="rearrangePanelsAvailable" :custom-metrics-available="customMetricsAvailable" :custom-metrics-path="customMetricsPath" :validate-query-path="validateQueryPath" - :external-dashboard-url="externalDashboardUrl" - :has-metrics="hasMetrics" :is-rearranging-panels="isRearrangingPanels" :selected-time-range="selectedTimeRange" @dateTimePickerInvalid="onDateTimePickerInvalid" @@ -441,14 +429,10 @@ export default { ref="goBackBtn" v-gl-tooltip class="mr-3 my-3" - :title="$options.i18n.goBackLabel" + :title="$options.i18n.collapsePanelTooltip" @click="onGoBack" > - <gl-icon - name="arrow-left" - :aria-label="$options.i18n.goBackLabel" - class="text-secondary" - /> + {{ $options.i18n.collapsePanelLabel }} </gl-button> </template> </dashboard-panel> diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue new file mode 100644 index 00000000000..68afa2ace01 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue @@ -0,0 +1,291 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import { + GlDeprecatedButton, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, + GlModal, + GlIcon, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import { PANEL_NEW_PAGE } from '../router/constants'; +import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; +import CreateDashboardModal from './create_dashboard_modal.vue'; +import { s__ } from '~/locale'; +import invalidUrl from '~/lib/utils/invalid_url'; +import { redirectTo } from '~/lib/utils/url_utility'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { getAddMetricTrackingOptions } from '../utils'; + +export default { + components: { + GlDeprecatedButton, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, + GlModal, + GlIcon, + DuplicateDashboardModal, + CreateDashboardModal, + CustomMetricsFormFields, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + props: { + addingMetricsAvailable: { + type: Boolean, + required: false, + default: false, + }, + customMetricsPath: { + type: String, + required: false, + default: invalidUrl, + }, + validateQueryPath: { + type: String, + required: false, + default: invalidUrl, + }, + defaultBranch: { + type: String, + required: true, + }, + isOotbDashboard: { + type: Boolean, + required: true, + }, + }, + data() { + return { customMetricsFormIsValid: null }; + }, + computed: { + ...mapState('monitoringDashboard', [ + 'projectPath', + 'isUpdatingStarredValue', + 'addDashboardDocumentationPath', + ]), + ...mapGetters('monitoringDashboard', ['selectedDashboard']), + isOutOfTheBoxDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard; + }, + isMenuItemEnabled() { + return { + addPanel: !this.isOotbDashboard, + createDashboard: Boolean(this.projectPath), + editDashboard: this.selectedDashboard?.can_edit, + }; + }, + isMenuItemShown() { + return { + duplicateDashboard: this.isOutOfTheBoxDashboard, + }; + }, + newPanelPageLocation() { + // Retains params/query if any + const { params, query } = this.$route ?? {}; + return { name: PANEL_NEW_PAGE, params, query }; + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['toggleStarredValue']), + setFormValidity(isValid) { + this.customMetricsFormIsValid = isValid; + }, + hideAddMetricModal() { + this.$refs.addMetricModal.hide(); + }, + getAddMetricTrackingOptions, + submitCustomMetricsForm() { + this.$refs.customMetricsForm.submit(); + }, + selectDashboard(dashboard) { + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent( + dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, + ); + redirectTo(`${baseURL}/${dashboardPath}`); + }, + }, + + modalIds: { + addMetric: 'addMetric', + createDashboard: 'createDashboard', + duplicateDashboard: 'duplicateDashboard', + }, + i18n: { + actionsMenu: s__('Metrics|More actions'), + duplicateDashboard: s__('Metrics|Duplicate current dashboard'), + starDashboard: s__('Metrics|Star dashboard'), + unstarDashboard: s__('Metrics|Unstar dashboard'), + addMetric: s__('Metrics|Add metric'), + addPanel: s__('Metrics|Add panel'), + addPanelInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'), + editDashboardInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'), + editDashboard: s__('Metrics|Edit dashboard YAML'), + createDashboard: s__('Metrics|Create new dashboard'), + }, +}; +</script> + +<template> + <!-- + This component should be replaced with a variant developed + as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 + The variant will create a dropdown with an icon, no text and no caret + --> + <gl-new-dropdown + v-gl-tooltip + data-testid="actions-menu" + data-qa-selector="actions_menu_dropdown" + right + no-caret + toggle-class="gl-px-3!" + :title="$options.i18n.actionsMenu" + > + <template #button-content> + <gl-icon class="gl-mr-0!" name="ellipsis_v" /> + </template> + + <template v-if="addingMetricsAvailable"> + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.addMetric" + data-qa-selector="add_metric_button" + data-testid="add-metric-item" + > + {{ $options.i18n.addMetric }} + </gl-new-dropdown-item> + <gl-modal + ref="addMetricModal" + :modal-id="$options.modalIds.addMetric" + :title="$options.i18n.addMetric" + data-testid="add-metric-modal" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + v-track-event="getAddMetricTrackingOptions()" + data-testid="add-metric-modal-submit-button" + :disabled="!customMetricsFormIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </template> + + <gl-new-dropdown-item + v-if="isMenuItemEnabled.addPanel" + data-testid="add-panel-item-enabled" + :to="newPanelPageLocation" + > + {{ $options.i18n.addPanel }} + </gl-new-dropdown-item> + + <!-- + wrapper for tooltip as button can be `disabled` + https://bootstrap-vue.org/docs/components/tooltip#disabled-elements + --> + <div v-else v-gl-tooltip :title="$options.i18n.addPanelInfo"> + <gl-new-dropdown-item + :alt="$options.i18n.addPanelInfo" + :to="newPanelPageLocation" + data-testid="add-panel-item-disabled" + disabled + class="gl-cursor-not-allowed" + > + <span class="gl-text-gray-400">{{ $options.i18n.addPanel }}</span> + </gl-new-dropdown-item> + </div> + + <gl-new-dropdown-item + v-if="isMenuItemEnabled.editDashboard" + :href="selectedDashboard ? selectedDashboard.project_blob_path : null" + data-qa-selector="edit_dashboard_button_enabled" + data-testid="edit-dashboard-item-enabled" + > + {{ $options.i18n.editDashboard }} + </gl-new-dropdown-item> + + <!-- + wrapper for tooltip as button can be `disabled` + https://bootstrap-vue.org/docs/components/tooltip#disabled-elements + --> + <div v-else v-gl-tooltip :title="$options.i18n.editDashboardInfo"> + <gl-new-dropdown-item + :alt="$options.i18n.editDashboardInfo" + :href="selectedDashboard ? selectedDashboard.project_blob_path : null" + data-testid="edit-dashboard-item-disabled" + disabled + class="gl-cursor-not-allowed" + > + <span class="gl-text-gray-400">{{ $options.i18n.editDashboard }}</span> + </gl-new-dropdown-item> + </div> + + <template v-if="isMenuItemShown.duplicateDashboard"> + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.duplicateDashboard" + data-testid="duplicate-dashboard-item" + > + {{ $options.i18n.duplicateDashboard }} + </gl-new-dropdown-item> + + <duplicate-dashboard-modal + :default-branch="defaultBranch" + :modal-id="$options.modalIds.duplicateDashboard" + data-testid="duplicate-dashboard-modal" + @dashboardDuplicated="selectDashboard" + /> + </template> + + <gl-new-dropdown-item + v-if="selectedDashboard" + data-testid="star-dashboard-item" + :disabled="isUpdatingStarredValue" + @click="toggleStarredValue()" + > + {{ selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard }} + </gl-new-dropdown-item> + + <gl-new-dropdown-divider /> + + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.createDashboard" + data-testid="create-dashboard-item" + :disabled="!isMenuItemEnabled.createDashboard" + :class="{ 'monitoring-actions-item-disabled': !isMenuItemEnabled.createDashboard }" + > + {{ $options.i18n.createDashboard }} + </gl-new-dropdown-item> + + <template v-if="isMenuItemEnabled.createDashboard"> + <create-dashboard-modal + data-testid="create-dashboard-modal" + :add-dashboard-documentation-path="addDashboardDocumentationPath" + :modal-id="$options.modalIds.createDashboard" + :project-path="projectPath" + /> + </template> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index fe6ca3a2a07..6a7bf81c643 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -3,23 +3,14 @@ import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlButton, - GlIcon, - GlDeprecatedButton, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, GlNewDropdown, - GlNewDropdownDivider, - GlNewDropdownItem, - GlModal, GlLoadingIcon, + GlNewDropdownItem, + GlNewDropdownHeader, GlSearchBoxByType, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -27,11 +18,9 @@ import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_p import DashboardsDropdown from './dashboards_dropdown.vue'; import RefreshButton from './refresh_button.vue'; -import CreateDashboardModal from './create_dashboard_modal.vue'; -import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; +import ActionsMenu from './dashboard_actions_menu.vue'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils'; +import { timeRangeToUrl } from '../utils'; import { timeRanges } from '~/vue_shared/constants'; import { timezones } from '../format_date'; @@ -39,30 +28,22 @@ export default { components: { Icon, GlButton, - GlIcon, - GlDeprecatedButton, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, GlNewDropdown, - GlNewDropdownDivider, + GlLoadingIcon, GlNewDropdownItem, + GlNewDropdownHeader, + GlSearchBoxByType, - GlModal, - CustomMetricsFormFields, DateTimePicker, DashboardsDropdown, RefreshButton, - DuplicateDashboardModal, - CreateDashboardModal, + + ActionsMenu, }, directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, - TrackEvent: TrackEventDirective, }, props: { defaultBranch: { @@ -89,16 +70,6 @@ export default { required: false, default: invalidUrl, }, - externalDashboardUrl: { - type: String, - required: false, - default: '', - }, - hasMetrics: { - type: Boolean, - required: false, - default: true, - }, isRearrangingPanels: { type: Boolean, required: true, @@ -107,32 +78,20 @@ export default { type: Object, required: true, }, - addDashboardDocumentationPath: { - type: String, - required: true, - }, - }, - data() { - return { - formIsValid: null, - }; }, computed: { ...mapState('monitoringDashboard', [ 'emptyState', 'environmentsLoading', 'currentEnvironmentName', - 'isUpdatingStarredValue', 'dashboardTimezone', 'projectPath', 'canAccessOperationsSettings', 'operationsSettingsPath', 'currentDashboard', + 'externalDashboardUrl', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), - isOutOfTheBoxDashboard() { - return this.selectedDashboard?.out_of_the_box_dashboard; - }, shouldShowEmptyState() { return Boolean(this.emptyState); }, @@ -146,24 +105,27 @@ export default { // Custom metrics only avaialble on system dashboards because // they are stored in the database. This can be improved. See: // https://gitlab.com/gitlab-org/gitlab/-/issues/28241 - this.selectedDashboard?.system_dashboard + this.selectedDashboard?.out_of_the_box_dashboard ); }, showRearrangePanelsBtn() { return !this.shouldShowEmptyState && this.rearrangePanelsAvailable; }, + environmentDropdownText() { + return this.currentEnvironmentName ?? ''; + }, displayUtc() { return this.dashboardTimezone === timezones.UTC; }, - shouldShowActionsMenu() { - return Boolean(this.projectPath); - }, shouldShowSettingsButton() { return this.canAccessOperationsSettings && this.operationsSettingsPath; }, + isOOTBDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard ?? false; + }, }, methods: { - ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']), + ...mapActions('monitoringDashboard', ['filterEnvironments']), selectDashboard(dashboard) { // Once the sidebar See metrics link is updated to the new URL, // this sort of hardcoding will not be necessary. @@ -187,16 +149,6 @@ export default { toggleRearrangingPanels() { this.$emit('setRearrangingPanels', !this.isRearrangingPanels); }, - setFormValidity(isValid) { - this.formIsValid = isValid; - }, - hideAddMetricModal() { - this.$refs.addMetricModal.hide(); - }, - getAddMetricTrackingOptions, - submitCustomMetricsForm() { - this.$refs.customMetricsForm.submit(); - }, getEnvironmentPath(environment) { // Once the sidebar See metrics link is updated to the new URL, // this sort of hardcoding will not be necessary. @@ -209,16 +161,6 @@ export default { return mergeUrlParams({ environment }, url); }, }, - modalIds: { - addMetric: 'addMetric', - createDashboard: 'createDashboard', - duplicateDashboard: 'duplicateDashboard', - }, - i18n: { - starDashboard: s__('Metrics|Star dashboard'), - unstarDashboard: s__('Metrics|Unstar dashboard'), - addMetric: s__('Metrics|Add metric'), - }, timeRanges, }; </script> @@ -232,7 +174,6 @@ export default { class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" - :modal-id="$options.modalIds.duplicateDashboard" @selectDashboard="selectDashboard" /> </div> @@ -240,39 +181,30 @@ export default { <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-dropdown + <gl-new-dropdown id="monitor-environments-dropdown" ref="monitorEnvironmentsDropdown" class="flex-grow-1" data-qa-selector="environments_dropdown" toggle-class="dropdown-menu-toggle" menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" + :text="environmentDropdownText" > <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> - {{ __('Environment') }} - </gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> + <gl-new-dropdown-header>{{ __('Environment') }}</gl-new-dropdown-header> + <gl-search-box-by-type class="m-2" @input="debouncedEnvironmentsSearch" /> + + <gl-loading-icon v-if="environmentsLoading" :inline="true" /> <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item + <gl-new-dropdown-item v-for="environment in filteredEnvironments" :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" + :is-check-item="true" + :is-checked="environment.name === currentEnvironmentName" :href="getEnvironmentPath(environment.id)" - >{{ environment.name }}</gl-dropdown-item > + {{ environment.name }} + </gl-new-dropdown-item> </div> <div v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" @@ -282,7 +214,7 @@ export default { {{ __('No matching results') }} </div> </div> - </gl-dropdown> + </gl-new-dropdown> </div> <div class="mb-2 pr-2 d-flex d-sm-block"> @@ -305,163 +237,56 @@ export default { <div class="flex-grow-1"></div> <div class="d-sm-flex"> - <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex"> - <!-- - wrapper for tooltip as button can be `disabled` - https://bootstrap-vue.org/docs/components/tooltip#disabled-elements - --> - <div - v-gl-tooltip - class="flex-grow-1" - :title=" - selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard - " - > - <gl-deprecated-button - ref="toggleStarBtn" - class="w-100" - :disabled="isUpdatingStarredValue" - variant="default" - @click="toggleStarredValue()" - > - <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" /> - </gl-deprecated-button> - </div> - </div> - <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> - <gl-deprecated-button + <gl-button :pressed="isRearrangingPanels" variant="default" class="flex-grow-1 js-rearrange-button" @click="toggleRearrangingPanels" > {{ __('Arrange charts') }} - </gl-deprecated-button> - </div> - <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> - <gl-deprecated-button - ref="addMetricBtn" - v-gl-modal="$options.modalIds.addMetric" - variant="outline-success" - data-qa-selector="add_metric_button" - class="flex-grow-1" - > - {{ $options.i18n.addMetric }} - </gl-deprecated-button> - <gl-modal - ref="addMetricModal" - :modal-id="$options.modalIds.addMetric" - :title="$options.i18n.addMetric" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal"> - {{ __('Cancel') }} - </gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - > - {{ __('Save changes') }} - </gl-deprecated-button> - </div> - </gl-modal> - </div> - - <div - v-if="selectedDashboard && selectedDashboard.can_edit" - class="mb-2 mr-2 d-flex d-sm-block" - > - <gl-deprecated-button - class="flex-grow-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - > - {{ __('Edit dashboard') }} - </gl-deprecated-button> + </gl-button> </div> <div v-if="externalDashboardUrl && externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block" > - <gl-deprecated-button + <gl-button class="flex-grow-1 js-external-dashboard-link" - variant="primary" + variant="info" + category="primary" :href="externalDashboardUrl" target="_blank" rel="noopener noreferrer" > {{ __('View full dashboard') }} <icon name="external-link" /> - </gl-deprecated-button> + </gl-button> </div> - <!-- This separator should be displayed only if at least one of the action menu or settings button are displayed --> - <span - v-if="shouldShowActionsMenu || shouldShowSettingsButton" - aria-hidden="true" - class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" - ></span> + <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> + <actions-menu + :adding-metrics-available="addingMetricsAvailable" + :custom-metrics-path="customMetricsPath" + :validate-query-path="validateQueryPath" + :default-branch="defaultBranch" + :is-ootb-dashboard="isOOTBDashboard" + /> + </div> - <div v-if="shouldShowActionsMenu" class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> - <gl-new-dropdown - v-gl-tooltip - right - class="gl-flex-grow-1" - data-testid="actions-menu" - :title="s__('Metrics|Create dashboard')" - :icon="'plus-square'" - > - <gl-new-dropdown-item - v-gl-modal="$options.modalIds.createDashboard" - data-testid="action-create-dashboard" - >{{ s__('Metrics|Create new dashboard') }}</gl-new-dropdown-item - > + <template v-if="shouldShowSettingsButton"> + <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> - <create-dashboard-modal - data-testid="create-dashboard-modal" - :add-dashboard-documentation-path="addDashboardDocumentationPath" - :modal-id="$options.modalIds.createDashboard" - :project-path="projectPath" + <div class="mb-2 mr-2 d-flex d-sm-block"> + <gl-button + v-gl-tooltip + data-testid="metrics-settings-button" + icon="settings" + :href="operationsSettingsPath" + :title="s__('Metrics|Metrics Settings')" /> - - <template v-if="isOutOfTheBoxDashboard"> - <gl-new-dropdown-divider /> - <gl-new-dropdown-item - ref="duplicateDashboardItem" - v-gl-modal="$options.modalIds.duplicateDashboard" - data-testid="action-duplicate-dashboard" - > - {{ s__('Metrics|Duplicate current dashboard') }} - </gl-new-dropdown-item> - </template> - </gl-new-dropdown> - </div> - - <div v-if="shouldShowSettingsButton" class="mb-2 mr-2 d-flex d-sm-block"> - <gl-button - v-gl-tooltip - data-testid="metrics-settings-button" - icon="settings" - :href="operationsSettingsPath" - :title="s__('Metrics|Metrics Settings')" - /> - </div> + </div> + </template> </div> - <duplicate-dashboard-modal - :default-branch="defaultBranch" - :modal-id="$options.modalIds.duplicateDashboard" - @dashboardDuplicated="selectDashboard" - /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 3e3c8408de3..278858d3a94 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -1,20 +1,23 @@ <script> import { mapState } from 'vuex'; -import { pickBy } from 'lodash'; -import invalidUrl from '~/lib/utils/invalid_url'; -import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; +import { mapValues, pickBy } from 'lodash'; import { GlResizeObserverDirective, GlIcon, + GlLink, GlLoadingIcon, GlNewDropdown as GlDropdown, GlNewDropdownItem as GlDropdownItem, GlNewDropdownDivider as GlDropdownDivider, GlModal, GlModalDirective, + GlSprintf, GlTooltip, GlTooltipDirective, } from '@gitlab/ui'; +import invalidUrl from '~/lib/utils/invalid_url'; +import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import { panelTypes } from '../constants'; @@ -22,6 +25,7 @@ import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; +import MonitorGaugeChart from './charts/gauge.vue'; import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; @@ -30,6 +34,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import AlertWidget from './alert_widget.vue'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { graphDataToCsv } from '../csv_export'; const events = { timeRangeZoom: 'timerangezoom', @@ -41,12 +46,14 @@ export default { MonitorEmptyChart, AlertWidget, GlIcon, + GlLink, GlLoadingIcon, GlTooltip, GlDropdown, GlDropdownItem, GlDropdownDivider, GlModal, + GlSprintf, }, directives: { GlResizeObserver: GlResizeObserverDirective, @@ -128,6 +135,15 @@ export default { return getters[`${this.namespace}/selectedDashboard`]; }, }), + fixedCurrentTimeRange() { + // convertToFixedRange throws an error if the time range + // is not properly set. + try { + return convertToFixedRange(this.timeRange); + } catch { + return {}; + } + }, title() { return this.graphData?.title || ''; }, @@ -148,13 +164,10 @@ export default { return null; }, csvText() { - const chartData = this.graphData?.metrics[0].result[0].values || []; - const yLabel = this.graphData.y_label; - const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/require-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); + if (this.graphData) { + return graphDataToCsv(this.graphData); + } + return null; }, downloadCsv() { const data = new Blob([this.csvText], { type: 'text/plain' }); @@ -172,6 +185,9 @@ export default { if (this.isPanelType(panelTypes.SINGLE_STAT)) { return MonitorSingleStatChart; } + if (this.isPanelType(panelTypes.GAUGE_CHART)) { + return MonitorGaugeChart; + } if (this.isPanelType(panelTypes.HEATMAP)) { return MonitorHeatmapChart; } @@ -217,7 +233,8 @@ export default { return ( this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART) || - this.isPanelType(panelTypes.SINGLE_STAT) + this.isPanelType(panelTypes.SINGLE_STAT) || + this.isPanelType(panelTypes.GAUGE_CHART) ); }, editCustomMetricLink() { @@ -328,6 +345,19 @@ export default { this.$refs.copyChartLink.$el.firstChild.click(); } }, + getAlertRunbooks(queries) { + const hasRunbook = alert => Boolean(alert.runbookUrl); + const graphAlertsWithRunbooks = pickBy(this.getGraphAlerts(queries), hasRunbook); + const alertToRunbookTransform = alert => { + const alertQuery = queries.find(query => query.metricId === alert.metricId); + return { + key: alert.metricId, + href: alert.runbookUrl, + label: alertQuery.label, + }; + }; + return mapValues(graphAlertsWithRunbooks, alertToRunbookTransform); + }, }, panelTypes, }; @@ -364,15 +394,21 @@ export default { data-qa-selector="prometheus_graph_widgets" > <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> + <!-- + This component should be replaced with a variant developed + as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 + The variant will create a dropdown with an icon, no text and no caret + --> <gl-dropdown v-gl-tooltip - toggle-class="shadow-none border-0" + toggle-class="gl-px-3!" + no-caret data-qa-selector="prometheus_widgets_dropdown" right :title="__('More actions')" > - <template slot="button-content"> - <gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" /> + <template #button-content> + <gl-icon class="gl-mr-0!" name="ellipsis_v" /> </template> <gl-dropdown-item v-if="expandBtnAvailable" @@ -423,6 +459,25 @@ export default { > {{ __('Alerts') }} </gl-dropdown-item> + <gl-dropdown-item + v-for="runbook in getAlertRunbooks(graphData.metrics)" + :key="runbook.key" + :href="safeUrl(runbook.href)" + data-testid="runbookLink" + target="_blank" + rel="noopener noreferrer" + > + <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <span> + <gl-sprintf :message="s__('Metrics|View runbook - %{label}')"> + <template #label> + {{ runbook.label }} + </template> + </gl-sprintf> + </span> + <gl-icon name="external-link" /> + </span> + </gl-dropdown-item> <template v-if="graphData.links && graphData.links.length"> <gl-dropdown-divider /> @@ -465,6 +520,7 @@ export default { :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" :timezone="dashboardTimezone" + :time-range="fixedCurrentTimeRange" v-bind="$attrs" v-on="$listeners" @datazoom="onDatazoom" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue new file mode 100644 index 00000000000..88d5a35146f --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -0,0 +1,199 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlCard, + GlForm, + GlFormGroup, + GlFormTextarea, + GlButton, + GlSprintf, + GlAlert, + GlTooltipDirective, +} from '@gitlab/ui'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; +import { timeRanges } from '~/vue_shared/constants'; +import DashboardPanel from './dashboard_panel.vue'; + +const initialYml = `title: Go heap size +type: area-chart +y_axis: + format: 'bytes' +metrics: + - metric_id: 'go_memstats_alloc_bytes_1' + query_range: 'go_memstats_alloc_bytes' +`; + +export default { + components: { + GlCard, + GlForm, + GlFormGroup, + GlFormTextarea, + GlButton, + GlSprintf, + GlAlert, + DashboardPanel, + DateTimePicker, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + data() { + return { + yml: initialYml, + }; + }, + computed: { + ...mapState('monitoringDashboard', [ + 'panelPreviewIsLoading', + 'panelPreviewError', + 'panelPreviewGraphData', + 'panelPreviewTimeRange', + 'panelPreviewIsShown', + 'projectPath', + 'addDashboardDocumentationPath', + ]), + }, + methods: { + ...mapActions('monitoringDashboard', [ + 'fetchPanelPreview', + 'fetchPanelPreviewMetrics', + 'setPanelPreviewTimeRange', + ]), + onSubmit() { + this.fetchPanelPreview(this.yml); + }, + onDateTimePickerInput(timeRange) { + this.setPanelPreviewTimeRange(timeRange); + // refetch data only if preview has been clicked + // and there are no errors + if (this.panelPreviewIsShown && !this.panelPreviewError) { + this.fetchPanelPreviewMetrics(); + } + }, + onRefresh() { + // refetch data only if preview has been clicked + // and there are no errors + if (this.panelPreviewIsShown && !this.panelPreviewError) { + this.fetchPanelPreviewMetrics(); + } + }, + }, + timeRanges, +}; +</script> +<template> + <div class="prometheus-panel-builder"> + <div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3"> + <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3"> + <template #header> + <h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2> + </template> + <template #default> + <p>{{ s__('Metrics|Define panel YAML below to preview panel.') }}</p> + <gl-form @submit.prevent="onSubmit"> + <gl-form-group :label="s__('Metrics|Panel YAML')" label-for="panel-yml-input"> + <gl-form-textarea + id="panel-yml-input" + v-model="yml" + class="gl-h-200! gl-font-monospace! gl-font-size-monospace!" + /> + </gl-form-group> + <div class="gl-text-right"> + <gl-button + ref="clipboardCopyBtn" + variant="success" + category="secondary" + :data-clipboard-text="yml" + class="gl-xs-w-full gl-xs-mb-3" + @click="$toast.show(s__('Metrics|Panel YAML copied'))" + > + {{ s__('Metrics|Copy YAML') }} + </gl-button> + <gl-button + type="submit" + variant="success" + :disabled="panelPreviewIsLoading" + class="js-no-auto-disable gl-xs-w-full" + > + {{ s__('Metrics|Preview panel') }} + </gl-button> + </div> + </gl-form> + </template> + </gl-card> + + <gl-card + class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3" + body-class="gl-display-flex gl-flex-direction-column" + > + <template #header> + <h2 class="gl-font-size-h2 gl-my-3"> + {{ s__('Metrics|2. Paste panel YAML into dashboard') }} + </h2> + </template> + <template #default> + <div + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-justify-content-center" + > + <p> + {{ s__('Metrics|Copy and paste the panel YAML into your dashboard YAML file.') }} + <br /> + <gl-sprintf + :message=" + s__( + 'Metrics|Dashboard files can be found in %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.', + ) + " + > + <template #code="{content}"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> + + <div class="gl-text-right"> + <gl-button + ref="viewDocumentationBtn" + category="secondary" + class="gl-xs-w-full gl-xs-mb-3" + variant="info" + target="_blank" + :href="addDashboardDocumentationPath" + > + {{ s__('Metrics|View documentation') }} + </gl-button> + <gl-button + ref="openRepositoryBtn" + variant="success" + :href="projectPath" + class="gl-xs-w-full" + > + {{ s__('Metrics|Open repository') }} + </gl-button> + </div> + </template> + </gl-card> + </div> + + <gl-alert v-if="panelPreviewError" variant="warning" :dismissible="false"> + {{ panelPreviewError }} + </gl-alert> + <date-time-picker + ref="dateTimePicker" + class="gl-flex-grow-1 preview-date-time-picker gl-xs-mb-3" + :value="panelPreviewTimeRange" + :options="$options.timeRanges" + @input="onDateTimePickerInput" + /> + <gl-button + v-gl-tooltip + data-testid="previewRefreshButton" + icon="retry" + :title="s__('Metrics|Refresh Prometheus data')" + @click="onRefresh" + /> + <dashboard-panel :graph-data="panelPreviewGraphData" /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 574f48a72fe..aed27b5ea51 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,11 +1,11 @@ <script> -import { mapState, mapActions, mapGetters } from 'vuex'; +import { mapState, mapGetters } from 'vuex'; import { GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownHeader, + GlNewDropdownDivider, GlSearchBoxByType, GlModalDirective, } from '@gitlab/ui'; @@ -17,10 +17,10 @@ const events = { export default { components: { GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownHeader, + GlNewDropdownDivider, GlSearchBoxByType, }, directives: { @@ -31,10 +31,6 @@ export default { type: String, required: true, }, - modalId: { - type: String, - required: true, - }, }, data() { return { @@ -44,9 +40,6 @@ export default { computed: { ...mapState('monitoringDashboard', ['allDashboards']), ...mapGetters('monitoringDashboard', ['selectedDashboard']), - isOutOfTheBoxDashboard() { - return this.selectedDashboard?.out_of_the_box_dashboard; - }, selectedDashboardText() { return this.selectedDashboard?.display_name; }, @@ -70,7 +63,6 @@ export default { }, }, methods: { - ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), dashboardDisplayName(dashboard) { return dashboard.display_name || dashboard.path || ''; }, @@ -81,16 +73,13 @@ export default { }; </script> <template> - <gl-dropdown + <gl-new-dropdown toggle-class="dropdown-menu-toggle" menu-class="monitor-dashboard-dropdown-menu" :text="selectedDashboardText" > <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-dashboard-dropdown-header text-center">{{ - __('Dashboard') - }}</gl-dropdown-header> - <gl-dropdown-divider /> + <gl-new-dropdown-header>{{ __('Dashboard') }}</gl-new-dropdown-header> <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" @@ -98,33 +87,36 @@ export default { /> <div class="flex-fill overflow-auto"> - <gl-dropdown-item + <gl-new-dropdown-item v-for="dashboard in starredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboardPath" - active-class="is-active" + :is-check-item="true" + :is-checked="dashboard.path === selectedDashboardPath" @click="selectDashboard(dashboard)" > - <div class="d-flex"> - {{ dashboardDisplayName(dashboard) }} - <gl-icon class="text-muted ml-auto" name="star" /> + <div class="gl-display-flex"> + <div class="gl-flex-grow-1 gl-min-w-0"> + <div class="gl-word-break-all"> + {{ dashboardDisplayName(dashboard) }} + </div> + </div> + <gl-icon class="text-muted gl-flex-shrink-0" name="star" /> </div> - </gl-dropdown-item> - - <gl-dropdown-divider + </gl-new-dropdown-item> + <gl-new-dropdown-divider v-if="starredDashboards.length && nonStarredDashboards.length" ref="starredListDivider" /> - <gl-dropdown-item + <gl-new-dropdown-item v-for="dashboard in nonStarredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboardPath" - active-class="is-active" + :is-check-item="true" + :is-checked="dashboard.path === selectedDashboardPath" @click="selectDashboard(dashboard)" > {{ dashboardDisplayName(dashboard) }} - </gl-dropdown-item> + </gl-new-dropdown-item> </div> <div @@ -134,18 +126,6 @@ export default { > {{ __('No matching results') }} </div> - - <!-- - This Duplicate Dashboard item will be removed from the dashboards dropdown - in https://gitlab.com/gitlab-org/gitlab/-/issues/223223 - --> - <template v-if="isOutOfTheBoxDashboard"> - <gl-dropdown-divider /> - - <gl-dropdown-item v-gl-modal="modalId" data-testid="duplicateDashboardItem"> - {{ s__('Metrics|Duplicate dashboard') }} - </gl-dropdown-item> - </template> </div> - </gl-dropdown> + </gl-new-dropdown> </template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue index 001cd0d47f1..db5b853d451 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -1,7 +1,7 @@ <script> -import { __, s__, sprintf } from '~/locale'; import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui'; import { escape as esc } from 'lodash'; +import { __, s__, sprintf } from '~/locale'; const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0]; diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue index dee4e5998ee..9cf492dd537 100644 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -1,6 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { GlEmptyState } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import { metricStates } from '../constants'; export default { diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue index 98b07d17694..ca1e9c4d0d4 100644 --- a/app/assets/javascripts/monitoring/components/links_section.vue +++ b/app/assets/javascripts/monitoring/components/links_section.vue @@ -23,7 +23,7 @@ export default { class="gl-mb-1 gl-mr-5 gl-display-flex gl-display-sm-block gl-hover-text-blue-600-children gl-word-break-all" > <gl-link :href="link.url" class="gl-text-gray-900 gl-text-decoration-none!" - ><gl-icon name="link" class="gl-text-gray-700 gl-vertical-align-text-bottom gl-mr-2" />{{ + ><gl-icon name="link" class="gl-text-gray-500 gl-vertical-align-text-bottom gl-mr-2" />{{ link.title }} </gl-link> diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue index 5481806c3e0..0e9605450ed 100644 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -1,7 +1,6 @@ <script> -import { n__, __ } from '~/locale'; +import Visibility from 'visibilityjs'; import { mapActions } from 'vuex'; - import { GlButtonGroup, GlButton, @@ -10,6 +9,9 @@ import { GlNewDropdownDivider, GlTooltipDirective, } from '@gitlab/ui'; +import { n__, __ } from '~/locale'; + +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const makeInterval = (length = 0, unit = 's') => { const shortLabel = `${length}${unit}`; @@ -53,6 +55,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], data() { return { refreshInterval: null, @@ -60,6 +63,12 @@ export default { }; }, computed: { + disableMetricDashboardRefreshRate() { + // Can refresh rates impact performance? + // Add "negative" feature flag called `disable_metric_dashboard_refresh_rate` + // See more at: https://gitlab.com/gitlab-org/gitlab/-/issues/229831 + return this.glFeatures.disableMetricDashboardRefreshRate; + }, dropdownText() { return this.refreshInterval?.shortLabel ?? __('Off'); }, @@ -90,7 +99,8 @@ export default { }; this.stopAutoRefresh(); - if (document.hidden) { + + if (Visibility.hidden()) { // Inactive tab? Skip fetch and schedule again schedule(); } else { @@ -142,7 +152,12 @@ export default { icon="retry" @click="refresh" /> - <gl-new-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> + <gl-new-dropdown + v-if="!disableMetricDashboardRefreshRate" + v-gl-tooltip + :title="s__('Metrics|Set refresh rate')" + :text="dropdownText" + > <gl-new-dropdown-item :is-check-item="true" :is-checked="refreshInterval === null" diff --git a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue index 4e48292c48d..5563a27301d 100644 --- a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue +++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue @@ -1,11 +1,11 @@ <script> -import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlFormGroup, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; export default { components: { GlFormGroup, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, }, props: { name: { @@ -41,13 +41,16 @@ export default { </script> <template> <gl-form-group :label="label"> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> - <gl-dropdown-item + <gl-deprecated-dropdown + toggle-class="dropdown-menu-toggle" + :text="text || s__('Metrics|Select a value')" + > + <gl-deprecated-dropdown-item v-for="val in options.values" :key="val.value" @click="onUpdate(val.value)" - >{{ val.text }}</gl-dropdown-item + >{{ val.text }}</gl-deprecated-dropdown-item > - </gl-dropdown> + </gl-deprecated-dropdown> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index afeb3318eb9..81ad3137b8b 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -87,6 +87,10 @@ export const panelTypes = { */ SINGLE_STAT: 'single-stat', /** + * Gauge + */ + GAUGE_CHART: 'gauge', + /** * Heatmap */ HEATMAP: 'heatmap', @@ -213,7 +217,7 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; * This technical debt is being tracked here * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 */ -export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; +export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; /** * GitLab provide metrics dashboards that are available to a user once @@ -272,3 +276,8 @@ export const keyboardShortcutKeys = { DOWNLOAD_CSV: 'd', CHART_COPY: 'c', }; + +export const thresholdModeTypes = { + ABSOLUTE: 'absolute', + PERCENTAGE: 'percentage', +}; diff --git a/app/assets/javascripts/monitoring/csv_export.js b/app/assets/javascripts/monitoring/csv_export.js new file mode 100644 index 00000000000..734e8dc07a7 --- /dev/null +++ b/app/assets/javascripts/monitoring/csv_export.js @@ -0,0 +1,147 @@ +import { getSeriesLabel } from '~/helpers/monitor_helper'; + +/** + * Returns a label for a header of the csv. + * + * Includes double quotes ("") in case the header includes commas or other separator. + * + * @param {String} axisLabel + * @param {String} metricLabel + * @param {Object} metricAttributes + */ +const csvHeader = (axisLabel, metricLabel, metricAttributes = {}) => + `${axisLabel} > ${getSeriesLabel(metricLabel, metricAttributes)}`; + +/** + * Returns an array with the header labels given a list of metrics + * + * ``` + * metrics = [ + * { + * label: "..." // user-defined label + * result: [ + * { + * metric: { ... } // metricAttributes + * }, + * ... + * ] + * }, + * ... + * ] + * ``` + * + * When metrics have a `label` or `metricAttributes`, they are + * used to generate the column name. + * + * @param {String} axisLabel - Main label + * @param {Array} metrics - Metrics with results + */ +const csvMetricHeaders = (axisLabel, metrics) => + metrics.flatMap(({ label, result }) => + // The `metric` in a `result` is a map of `metricAttributes` + // contains key-values to identify the series, rename it + // here for clarity. + result.map(({ metric: metricAttributes }) => { + return csvHeader(axisLabel, label, metricAttributes); + }), + ); + +/** + * Returns a (flat) array with all the values arrays in each + * metric and series + * + * ``` + * metrics = [ + * { + * result: [ + * { + * values: [ ... ] // `values` + * }, + * ... + * ] + * }, + * ... + * ] + * ``` + * + * @param {Array} metrics - Metrics with results + */ +const csvMetricValues = metrics => + metrics.flatMap(({ result }) => result.map(res => res.values || [])); + +/** + * Returns headers and rows for csv, sorted by their timestamp. + * + * { + * headers: ["timestamp", "<col_1_name>", "col_2_name"], + * rows: [ + * [ <timestamp>, <col_1_value>, <col_2_value> ], + * [ <timestamp>, <col_1_value>, <col_2_value> ] + * ... + * ] + * } + * + * @param {Array} metricHeaders + * @param {Array} metricValues + */ +const csvData = (metricHeaders, metricValues) => { + const rowsByTimestamp = {}; + + metricValues.forEach((values, colIndex) => { + values.forEach(([timestamp, value]) => { + if (!rowsByTimestamp[timestamp]) { + rowsByTimestamp[timestamp] = []; + } + // `value` should be in the right column + rowsByTimestamp[timestamp][colIndex] = value; + }); + }); + + const rows = Object.keys(rowsByTimestamp) + .sort() + .map(timestamp => { + // force each row to have the same number of entries + rowsByTimestamp[timestamp].length = metricHeaders.length; + // add timestamp as the first entry + return [timestamp, ...rowsByTimestamp[timestamp]]; + }); + + // Escape double quotes and enclose headers: + // "If double-quotes are used to enclose fields, then a double-quote + // appearing inside a field must be escaped by preceding it with + // another double quote." + // https://tools.ietf.org/html/rfc4180#page-2 + const headers = metricHeaders.map(header => `"${header.replace(/"/g, '""')}"`); + + return { + headers: ['timestamp', ...headers], + rows, + }; +}; + +/** + * Returns dashboard panel's data in a string in CSV format + * + * @param {Object} graphData - Panel contents + * @returns {String} + */ +// eslint-disable-next-line import/prefer-default-export +export const graphDataToCsv = graphData => { + const delimiter = ','; + const br = '\r\n'; + const { metrics = [], y_label: axisLabel } = graphData; + + const metricsWithResults = metrics.filter(metric => metric.result); + const metricHeaders = csvMetricHeaders(axisLabel, metricsWithResults); + const metricValues = csvMetricValues(metricsWithResults); + const { headers, rows } = csvData(metricHeaders, metricValues); + + if (rows.length === 0) { + return ''; + } + + const headerLine = headers.join(delimiter) + br; + const lines = rows.map(row => row.join(delimiter)); + + return headerLine + lines.join(br) + br; +}; diff --git a/app/assets/javascripts/monitoring/pages/panel_new_page.vue b/app/assets/javascripts/monitoring/pages/panel_new_page.vue new file mode 100644 index 00000000000..8ff6adb47ca --- /dev/null +++ b/app/assets/javascripts/monitoring/pages/panel_new_page.vue @@ -0,0 +1,45 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { DASHBOARD_PAGE } from '../router/constants'; +import DashboardPanelBuilder from '../components/dashboard_panel_builder.vue'; + +export default { + components: { + GlButton, + DashboardPanelBuilder, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + computed: { + ...mapState('monitoringDashboard', ['panelPreviewYml']), + dashboardPageLocation() { + return { + ...this.$route, + name: DASHBOARD_PAGE, + }; + }, + }, + i18n: { + backToDashboard: s__('Metrics|Back to dashboard'), + }, +}; +</script> +<template> + <div class="gl-mt-5"> + <div class="gl-display-flex gl-align-items-baseline gl-mb-5"> + <gl-button + v-gl-tooltip + icon="go-back" + :to="dashboardPageLocation" + :aria-label="$options.i18n.backToDashboard" + :title="$options.i18n.backToDashboard" + class="gl-mr-5" + /> + <h1 class="gl-font-size-h1 gl-my-0">{{ s__('Metrics|Add panel') }}</h1> + </div> + <dashboard-panel-builder /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql index 27b49860b8a..32b982ff195 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -5,6 +5,7 @@ query getAnnotations( $startingFrom: Time! ) { project(fullPath: $projectPath) { + id environments(name: $environmentName) { nodes { id diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql index 17cd1b2c342..48d0a780fc7 100644 --- a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql @@ -1,5 +1,6 @@ query getEnvironments($projectPath: ID!, $search: String, $states: [String!]) { project(fullPath: $projectPath) { + id data: environments(search: $search, states: $states) { environments: nodes { name diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js new file mode 100644 index 00000000000..28064361768 --- /dev/null +++ b/app/assets/javascripts/monitoring/requests/index.js @@ -0,0 +1,46 @@ +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import { PROMETHEUS_TIMEOUT } from '../constants'; + +const cancellableBackOffRequest = makeRequestCallback => + backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.NO_CONTENT) { + next(); + } else { + stop(resp); + } + }) + // If the request is cancelled by axios + // then consider it as noop so that its not + // caught by subsequent catches + .catch(thrown => (axios.isCancel(thrown) ? undefined : stop(thrown))); + }, PROMETHEUS_TIMEOUT); + +export const getDashboard = (dashboardEndpoint, params) => + cancellableBackOffRequest(() => axios.get(dashboardEndpoint, { params })).then( + axiosResponse => axiosResponse.data, + ); + +export const getPrometheusQueryData = (prometheusEndpoint, params, opts) => + cancellableBackOffRequest(() => axios.get(prometheusEndpoint, { params, ...opts })) + .then(axiosResponse => axiosResponse.data) + .then(prometheusResponse => prometheusResponse.data) + .catch(error => { + // Prometheus returns errors in specific cases + // https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview + const { response = {} } = error; + if ( + response.status === statusCodes.BAD_REQUEST || + response.status === statusCodes.UNPROCESSABLE_ENTITY || + response.status === statusCodes.SERVICE_UNAVAILABLE + ) { + const { data } = response; + if (data?.status === 'error' && data?.error) { + throw new Error(data.error); + } + } + throw error; + }); diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js index fedfebe33e9..7834c14a65d 100644 --- a/app/assets/javascripts/monitoring/router/constants.js +++ b/app/assets/javascripts/monitoring/router/constants.js @@ -1,4 +1,7 @@ -export const BASE_DASHBOARD_PAGE = 'dashboard'; -export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard'; +export const DASHBOARD_PAGE = 'dashboard'; +export const PANEL_NEW_PAGE = 'panel_new'; -export default {}; +export default { + DASHBOARD_PAGE, + PANEL_NEW_PAGE, +}; diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js index 4b82791178a..cc43fd8622a 100644 --- a/app/assets/javascripts/monitoring/router/routes.js +++ b/app/assets/javascripts/monitoring/router/routes.js @@ -1,6 +1,7 @@ import DashboardPage from '../pages/dashboard_page.vue'; +import PanelNewPage from '../pages/panel_new_page.vue'; -import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; +import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from './constants'; /** * Because the cluster health page uses the dashboard @@ -11,13 +12,13 @@ import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; */ export default [ { - name: BASE_DASHBOARD_PAGE, - path: '/', - component: DashboardPage, + name: PANEL_NEW_PAGE, + path: '/:dashboard(.+)?/panel/new', + component: PanelNewPage, }, { - name: CUSTOM_DASHBOARD_PAGE, - path: '/:dashboard(.*)', + name: DASHBOARD_PAGE, + path: '/:dashboard(.+)?', component: DashboardPage, }, ]; diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js index 4b7337972fe..a67675f1a3d 100644 --- a/app/assets/javascripts/monitoring/services/alerts_service.js +++ b/app/assets/javascripts/monitoring/services/alerts_service.js @@ -1,28 +1,39 @@ import axios from '~/lib/utils/axios_utils'; +const mapAlert = ({ runbook_url, ...alert }) => { + return { runbookUrl: runbook_url, ...alert }; +}; + export default class AlertsService { constructor({ alertsEndpoint }) { this.alertsEndpoint = alertsEndpoint; } getAlerts() { - return axios.get(this.alertsEndpoint).then(resp => resp.data); + return axios.get(this.alertsEndpoint).then(resp => mapAlert(resp.data)); } - createAlert({ prometheus_metric_id, operator, threshold }) { + createAlert({ prometheus_metric_id, operator, threshold, runbookUrl }) { return axios - .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) - .then(resp => resp.data); + .post(this.alertsEndpoint, { + prometheus_metric_id, + operator, + threshold, + runbook_url: runbookUrl, + }) + .then(resp => mapAlert(resp.data)); } // eslint-disable-next-line class-methods-use-this readAlert(alertPath) { - return axios.get(alertPath).then(resp => resp.data); + return axios.get(alertPath).then(resp => mapAlert(resp.data)); } // eslint-disable-next-line class-methods-use-this - updateAlert(alertPath, { operator, threshold }) { - return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); + updateAlert(alertPath, { operator, threshold, runbookUrl }) { + return axios + .put(alertPath, { operator, threshold, runbook_url: runbookUrl }) + .then(resp => mapAlert(resp.data)); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index a441882a47d..16a685305dc 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { gqClient, @@ -13,16 +13,14 @@ import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; -import statusCodes from '../../lib/utils/http_status'; -import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; +import { getDashboard, getPrometheusQueryData } from '../requests'; -import { - PROMETHEUS_TIMEOUT, - ENVIRONMENT_AVAILABLE_STATE, - DEFAULT_DASHBOARD_PATH, - VARIABLE_TYPES, -} from '../constants'; +import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; + +const axiosCancelToken = axios.CancelToken; +let cancelTokenSource; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -38,29 +36,18 @@ function prometheusMetricQueryParams(timeRange) { }; } -function backOffRequest(makeRequestCallback) { - return backOff((next, stop) => { - makeRequestCallback() - .then(resp => { - if (resp.status === statusCodes.NO_CONTENT) { - next(); - } else { - stop(resp); - } - }) - .catch(stop); - }, PROMETHEUS_TIMEOUT); -} - -function getPrometheusQueryData(prometheusEndpoint, params) { - return backOffRequest(() => axios.get(prometheusEndpoint, { params })) - .then(res => res.data) - .then(response => { - if (response.status === 'error') { - throw new Error(response.error); - } - return response.data; - }); +/** + * Extract error messages from API or HTTP request errors. + * + * - API errors are in `error.response.data.message` + * - HTTP (axios) errors are in `error.messsage` + * + * @param {Object} error + * @returns {String} User friendly error message + */ +function extractErrorMessage(error) { + const message = error?.response?.data?.message; + return message ?? error.message; } // Setup @@ -126,8 +113,7 @@ export const fetchDashboard = ({ state, commit, dispatch, getters }) => { params.dashboard = getters.fullDashboardPath; } - return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) - .then(resp => resp.data) + return getDashboard(state.dashboardEndpoint, params) .then(response => { dispatch('receiveMetricsDashboardSuccess', { response }); /** @@ -329,7 +315,7 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { export const fetchAnnotations = ({ state, dispatch, getters }) => { const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH; return gqClient .mutate({ mutation: getAnnotations, @@ -362,12 +348,12 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => { /** - * Normally, the default dashboard won't throw any validation warnings. + * Normally, the overview dashboard won't throw any validation warnings. * - * However, if a bug sneaks into the default dashboard making it invalid, + * However, if a bug sneaks into the overview dashboard making it invalid, * this might come handy for our clients */ - const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH; return gqClient .mutate({ mutation: getDashboardValidationWarnings, @@ -484,12 +470,10 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery if (variable.type === VARIABLE_TYPES.metric_label_values) { const { prometheusEndpointPath, label } = variable.options; - const optionsRequest = backOffRequest(() => - axios.get(prometheusEndpointPath, { - params: { start_time, end_time }, - }), - ) - .then(({ data }) => data.data) + const optionsRequest = getPrometheusQueryData(prometheusEndpointPath, { + start_time, + end_time, + }) .then(data => { commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); }) @@ -507,5 +491,59 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery return Promise.all(optionsRequests); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +// Panel Builder + +export const setPanelPreviewTimeRange = ({ commit }, timeRange) => { + commit(types.SET_PANEL_PREVIEW_TIME_RANGE, timeRange); +}; + +export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml) => { + if (!panelPreviewYml) { + return null; + } + + commit(types.SET_PANEL_PREVIEW_IS_SHOWN, true); + commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml); + + return axios + .post(state.panelPreviewEndpoint, { panel_yaml: panelPreviewYml }) + .then(({ data }) => { + commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data); + + dispatch('fetchPanelPreviewMetrics'); + }) + .catch(error => { + commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, extractErrorMessage(error)); + }); +}; + +export const fetchPanelPreviewMetrics = ({ state, commit }) => { + if (cancelTokenSource) { + cancelTokenSource.cancel(); + } + cancelTokenSource = axiosCancelToken.source(); + + const defaultQueryParams = prometheusMetricQueryParams(state.panelPreviewTimeRange); + + state.panelPreviewGraphData.metrics.forEach((metric, index) => { + commit(types.REQUEST_PANEL_PREVIEW_METRIC_RESULT, { index }); + + const params = { ...defaultQueryParams }; + if (metric.step) { + params.step = metric.step; + } + return getPrometheusQueryData(metric.prometheusEndpointPath, params, { + cancelToken: cancelTokenSource.token, + }) + .then(data => { + commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS, { index, data }); + }) + .catch(error => { + Sentry.captureException(error); + + commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE, { index, error }); + // Continue to throw error so the panel builder can notify using createFlash + throw error; + }); + }); +}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/actions.js b/app/assets/javascripts/monitoring/stores/embed_group/actions.js index cbe0950d954..4a7572bdbd9 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/actions.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/actions.js @@ -1,5 +1,4 @@ import * as types from './mutation_types'; +// eslint-disable-next-line import/prefer-default-export export const addModule = ({ commit }, data) => commit(types.ADD_MODULE, data); - -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/getters.js b/app/assets/javascripts/monitoring/stores/embed_group/getters.js index 9b08cf762c1..096d8d03096 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/getters.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/getters.js @@ -1,4 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export export const metricsWithData = (state, getters, rootState, rootGetters) => state.modules.map(module => rootGetters[`${module}/metricsWithData`]().length); - -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js b/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js index e7a425d3623..7fd3f0f8647 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js @@ -1,3 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export export const ADD_MODULE = 'ADD_MODULE'; - -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 3aa711a0509..8ed83cf02fe 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -170,6 +170,3 @@ export const getCustomVariablesParams = state => */ export const fullDashboardPath = state => normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index d408628fc4d..1d7279912cc 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -46,3 +46,17 @@ export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL'; + +// Panel preview +export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW'; +export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS'; +export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE'; + +export const REQUEST_PANEL_PREVIEW_METRIC_RESULT = 'REQUEST_PANEL_PREVIEW_METRIC_RESULT'; +export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS = + 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS'; +export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE = + 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE'; + +export const SET_PANEL_PREVIEW_TIME_RANGE = 'SET_PANEL_PREVIEW_TIME_RANGE'; +export const SET_PANEL_PREVIEW_IS_SHOWN = 'SET_PANEL_PREVIEW_IS_SHOWN'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 744441c8935..09a5861b475 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; -import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils'; +import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils'; import httpStatusCodes from '~/lib/utils/http_status'; -import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; +import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils'; import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; import { optionsFromSeriesData } from './variable_mapping'; @@ -53,6 +53,14 @@ const emptyStateFromError = error => { return metricStates.UNKNOWN_ERROR; }; +export const metricStateFromData = data => { + if (data?.result?.length) { + const result = normalizeQueryResponseData(data); + return { state: metricStates.OK, result: Object.freeze(result) }; + } + return { state: metricStates.NO_DATA, result: null }; +}; + export default { /** * Dashboard panels structure and global state @@ -154,17 +162,11 @@ export default { }, [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { const metric = findMetricInDashboard(metricId, state.dashboard); - metric.loading = false; + const metricState = metricStateFromData(data); - if (!data.result || data.result.length === 0) { - metric.state = metricStates.NO_DATA; - metric.result = null; - } else { - const result = normalizeQueryResponseData(data); - - metric.state = metricStates.OK; - metric.result = Object.freeze(result); - } + metric.loading = false; + metric.state = metricState.state; + metric.result = metricState.result; }, [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { const metric = findMetricInDashboard(metricId, state.dashboard); @@ -218,4 +220,54 @@ export default { // Add new options with assign to ensure Vue reactivity Object.assign(variable.options, { values }); }, + + [types.REQUEST_PANEL_PREVIEW](state, panelPreviewYml) { + state.panelPreviewIsLoading = true; + + state.panelPreviewYml = panelPreviewYml; + state.panelPreviewGraphData = null; + state.panelPreviewError = null; + }, + [types.RECEIVE_PANEL_PREVIEW_SUCCESS](state, payload) { + state.panelPreviewIsLoading = false; + + state.panelPreviewGraphData = mapPanelToViewModel(payload); + state.panelPreviewError = null; + }, + [types.RECEIVE_PANEL_PREVIEW_FAILURE](state, error) { + state.panelPreviewIsLoading = false; + + state.panelPreviewGraphData = null; + state.panelPreviewError = error; + }, + + [types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](state, { index }) { + const metric = state.panelPreviewGraphData.metrics[index]; + + metric.loading = true; + if (!metric.result) { + metric.state = metricStates.LOADING; + } + }, + [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](state, { index, data }) { + const metric = state.panelPreviewGraphData.metrics[index]; + const metricState = metricStateFromData(data); + + metric.loading = false; + metric.state = metricState.state; + metric.result = metricState.result; + }, + [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](state, { index, error }) { + const metric = state.panelPreviewGraphData.metrics[index]; + + metric.loading = false; + metric.state = emptyStateFromError(error); + metric.result = null; + }, + [types.SET_PANEL_PREVIEW_TIME_RANGE](state, timeRange) { + state.panelPreviewTimeRange = timeRange; + }, + [types.SET_PANEL_PREVIEW_IS_SHOWN](state, isPreviewShown) { + state.panelPreviewIsShown = isPreviewShown; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 89738756ffe..ef8b1adb624 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,12 +1,14 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { timezones } from '../format_date'; import { dashboardEmptyStates } from '../constants'; +import { defaultTimeRange } from '~/vue_shared/constants'; export default () => ({ // API endpoints deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, dashboardsEndpoint: invalidUrl, + panelPreviewEndpoint: invalidUrl, // Dashboard request parameters timeRange: null, @@ -59,6 +61,15 @@ export default () => ({ * via the dashboard yml file. */ links: [], + + // Panel editor / builder + panelPreviewYml: '', + panelPreviewIsLoading: false, + panelPreviewGraphData: null, + panelPreviewError: null, + panelPreviewTimeRange: defaultTimeRange, + panelPreviewIsShown: false, + // Other project data dashboardTimezone: timezones.LOCAL, annotations: [], @@ -69,9 +80,11 @@ export default () => ({ currentEnvironmentName: null, // GitLab paths to other pages + externalDashboardUrl: '', projectPath: null, operationsSettingsPath: '', logsPath: invalidUrl, + addDashboardDocumentationPath: '', // static paths customDashboardBasePath: '', diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 51562593ee8..df7f22e622f 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -176,7 +176,11 @@ export const mapPanelToViewModel = ({ field, metrics = [], links = [], + min_value, max_value, + split, + thresholds, + format, }) => { // Both `x_axis.name` and `x_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/210521 @@ -195,7 +199,11 @@ export const mapPanelToViewModel = ({ yAxis, xAxis, field, + minValue: min_value, maxValue: max_value, + split, + thresholds, + format, links: links.map(mapLinksToViewModel), metrics: mapToMetricsViewModel(metrics), }; @@ -465,9 +473,9 @@ export const addPrefixToCustomVariableParams = name => `variables[${name}]`; * metrics dashboard to work with custom dashboard file names instead * of the entire path. * - * If dashboard is empty, it is the default dashboard. + * If dashboard is empty, it is the overview dashboard. * If dashboard is set, it usually is a custom dashboard unless - * explicitly it is set to default dashboard path. + * explicitly it is set to overview dashboard path. * * @param {String} dashboard dashboard path * @param {String} dashboardPrefix custom dashboard directory prefix diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 0c6fcad9dd0..92bbce498d5 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -24,13 +24,16 @@ export const stateAndPropsFromDataset = (dataset = {}) => { deploymentsEndpoint, dashboardEndpoint, dashboardsEndpoint, + panelPreviewEndpoint, dashboardTimezone, canAccessOperationsSettings, operationsSettingsPath, projectPath, logsPath, + externalDashboardUrl, currentEnvironmentName, customDashboardBasePath, + addDashboardDocumentationPath, ...dataProps } = dataset; @@ -45,13 +48,16 @@ export const stateAndPropsFromDataset = (dataset = {}) => { deploymentsEndpoint, dashboardEndpoint, dashboardsEndpoint, + panelPreviewEndpoint, dashboardTimezone, canAccessOperationsSettings, operationsSettingsPath, projectPath, logsPath, + externalDashboardUrl, currentEnvironmentName, customDashboardBasePath, + addDashboardDocumentationPath, }, dataProps, }; diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js index cd426f1a221..c6b323f6360 100644 --- a/app/assets/javascripts/monitoring/validators.js +++ b/app/assets/javascripts/monitoring/validators.js @@ -1,3 +1,12 @@ +import { isSafeURL } from '~/lib/utils/url_utility'; + +const isRunbookUrlValid = runbookUrl => { + if (!runbookUrl) { + return true; + } + return isSafeURL(runbookUrl); +}; + // Prop validator for alert information, expecting an object like the example below. // // { @@ -8,6 +17,7 @@ // query: "rate(http_requests_total[5m])[30m:1m]", // threshold: 0.002, // title: "Core Usage (Total)", +// runbookUrl: "https://www.gitlab.com/my-project/-/wikis/runbook" // } // } export function alertsValidator(value) { @@ -19,7 +29,8 @@ export function alertsValidator(value) { alert.metricId && typeof alert.metricId === 'string' && alert.operator && - typeof alert.threshold === 'number' + typeof alert.threshold === 'number' && + isRunbookUrlValid(alert.runbookUrl) ); }); } |