diff options
Diffstat (limited to 'app/assets/javascripts/monitoring')
25 files changed, 1820 insertions, 186 deletions
diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue new file mode 100644 index 00000000000..86a793c854e --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -0,0 +1,286 @@ +<script> +import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import 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: { + AlertWidgetForm, + GlBadge, + GlLoadingIcon, + GlIcon, + GlTooltip, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + alertsEndpoint: { + type: String, + required: true, + }, + showLoadingState: { + type: Boolean, + required: false, + default: true, + }, + // { [alertPath]: { alert_attributes } }. Populated from subsequent API calls. + // Includes only the metrics/alerts to be managed by this widget. + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + // [{ metric+query_attributes }]. Represents queries (and alerts) we know about + // on intial fetch. Essentially used for reference. + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + service: null, + errorMessage: null, + isLoading: false, + apiAction: 'create', + }; + }, + i18n: { + alertsCountMsg: s__('PrometheusAlerts|%{count} alerts applied'), + singleFiringMsg: s__('PrometheusAlerts|Firing: %{alert}'), + multipleFiringMsg: s__('PrometheusAlerts|%{firingCount} firing'), + firingAlertsTooltip: s__('PrometheusAlerts|Firing: %{alerts}'), + }, + computed: { + singleAlertSummary() { + return { + message: this.isFiring ? this.$options.i18n.singleFiringMsg : this.thresholds[0], + alert: this.thresholds[0], + }; + }, + multipleAlertsSummary() { + return { + message: this.isFiring + ? `${this.$options.i18n.alertsCountMsg}, ${this.$options.i18n.multipleFiringMsg}` + : this.$options.i18n.alertsCountMsg, + count: this.thresholds.length, + firingCount: this.firingAlerts.length, + }; + }, + shouldShowLoadingIcon() { + return this.showLoadingState && this.isLoading; + }, + thresholds() { + const alertsToManage = Object.keys(this.alertsToManage); + return alertsToManage.map(this.formatAlertSummary); + }, + hasAlerts() { + return Boolean(Object.keys(this.alertsToManage).length); + }, + hasMultipleAlerts() { + return this.thresholds.length > 1; + }, + isFiring() { + return Boolean(this.firingAlerts.length); + }, + firingAlerts() { + return values(this.alertsToManage).filter(alert => + this.passedAlertThreshold(this.getQueryData(alert), alert), + ); + }, + formattedFiringAlerts() { + return this.firingAlerts.map(alert => this.formatAlertSummary(alert.alert_path)); + }, + configuredAlert() { + return this.hasAlerts ? values(this.alertsToManage)[0].metricId : ''; + }, + }, + created() { + this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint }); + this.fetchAlertData(); + }, + methods: { + fetchAlertData() { + this.isLoading = true; + + const queriesWithAlerts = this.relevantQueries.filter(query => query.alert_path); + + return Promise.all( + queriesWithAlerts.map(query => + this.service + .readAlert(query.alert_path) + .then(alertAttributes => this.setAlert(alertAttributes, query.metricId)), + ), + ) + .then(() => { + this.isLoading = false; + }) + .catch(() => { + createFlash(s__('PrometheusAlerts|Error fetching alert')); + this.isLoading = false; + }); + }, + setAlert(alertAttributes, metricId) { + this.$emit('setAlerts', alertAttributes.alert_path, { ...alertAttributes, metricId }); + }, + removeAlert(alertPath) { + this.$emit('setAlerts', alertPath, null); + }, + formatAlertSummary(alertPath) { + const alert = this.alertsToManage[alertPath]; + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return `${alertQuery.label} ${alert.operator} ${alert.threshold}`; + }, + passedAlertThreshold(data, alert) { + const { threshold, operator } = alert; + + switch (operator) { + case OPERATORS.greaterThan: + return data.some(value => value > threshold); + case OPERATORS.lessThan: + return data.some(value => value < threshold); + case OPERATORS.equalTo: + return data.some(value => value === threshold); + default: + return false; + } + }, + getQueryData(alert) { + const alertQuery = this.relevantQueries.find(query => query.metricId === alert.metricId); + + return get(alertQuery, 'result[0].values', []).map(value => get(value, '[1]', null)); + }, + showModal() { + this.$root.$emit('bv::show::modal', this.modalId); + }, + hideModal() { + this.errorMessage = null; + this.$root.$emit('bv::hide::modal', this.modalId); + }, + handleSetApiAction(apiAction) { + this.apiAction = apiAction; + }, + handleCreate({ operator, threshold, prometheus_metric_id }) { + const newAlert = { operator, threshold, prometheus_metric_id }; + this.isLoading = true; + this.service + .createAlert(newAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, prometheus_metric_id); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error creating alert'); + this.isLoading = false; + }); + }, + handleUpdate({ alert, operator, threshold }) { + const updatedAlert = { operator, threshold }; + this.isLoading = true; + this.service + .updateAlert(alert, updatedAlert) + .then(alertAttributes => { + this.setAlert(alertAttributes, this.alertsToManage[alert].metricId); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error saving alert'); + this.isLoading = false; + }); + }, + handleDelete({ alert }) { + this.isLoading = true; + this.service + .deleteAlert(alert) + .then(() => { + this.removeAlert(alert); + this.isLoading = false; + this.hideModal(); + }) + .catch(() => { + this.errorMessage = s__('PrometheusAlerts|Error deleting alert'); + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="prometheus-alert-widget dropdown flex-grow-2 overflow-hidden"> + <gl-loading-icon v-if="shouldShowLoadingIcon" :inline="true" /> + <span v-else-if="errorMessage" ref="alertErrorMessage" class="alert-error-message">{{ + errorMessage + }}</span> + <span + v-else-if="hasAlerts" + ref="alertCurrentSetting" + class="alert-current-setting cursor-pointer d-flex" + @click="showModal" + > + <gl-badge + :variant="isFiring ? 'danger' : 'secondary'" + pill + class="d-flex-center text-truncate" + > + <gl-icon name="warning" :size="16" class="flex-shrink-0" /> + <span class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me"> + <gl-sprintf + :message=" + hasMultipleAlerts ? multipleAlertsSummary.message : singleAlertSummary.message + " + > + <template #alert> + {{ singleAlertSummary.alert }} + </template> + <template #count> + {{ multipleAlertsSummary.count }} + </template> + <template #firingCount> + {{ multipleAlertsSummary.firingCount }} + </template> + </gl-sprintf> + </span> + </gl-badge> + <gl-tooltip v-if="hasMultipleAlerts && isFiring" :target="() => $refs.alertCurrentSetting"> + <gl-sprintf :message="$options.i18n.firingAlertsTooltip"> + <template #alerts> + <div v-for="alert in formattedFiringAlerts" :key="alert.alert_path"> + {{ alert }} + </div> + </template> + </gl-sprintf> + </gl-tooltip> + </span> + <alert-widget-form + ref="widgetForm" + :disabled="isLoading" + :alerts-to-manage="alertsToManage" + :relevant-queries="relevantQueries" + :error-message="errorMessage" + :configured-alert="configuredAlert" + :modal-id="modalId" + @create="handleCreate" + @update="handleUpdate" + @delete="handleDelete" + @cancel="hideModal" + @setAction="handleSetApiAction" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue new file mode 100644 index 00000000000..74324daa1e3 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -0,0 +1,307 @@ +<script> +import { isEmpty, findKey } from 'lodash'; +import Vue from 'vue'; +import { + GlLink, + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlTooltipDirective, +} from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import Icon from '~/vue_shared/components/icon.vue'; +import { alertsValidator, queriesValidator } from '../validators'; +import { OPERATORS } from '../constants'; + +Vue.use(Translate); + +const SUBMIT_ACTION_TEXT = { + create: __('Add'), + update: __('Save'), + delete: __('Delete'), +}; + +const SUBMIT_BUTTON_CLASS = { + create: 'btn-success', + update: 'btn-success', + delete: 'btn-remove', +}; + +export default { + components: { + GlDeprecatedButton, + GlButtonGroup, + GlFormGroup, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlModal, + GlLink, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + mixins: [glFeatureFlagsMixin()], + props: { + disabled: { + type: Boolean, + required: true, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + configuredAlert: { + type: String, + required: false, + default: '', + }, + alertsToManage: { + type: Object, + required: false, + default: () => ({}), + validator: alertsValidator, + }, + relevantQueries: { + type: Array, + required: true, + validator: queriesValidator, + }, + modalId: { + type: String, + required: true, + }, + }, + data() { + return { + operators: OPERATORS, + operator: null, + threshold: null, + prometheusMetricId: null, + selectedAlert: {}, + alertQuery: '', + }; + }, + computed: { + isValidQuery() { + // TODO: Add query validation check (most likely via http request) + return this.alertQuery.length ? true : null; + }, + currentQuery() { + return this.relevantQueries.find(query => query.metricId === this.prometheusMetricId) || {}; + }, + formDisabled() { + // We need a prometheusMetricId to determine whether we're + // creating/updating/deleting + return this.disabled || !(this.prometheusMetricId || this.isValidQuery); + }, + supportsComputedAlerts() { + return this.glFeatures.prometheusComputedAlerts; + }, + queryDropdownLabel() { + return this.currentQuery.label || s__('PrometheusAlerts|Select query'); + }, + haveValuesChanged() { + return ( + this.operator && + this.threshold === Number(this.threshold) && + (this.operator !== this.selectedAlert.operator || + this.threshold !== this.selectedAlert.threshold) + ); + }, + submitAction() { + if (isEmpty(this.selectedAlert)) return 'create'; + if (this.haveValuesChanged) return 'update'; + return 'delete'; + }, + submitActionText() { + return SUBMIT_ACTION_TEXT[this.submitAction]; + }, + submitButtonClass() { + return SUBMIT_BUTTON_CLASS[this.submitAction]; + }, + isSubmitDisabled() { + return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged); + }, + dropdownTitle() { + return this.submitAction === 'create' + ? s__('PrometheusAlerts|Add alert') + : s__('PrometheusAlerts|Edit alert'); + }, + }, + watch: { + alertsToManage() { + this.resetAlertData(); + }, + submitAction() { + this.$emit('setAction', this.submitAction); + }, + }, + methods: { + selectQuery(queryId) { + const existingAlertPath = findKey(this.alertsToManage, alert => alert.metricId === queryId); + const existingAlert = this.alertsToManage[existingAlertPath]; + + if (existingAlert) { + this.selectedAlert = existingAlert; + this.operator = existingAlert.operator; + this.threshold = existingAlert.threshold; + } else { + this.selectedAlert = {}; + this.operator = this.operators.greaterThan; + this.threshold = null; + } + + this.prometheusMetricId = queryId; + }, + handleHidden() { + this.resetAlertData(); + this.$emit('cancel'); + }, + handleSubmit(e) { + e.preventDefault(); + this.$emit(this.submitAction, { + alert: this.selectedAlert.alert_path, + operator: this.operator, + threshold: this.threshold, + prometheus_metric_id: this.prometheusMetricId, + }); + }, + handleShown() { + if (this.configuredAlert) { + this.selectQuery(this.configuredAlert); + } else if (this.relevantQueries.length === 1) { + this.selectQuery(this.relevantQueries[0].metricId); + } + }, + resetAlertData() { + this.operator = null; + this.threshold = null; + this.prometheusMetricId = null; + this.selectedAlert = {}; + }, + getAlertFormActionTrackingOption() { + const label = `${this.submitAction}_alert`; + return { + category: document.body.dataset.page, + action: 'click_button', + label, + }; + }, + }, + alertQueryText: { + label: __('Query'), + validFeedback: __('Query is valid'), + invalidFeedback: __('Invalid query'), + descriptionTooltip: __( + 'Example: Usage = single query. (Requested) / (Capacity) = multiple queries combined into a formula.', + ), + }, +}; +</script> + +<template> + <gl-modal + ref="alertModal" + :title="dropdownTitle" + :modal-id="modalId" + :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" + :ok-disabled="formDisabled" + @ok="handleSubmit" + @hidden="handleHidden" + @shown="handleShown" + > + <div v-if="errorMessage" class="alert-modal-message danger_message">{{ errorMessage }}</div> + <div class="alert-form"> + <gl-form-group + v-if="supportsComputedAlerts" + :label="$options.alertQueryText.label" + label-for="alert-query-input" + :valid-feedback="$options.alertQueryText.validFeedback" + :invalid-feedback="$options.alertQueryText.invalidFeedback" + :state="isValidQuery" + > + <gl-form-input id="alert-query-input" v-model.trim="alertQuery" :state="isValidQuery" /> + <template #description> + <div class="d-flex align-items-center"> + {{ __('Single or combined queries') }} + <icon + v-gl-tooltip="$options.alertQueryText.descriptionTooltip" + name="question" + class="prepend-left-4" + /> + </div> + </template> + </gl-form-group> + <gl-form-group v-else label-for="alert-query-dropdown" :label="$options.alertQueryText.label"> + <gl-dropdown + id="alert-query-dropdown" + :text="queryDropdownLabel" + toggle-class="dropdown-menu-toggle qa-alert-query-dropdown" + > + <gl-dropdown-item + v-for="query in relevantQueries" + :key="query.metricId" + data-qa-selector="alert_query_option" + @click="selectQuery(query.metricId)" + > + {{ query.label }} + </gl-dropdown-item> + </gl-dropdown> + </gl-form-group> + <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> + <gl-deprecated-button + :class="{ active: operator === operators.greaterThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.greaterThan" + > + {{ operators.greaterThan }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.equalTo }" + :disabled="formDisabled" + type="button" + @click="operator = operators.equalTo" + > + {{ operators.equalTo }} + </gl-deprecated-button> + <gl-deprecated-button + :class="{ active: operator === operators.lessThan }" + :disabled="formDisabled" + type="button" + @click="operator = operators.lessThan" + > + {{ operators.lessThan }} + </gl-deprecated-button> + </gl-button-group> + <gl-form-group :label="s__('PrometheusAlerts|Threshold')" label-for="alerts-threshold"> + <gl-form-input + id="alerts-threshold" + v-model.number="threshold" + :disabled="formDisabled" + type="number" + data-qa-selector="alert_threshold_field" + /> + </gl-form-group> + </div> + <template #modal-ok> + <gl-link + v-track-event="getAlertFormActionTrackingOption()" + class="text-reset text-decoration-none" + > + {{ submitActionText }} + </gl-link> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue index 447f8845506..34da5885c97 100644 --- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -3,7 +3,7 @@ import { flattenDeep, isNumber } from 'lodash'; import { GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { roundOffFloat } from '~/lib/utils/common_utils'; import { hexToRgb } from '~/lib/utils/color_utils'; -import { areaOpacityValues, symbolSizes, colorValues } from '../../constants'; +import { areaOpacityValues, symbolSizes, colorValues, panelTypes } from '../../constants'; import { graphDataValidatorForAnomalyValues } from '../../utils'; import MonitorTimeSeriesChart from './time_series.vue'; @@ -91,7 +91,7 @@ export default { ]); return { ...this.graphData, - type: 'line-chart', + type: panelTypes.LINE_CHART, metrics: [metricQuery], }; }, @@ -209,7 +209,7 @@ export default { :series-config="metricSeriesConfig" > <slot></slot> - <template v-slot:tooltipContent="slotProps"> + <template #tooltip-content="slotProps"> <div v-for="(content, seriesIndex) in slotProps.tooltip.content" :key="seriesIndex" diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index 0a0165a113e..55a25ee09fd 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -63,7 +63,7 @@ export default { }; </script> <template> - <div v-gl-resize-observer-directive="onResize" class="col-12 col-lg-6"> + <div v-gl-resize-observer-directive="onResize"> <gl-heatmap ref="heatmapChart" v-bind="$attrs" diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index bf40e8f448e..8f37a12af75 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -6,7 +6,7 @@ import dateFormat from 'dateformat'; import { s__, __ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; +import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants'; import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options'; import { annotationsYAxis, generateAnnotationsSeries } from './annotations'; import { makeDataSeries } from '~/helpers/monitor_helper'; @@ -64,10 +64,10 @@ export default { required: false, default: '', }, - singleEmbed: { - type: Boolean, + height: { + type: Number, required: false, - default: false, + default: chartHeight, }, thresholds: { type: Array, @@ -100,7 +100,6 @@ export default { sha: '', }, width: 0, - height: chartHeight, svgs: {}, primaryColor: null, throttledDatazoom: null, @@ -211,8 +210,8 @@ export default { }, glChartComponent() { const chartTypes = { - 'area-chart': GlAreaChart, - 'line-chart': GlLineChart, + [panelTypes.AREA_CHART]: GlAreaChart, + [panelTypes.LINE_CHART]: GlLineChart, }; return chartTypes[this.graphData.type] || GlAreaChart; }, diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 4d60b02d0df..2018c706b11 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,8 +1,10 @@ <script> -import { debounce, pickBy } from 'lodash'; +import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import { + GlIcon, + GlButton, GlDeprecatedButton, GlDropdown, GlDropdownItem, @@ -14,10 +16,10 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from './dashboard_panel.vue'; import { s__ } from '~/locale'; import createFlash from '~/flash'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; @@ -28,17 +30,27 @@ import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; import DashboardsDropdown from './dashboards_dropdown.vue'; +import VariablesSection from './variables_section.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils'; +import { + getAddMetricTrackingOptions, + timeRangeToUrl, + timeRangeFromUrl, + panelToUrl, + expandedPanelPayloadFromUrl, + convertVariablesForURL, +} from '../utils'; import { metricStates } from '../constants'; import { defaultTimeRange, timeRanges } from '~/vue_shared/constants'; export default { components: { VueDraggable, - PanelType, + DashboardPanel, Icon, + GlIcon, + GlButton, GlDeprecatedButton, GlDropdown, GlLoadingIcon, @@ -54,13 +66,14 @@ export default { EmptyState, GroupEmptyState, DashboardsDropdown, + + VariablesSection, }, directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, - mixins: [glFeatureFlagsMixin()], props: { externalDashboardUrl: { type: String, @@ -197,7 +210,6 @@ export default { }, data() { return { - state: 'gettingStarted', formIsValid: null, selectedTimeRange: timeRangeFromUrl() || defaultTimeRange, hasValidDates: true, @@ -212,16 +224,16 @@ export default { 'showEmptyState', 'useDashboardEndpoint', 'allDashboards', - 'additionalPanelTypesEnabled', 'environmentsLoading', + 'expandedPanel', + 'promVariables', + 'isUpdatingStarredValue', + ]), + ...mapGetters('monitoringDashboard', [ + 'selectedDashboard', + 'getMetricStates', + 'filteredEnvironments', ]), - ...mapGetters('monitoringDashboard', ['getMetricStates', 'filteredEnvironments']), - firstDashboard() { - return this.allDashboards.length > 0 ? this.allDashboards[0] : {}; - }, - selectedDashboard() { - return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; - }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; }, @@ -229,20 +241,44 @@ export default { return ( this.customMetricsAvailable && !this.showEmptyState && - this.firstDashboard === this.selectedDashboard - ); - }, - hasHeaderButtons() { - return ( - this.addingMetricsAvailable || - this.showRearrangePanelsBtn || - this.selectedDashboard.can_edit || - this.externalDashboardUrl.length + // 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 ); }, shouldShowEnvironmentsDropdownNoMatchedMsg() { return !this.environmentsLoading && this.filteredEnvironments.length === 0; }, + shouldShowVariablesSection() { + return Object.keys(this.promVariables).length > 0; + }, + }, + watch: { + dashboard(newDashboard) { + try { + const expandedPanel = expandedPanelPayloadFromUrl(newDashboard); + if (expandedPanel) { + this.setExpandedPanel(expandedPanel); + } + } catch { + createFlash( + s__( + 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.', + ), + ); + } + }, + expandedPanel: { + handler({ group, panel }) { + const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; + updateHistory({ + url: panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), group, panel), + title: document.title, + }); + }, + deep: true, + }, }, created() { this.setInitialState({ @@ -255,6 +291,10 @@ export default { logsPath: this.logsPath, currentEnvironmentName: this.currentEnvironmentName, }); + window.addEventListener('keyup', this.onKeyup); + }, + destroyed() { + window.removeEventListener('keyup', this.onKeyup); }, mounted() { if (!this.hasMetrics) { @@ -273,6 +313,9 @@ export default { 'setInitialState', 'setPanelGroupMetrics', 'filterEnvironments', + 'setExpandedPanel', + 'clearExpandedPanel', + 'toggleStarredValue', ]), updatePanels(key, panels) { this.setPanelGroupMetrics({ @@ -299,11 +342,9 @@ export default { // As a fallback, switch to default time range instead this.selectedTimeRange = defaultTimeRange; }, - - generateLink(group, title, yLabel) { - const dashboard = this.currentDashboard || this.firstDashboard.path; - const params = pickBy({ dashboard, group, title, y_label: yLabel }, value => value != null); - return mergeUrlParams(params, window.location.href); + generatePanelUrl(groupKey, panel) { + const dashboardPath = this.currentDashboard || this.selectedDashboard?.path; + return panelToUrl(dashboardPath, convertVariablesForURL(this.promVariables), groupKey, panel); }, hideAddMetricModal() { this.$refs.addMetricModal.hide(); @@ -366,11 +407,28 @@ export default { }); this.selectedTimeRange = { start, end }; }, + onExpandPanel(group, panel) { + this.setExpandedPanel({ group, panel }); + }, + onGoBack() { + this.clearExpandedPanel(); + }, + onKeyup(event) { + const { key } = event; + if (key === ESC_KEY || key === ESC_KEY_IE11) { + this.clearExpandedPanel(); + } + }, }, addMetric: { title: s__('Metrics|Add metric'), modalId: 'add-metric', }, + i18n: { + goBackLabel: s__('Metrics|Go back (Esc)'), + starDashboard: s__('Metrics|Star dashboard'), + unstarDashboard: s__('Metrics|Unstar dashboard'), + }, }; </script> @@ -388,7 +446,6 @@ export default { class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" - :selected-dashboard="selectedDashboard" @selectDashboard="selectDashboard($event)" /> </div> @@ -443,7 +500,7 @@ export default { <date-time-picker ref="dateTimePicker" class="flex-grow-1 show-last-dropdown" - data-qa-selector="show_last_dropdown" + data-qa-selector="range_picker_dropdown" :value="selectedTimeRange" :options="timeRanges" @input="onDateTimePickerInput" @@ -467,6 +524,32 @@ 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 :pressed="isRearrangingPanels" @@ -516,7 +599,10 @@ export default { </gl-modal> </div> - <div v-if="selectedDashboard.can_edit" class="mb-2 mr-2 d-flex d-sm-block"> + <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" @@ -539,61 +625,92 @@ export default { </div> </div> </div> - + <variables-section v-if="shouldShowVariablesSection && !showEmptyState" /> <div v-if="!showEmptyState"> - <graph-group - v-for="(groupData, index) in dashboard.panelGroups" - :key="`${groupData.group}.${groupData.priority}`" - :name="groupData.group" - :show-panels="showPanels" - :collapse-group="collapseGroup(groupData.key)" + <dashboard-panel + v-show="expandedPanel.panel" + ref="expandedPanel" + :settings-path="settingsPath" + :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)" + :graph-data="expandedPanel.panel" + :alerts-endpoint="alertsEndpoint" + :height="600" + :prometheus-alerts-available="prometheusAlertsAvailable" + @timerangezoom="onTimeRangeZoom" > - <vue-draggable - v-if="!groupSingleEmptyState(groupData.key)" - :value="groupData.panels" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" - @input="updatePanels(groupData.key, $event)" + <template #topLeft> + <gl-button + ref="goBackBtn" + v-gl-tooltip + class="mr-3 my-3" + :title="$options.i18n.goBackLabel" + @click="onGoBack" + > + <gl-icon + name="arrow-left" + :aria-label="$options.i18n.goBackLabel" + class="text-secondary" + /> + </gl-button> + </template> + </dashboard-panel> + + <div v-show="!expandedPanel.panel"> + <graph-group + v-for="groupData in dashboard.panelGroups" + :key="`${groupData.group}.${groupData.priority}`" + :name="groupData.group" + :show-panels="showPanels" + :collapse-group="collapseGroup(groupData.key)" > - <div - v-for="(graphData, graphIndex) in groupData.panels" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" + <vue-draggable + v-if="!groupSingleEmptyState(groupData.key)" + :value="groupData.panels" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updatePanels(groupData.key, $event)" > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removePanel(groupData.key, groupData.panels, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"> - <icon name="close" /> - </a> - </div> + <div + v-for="(graphData, graphIndex) in groupData.panels" + :key="`dashboard-panel-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" + > + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removePanel(groupData.key, groupData.panels, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')"> + <icon name="close" /> + </a> + </div> - <panel-type - :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" - :graph-data="graphData" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - @timerangezoom="onTimeRangeZoom" - /> + <dashboard-panel + :settings-path="settingsPath" + :clipboard-text="generatePanelUrl(groupData.group, graphData)" + :graph-data="graphData" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" + @timerangezoom="onTimeRangeZoom" + @expand="onExpandPanel(groupData.group, graphData)" + /> + </div> </div> + </vue-draggable> + <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> + <group-empty-state + ref="empty-group" + :documentation-path="documentationPath" + :settings-path="settingsPath" + :selected-state="groupSingleEmptyState(groupData.key)" + :svg-path="emptyNoDataSmallSvgPath" + /> </div> - </vue-draggable> - <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> - <group-empty-state - ref="empty-group" - :documentation-path="documentationPath" - :settings-path="settingsPath" - :selected-state="groupSingleEmptyState(groupData.key)" - :svg-path="emptyNoDataSmallSvgPath" - /> - </div> - </graph-group> + </graph-group> + </div> </div> <empty-state v-else diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 2beae0d9540..48825fda5c8 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -14,6 +14,9 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import { __, n__ } from '~/locale'; +import { panelTypes } from '../constants'; + +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'; @@ -21,22 +24,20 @@ import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; import MonitorStackedColumnChart from './charts/stacked_column.vue'; -import MonitorEmptyChart from './charts/empty_chart.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; +import AlertWidget from './alert_widget.vue'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; const events = { timeRangeZoom: 'timerangezoom', + expand: 'expand', }; export default { components: { - MonitorSingleStatChart, - MonitorColumnChart, - MonitorBarChart, - MonitorHeatmapChart, - MonitorStackedColumnChart, MonitorEmptyChart, + AlertWidget, GlIcon, GlLoadingIcon, GlTooltip, @@ -58,28 +59,41 @@ export default { }, graphData: { type: Object, - required: true, - }, - index: { - type: String, required: false, - default: '', + default: null, }, groupId: { type: String, required: false, - default: 'panel-type-chart', + default: 'dashboard-panel', }, namespace: { type: String, required: false, default: 'monitoringDashboard', }, + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, + settingsPath: { + type: String, + required: false, + default: null, + }, }, data() { return { showTitleTooltip: false, zoomedTimeRange: null, + allAlerts: {}, + expandBtnAvailable: Boolean(this.$listeners[events.expand]), }; }, computed: { @@ -101,23 +115,18 @@ export default { timeRange(state) { return state[this.namespace].timeRange; }, + metricsSavedToDb(state, getters) { + return getters[`${this.namespace}/metricsSavedToDb`]; + }, }), title() { - return this.graphData.title || ''; - }, - alertWidgetAvailable() { - // This method is extended by ee functionality - return false; + return this.graphData?.title || ''; }, graphDataHasResult() { - return ( - this.graphData.metrics && - this.graphData.metrics[0].result && - this.graphData.metrics[0].result.length > 0 - ); + return this.graphData?.metrics?.[0]?.result?.length > 0; }, graphDataIsLoading() { - const { metrics = [] } = this.graphData; + const metrics = this.graphData?.metrics || []; return metrics.some(({ loading }) => loading); }, logsPathWithTimeRange() { @@ -129,7 +138,7 @@ export default { return null; }, csvText() { - const chartData = this.graphData.metrics[0].result[0].values; + 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) => { @@ -141,27 +150,77 @@ export default { const data = new Blob([this.csvText], { type: 'text/plain' }); return window.URL.createObjectURL(data); }, - timeChartComponent() { - if (this.isPanelType('anomaly-chart')) { + + /** + * A chart is "basic" if it doesn't support + * the same features as the TimeSeries based components + * such as "annotations". + * + * @returns Vue Component wrapping a basic visualization + */ + basicChartComponent() { + if (this.isPanelType(panelTypes.SINGLE_STAT)) { + return MonitorSingleStatChart; + } + if (this.isPanelType(panelTypes.HEATMAP)) { + return MonitorHeatmapChart; + } + if (this.isPanelType(panelTypes.BAR)) { + return MonitorBarChart; + } + if (this.isPanelType(panelTypes.COLUMN)) { + return MonitorColumnChart; + } + if (this.isPanelType(panelTypes.STACKED_COLUMN)) { + return MonitorStackedColumnChart; + } + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { + return MonitorAnomalyChart; + } + return null; + }, + + /** + * In monitoring, Time Series charts typically support + * a larger feature set like "annotations", "deployment + * data", alert "thresholds" and "datazoom". + * + * This is intentional as Time Series are more frequently + * used. + * + * @returns Vue Component wrapping a time series visualization, + * Area Charts are rendered by default. + */ + timeSeriesChartComponent() { + if (this.isPanelType(panelTypes.ANOMALY_CHART)) { return MonitorAnomalyChart; } return MonitorTimeSeriesChart; }, isContextualMenuShown() { - return ( - this.graphDataHasResult && - !this.isPanelType('single-stat') && - !this.isPanelType('heatmap') && - !this.isPanelType('column') && - !this.isPanelType('stacked-column') - ); + return Boolean(this.graphDataHasResult && !this.basicChartComponent); }, editCustomMetricLink() { + if (this.graphData.metrics.length > 1) { + return this.settingsPath; + } return this.graphData?.metrics[0].edit_path; }, editCustomMetricLinkText() { return n__('Metrics|Edit metric', 'Metrics|Edit metrics', this.graphData.metrics.length); }, + hasMetricsInDb() { + const { metrics = [] } = this.graphData; + return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId)); + }, + alertWidgetAvailable() { + return ( + this.prometheusAlertsAvailable && + this.alertsEndpoint && + this.graphData && + this.hasMetricsInDb + ); + }, }, mounted() { this.refreshTitleTooltip(); @@ -176,7 +235,7 @@ export default { return Object.values(this.getGraphAlerts(queries)); }, isPanelType(type) { - return this.graphData.type && this.graphData.type === type; + return this.graphData?.type === type; }, showToast() { this.$toast.show(__('Link copied')); @@ -197,15 +256,27 @@ export default { this.zoomedTimeRange = { start, end }; this.$emit(events.timeRangeZoom, { start, end }); }, + onExpand() { + this.$emit(events.expand); + }, + setAlerts(alertPath, alertAttributes) { + if (alertAttributes) { + this.$set(this.allAlerts, alertPath, alertAttributes); + } else { + this.$delete(this.allAlerts, alertPath); + } + }, }, + panelTypes, }; </script> <template> <div v-gl-resize-observer="onResize" class="prometheus-graph"> <div class="d-flex align-items-center mr-3"> + <slot name="topLeft"></slot> <h5 ref="graphTitle" - class="prometheus-graph-title gl-font-size-large font-weight-bold text-truncate append-right-8" + class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate append-right-8" > {{ title }} </h5> @@ -215,7 +286,7 @@ export default { <alert-widget v-if="isContextualMenuShown && alertWidgetAvailable" class="mx-1" - :modal-id="`alert-modal-${index}`" + :modal-id="`alert-modal-${graphData.id}`" :alerts-endpoint="alertsEndpoint" :relevant-queries="graphData.metrics" :alerts-to-manage="getGraphAlerts(graphData.metrics)" @@ -243,6 +314,14 @@ export default { <gl-icon name="ellipsis_v" class="text-secondary" /> </template> <gl-dropdown-item + v-if="expandBtnAvailable" + ref="expandBtn" + :href="clipboardText" + @click.prevent="onExpand" + > + {{ s__('Metrics|Expand panel') }} + </gl-dropdown-item> + <gl-dropdown-item v-if="editCustomMetricLink" ref="editMetricLink" :href="editCustomMetricLink" @@ -271,13 +350,14 @@ export default { ref="copyChartLink" v-track-event="generateLinkToChartOptions(clipboardText)" :data-clipboard-text="clipboardText" + data-qa-selector="generate_chart_link_menu_item" @click="showToast(clipboardText)" > {{ __('Copy link to chart') }} </gl-dropdown-item> <gl-dropdown-item v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}`" + v-gl-modal="`alert-modal-${graphData.id}`" data-qa-selector="alert_widget_menu_item" > {{ __('Alerts') }} @@ -287,38 +367,27 @@ export default { </div> </div> - <monitor-single-stat-chart - v-if="isPanelType('single-stat') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-heatmap-chart - v-else-if="isPanelType('heatmap') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-bar-chart - v-else-if="isPanelType('bar') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-column-chart - v-else-if="isPanelType('column') && graphDataHasResult" - :graph-data="graphData" - /> - <monitor-stacked-column-chart - v-else-if="isPanelType('stacked-column') && graphDataHasResult" + <monitor-empty-chart v-if="!graphDataHasResult" /> + <component + :is="basicChartComponent" + v-else-if="basicChartComponent" :graph-data="graphData" + v-bind="$attrs" + v-on="$listeners" /> <component - :is="timeChartComponent" - v-else-if="graphDataHasResult" - ref="timeChart" + :is="timeSeriesChartComponent" + v-else + ref="timeSeriesChart" :graph-data="graphData" :deployment-data="deploymentData" :annotations="annotations" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" + v-bind="$attrs" + v-on="$listeners" @datazoom="onDatazoom" /> - <monitor-empty-chart v-else v-bind="$attrs" v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 8f3e0a6ec75..8b86890715f 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,7 +1,8 @@ <script> -import { mapState, mapActions } from 'vuex'; +import { mapState, mapActions, mapGetters } from 'vuex'; import { GlAlert, + GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, @@ -21,6 +22,7 @@ const events = { export default { components: { GlAlert, + GlIcon, GlDropdown, GlDropdownItem, GlDropdownHeader, @@ -34,11 +36,6 @@ export default { GlModal: GlModalDirective, }, props: { - selectedDashboard: { - type: Object, - required: false, - default: () => ({}), - }, defaultBranch: { type: String, required: true, @@ -54,26 +51,41 @@ export default { }, computed: { ...mapState('monitoringDashboard', ['allDashboards']), + ...mapGetters('monitoringDashboard', ['selectedDashboard']), isSystemDashboard() { - return this.selectedDashboard.system_dashboard; + return this.selectedDashboard?.system_dashboard; }, selectedDashboardText() { - return this.selectedDashboard.display_name; + return this.selectedDashboard?.display_name; + }, + selectedDashboardPath() { + return this.selectedDashboard?.path; }, + filteredDashboards() { - return this.allDashboards.filter(({ display_name }) => + return this.allDashboards.filter(({ display_name = '' }) => display_name.toLowerCase().includes(this.searchTerm.toLowerCase()), ); }, shouldShowNoMsgContainer() { return this.filteredDashboards.length === 0; }, + starredDashboards() { + return this.filteredDashboards.filter(({ starred }) => starred); + }, + nonStarredDashboards() { + return this.filteredDashboards.filter(({ starred }) => !starred); + }, + okButtonText() { return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate'); }, }, methods: { ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + dashboardDisplayName(dashboard) { + return dashboard.display_name || dashboard.path || ''; + }, selectDashboard(dashboard) { this.$emit(events.selectDashboard, dashboard); }, @@ -127,15 +139,34 @@ export default { v-model="searchTerm" class="m-2" /> + <div class="flex-fill overflow-auto"> <gl-dropdown-item - v-for="dashboard in filteredDashboards" + v-for="dashboard in starredDashboards" + :key="dashboard.path" + :active="dashboard.path === selectedDashboardPath" + active-class="is-active" + @click="selectDashboard(dashboard)" + > + <div class="d-flex"> + {{ dashboardDisplayName(dashboard) }} + <gl-icon class="text-muted ml-auto" name="star" /> + </div> + </gl-dropdown-item> + + <gl-dropdown-divider + v-if="starredDashboards.length && nonStarredDashboards.length" + ref="starredListDivider" + /> + + <gl-dropdown-item + v-for="dashboard in nonStarredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboard.path" + :active="dashboard.path === selectedDashboardPath" active-class="is-active" @click="selectDashboard(dashboard)" > - {{ dashboard.display_name || dashboard.path }} + {{ dashboardDisplayName(dashboard) }} </gl-dropdown-item> </div> diff --git a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue index 3f8b0f76997..1557a49137e 100644 --- a/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue +++ b/app/assets/javascripts/monitoring/components/embeds/metric_embed.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { defaultTimeRange } from '~/vue_shared/constants'; import { timeRangeFromUrl, removeTimeRangeParams } from '../../utils'; @@ -10,7 +10,7 @@ let sidebarMutationObserver; export default { components: { - PanelType, + DashboardPanel, }, props: { containerClass: { @@ -113,9 +113,9 @@ export default { </script> <template> <div class="metrics-embed p-0 d-flex flex-wrap" :class="embedClass"> - <panel-type + <dashboard-panel v-for="(graphData, graphIndex) in charts" - :key="`panel-type-${graphIndex}`" + :key="`dashboard-panel-${graphIndex}`" :class="panelClass" :graph-data="graphData" :group-id="dashboardUrl" diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue new file mode 100644 index 00000000000..0ac7c0b80df --- /dev/null +++ b/app/assets/javascripts/monitoring/components/variables/custom_variable.vue @@ -0,0 +1,50 @@ +<script> +import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlDropdown, + GlDropdownItem, + }, + props: { + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + options: { + type: Array, + required: true, + }, + }, + computed: { + defaultText() { + const selectedOpt = this.options.find(opt => opt.value === this.value); + return selectedOpt?.text || this.value; + }, + }, + methods: { + onUpdate(value) { + this.$emit('onUpdate', this.name, value); + }, + }, +}; +</script> +<template> + <gl-form-group :label="label"> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText"> + <gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{ + opt.text + }}</gl-dropdown-item> + </gl-dropdown> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables/text_variable.vue b/app/assets/javascripts/monitoring/components/variables/text_variable.vue new file mode 100644 index 00000000000..ce0d19760e2 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/variables/text_variable.vue @@ -0,0 +1,39 @@ +<script> +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; + +export default { + components: { + GlFormGroup, + GlFormInput, + }, + props: { + name: { + type: String, + required: true, + }, + label: { + type: String, + required: true, + }, + value: { + type: String, + required: true, + }, + }, + methods: { + onUpdate(event) { + this.$emit('onUpdate', this.name, event.target.value); + }, + }, +}; +</script> +<template> + <gl-form-group :label="label"> + <gl-form-input + :value="value" + :name="name" + @keyup.native.enter="onUpdate" + @blur.native="onUpdate" + /> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue new file mode 100644 index 00000000000..e054c9d8e26 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/variables_section.vue @@ -0,0 +1,56 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import CustomVariable from './variables/custom_variable.vue'; +import TextVariable from './variables/text_variable.vue'; +import { setPromCustomVariablesFromUrl } from '../utils'; + +export default { + components: { + CustomVariable, + TextVariable, + }, + computed: { + ...mapState('monitoringDashboard', ['promVariables']), + }, + methods: { + ...mapActions('monitoringDashboard', ['fetchDashboardData', 'updateVariableValues']), + refreshDashboard(variable, value) { + if (this.promVariables[variable].value !== value) { + const changedVariable = { key: variable, value }; + // update the Vuex store + this.updateVariableValues(changedVariable); + // the below calls can ideally be moved out of the + // component and into the actions and let the + // mutation respond directly. + // This can be further investigate in + // https://gitlab.com/gitlab-org/gitlab/-/issues/217713 + setPromCustomVariablesFromUrl(this.promVariables); + // fetch data + this.fetchDashboardData(); + } + }, + variableComponent(type) { + const types = { + text: TextVariable, + custom: CustomVariable, + }; + return types[type] || TextVariable; + }, + }, +}; +</script> +<template> + <div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section"> + <div v-for="(variable, key) in promVariables" :key="key" class="mb-1 pr-2 d-flex d-sm-block"> + <component + :is="variableComponent(variable.type)" + class="mb-0 flex-grow-1" + :label="variable.label" + :value="variable.value" + :name="key" + :options="variable.options" + @onUpdate="refreshDashboard" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 0b393f19789..0c2eafeed54 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -48,6 +48,55 @@ export const metricStates = { UNKNOWN_ERROR: 'UNKNOWN_ERROR', }; +/** + * Supported panel types in dashboards, values of `panel.type`. + * + * Values should not be changed as they correspond to + * values in users the `.yml` dashboard definition. + */ +export const panelTypes = { + /** + * Area Chart + * + * Time Series chart with an area + */ + AREA_CHART: 'area-chart', + /** + * Line Chart + * + * Time Series chart with a line + */ + LINE_CHART: 'line-chart', + /** + * Anomaly Chart + * + * Time Series chart with 3 metrics + */ + ANOMALY_CHART: 'anomaly-chart', + /** + * Single Stat + * + * Single data point visualization + */ + SINGLE_STAT: 'single-stat', + /** + * Heatmap + */ + HEATMAP: 'heatmap', + /** + * Bar chart + */ + BAR: 'bar', + /** + * Column chart + */ + COLUMN: 'column', + /** + * Stacked column chart + */ + STACKED_COLUMN: 'stacked-column', +}; + export const sidebarAnimationDuration = 300; // milliseconds. export const chartHeight = 300; @@ -143,3 +192,38 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 */ export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; + +export const OPERATORS = { + greaterThan: '>', + equalTo: '==', + lessThan: '<', +}; + +/** + * Dashboard yml files support custom user-defined variables that + * are rendered as input elements in the monitoring dashboard. + * These values can be edited by the user and are passed on to the + * the backend and eventually to Prometheus API proxy. + * + * As of 13.0, the supported types are: + * simple custom -> dropdown elements + * advanced custom -> dropdown elements + * text -> text input elements + * + * Custom variables have a simple and a advanced variant. + */ +export const VARIABLE_TYPES = { + custom: 'custom', + text: 'text', +}; + +/** + * The names of templating variables defined in the dashboard yml + * file are prefixed with a constant so that it doesn't collide with + * other URL params that the monitoring dashboard relies on for + * features like panel fullscreen etc. + * + * The prefix is added before it is appended to the URL and removed + * before passing the data to the backend. + */ +export const VARIABLE_PREFIX = 'var-'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index d296f5b7a66..2bbf9ef9d78 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; -import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue'; +import Dashboard from '~/monitoring/components/dashboard.vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import store from './stores'; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js new file mode 100644 index 00000000000..afe5ee0938d --- /dev/null +++ b/app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js @@ -0,0 +1,13 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; +import initCeBundle from '~/monitoring/monitoring_bundle'; + +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + initCeBundle({ + customMetricsAvailable: parseBoolean(el.dataset.customMetricsAvailable), + prometheusAlertsAvailable: parseBoolean(el.dataset.prometheusAlertsAvailable), + }); + } +}; diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js new file mode 100644 index 00000000000..4b7337972fe --- /dev/null +++ b/app/assets/javascripts/monitoring/services/alerts_service.js @@ -0,0 +1,32 @@ +import axios from '~/lib/utils/axios_utils'; + +export default class AlertsService { + constructor({ alertsEndpoint }) { + this.alertsEndpoint = alertsEndpoint; + } + + getAlerts() { + return axios.get(this.alertsEndpoint).then(resp => resp.data); + } + + createAlert({ prometheus_metric_id, operator, threshold }) { + return axios + .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) + .then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + readAlert(alertPath) { + return axios.get(alertPath).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + updateAlert(alertPath, { operator, threshold }) { + return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); + } + + // eslint-disable-next-line class-methods-use-this + deleteAlert(alertPath) { + return axios.delete(alertPath).then(resp => resp.data); + } +} diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index f04f775761c..b057afa2264 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -3,6 +3,8 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import { parseTemplatingVariables } from './variable_mapping'; +import { mergeURLVariables } from '../utils'; import { gqClient, parseEnvironmentsResponse, @@ -13,11 +15,7 @@ import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; import statusCodes from '../../lib/utils/http_status'; -import { - backOff, - convertObjectPropsToCamelCase, - isFeatureFlagEnabled, -} from '../../lib/utils/common_utils'; +import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; import { @@ -80,6 +78,10 @@ export const setTimeRange = ({ commit }, timeRange) => { commit(types.SET_TIME_RANGE, timeRange); }; +export const setVariables = ({ commit }, variables) => { + commit(types.SET_VARIABLES, variables); +}; + export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); dispatch('fetchEnvironmentsData'); @@ -89,19 +91,30 @@ export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; +export const setExpandedPanel = ({ commit }, { group, panel }) => { + commit(types.SET_EXPANDED_PANEL, { group, panel }); +}; + +export const clearExpandedPanel = ({ commit }) => { + commit(types.SET_EXPANDED_PANEL, { + group: null, + panel: null, + }); +}; + // All Data +/** + * Fetch all dashboard data. + * + * @param {Object} store + * @returns A promise that resolves when the dashboard + * skeleton has been loaded. + */ export const fetchData = ({ dispatch }) => { dispatch('fetchEnvironmentsData'); dispatch('fetchDashboard'); - /** - * Annotations data is not yet fetched. This will be - * ready after the BE piece is implemented. - * https://gitlab.com/gitlab-org/gitlab/-/issues/211330 - */ - if (isFeatureFlagEnabled('metricsDashboardAnnotations')) { - dispatch('fetchAnnotations'); - } + dispatch('fetchAnnotations'); }; // Metrics dashboard @@ -148,6 +161,7 @@ export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response commit(types.SET_ALL_DASHBOARDS, all_dashboards); commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard); + commit(types.SET_VARIABLES, mergeURLVariables(parseTemplatingVariables(dashboard.templating))); commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data)); return dispatch('fetchDashboardData'); @@ -200,12 +214,19 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { * * @param {metric} metric */ -export const fetchPrometheusMetric = ({ commit }, { metric, defaultQueryParams }) => { +export const fetchPrometheusMetric = ( + { commit, state, getters }, + { metric, defaultQueryParams }, +) => { const queryParams = { ...defaultQueryParams }; if (metric.step) { queryParams.step = metric.step; } + if (Object.keys(state.promVariables).length > 0) { + queryParams.variables = getters.getCustomVariablesArray; + } + commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams) @@ -327,6 +348,35 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN // Dashboard manipulation +export const toggleStarredValue = ({ commit, state, getters }) => { + const { selectedDashboard } = getters; + + if (state.isUpdatingStarredValue) { + // Prevent repeating requests for the same change + return; + } + if (!selectedDashboard) { + return; + } + + const method = selectedDashboard.starred ? 'DELETE' : 'POST'; + const url = selectedDashboard.user_starred_path; + const newStarredValue = !selectedDashboard.starred; + + commit(types.REQUEST_DASHBOARD_STARRING); + + axios({ + url, + method, + }) + .then(() => { + commit(types.RECEIVE_DASHBOARD_STARRING_SUCCESS, newStarredValue); + }) + .catch(() => { + commit(types.RECEIVE_DASHBOARD_STARRING_FAILURE); + }); +}; + /** * Set a new array of metrics to a panel group * @param {*} data An object containing @@ -364,5 +414,11 @@ export const duplicateSystemDashboard = ({ state }, payload) => { }); }; +// Variables manipulation + +export const updateVariableValues = ({ commit }, updatedVariable) => { + commit(types.UPDATE_VARIABLE_VALUES, updatedVariable); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index a6d80c5063e..ae3ff5596e1 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,9 +1,25 @@ +import { flatMap } from 'lodash'; import { NOT_IN_DB_PREFIX } from '../constants'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); /** + * Returns a reference to the currently selected dashboard + * from the list of dashboards. + * + * @param {Object} state + */ +export const selectedDashboard = state => { + const { allDashboards } = state; + return ( + allDashboards.find(d => d.path === state.currentDashboard) || + allDashboards.find(d => d.default) || + null + ); +}; + +/** * Get all state for metric in the dashboard or a group. The * states are not repeated so the dashboard or group can show * a global state. @@ -96,5 +112,17 @@ export const filteredEnvironments = state => env.name.toLowerCase().includes((state.environmentsSearchTerm || '').trim().toLowerCase()), ); +/** + * Maps an variables object to an array along with stripping + * the variable prefix. + * + * @param {Object} variables - Custom variables provided by the user + * @returns {Array} The custom variables array to be send to the API + * in the format of [variable1, variable1_value] + */ + +export const getCustomVariablesArray = state => + flatMap(state.promVariables, (variable, key) => [key, variable.value]); + // 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 27a9a67edaa..d60334609fd 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -1,7 +1,13 @@ -// Dashboard "skeleton", groups, panels and metrics +// Dashboard "skeleton", groups, panels, metrics, query variables export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; +export const SET_VARIABLES = 'SET_VARIABLES'; +export const UPDATE_VARIABLE_VALUES = 'UPDATE_VARIABLE_VALUES'; + +export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING'; +export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; +export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; @@ -31,5 +37,5 @@ export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE' export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; 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'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index aa31b6642d7..f41cf3fc477 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,5 +1,7 @@ -import pick from 'lodash/pick'; +import Vue from 'vue'; +import { pick } from 'lodash'; import * as types from './mutation_types'; +import { selectedDashboard } from './getters'; import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; import { endpointKeys, initialStateKeys, metricStates } from '../constants'; @@ -71,6 +73,23 @@ export default { state.showEmptyState = true; }, + [types.REQUEST_DASHBOARD_STARRING](state) { + state.isUpdatingStarredValue = true; + }, + [types.RECEIVE_DASHBOARD_STARRING_SUCCESS](state, newStarredValue) { + const dashboard = selectedDashboard(state); + const index = state.allDashboards.findIndex(d => d === dashboard); + + state.isUpdatingStarredValue = false; + + // Trigger state updates in the reactivity system for this change + // https://vuejs.org/v2/guide/reactivity.html#For-Arrays + Vue.set(state.allDashboards, index, { ...dashboard, starred: newStarredValue }); + }, + [types.RECEIVE_DASHBOARD_STARRING_FAILURE](state) { + state.isUpdatingStarredValue = false; + }, + /** * Deployments and environments */ @@ -134,6 +153,8 @@ export default { metric.loading = false; metric.result = null; }, + + // Parameters and other information [types.SET_INITIAL_STATE](state, initialState = {}) { Object.assign(state, pick(initialState, initialStateKeys)); }, @@ -163,4 +184,17 @@ export default { [types.SET_ENVIRONMENTS_FILTER](state, searchTerm) { state.environmentsSearchTerm = searchTerm; }, + [types.SET_EXPANDED_PANEL](state, { group, panel }) { + state.expandedPanel.group = group; + state.expandedPanel.panel = panel; + }, + [types.SET_VARIABLES](state, variables) { + state.promVariables = variables; + }, + [types.UPDATE_VARIABLE_VALUES](state, updatedVariable) { + Object.assign(state.promVariables[updatedVariable.key], { + ...state.promVariables[updatedVariable.key], + value: updatedVariable.value, + }); + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e60510e747b..9ae1da93e5f 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -14,10 +14,27 @@ export default () => ({ emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, + isUpdatingStarredValue: false, dashboard: { panelGroups: [], }, + /** + * Panel that is currently "zoomed" in as + * a single panel in view. + */ + expandedPanel: { + /** + * {?String} Panel's group name. + */ + group: null, + /** + * {?Object} Panel content from `dashboard` + * null when no panel is expanded. + */ + panel: null, + }, allDashboards: [], + promVariables: {}, // Other project data annotations: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 9f06d18c46f..a47e5f598f5 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -144,6 +144,7 @@ const mapYAxisToViewModel = ({ * @returns {Object} */ const mapPanelToViewModel = ({ + id = null, title = '', type, x_axis = {}, @@ -162,6 +163,7 @@ const mapPanelToViewModel = ({ const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line babel/camelcase return { + id, title, type, xLabel: xAxis.name, diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js new file mode 100644 index 00000000000..bfb469da19e --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -0,0 +1,167 @@ +import { isString } from 'lodash'; +import { VARIABLE_TYPES } from '../constants'; + +/** + * This file exclusively deals with parsing user-defined variables + * in dashboard yml file. + * + * As of 13.0, simple text, advanced text, simple custom and + * advanced custom variables are supported. + * + * In the future iterations, text and query variables will be + * supported + * + */ + +/** + * Simple text variable is a string value only. + * This method parses such variables to a standard format. + * + * @param {String|Object} simpleTextVar + * @returns {Object} + */ +const textSimpleVariableParser = simpleTextVar => ({ + type: VARIABLE_TYPES.text, + label: null, + value: simpleTextVar, +}); + +/** + * Advanced text variable is an object. + * This method parses such variables to a standard format. + * + * @param {Object} advTextVar + * @returns {Object} + */ +const textAdvancedVariableParser = advTextVar => ({ + type: VARIABLE_TYPES.text, + label: advTextVar.label, + value: advTextVar.options.default_value, +}); + +/** + * Normalize simple and advanced custom variable options to a standard + * format + * @param {Object} custom variable option + * @returns {Object} normalized custom variable options + */ +const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ + default: defaultOpt, + text, + value, +}); + +/** + * Custom advanced variables are rendered as dropdown elements in the dashboard + * header. This method parses advanced custom variables. + * + * The default value is the option with default set to true or the first option + * if none of the options have default prop true. + * + * @param {Object} advVariable advance custom variable + * @returns {Object} + */ +const customAdvancedVariableParser = advVariable => { + const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions); + const defaultOpt = options.find(opt => opt.default === true) || options[0]; + return { + type: VARIABLE_TYPES.custom, + label: advVariable.label, + value: defaultOpt?.value, + options, + }; +}; + +/** + * Simple custom variables have an array of values. + * This method parses such variables options to a standard format. + * + * @param {String} opt option from simple custom variable + * @returns {Object} + */ +const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); + +/** + * Custom simple variables are rendered as dropdown elements in the dashboard + * header. This method parses simple custom variables. + * + * Simple custom variables do not have labels so its set to null here. + * + * The default value is set to the first option as the user cannot + * set a default value for this format + * + * @param {Array} customVariable array of options + * @returns {Object} + */ +const customSimpleVariableParser = simpleVar => { + const options = (simpleVar || []).map(parseSimpleCustomOptions); + return { + type: VARIABLE_TYPES.custom, + value: options[0].value, + label: null, + options: options.map(normalizeCustomVariableOptions), + }; +}; + +/** + * Utility method to determine if a custom variable is + * simple or not. If its not simple, it is advanced. + * + * @param {Array|Object} customVar Array if simple, object if advanced + * @returns {Boolean} true if simple, false if advanced + */ +const isSimpleCustomVariable = customVar => Array.isArray(customVar); + +/** + * This method returns a parser based on the type of the variable. + * Currently, the supported variables are simple custom and + * advanced custom only. In the future, this method will support + * text and query variables. + * + * @param {Array|Object} variable + * @return {Function} parser method + */ +const getVariableParser = variable => { + if (isSimpleCustomVariable(variable)) { + return customSimpleVariableParser; + } else if (variable.type === VARIABLE_TYPES.custom) { + return customAdvancedVariableParser; + } else if (variable.type === VARIABLE_TYPES.text) { + return textAdvancedVariableParser; + } else if (isString(variable)) { + return textSimpleVariableParser; + } + return () => null; +}; + +/** + * This method parses the templating property in the dashboard yml file. + * The templating property has variables that are rendered as input elements + * for the user to edit. The values from input elements are relayed to + * backend and eventually Prometheus API. + * + * This method currently is not used anywhere. Once the issue + * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed, + * this method will have been used by the monitoring dashboard. + * + * @param {Object} templating templating variables from the dashboard yml file + * @returns {Object} a map of processed templating variables + */ +export const parseTemplatingVariables = ({ variables = {} } = {}) => + Object.entries(variables).reduce((acc, [key, variable]) => { + // get the parser + const parser = getVariableParser(variable); + // parse the variable + const parsedVar = parser(variable); + // for simple custom variable label is null and it should be + // replace with key instead + if (parsedVar) { + acc[key] = { + ...parsedVar, + label: parsedVar.label || key, + }; + } + return acc; + }, {}); + +export default {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 7c6cd19eb7b..1f028ffbcad 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,9 +1,23 @@ -import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility'; +import { pickBy, mapKeys } from 'lodash'; +import { + queryToObject, + mergeUrlParams, + removeParams, + updateHistory, +} from '~/lib/utils/url_utility'; import { timeRangeParamNames, timeRangeFromParams, timeRangeToParams, } from '~/lib/utils/datetime_range'; +import { VARIABLE_PREFIX } from './constants'; + +/** + * List of non time range url parameters + * This will be removed once we add support for free text variables + * via the dashboard yaml files in https://gitlab.com/gitlab-org/gitlab/-/issues/215689 + */ +export const dashboardParams = ['dashboard', 'group', 'title', 'y_label', 'embedded']; /** * This method is used to validate if the graph data format for a chart component @@ -28,7 +42,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => { ); }; -/* eslint-disable @gitlab/require-i18n-strings */ /** * Checks that element that triggered event is located on cluster health check dashboard * @param {HTMLElement} element to check against @@ -36,6 +49,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => { */ const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show'); +/* eslint-disable @gitlab/require-i18n-strings */ /** * Tracks snowplow event when user generates link to metric chart * @param {String} chart link that will be sent as a property for the event @@ -71,6 +85,7 @@ export const downloadCSVOptions = title => { return { category, action, label: 'Chart title', property: title }; }; +/* eslint-enable @gitlab/require-i18n-strings */ /** * Generate options for snowplow to track adding a new metric via the dashboard @@ -113,6 +128,78 @@ export const timeRangeFromUrl = (search = window.location.search) => { }; /** + * Variable labels are used as names for the dropdowns and also + * as URL params. Prefixing the name reduces the risk of + * collision with other URL params + * + * @param {String} label label for the template variable + * @returns {String} + */ +export const addPrefixToLabel = label => `${VARIABLE_PREFIX}${label}`; + +/** + * Before the templating variables are passed to the backend the + * prefix needs to be removed. + * + * This method removes the prefix at the beginning of the string. + * + * @param {String} label label to remove prefix from + * @returns {String} + */ +export const removePrefixFromLabel = label => + (label || '').replace(new RegExp(`^${VARIABLE_PREFIX}`), ''); + +/** + * Convert parsed template variables to an object + * with just keys and values. Prepare the promVariables + * to be added to the URL. Keys of the object will + * have a prefix so that these params can be + * differentiated from other URL params. + * + * @param {Object} variables + * @returns {Object} + */ +export const convertVariablesForURL = variables => + Object.keys(variables || {}).reduce((acc, key) => { + acc[addPrefixToLabel(key)] = variables[key]?.value; + return acc; + }, {}); + +/** + * User-defined variables from the URL are extracted. The variables + * begin with a constant prefix so that it doesn't collide with + * other URL params. + * + * @param {String} New URL + * @returns {Object} The custom variables defined by the user in the URL + */ + +export const getPromCustomVariablesFromUrl = (search = window.location.search) => { + const params = queryToObject(search); + // pick the params with variable prefix + const paramsWithVars = pickBy(params, (val, key) => key.startsWith(VARIABLE_PREFIX)); + // remove the prefix before storing in the Vuex store + return mapKeys(paramsWithVars, (val, key) => removePrefixFromLabel(key)); +}; + +/** + * Update the URL with promVariables. This usually get triggered when + * the user interacts with the dynamic input elements in the monitoring + * dashboard header. + * + * @param {Object} promVariables user defined variables + */ +export const setPromCustomVariablesFromUrl = promVariables => { + // prep the variables to append to URL + const parsedVariables = convertVariablesForURL(promVariables); + // update the URL + updateHistory({ + url: mergeUrlParams(parsedVariables, window.location.href), + title: document.title, + }); +}; + +/** * Returns a URL with no time range based on the current URL. * * @param {String} New URL @@ -133,6 +220,81 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => { }; /** + * Locates a panel (and its corresponding group) given a (URL) search query. Returns + * it as payload for the store to set the right expandaded panel. + * + * Params used to locate a panel are: + * - group: Group identifier + * - title: Panel title + * - y_label: Panel y_label + * + * @param {Object} dashboard - Dashboard reference from the Vuex store + * @param {String} search - URL location search query + * @returns {Object} payload - Payload for expanded panel to be displayed + * @returns {String} payload.group - Group where panel is located + * @returns {Object} payload.panel - Dashboard panel (graphData) reference + * @throws Will throw an error if Panel cannot be located. + */ +export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => { + const params = queryToObject(search); + + // Search for the panel if any of the search params is identified + if (params.group || params.title || params.y_label) { + const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group); + const panel = panelGroup.panels.find( + // eslint-disable-next-line babel/camelcase + ({ y_label, title }) => y_label === params.y_label && title === params.title, + ); + + if (!panel) { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('Panel could no found by URL parameters.'); + } + return { group: panelGroup.group, panel }; + } + return null; +}; + +/** + * Convert panel information to a URL for the user to + * bookmark or share highlighting a specific panel. + * + * If no group/panel is set, the dashboard URL is returned. + * + * @param {?String} dashboard - Dashboard path, used as identifier for a dashboard + * @param {?Object} promVariables - Custom variables that came from the URL + * @param {?String} group - Group Identifier + * @param {?Object} panel - Panel object from the dashboard + * @param {?String} url - Base URL including current search params + * @returns Dashboard URL which expands a panel (chart) + */ +export const panelToUrl = ( + dashboard = null, + promVariables, + group, + panel, + url = window.location.href, +) => { + const params = { + dashboard, + ...promVariables, + }; + + if (group && panel) { + params.group = group; + params.title = panel.title; + params.y_label = panel.y_label; + } else { + // Remove existing parameters if any + params.group = null; + params.title = null; + params.y_label = null; + } + + return mergeUrlParams(params, url); +}; + +/** * Get the metric value from first data point. * Currently only used for bar charts * @@ -191,4 +353,39 @@ export const barChartsDataParser = (data = []) => {}, ); +/** + * Custom variables are defined in the dashboard yml file + * and their values can be passed through the URL. + * + * On component load, this method merges variables data + * from the yml file with URL data to store in the Vuex store. + * Not all params coming from the URL need to be stored. Only + * the ones that have a corresponding variable defined in the + * yml file. + * + * This ensures that there is always a single source of truth + * for variables + * + * This method can be improved further. See the below issue + * https://gitlab.com/gitlab-org/gitlab/-/issues/217713 + * + * @param {Object} varsFromYML template variables from yml file + * @returns {Object} + */ +export const mergeURLVariables = (varsFromYML = {}) => { + const varsFromURL = getPromCustomVariablesFromUrl(); + const variables = {}; + Object.keys(varsFromYML).forEach(key => { + if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) { + variables[key] = { + ...varsFromYML[key], + value: varsFromURL[key], + }; + } else { + variables[key] = varsFromYML[key]; + } + }); + return variables; +}; + export default {}; diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js new file mode 100644 index 00000000000..cd426f1a221 --- /dev/null +++ b/app/assets/javascripts/monitoring/validators.js @@ -0,0 +1,44 @@ +// Prop validator for alert information, expecting an object like the example below. +// +// { +// '/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37': { +// alert_path: "/root/autodevops-deploy/prometheus/alerts/16.json?environment_id=37", +// metricId: '1', +// operator: ">", +// query: "rate(http_requests_total[5m])[30m:1m]", +// threshold: 0.002, +// title: "Core Usage (Total)", +// } +// } +export function alertsValidator(value) { + return Object.keys(value).every(key => { + const alert = value[key]; + return ( + alert.alert_path && + key === alert.alert_path && + alert.metricId && + typeof alert.metricId === 'string' && + alert.operator && + typeof alert.threshold === 'number' + ); + }); +} + +// Prop validator for query information, expecting an array like the example below. +// +// [ +// { +// metricId: '16', +// label: 'Total Cores' +// }, +// { +// metricId: '17', +// label: 'Sub-total Cores' +// } +// ] +export function queriesValidator(value) { + return value.every( + query => + query.metricId && typeof query.metricId === 'string' && typeof query.label === 'string', + ); +} |