summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/monitoring
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
commit9f46488805e86b1bc341ea1620b866016c2ce5ed (patch)
treef9748c7e287041e37d6da49e0a29c9511dc34768 /app/assets/javascripts/monitoring
parentdfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff)
downloadgitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'app/assets/javascripts/monitoring')
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget.vue286
-rw-r--r--app/assets/javascripts/monitoring/components/alert_widget_form.vue307
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue6
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue13
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue277
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue (renamed from app/assets/javascripts/monitoring/components/panel_type.vue)187
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue55
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/metric_embed.vue8
-rw-r--r--app/assets/javascripts/monitoring/components/variables/custom_variable.vue50
-rw-r--r--app/assets/javascripts/monitoring/components/variables/text_variable.vue39
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue56
-rw-r--r--app/assets/javascripts/monitoring/constants.js84
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle.js2
-rw-r--r--app/assets/javascripts/monitoring/monitoring_bundle_with_alerts.js13
-rw-r--r--app/assets/javascripts/monitoring/services/alerts_service.js32
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js84
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js28
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js10
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js36
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js17
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js2
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js167
-rw-r--r--app/assets/javascripts/monitoring/utils.js201
-rw-r--r--app/assets/javascripts/monitoring/validators.js44
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',
+ );
+}