summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/monitoring/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/monitoring/components')
-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
12 files changed, 1120 insertions, 166 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>