summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/monitoring
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/monitoring')
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/charts/heatmap.vue5
-rw-r--r--app/assets/javascripts/monitoring/components/charts/options.js18
-rw-r--r--app/assets/javascripts/monitoring/components/charts/single_stat.vue11
-rw-r--r--app/assets/javascripts/monitoring/components/charts/stacked_column.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue2
-rw-r--r--app/assets/javascripts/monitoring/components/create_dashboard_modal.vue66
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue121
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_header.vue168
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel.vue67
-rw-r--r--app/assets/javascripts/monitoring/components/dashboards_dropdown.vue85
-rw-r--r--app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue95
-rw-r--r--app/assets/javascripts/monitoring/components/empty_state.vue74
-rw-r--r--app/assets/javascripts/monitoring/components/graph_group.vue19
-rw-r--r--app/assets/javascripts/monitoring/components/refresh_button.vue163
-rw-r--r--app/assets/javascripts/monitoring/components/variables/dropdown_field.vue (renamed from app/assets/javascripts/monitoring/components/variables/custom_variable.vue)19
-rw-r--r--app/assets/javascripts/monitoring/components/variables/text_field.vue (renamed from app/assets/javascripts/monitoring/components/variables/text_variable.vue)2
-rw-r--r--app/assets/javascripts/monitoring/components/variables_section.vue33
-rw-r--r--app/assets/javascripts/monitoring/constants.js30
-rw-r--r--app/assets/javascripts/monitoring/format_date.js1
-rw-r--r--app/assets/javascripts/monitoring/monitoring_app.js35
-rw-r--r--app/assets/javascripts/monitoring/pages/dashboard_page.vue11
-rw-r--r--app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql18
-rw-r--r--app/assets/javascripts/monitoring/router/constants.js1
-rw-r--r--app/assets/javascripts/monitoring/router/routes.js9
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js124
-rw-r--r--app/assets/javascripts/monitoring/stores/getters.js35
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js13
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js67
-rw-r--r--app/assets/javascripts/monitoring/stores/state.js31
-rw-r--r--app/assets/javascripts/monitoring/stores/utils.js198
-rw-r--r--app/assets/javascripts/monitoring/stores/variable_mapping.js158
-rw-r--r--app/assets/javascripts/monitoring/utils.js51
33 files changed, 1362 insertions, 374 deletions
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
index 34da5885c97..ac401c6e381 100644
--- a/app/assets/javascripts/monitoring/components/charts/anomaly.vue
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -218,7 +218,7 @@ export default {
<gl-chart-series-label :color="content.color">
{{ content.name }}
</gl-chart-series-label>
- <div class="prepend-left-32">
+ <div class="gl-ml-7">
{{ yValueFormatted(seriesIndex, content.dataIndex) }}
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
index f6f266dacf3..ddb44f7b1be 100644
--- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue
+++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue
@@ -48,7 +48,10 @@ export default {
return this.result.values.map(val => {
const [yLabel] = val;
- return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone });
+ return formatDate(new Date(yLabel), {
+ format: formats.shortTime,
+ timezone: this.timezone,
+ });
});
},
result() {
diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js
index f7822e69b1d..42252dd5897 100644
--- a/app/assets/javascripts/monitoring/components/charts/options.js
+++ b/app/assets/javascripts/monitoring/components/charts/options.js
@@ -17,7 +17,9 @@ const defaultTooltipFormat = defaultFormat;
const defaultTooltipPrecision = 3;
// Give enough space for y-axis with units and name.
-const chartGridLeft = 75;
+const chartGridLeft = 63; // larger gap than gitlab-ui's default to fit formatted numbers
+const chartGridRight = 10; // half of the scroll-handle icon for data zoom
+const yAxisNameGap = chartGridLeft - 12; // offset the axis label line-height
// Axis options
@@ -62,7 +64,7 @@ export const getYAxisOptions = ({
precision = defaultYAxisPrecision,
} = {}) => {
return {
- nameGap: 63, // larger gap than gitlab-ui's default to fit with formatted numbers
+ nameGap: yAxisNameGap,
scale: true,
boundaryGap: yAxisBoundaryGap,
@@ -74,11 +76,14 @@ export const getYAxisOptions = ({
};
};
-export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({
+export const getTimeAxisOptions = ({
+ timezone = timezones.LOCAL,
+ format = formats.shortDateTime,
+} = {}) => ({
name: __('Time'),
type: axisTypes.time,
axisLabel: {
- formatter: date => formatDate(date, { format: formats.shortTime, timezone }),
+ formatter: date => formatDate(date, { format, timezone }),
},
axisPointer: {
snap: false,
@@ -90,7 +95,10 @@ export const getTimeAxisOptions = ({ timezone = timezones.LOCAL } = {}) => ({
/**
* Grid with enough room to display chart.
*/
-export const getChartGrid = ({ left = chartGridLeft } = {}) => ({ left });
+export const getChartGrid = ({ left = chartGridLeft, right = chartGridRight } = {}) => ({
+ left,
+ right,
+});
// Tooltip options
diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
index eee5eaa5eca..106c76a97dc 100644
--- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue
+++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue
@@ -1,9 +1,11 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
+import { __ } from '~/locale';
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
import { graphDataValidatorForValues } from '../../utils';
const defaultPrecision = 2;
+const emptyStateMsg = __('No data to display');
export default {
components: {
@@ -21,6 +23,9 @@ export default {
queryInfo() {
return this.graphData.metrics[0];
},
+ queryMetric() {
+ return this.queryInfo.result[0]?.metric;
+ },
queryResult() {
return this.queryInfo.result[0]?.value[1];
},
@@ -33,6 +38,12 @@ export default {
statValue() {
let formatter;
+ // if field is present the metric value is not displayed. Hence
+ // the early exit without formatting.
+ if (this.graphData?.field) {
+ return this.queryMetric?.[this.graphData.field] ?? emptyStateMsg;
+ }
+
if (this.graphData?.maxValue) {
formatter = getFormatter(SUPPORTED_FORMATS.percent);
return formatter(this.queryResult / Number(this.graphData.maxValue), defaultPrecision);
diff --git a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
index ac31d107e63..9bcd4419a14 100644
--- a/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
+++ b/app/assets/javascripts/monitoring/components/charts/stacked_column.vue
@@ -6,7 +6,7 @@ import { chartHeight, legendLayoutTypes } from '../../constants';
import { s__ } from '~/locale';
import { graphDataValidatorForValues } from '../../utils';
import { getTimeAxisOptions, axisTypes } from './options';
-import { timezones } from '../../format_date';
+import { formats, timezones } from '../../format_date';
export default {
components: {
@@ -97,7 +97,7 @@ export default {
chartOptions() {
return {
xAxis: {
- ...getTimeAxisOptions({ timezone: this.timezone }),
+ ...getTimeAxisOptions({ timezone: this.timezone, format: formats.shortTime }),
type: this.xAxisType,
},
dataZoom: [this.dataZoomConfig],
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 28af2d8ba77..f2add429a80 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -415,7 +415,7 @@ export default {
<gl-chart-series-label :color="isMultiSeries ? content.color : ''">
{{ content.name }}
</gl-chart-series-label>
- <div class="prepend-left-32">
+ <div class="gl-ml-7">
{{ content.value }}
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
new file mode 100644
index 00000000000..74799002b17
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/create_dashboard_modal.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import { isSafeURL } from '~/lib/utils/url_utility';
+
+export default {
+ components: { GlButton, GlModal, GlSprintf },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ projectPath: {
+ type: String,
+ required: true,
+ validator: isSafeURL,
+ },
+ addDashboardDocumentationPath: {
+ type: String,
+ required: true,
+ },
+ },
+ methods: {
+ cancelHandler() {
+ this.$refs.modal.hide();
+ },
+ },
+ i18n: {
+ titleText: s__('Metrics|Create your dashboard configuration file'),
+ mainText: s__(
+ 'Metrics|To create a new dashboard, add a new YAML file to %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.',
+ ),
+ },
+};
+</script>
+
+<template>
+ <gl-modal ref="modal" :modal-id="modalId" :title="$options.i18n.titleText">
+ <p>
+ <gl-sprintf :message="$options.i18n.mainText">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <template #modal-footer>
+ <gl-button category="secondary" @click="cancelHandler">{{ s__('Metrics|Cancel') }}</gl-button>
+ <gl-button
+ category="secondary"
+ variant="info"
+ target="_blank"
+ :href="addDashboardDocumentationPath"
+ data-testid="create-dashboard-modal-docs-button"
+ >
+ {{ s__('Metrics|View documentation') }}
+ </gl-button>
+ <gl-button
+ variant="success"
+ data-testid="create-dashboard-modal-repo-button"
+ :href="projectPath"
+ >
+ {{ s__('Metrics|Open repository') }}
+ </gl-button>
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index f54319d283e..bde62275797 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,6 +1,7 @@
<script>
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
+import Mousetrap from 'mousetrap';
import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import DashboardHeader from './dashboard_header.vue';
import DashboardPanel from './dashboard_panel.vue';
@@ -24,7 +25,7 @@ import {
expandedPanelPayloadFromUrl,
convertVariablesForURL,
} from '../utils';
-import { metricStates } from '../constants';
+import { metricStates, keyboardShortcutKeys } from '../constants';
import { defaultTimeRange } from '~/vue_shared/constants';
export default {
@@ -71,6 +72,10 @@ export default {
type: String,
required: true,
},
+ addDashboardDocumentationPath: {
+ type: String,
+ required: true,
+ },
settingsPath: {
type: String,
required: true,
@@ -149,21 +154,25 @@ export default {
selectedTimeRange: timeRangeFromUrl() || defaultTimeRange,
isRearrangingPanels: false,
originalDocumentTitle: document.title,
+ hoveredPanel: '',
};
},
computed: {
...mapState('monitoringDashboard', [
'dashboard',
'emptyState',
- 'showEmptyState',
'expandedPanel',
'variables',
'links',
'currentDashboard',
+ 'hasDashboardValidationWarnings',
]),
...mapGetters('monitoringDashboard', ['selectedDashboard', 'getMetricStates']),
+ shouldShowEmptyState() {
+ return Boolean(this.emptyState);
+ },
shouldShowVariablesSection() {
- return Object.keys(this.variables).length > 0;
+ return Boolean(this.variables.length);
},
shouldShowLinksSection() {
return Object.keys(this.links).length > 0;
@@ -197,12 +206,29 @@ export default {
selectedDashboard(dashboard) {
this.prependToDocumentTitle(dashboard?.display_name);
},
+ hasDashboardValidationWarnings(hasWarnings) {
+ /**
+ * This watcher is set for future SPA behaviour of the dashboard
+ */
+ if (hasWarnings) {
+ createFlash(
+ s__(
+ 'Metrics|Your dashboard schema is invalid. Edit the dashboard to correct the YAML schema.',
+ ),
+ 'warning',
+ );
+ }
+ },
},
created() {
window.addEventListener('keyup', this.onKeyup);
+
+ Mousetrap.bind(Object.values(keyboardShortcutKeys), this.runShortcut);
},
destroyed() {
window.removeEventListener('keyup', this.onKeyup);
+
+ Mousetrap.unbind(Object.values(keyboardShortcutKeys));
},
mounted() {
if (!this.hasMetrics) {
@@ -254,6 +280,14 @@ export default {
return null;
},
/**
+ * Return true if the entire group is loading.
+ * @param {String} groupKey - Identifier for group
+ * @returns {boolean}
+ */
+ isGroupLoading(groupKey) {
+ return this.groupSingleEmptyState(groupKey) === metricStates.LOADING;
+ },
+ /**
* A group should be not collapsed if any metric is loaded (OK)
*
* @param {String} groupKey - Identifier for group
@@ -302,6 +336,66 @@ export default {
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
+ isPanelHalfWidth(panelIndex, totalPanels) {
+ /**
+ * A single panel on a row should take the full width of its parent.
+ * All others should have half the width their parent.
+ */
+ const isNumberOfPanelsEven = totalPanels % 2 === 0;
+ const isLastPanel = panelIndex === totalPanels - 1;
+
+ return isNumberOfPanelsEven || !isLastPanel;
+ },
+ /**
+ * TODO: Investigate this to utilize the eventBus from Vue
+ * The intentation behind this cleanup is to allow for better tests
+ * as well as use the correct eventBus facilities that are compatible
+ * with Vue 3
+ * https://gitlab.com/gitlab-org/gitlab/-/issues/225583
+ */
+ //
+ runShortcut(e) {
+ const panel = this.$refs[this.hoveredPanel];
+
+ if (!panel) return;
+
+ const [panelInstance] = panel;
+ let actionToRun = '';
+
+ switch (e.key) {
+ case keyboardShortcutKeys.EXPAND:
+ actionToRun = 'onExpandFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.VISIT_LOGS:
+ actionToRun = 'visitLogsPageFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.SHOW_ALERT:
+ actionToRun = 'showAlertModalFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.DOWNLOAD_CSV:
+ actionToRun = 'downloadCsvFromKeyboardShortcut';
+ break;
+
+ case keyboardShortcutKeys.CHART_COPY:
+ actionToRun = 'copyChartLinkFromKeyboardShotcut';
+ break;
+
+ default:
+ actionToRun = 'onExpandFromKeyboardShortcut';
+ break;
+ }
+
+ panelInstance[actionToRun]();
+ },
+ setHoveredPanel(groupKey, graphIndex) {
+ this.hoveredPanel = `dashboard-panel-${groupKey}-${graphIndex}`;
+ },
+ clearHoveredPanel() {
+ this.hoveredPanel = '';
+ },
},
i18n: {
goBackLabel: s__('Metrics|Go back (Esc)'),
@@ -315,6 +409,7 @@ export default {
v-if="showHeader"
ref="prometheusGraphsHeader"
class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light"
+ :add-dashboard-documentation-path="addDashboardDocumentationPath"
:default-branch="defaultBranch"
:rearrange-panels-available="rearrangePanelsAvailable"
:custom-metrics-available="customMetricsAvailable"
@@ -327,9 +422,9 @@ export default {
@dateTimePickerInvalid="onDateTimePickerInvalid"
@setRearrangingPanels="onSetRearrangingPanels"
/>
- <variables-section v-if="shouldShowVariablesSection && !showEmptyState" />
- <links-section v-if="shouldShowLinksSection && !showEmptyState" />
- <div v-if="!showEmptyState">
+ <template v-if="!shouldShowEmptyState">
+ <variables-section v-if="shouldShowVariablesSection" />
+ <links-section v-if="shouldShowLinksSection" />
<dashboard-panel
v-show="expandedPanel.panel"
ref="expandedPanel"
@@ -364,6 +459,7 @@ export default {
:key="`${groupData.group}.${groupData.priority}`"
:name="groupData.group"
:show-panels="showPanels"
+ :is-loading="isGroupLoading(groupData.key)"
:collapse-group="collapseGroup(groupData.key)"
>
<vue-draggable
@@ -377,8 +473,14 @@ export default {
<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 }"
+ data-testid="dashboard-panel-layout-wrapper"
+ class="col-12 px-2 mb-2 draggable"
+ :class="{
+ 'draggable-enabled': isRearrangingPanels,
+ 'col-lg-6': isPanelHalfWidth(graphIndex, groupData.panels.length),
+ }"
+ @mouseover="setHoveredPanel(groupData.key, graphIndex)"
+ @mouseout="clearHoveredPanel"
>
<div class="position-relative draggable-panel js-draggable-panel">
<div
@@ -392,6 +494,7 @@ export default {
</div>
<dashboard-panel
+ :ref="`dashboard-panel-${groupData.key}-${graphIndex}`"
:settings-path="settingsPath"
:clipboard-text="generatePanelUrl(groupData.group, graphData)"
:graph-data="graphData"
@@ -414,7 +517,7 @@ export default {
</div>
</graph-group>
</div>
- </div>
+ </template>
<empty-state
v-else
:selected-state="emptyState"
diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue
index 16a21ae0d3c..fe6ca3a2a07 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_header.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue
@@ -2,12 +2,16 @@
import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import {
+ GlButton,
GlIcon,
GlDeprecatedButton,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownItem,
GlModal,
GlLoadingIcon,
GlSearchBoxByType,
@@ -22,6 +26,9 @@ import Icon from '~/vue_shared/components/icon.vue';
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
+import RefreshButton from './refresh_button.vue';
+import CreateDashboardModal from './create_dashboard_modal.vue';
+import DuplicateDashboardModal from './duplicate_dashboard_modal.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils';
@@ -31,6 +38,7 @@ import { timezones } from '../format_date';
export default {
components: {
Icon,
+ GlButton,
GlIcon,
GlDeprecatedButton,
GlDropdown,
@@ -38,12 +46,18 @@ export default {
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
+ GlNewDropdown,
+ GlNewDropdownDivider,
+ GlNewDropdownItem,
GlSearchBoxByType,
GlModal,
CustomMetricsFormFields,
DateTimePicker,
DashboardsDropdown,
+ RefreshButton,
+ DuplicateDashboardModal,
+ CreateDashboardModal,
},
directives: {
GlModal: GlModalDirective,
@@ -93,6 +107,10 @@ export default {
type: Object,
required: true,
},
+ addDashboardDocumentationPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -101,20 +119,30 @@ export default {
},
computed: {
...mapState('monitoringDashboard', [
+ 'emptyState',
'environmentsLoading',
'currentEnvironmentName',
'isUpdatingStarredValue',
- 'showEmptyState',
'dashboardTimezone',
+ 'projectPath',
+ 'canAccessOperationsSettings',
+ 'operationsSettingsPath',
+ 'currentDashboard',
]),
...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']),
+ isOutOfTheBoxDashboard() {
+ return this.selectedDashboard?.out_of_the_box_dashboard;
+ },
+ shouldShowEmptyState() {
+ return Boolean(this.emptyState);
+ },
shouldShowEnvironmentsDropdownNoMatchedMsg() {
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
},
addingMetricsAvailable() {
return (
this.customMetricsAvailable &&
- !this.showEmptyState &&
+ !this.shouldShowEmptyState &&
// 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
@@ -122,23 +150,29 @@ export default {
);
},
showRearrangePanelsBtn() {
- return !this.showEmptyState && this.rearrangePanelsAvailable;
+ return !this.shouldShowEmptyState && this.rearrangePanelsAvailable;
},
displayUtc() {
return this.dashboardTimezone === timezones.UTC;
},
+ shouldShowActionsMenu() {
+ return Boolean(this.projectPath);
+ },
+ shouldShowSettingsButton() {
+ return this.canAccessOperationsSettings && this.operationsSettingsPath;
+ },
},
methods: {
- ...mapActions('monitoringDashboard', [
- 'filterEnvironments',
- 'fetchDashboardData',
- 'toggleStarredValue',
- ]),
+ ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']),
selectDashboard(dashboard) {
- const params = {
- dashboard: dashboard.path,
- };
- redirectTo(mergeUrlParams(params, window.location.href));
+ // Once the sidebar See metrics link is updated to the new URL,
+ // this sort of hardcoding will not be necessary.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ const baseURL = `${this.projectPath}/-/metrics`;
+ const dashboardPath = encodeURIComponent(
+ dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name,
+ );
+ redirectTo(`${baseURL}/${dashboardPath}`);
},
debouncedEnvironmentsSearch: debounce(function environmentsSearchOnInput(searchTerm) {
this.filterEnvironments(searchTerm);
@@ -149,9 +183,6 @@ export default {
onDateTimePickerInvalid() {
this.$emit('dateTimePickerInvalid');
},
- refreshDashboard() {
- this.fetchDashboardData();
- },
toggleRearrangingPanels() {
this.$emit('setRearrangingPanels', !this.isRearrangingPanels);
@@ -166,14 +197,27 @@ export default {
submitCustomMetricsForm() {
this.$refs.customMetricsForm.submit();
},
+ getEnvironmentPath(environment) {
+ // Once the sidebar See metrics link is updated to the new URL,
+ // this sort of hardcoding will not be necessary.
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/229277
+ const baseURL = `${this.projectPath}/-/metrics`;
+ const dashboardPath = encodeURIComponent(this.currentDashboard || '');
+ // The environment_metrics_spec.rb requires the URL to not have
+ // slashes. Hence, this additional check.
+ const url = dashboardPath ? `${baseURL}/${dashboardPath}` : baseURL;
+ return mergeUrlParams({ environment }, url);
+ },
},
- addMetric: {
- title: s__('Metrics|Add metric'),
- modalId: 'add-metric',
+ modalIds: {
+ addMetric: 'addMetric',
+ createDashboard: 'createDashboard',
+ duplicateDashboard: 'duplicateDashboard',
},
i18n: {
starDashboard: s__('Metrics|Star dashboard'),
unstarDashboard: s__('Metrics|Unstar dashboard'),
+ addMetric: s__('Metrics|Add metric'),
},
timeRanges,
};
@@ -181,17 +225,20 @@ export default {
<template>
<div ref="prometheusGraphsHeader">
- <div class="mb-2 pr-2 d-flex d-sm-block">
+ <div class="mb-2 mr-2 d-flex d-sm-block">
<dashboards-dropdown
id="monitor-dashboards-dropdown"
data-qa-selector="dashboards_filter_dropdown"
class="flex-grow-1"
toggle-class="dropdown-menu-toggle"
:default-branch="defaultBranch"
+ :modal-id="$options.modalIds.duplicateDashboard"
@selectDashboard="selectDashboard"
/>
</div>
+ <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span>
+
<div class="mb-2 pr-2 d-flex d-sm-block">
<gl-dropdown
id="monitor-environments-dropdown"
@@ -223,7 +270,7 @@ export default {
:key="environment.id"
:active="environment.name === currentEnvironmentName"
active-class="is-active"
- :href="environment.metrics_path"
+ :href="getEnvironmentPath(environment.id)"
>{{ environment.name }}</gl-dropdown-item
>
</div>
@@ -252,16 +299,7 @@ export default {
</div>
<div class="mb-2 pr-2 d-flex d-sm-block">
- <gl-deprecated-button
- ref="refreshDashboardBtn"
- v-gl-tooltip
- class="flex-grow-1"
- variant="default"
- :title="s__('Metrics|Refresh dashboard')"
- @click="refreshDashboard"
- >
- <icon name="retry" />
- </gl-deprecated-button>
+ <refresh-button />
</div>
<div class="flex-grow-1"></div>
@@ -304,17 +342,17 @@ export default {
<div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block">
<gl-deprecated-button
ref="addMetricBtn"
- v-gl-modal="$options.addMetric.modalId"
+ v-gl-modal="$options.modalIds.addMetric"
variant="outline-success"
data-qa-selector="add_metric_button"
class="flex-grow-1"
>
- {{ $options.addMetric.title }}
+ {{ $options.i18n.addMetric }}
</gl-deprecated-button>
<gl-modal
ref="addMetricModal"
- :modal-id="$options.addMetric.modalId"
- :title="$options.addMetric.title"
+ :modal-id="$options.modalIds.addMetric"
+ :title="$options.i18n.addMetric"
>
<form ref="customMetricsForm" :action="customMetricsPath" method="post">
<custom-metrics-form-fields
@@ -353,7 +391,10 @@ export default {
</gl-deprecated-button>
</div>
- <div v-if="externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block">
+ <div
+ v-if="externalDashboardUrl && externalDashboardUrl.length"
+ class="mb-2 mr-2 d-flex d-sm-block"
+ >
<gl-deprecated-button
class="flex-grow-1 js-external-dashboard-link"
variant="primary"
@@ -364,6 +405,63 @@ export default {
{{ __('View full dashboard') }} <icon name="external-link" />
</gl-deprecated-button>
</div>
+
+ <!-- This separator should be displayed only if at least one of the action menu or settings button are displayed -->
+ <span
+ v-if="shouldShowActionsMenu || shouldShowSettingsButton"
+ aria-hidden="true"
+ class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"
+ ></span>
+
+ <div v-if="shouldShowActionsMenu" class="gl-mb-3 gl-mr-3 d-flex d-sm-block">
+ <gl-new-dropdown
+ v-gl-tooltip
+ right
+ class="gl-flex-grow-1"
+ data-testid="actions-menu"
+ :title="s__('Metrics|Create dashboard')"
+ :icon="'plus-square'"
+ >
+ <gl-new-dropdown-item
+ v-gl-modal="$options.modalIds.createDashboard"
+ data-testid="action-create-dashboard"
+ >{{ s__('Metrics|Create new dashboard') }}</gl-new-dropdown-item
+ >
+
+ <create-dashboard-modal
+ data-testid="create-dashboard-modal"
+ :add-dashboard-documentation-path="addDashboardDocumentationPath"
+ :modal-id="$options.modalIds.createDashboard"
+ :project-path="projectPath"
+ />
+
+ <template v-if="isOutOfTheBoxDashboard">
+ <gl-new-dropdown-divider />
+ <gl-new-dropdown-item
+ ref="duplicateDashboardItem"
+ v-gl-modal="$options.modalIds.duplicateDashboard"
+ data-testid="action-duplicate-dashboard"
+ >
+ {{ s__('Metrics|Duplicate current dashboard') }}
+ </gl-new-dropdown-item>
+ </template>
+ </gl-new-dropdown>
+ </div>
+
+ <div v-if="shouldShowSettingsButton" class="mb-2 mr-2 d-flex d-sm-block">
+ <gl-button
+ v-gl-tooltip
+ data-testid="metrics-settings-button"
+ icon="settings"
+ :href="operationsSettingsPath"
+ :title="s__('Metrics|Metrics Settings')"
+ />
+ </div>
</div>
+ <duplicate-dashboard-modal
+ :default-branch="defaultBranch"
+ :modal-id="$options.modalIds.duplicateDashboard"
+ @dashboardDuplicated="selectDashboard"
+ />
</div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
index 9545a211bbd..3e3c8408de3 100644
--- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue
@@ -2,6 +2,7 @@
import { mapState } from 'vuex';
import { pickBy } from 'lodash';
import invalidUrl from '~/lib/utils/invalid_url';
+import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility';
import {
GlResizeObserverDirective,
GlIcon,
@@ -29,7 +30,6 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
-import { isSafeURL } from '~/lib/utils/url_utility';
const events = {
timeRangeZoom: 'timerangezoom',
@@ -132,7 +132,8 @@ export default {
return this.graphData?.title || '';
},
graphDataHasResult() {
- return this.graphData?.metrics?.[0]?.result?.length > 0;
+ const metrics = this.graphData?.metrics || [];
+ return metrics.some(({ result }) => result?.length > 0);
},
graphDataIsLoading() {
const metrics = this.graphData?.metrics || [];
@@ -207,7 +208,17 @@ export default {
return MonitorTimeSeriesChart;
},
isContextualMenuShown() {
- return Boolean(this.graphDataHasResult && !this.basicChartComponent);
+ if (!this.graphDataHasResult) {
+ return false;
+ }
+ // Only a few charts have a contextual menu, support
+ // for more chart types planned at:
+ // https://gitlab.com/groups/gitlab-org/-/epics/3573
+ return (
+ this.isPanelType(panelTypes.AREA_CHART) ||
+ this.isPanelType(panelTypes.LINE_CHART) ||
+ this.isPanelType(panelTypes.SINGLE_STAT)
+ );
},
editCustomMetricLink() {
if (this.graphData.metrics.length > 1) {
@@ -223,13 +234,19 @@ export default {
return metrics.some(({ metricId }) => this.metricsSavedToDb.includes(metricId));
},
alertWidgetAvailable() {
+ const supportsAlerts =
+ this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART);
return (
+ supportsAlerts &&
this.prometheusAlertsAvailable &&
this.alertsEndpoint &&
this.graphData &&
this.hasMetricsInDb
);
},
+ alertModalId() {
+ return `alert-modal-${this.graphData.id}`;
+ },
},
mounted() {
this.refreshTitleTooltip();
@@ -268,6 +285,11 @@ export default {
onExpand() {
this.$emit(events.expand);
},
+ onExpandFromKeyboardShortcut() {
+ if (this.isContextualMenuShown) {
+ this.onExpand();
+ }
+ },
setAlerts(alertPath, alertAttributes) {
if (alertAttributes) {
this.$set(this.allAlerts, alertPath, alertAttributes);
@@ -278,18 +300,45 @@ export default {
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
+ showAlertModal() {
+ this.$root.$emit('bv::show::modal', this.alertModalId);
+ },
+ showAlertModalFromKeyboardShortcut() {
+ if (this.isContextualMenuShown) {
+ this.showAlertModal();
+ }
+ },
+ visitLogsPage() {
+ if (this.logsPathWithTimeRange) {
+ visitUrl(relativePathToAbsolute(this.logsPathWithTimeRange, getBaseURL()));
+ }
+ },
+ visitLogsPageFromKeyboardShortcut() {
+ if (this.isContextualMenuShown) {
+ this.visitLogsPage();
+ }
+ },
+ downloadCsvFromKeyboardShortcut() {
+ if (this.csvText && this.isContextualMenuShown) {
+ this.$refs.downloadCsvLink.$el.firstChild.click();
+ }
+ },
+ copyChartLinkFromKeyboardShotcut() {
+ if (this.clipboardText && this.isContextualMenuShown) {
+ this.$refs.copyChartLink.$el.firstChild.click();
+ }
+ },
},
panelTypes,
};
</script>
<template>
<div v-gl-resize-observer="onResize" class="prometheus-graph">
- <div class="d-flex align-items-center mr-3">
+ <div class="d-flex align-items-center">
<slot name="topLeft"></slot>
<h5
ref="graphTitle"
class="prometheus-graph-title gl-font-lg font-weight-bold text-truncate gl-mr-3"
- tabindex="0"
>
{{ title }}
</h5>
@@ -299,7 +348,7 @@ export default {
<alert-widget
v-if="isContextualMenuShown && alertWidgetAvailable"
class="mx-1"
- :modal-id="`alert-modal-${graphData.id}`"
+ :modal-id="alertModalId"
:alerts-endpoint="alertsEndpoint"
:relevant-queries="graphData.metrics"
:alerts-to-manage="getGraphAlerts(graphData.metrics)"
@@ -314,7 +363,7 @@ export default {
ref="contextualMenu"
data-qa-selector="prometheus_graph_widgets"
>
- <div class="d-flex align-items-center">
+ <div data-testid="dropdown-wrapper" class="d-flex align-items-center">
<gl-dropdown
v-gl-tooltip
toggle-class="shadow-none border-0"
@@ -369,13 +418,13 @@ export default {
</gl-dropdown-item>
<gl-dropdown-item
v-if="alertWidgetAvailable"
- v-gl-modal="`alert-modal-${graphData.id}`"
+ v-gl-modal="alertModalId"
data-qa-selector="alert_widget_menu_item"
>
{{ __('Alerts') }}
</gl-dropdown-item>
- <template v-if="graphData.links.length">
+ <template v-if="graphData.links && graphData.links.length">
<gl-dropdown-divider />
<gl-dropdown-item
v-for="(link, index) in graphData.links"
diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
index 8b86890715f..574f48a72fe 100644
--- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
+++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue
@@ -1,19 +1,14 @@
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import {
- GlAlert,
GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
GlSearchBoxByType,
- GlModal,
- GlLoadingIcon,
GlModalDirective,
} from '@gitlab/ui';
-import { s__ } from '~/locale';
-import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
const events = {
selectDashboard: 'selectDashboard',
@@ -21,16 +16,12 @@ const events = {
export default {
components: {
- GlAlert,
GlIcon,
GlDropdown,
GlDropdownItem,
GlDropdownHeader,
GlDropdownDivider,
GlSearchBoxByType,
- GlModal,
- GlLoadingIcon,
- DuplicateDashboardForm,
},
directives: {
GlModal: GlModalDirective,
@@ -40,20 +31,21 @@ export default {
type: String,
required: true,
},
+ modalId: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
- alert: null,
- loading: false,
- form: {},
searchTerm: '',
};
},
computed: {
...mapState('monitoringDashboard', ['allDashboards']),
...mapGetters('monitoringDashboard', ['selectedDashboard']),
- isSystemDashboard() {
- return this.selectedDashboard?.system_dashboard;
+ isOutOfTheBoxDashboard() {
+ return this.selectedDashboard?.out_of_the_box_dashboard;
},
selectedDashboardText() {
return this.selectedDashboard?.display_name;
@@ -76,10 +68,6 @@ export default {
nonStarredDashboards() {
return this.filteredDashboards.filter(({ starred }) => !starred);
},
-
- okButtonText() {
- return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
- },
},
methods: {
...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
@@ -89,37 +77,6 @@ export default {
selectDashboard(dashboard) {
this.$emit(events.selectDashboard, dashboard);
},
- ok(bvModalEvt) {
- // Prevent modal from hiding in case submit fails
- bvModalEvt.preventDefault();
-
- this.loading = true;
- this.alert = null;
- this.duplicateSystemDashboard(this.form)
- .then(createdDashboard => {
- this.loading = false;
- this.alert = null;
-
- // Trigger hide modal as submit is successful
- this.$refs.duplicateDashboardModal.hide();
-
- // Dashboards in the default branch become available immediately.
- // Not so in other branches, so we refresh the current dashboard
- const dashboard =
- this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
- this.$emit(events.selectDashboard, dashboard);
- })
- .catch(error => {
- this.loading = false;
- this.alert = error;
- });
- },
- hide() {
- this.alert = null;
- },
- formChange(form) {
- this.form = form;
- },
},
};
</script>
@@ -178,32 +135,14 @@ export default {
{{ __('No matching results') }}
</div>
- <template v-if="isSystemDashboard">
+ <!--
+ This Duplicate Dashboard item will be removed from the dashboards dropdown
+ in https://gitlab.com/gitlab-org/gitlab/-/issues/223223
+ -->
+ <template v-if="isOutOfTheBoxDashboard">
<gl-dropdown-divider />
- <gl-modal
- ref="duplicateDashboardModal"
- modal-id="duplicateDashboardModal"
- :title="s__('Metrics|Duplicate dashboard')"
- ok-variant="success"
- @ok="ok"
- @hide="hide"
- >
- <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
- {{ alert }}
- </gl-alert>
- <duplicate-dashboard-form
- :dashboard="selectedDashboard"
- :default-branch="defaultBranch"
- @change="formChange"
- />
- <template #modal-ok>
- <gl-loading-icon v-if="loading" inline color="light" />
- {{ okButtonText }}
- </template>
- </gl-modal>
-
- <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'">
+ <gl-dropdown-item v-gl-modal="modalId" data-testid="duplicateDashboardItem">
{{ s__('Metrics|Duplicate dashboard') }}
</gl-dropdown-item>
</template>
diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
new file mode 100644
index 00000000000..e64afc01fd9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_modal.vue
@@ -0,0 +1,95 @@
+<script>
+import { mapActions, mapGetters } from 'vuex';
+import { GlAlert, GlLoadingIcon, GlModal } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import DuplicateDashboardForm from './duplicate_dashboard_form.vue';
+
+const events = {
+ dashboardDuplicated: 'dashboardDuplicated',
+};
+
+export default {
+ components: { GlAlert, GlLoadingIcon, GlModal, DuplicateDashboardForm },
+ props: {
+ defaultBranch: {
+ type: String,
+ required: true,
+ },
+ modalId: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ alert: null,
+ loading: false,
+ form: {},
+ };
+ },
+ computed: {
+ ...mapGetters('monitoringDashboard', ['selectedDashboard']),
+ okButtonText() {
+ return this.loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate');
+ },
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']),
+ ok(bvModalEvt) {
+ // Prevent modal from hiding in case submit fails
+ bvModalEvt.preventDefault();
+
+ this.loading = true;
+ this.alert = null;
+ this.duplicateSystemDashboard(this.form)
+ .then(createdDashboard => {
+ this.loading = false;
+ this.alert = null;
+
+ // Trigger hide modal as submit is successful
+ this.$refs.duplicateDashboardModal.hide();
+
+ // Dashboards in the default branch become available immediately.
+ // Not so in other branches, so we refresh the current dashboard
+ const dashboard =
+ this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard;
+ this.$emit(events.dashboardDuplicated, dashboard);
+ })
+ .catch(error => {
+ this.loading = false;
+ this.alert = error;
+ });
+ },
+ hide() {
+ this.alert = null;
+ },
+ formChange(form) {
+ this.form = form;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="duplicateDashboardModal"
+ :modal-id="modalId"
+ :title="s__('Metrics|Duplicate dashboard')"
+ ok-variant="success"
+ @ok="ok"
+ @hide="hide"
+ >
+ <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null">
+ {{ alert }}
+ </gl-alert>
+ <duplicate-dashboard-form
+ :dashboard="selectedDashboard"
+ :default-branch="defaultBranch"
+ @change="formChange"
+ />
+ <template #modal-ok>
+ <gl-loading-icon v-if="loading" inline color="light" />
+ {{ okButtonText }}
+ </template>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue
index d3157b731b2..5e7c9b5d906 100644
--- a/app/assets/javascripts/monitoring/components/empty_state.vue
+++ b/app/assets/javascripts/monitoring/components/empty_state.vue
@@ -1,12 +1,19 @@
<script>
-import { GlEmptyState } from '@gitlab/ui';
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale';
+import { dashboardEmptyStates } from '../constants';
export default {
components: {
+ GlLoadingIcon,
GlEmptyState,
},
props: {
+ selectedState: {
+ type: String,
+ required: true,
+ validator: state => Object.values(dashboardEmptyStates).includes(state),
+ },
documentationPath: {
type: String,
required: true,
@@ -21,10 +28,6 @@ export default {
required: false,
default: '',
},
- selectedState: {
- type: String,
- required: true,
- },
emptyGettingStartedSvgPath: {
type: String,
required: true,
@@ -53,52 +56,49 @@ export default {
},
data() {
return {
+ /**
+ * Possible empty states.
+ * Keys in each state must match GlEmptyState props
+ */
states: {
- gettingStarted: {
- svgUrl: this.emptyGettingStartedSvgPath,
+ [dashboardEmptyStates.GETTING_STARTED]: {
+ svgPath: this.emptyGettingStartedSvgPath,
title: __('Get started with performance monitoring'),
description: __(`Stay updated about the performance and health
of your environment by configuring Prometheus to monitor your deployments.`),
- buttonText: __('Install on clusters'),
- buttonPath: this.clustersPath,
+ primaryButtonText: __('Install on clusters'),
+ primaryButtonLink: this.clustersPath,
secondaryButtonText: __('Configure existing installation'),
- secondaryButtonPath: this.settingsPath,
+ secondaryButtonLink: this.settingsPath,
},
- loading: {
- svgUrl: this.emptyLoadingSvgPath,
- title: __('Waiting for performance data'),
- description: __(`Creating graphs uses the data from the Prometheus server.
- If this takes a long time, ensure that data is available.`),
- buttonText: __('View documentation'),
- buttonPath: this.documentationPath,
- secondaryButtonText: '',
- secondaryButtonPath: '',
- },
- noData: {
- svgUrl: this.emptyNoDataSvgPath,
+ [dashboardEmptyStates.NO_DATA]: {
+ svgPath: this.emptyNoDataSvgPath,
title: __('No data found'),
description: __(`You are connected to the Prometheus server, but there is currently
no data to display.`),
- buttonText: __('Configure Prometheus'),
- buttonPath: this.settingsPath,
+ primaryButtonText: __('Configure Prometheus'),
+ primaryButtonLink: this.settingsPath,
secondaryButtonText: '',
- secondaryButtonPath: '',
+ secondaryButtonLink: '',
},
- unableToConnect: {
- svgUrl: this.emptyUnableToConnectSvgPath,
+ [dashboardEmptyStates.UNABLE_TO_CONNECT]: {
+ svgPath: this.emptyUnableToConnectSvgPath,
title: __('Unable to connect to Prometheus server'),
description: __(
'Ensure connectivity is available from the GitLab server to the Prometheus server',
),
- buttonText: __('View documentation'),
- buttonPath: this.documentationPath,
+ primaryButtonText: __('View documentation'),
+ primaryButtonLink: this.documentationPath,
secondaryButtonText: __('Configure Prometheus'),
- secondaryButtonPath: this.settingsPath,
+ secondaryButtonLink: this.settingsPath,
},
},
};
},
computed: {
+ isLoading() {
+ return this.selectedState === dashboardEmptyStates.LOADING;
+ },
currentState() {
return this.states[this.selectedState];
},
@@ -107,14 +107,8 @@ export default {
</script>
<template>
- <gl-empty-state
- :title="currentState.title"
- :description="currentState.description"
- :primary-button-text="currentState.buttonText"
- :primary-button-link="currentState.buttonPath"
- :secondary-button-text="currentState.secondaryButtonText"
- :secondary-button-link="currentState.secondaryButtonPath"
- :svg-path="currentState.svgUrl"
- :compact="compact"
- />
+ <div>
+ <gl-loading-icon v-if="isLoading" size="xl" class="gl-my-9" />
+ <gl-empty-state v-if="currentState" v-bind="currentState" :compact="compact" />
+ </div>
</template>
diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue
index 08fcfa3bc56..ecb8ef4a0d0 100644
--- a/app/assets/javascripts/monitoring/components/graph_group.vue
+++ b/app/assets/javascripts/monitoring/components/graph_group.vue
@@ -1,9 +1,10 @@
<script>
-import Icon from '~/vue_shared/components/icon.vue';
+import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
export default {
components: {
- Icon,
+ GlLoadingIcon,
+ GlIcon,
},
props: {
name: {
@@ -15,6 +16,11 @@ export default {
required: false,
default: true,
},
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
/**
* Initial value of collapse on mount.
*/
@@ -52,18 +58,21 @@ export default {
</script>
<template>
- <div v-if="showPanels" ref="graph-group" class="card prometheus-panel" tabindex="0">
+ <div v-if="showPanels" ref="graph-group" class="card prometheus-panel">
<div class="card-header d-flex align-items-center">
<h4 class="flex-grow-1">{{ name }}</h4>
+ <gl-loading-icon v-if="isLoading" name="loading" />
<a
data-testid="group-toggle-button"
+ :aria-label="__('Toggle collapse')"
+ :icon="caretIcon"
role="button"
- class="js-graph-group-toggle gl-text-gray-900"
+ class="js-graph-group-toggle gl-display-flex gl-ml-2 gl-text-gray-900"
tabindex="0"
@click="collapse"
@keyup.enter="collapse"
>
- <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" />
+ <gl-icon :name="caretIcon" />
</a>
</div>
<div
diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue
new file mode 100644
index 00000000000..5481806c3e0
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/refresh_button.vue
@@ -0,0 +1,163 @@
+<script>
+import { n__, __ } from '~/locale';
+import { mapActions } from 'vuex';
+
+import {
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ GlNewDropdownDivider,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+const makeInterval = (length = 0, unit = 's') => {
+ const shortLabel = `${length}${unit}`;
+ switch (unit) {
+ case 'd':
+ return {
+ interval: length * 24 * 60 * 60 * 1000,
+ shortLabel,
+ label: n__('%d day', '%d days', length),
+ };
+ case 'h':
+ return {
+ interval: length * 60 * 60 * 1000,
+ shortLabel,
+ label: n__('%d hour', '%d hours', length),
+ };
+ case 'm':
+ return {
+ interval: length * 60 * 1000,
+ shortLabel,
+ label: n__('%d minute', '%d minutes', length),
+ };
+ case 's':
+ default:
+ return {
+ interval: length * 1000,
+ shortLabel,
+ label: n__('%d second', '%d seconds', length),
+ };
+ }
+};
+
+export default {
+ components: {
+ GlButtonGroup,
+ GlButton,
+ GlNewDropdown,
+ GlNewDropdownItem,
+ GlNewDropdownDivider,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ data() {
+ return {
+ refreshInterval: null,
+ timeoutId: null,
+ };
+ },
+ computed: {
+ dropdownText() {
+ return this.refreshInterval?.shortLabel ?? __('Off');
+ },
+ },
+ watch: {
+ refreshInterval() {
+ if (this.refreshInterval !== null) {
+ this.startAutoRefresh();
+ } else {
+ this.stopAutoRefresh();
+ }
+ },
+ },
+ destroyed() {
+ this.stopAutoRefresh();
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['fetchDashboardData']),
+
+ refresh() {
+ this.fetchDashboardData();
+ },
+ startAutoRefresh() {
+ const schedule = () => {
+ if (this.refreshInterval) {
+ this.timeoutId = setTimeout(this.startAutoRefresh, this.refreshInterval.interval);
+ }
+ };
+
+ this.stopAutoRefresh();
+ if (document.hidden) {
+ // Inactive tab? Skip fetch and schedule again
+ schedule();
+ } else {
+ // Active tab! Fetch data and then schedule when settled
+ // eslint-disable-next-line promise/catch-or-return
+ this.fetchDashboardData().finally(schedule);
+ }
+ },
+ stopAutoRefresh() {
+ clearTimeout(this.timeoutId);
+ this.timeoutId = null;
+ },
+
+ setRefreshInterval(option) {
+ this.refreshInterval = option;
+ },
+ removeRefreshInterval() {
+ this.refreshInterval = null;
+ },
+ isChecked(option) {
+ if (this.refreshInterval) {
+ return option.interval === this.refreshInterval.interval;
+ }
+ return false;
+ },
+ },
+
+ refreshIntervals: [
+ makeInterval(5),
+ makeInterval(10),
+ makeInterval(30),
+ makeInterval(5, 'm'),
+ makeInterval(30, 'm'),
+ makeInterval(1, 'h'),
+ makeInterval(2, 'h'),
+ makeInterval(12, 'h'),
+ makeInterval(1, 'd'),
+ ],
+};
+</script>
+
+<template>
+ <gl-button-group>
+ <gl-button
+ v-gl-tooltip
+ class="gl-flex-grow-1"
+ variant="default"
+ :title="s__('Metrics|Refresh dashboard')"
+ icon="retry"
+ @click="refresh"
+ />
+ <gl-new-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText">
+ <gl-new-dropdown-item
+ :is-check-item="true"
+ :is-checked="refreshInterval === null"
+ @click="removeRefreshInterval()"
+ >{{ __('Off') }}</gl-new-dropdown-item
+ >
+ <gl-new-dropdown-divider />
+ <gl-new-dropdown-item
+ v-for="(option, i) in $options.refreshIntervals"
+ :key="i"
+ :is-check-item="true"
+ :is-checked="isChecked(option)"
+ @click="setRefreshInterval(option)"
+ >{{ option.label }}</gl-new-dropdown-item
+ >
+ </gl-new-dropdown>
+ </gl-button-group>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
index 0ac7c0b80df..4e48292c48d 100644
--- a/app/assets/javascripts/monitoring/components/variables/custom_variable.vue
+++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue
@@ -22,29 +22,32 @@ export default {
default: '',
},
options: {
- type: Array,
+ type: Object,
required: true,
},
},
computed: {
- defaultText() {
- const selectedOpt = this.options.find(opt => opt.value === this.value);
+ text() {
+ const selectedOpt = this.options.values?.find(opt => opt.value === this.value);
return selectedOpt?.text || this.value;
},
},
methods: {
onUpdate(value) {
- this.$emit('onUpdate', this.name, value);
+ this.$emit('input', 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 toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')">
+ <gl-dropdown-item
+ v-for="val in options.values"
+ :key="val.value"
+ @click="onUpdate(val.value)"
+ >{{ val.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_field.vue
index ce0d19760e2..a0418806e5f 100644
--- a/app/assets/javascripts/monitoring/components/variables/text_variable.vue
+++ b/app/assets/javascripts/monitoring/components/variables/text_field.vue
@@ -22,7 +22,7 @@ export default {
},
methods: {
onUpdate(event) {
- this.$emit('onUpdate', this.name, event.target.value);
+ this.$emit('input', event.target.value);
},
},
};
diff --git a/app/assets/javascripts/monitoring/components/variables_section.vue b/app/assets/javascripts/monitoring/components/variables_section.vue
index 3d1d111d5b3..25d900b07ad 100644
--- a/app/assets/javascripts/monitoring/components/variables_section.vue
+++ b/app/assets/javascripts/monitoring/components/variables_section.vue
@@ -1,13 +1,14 @@
<script>
import { mapState, mapActions } from 'vuex';
-import CustomVariable from './variables/custom_variable.vue';
-import TextVariable from './variables/text_variable.vue';
+import DropdownField from './variables/dropdown_field.vue';
+import TextField from './variables/text_field.vue';
import { setCustomVariablesFromUrl } from '../utils';
+import { VARIABLE_TYPES } from '../constants';
export default {
components: {
- CustomVariable,
- TextVariable,
+ DropdownField,
+ TextField,
},
computed: {
...mapState('monitoringDashboard', ['variables']),
@@ -15,10 +16,9 @@ export default {
methods: {
...mapActions('monitoringDashboard', ['updateVariablesAndFetchData']),
refreshDashboard(variable, value) {
- if (this.variables[variable].value !== value) {
- const changedVariable = { key: variable, value };
+ if (variable.value !== value) {
+ this.updateVariablesAndFetchData({ name: variable.name, value });
// update the Vuex store
- this.updateVariablesAndFetchData(changedVariable);
// the below calls can ideally be moved out of the
// component and into the actions and let the
// mutation respond directly.
@@ -27,27 +27,26 @@ export default {
setCustomVariablesFromUrl(this.variables);
}
},
- variableComponent(type) {
- const types = {
- text: TextVariable,
- custom: CustomVariable,
- };
- return types[type] || TextVariable;
+ variableField(type) {
+ if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) {
+ return DropdownField;
+ }
+ return TextField;
},
},
};
</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 variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
+ <div v-for="variable in variables" :key="variable.name" class="mb-1 pr-2 d-flex d-sm-block">
<component
- :is="variableComponent(variable.type)"
+ :is="variableField(variable.type)"
class="mb-0 flex-grow-1"
:label="variable.label"
:value="variable.value"
- :name="key"
+ :name="variable.name"
:options="variable.options"
- @onUpdate="refreshDashboard"
+ @input="refreshDashboard(variable, $event)"
/>
</div>
</div>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 50330046c99..afeb3318eb9 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -1,5 +1,12 @@
export const PROMETHEUS_TIMEOUT = 120000; // TWO_MINUTES
+export const dashboardEmptyStates = {
+ GETTING_STARTED: 'gettingStarted',
+ LOADING: 'loading',
+ NO_DATA: 'noData',
+ UNABLE_TO_CONNECT: 'unableToConnect',
+};
+
/**
* States and error states in Prometheus Queries (PromQL) for metrics
*/
@@ -208,6 +215,14 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z';
*/
export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml';
+/**
+ * GitLab provide metrics dashboards that are available to a user once
+ * the Prometheus managed app has been installed, without any extra setup
+ * required. These "out of the box" dashboards are defined under the
+ * `config/prometheus` path.
+ */
+export const OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX = 'config/prometheus/';
+
export const OPERATORS = {
greaterThan: '>',
equalTo: '==',
@@ -230,6 +245,7 @@ export const OPERATORS = {
export const VARIABLE_TYPES = {
custom: 'custom',
text: 'text',
+ metric_label_values: 'metric_label_values',
};
/**
@@ -242,3 +258,17 @@ export const VARIABLE_TYPES = {
* before passing the data to the backend.
*/
export const VARIABLE_PREFIX = 'var-';
+
+/**
+ * All of the actions inside each panel dropdown can be accessed
+ * via keyboard shortcuts than can be activated via mouse hovers
+ * and or focus via tabs.
+ */
+
+export const keyboardShortcutKeys = {
+ EXPAND: 'e',
+ VISIT_LOGS: 'l',
+ SHOW_ALERT: 'a',
+ DOWNLOAD_CSV: 'd',
+ CHART_COPY: 'c',
+};
diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js
index a50d441a09e..c7bc626eb11 100644
--- a/app/assets/javascripts/monitoring/format_date.js
+++ b/app/assets/javascripts/monitoring/format_date.js
@@ -14,6 +14,7 @@ export const timezones = {
export const formats = {
shortTime: 'h:MM TT',
+ shortDateTime: 'm/d h:MM TT',
default: 'dd mmm yyyy, h:MMTT (Z)',
};
diff --git a/app/assets/javascripts/monitoring/monitoring_app.js b/app/assets/javascripts/monitoring/monitoring_app.js
index 08543fa6eb3..307154c9a84 100644
--- a/app/assets/javascripts/monitoring/monitoring_app.js
+++ b/app/assets/javascripts/monitoring/monitoring_app.js
@@ -1,9 +1,8 @@
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
-import { parseBoolean } from '~/lib/utils/common_utils';
-import { getParameterValues } from '~/lib/utils/url_utility';
import { createStore } from './stores';
import createRouter from './router';
+import { stateAndPropsFromDataset } from './utils';
Vue.use(GlToast);
@@ -11,36 +10,10 @@ export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) {
- const [currentDashboard] = getParameterValues('dashboard');
-
- const {
- deploymentsEndpoint,
- dashboardEndpoint,
- dashboardsEndpoint,
- projectPath,
- logsPath,
- currentEnvironmentName,
- dashboardTimezone,
- metricsDashboardBasePath,
- ...dataProps
- } = el.dataset;
-
- const store = createStore({
- currentDashboard,
- deploymentsEndpoint,
- dashboardEndpoint,
- dashboardsEndpoint,
- dashboardTimezone,
- projectPath,
- logsPath,
- currentEnvironmentName,
- });
-
- // HTML attributes are always strings, parse other types.
- dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
- dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
- dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
+ const { metricsDashboardBasePath, ...dataset } = el.dataset;
+ const { initState, dataProps } = stateAndPropsFromDataset(dataset);
+ const store = createStore(initState);
const router = createRouter(metricsDashboardBasePath);
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/monitoring/pages/dashboard_page.vue b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
index 519a20d7be3..df0e2d7f8f6 100644
--- a/app/assets/javascripts/monitoring/pages/dashboard_page.vue
+++ b/app/assets/javascripts/monitoring/pages/dashboard_page.vue
@@ -1,4 +1,5 @@
<script>
+import { mapActions } from 'vuex';
import Dashboard from '../components/dashboard.vue';
export default {
@@ -11,6 +12,16 @@ export default {
required: true,
},
},
+ created() {
+ // This is to support the older URL <project>/-/environments/:env_id/metrics?dashboard=:path
+ // and the new format <project>/-/metrics/:dashboardPath
+ const encodedDashboard = this.$route.query.dashboard || this.$route.params.dashboard;
+ const currentDashboard = encodedDashboard ? decodeURIComponent(encodedDashboard) : null;
+ this.setCurrentDashboard({ currentDashboard });
+ },
+ methods: {
+ ...mapActions('monitoringDashboard', ['setCurrentDashboard']),
+ },
};
</script>
<template>
diff --git a/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
new file mode 100644
index 00000000000..302383512d3
--- /dev/null
+++ b/app/assets/javascripts/monitoring/queries/getDashboardValidationWarnings.query.graphql
@@ -0,0 +1,18 @@
+query getDashboardValidationWarnings(
+ $projectPath: ID!
+ $environmentName: String
+ $dashboardPath: String!
+) {
+ project(fullPath: $projectPath) {
+ id
+ environments(name: $environmentName) {
+ nodes {
+ name
+ metricsDashboard(path: $dashboardPath) {
+ path
+ schemaValidationWarnings
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js
index acfcd03f928..fedfebe33e9 100644
--- a/app/assets/javascripts/monitoring/router/constants.js
+++ b/app/assets/javascripts/monitoring/router/constants.js
@@ -1,3 +1,4 @@
export const BASE_DASHBOARD_PAGE = 'dashboard';
+export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard';
export default {};
diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js
index 1e0cc1715a7..4b82791178a 100644
--- a/app/assets/javascripts/monitoring/router/routes.js
+++ b/app/assets/javascripts/monitoring/router/routes.js
@@ -1,6 +1,6 @@
import DashboardPage from '../pages/dashboard_page.vue';
-import { BASE_DASHBOARD_PAGE } from './constants';
+import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants';
/**
* Because the cluster health page uses the dashboard
@@ -12,7 +12,12 @@ import { BASE_DASHBOARD_PAGE } from './constants';
export default [
{
name: BASE_DASHBOARD_PAGE,
- path: '*',
+ path: '/',
+ component: DashboardPage,
+ },
+ {
+ name: CUSTOM_DASHBOARD_PAGE,
+ path: '/:dashboard(.*)',
component: DashboardPage,
},
];
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 3a9cccec438..a441882a47d 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -12,6 +12,7 @@ import {
import trackDashboardLoad from '../monitoring_tracking_helper';
import getEnvironments from '../queries/getEnvironments.query.graphql';
import getAnnotations from '../queries/getAnnotations.query.graphql';
+import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql';
import statusCodes from '../../lib/utils/http_status';
import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale';
@@ -20,6 +21,7 @@ import {
PROMETHEUS_TIMEOUT,
ENVIRONMENT_AVAILABLE_STATE,
DEFAULT_DASHBOARD_PATH,
+ VARIABLE_TYPES,
} from '../constants';
function prometheusMetricQueryParams(timeRange) {
@@ -50,15 +52,14 @@ function backOffRequest(makeRequestCallback) {
}, PROMETHEUS_TIMEOUT);
}
-function getPrometheusMetricResult(prometheusEndpoint, params) {
+function getPrometheusQueryData(prometheusEndpoint, params) {
return backOffRequest(() => axios.get(prometheusEndpoint, { params }))
.then(res => res.data)
.then(response => {
if (response.status === 'error') {
throw new Error(response.error);
}
-
- return response.data.result;
+ return response.data;
});
}
@@ -76,10 +77,6 @@ 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');
@@ -100,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => {
});
};
+export const setCurrentDashboard = ({ commit }, { currentDashboard }) => {
+ commit(types.SET_CURRENT_DASHBOARD, currentDashboard);
+};
+
// All Data
/**
@@ -117,17 +118,27 @@ export const fetchData = ({ dispatch }) => {
// Metrics dashboard
-export const fetchDashboard = ({ state, commit, dispatch }) => {
+export const fetchDashboard = ({ state, commit, dispatch, getters }) => {
dispatch('requestMetricsDashboard');
const params = {};
- if (state.currentDashboard) {
- params.dashboard = state.currentDashboard;
+ if (getters.fullDashboardPath) {
+ params.dashboard = getters.fullDashboardPath;
}
return backOffRequest(() => axios.get(state.dashboardEndpoint, { params }))
.then(resp => resp.data)
- .then(response => dispatch('receiveMetricsDashboardSuccess', { response }))
+ .then(response => {
+ dispatch('receiveMetricsDashboardSuccess', { response });
+ /**
+ * After the dashboard is fetched, there can be non-blocking invalid syntax
+ * in the dashboard file. This call will fetch such syntax warnings
+ * and surface a warning on the UI. If the invalid syntax is blocking,
+ * the `fetchDashboard` returns a 404 with error messages that are displayed
+ * on the UI.
+ */
+ dispatch('fetchDashboardValidationWarnings');
+ })
.catch(error => {
Sentry.captureException(error);
@@ -181,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.reject();
}
+ // Time range params must be pre-calculated once for all metrics and options
+ // A subsequent call, may calculate a different time range
const defaultQueryParams = prometheusMetricQueryParams(state.timeRange);
+ dispatch('fetchVariableMetricLabelValues', { defaultQueryParams });
+
const promises = [];
state.dashboard.panelGroups.forEach(group => {
group.panels.forEach(panel => {
@@ -194,7 +209,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.all(promises)
.then(() => {
- const dashboardType = state.currentDashboard === '' ? 'default' : 'custom';
+ const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom';
trackDashboardLoad({
label: `${dashboardType}_metrics_dashboard`,
value: getters.metricsWithData().length,
@@ -220,7 +235,7 @@ export const fetchPrometheusMetric = (
queryParams.step = metric.step;
}
- if (Object.keys(state.variables).length > 0) {
+ if (state.variables.length > 0) {
queryParams = {
...queryParams,
...getters.getCustomVariablesParams,
@@ -229,9 +244,9 @@ export const fetchPrometheusMetric = (
commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId });
- return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams)
- .then(result => {
- commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result });
+ return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams)
+ .then(data => {
+ commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data });
})
.catch(error => {
Sentry.captureException(error);
@@ -312,9 +327,9 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => {
commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
};
-export const fetchAnnotations = ({ state, dispatch }) => {
+export const fetchAnnotations = ({ state, dispatch, getters }) => {
const { start } = convertToFixedRange(state.timeRange);
- const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH;
+ const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
return gqClient
.mutate({
mutation: getAnnotations,
@@ -345,6 +360,46 @@ export const receiveAnnotationsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data);
export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE);
+export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => {
+ /**
+ * Normally, the default dashboard won't throw any validation warnings.
+ *
+ * However, if a bug sneaks into the default dashboard making it invalid,
+ * this might come handy for our clients
+ */
+ const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH;
+ return gqClient
+ .mutate({
+ mutation: getDashboardValidationWarnings,
+ variables: {
+ projectPath: removeLeadingSlash(state.projectPath),
+ environmentName: state.currentEnvironmentName,
+ dashboardPath,
+ },
+ })
+ .then(resp => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard)
+ .then(({ schemaValidationWarnings } = {}) => {
+ const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0;
+ /**
+ * The payload of the dispatch is a boolean, because at the moment a standard
+ * warning message is shown instead of the warnings the BE returns
+ */
+ dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false);
+ })
+ .catch(err => {
+ Sentry.captureException(err);
+ dispatch('receiveDashboardValidationWarningsFailure');
+ createFlash(
+ s__('Metrics|There was an error getting dashboard validation warnings information.'),
+ );
+ });
+};
+
+export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) =>
+ commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings);
+export const receiveDashboardValidationWarningsFailure = ({ commit }) =>
+ commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE);
+
// Dashboard manipulation
export const toggleStarredValue = ({ commit, state, getters }) => {
@@ -416,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
// Variables manipulation
export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => {
- commit(types.UPDATE_VARIABLES, updatedVariable);
+ commit(types.UPDATE_VARIABLE_VALUE, updatedVariable);
return dispatch('fetchDashboardData');
};
+export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => {
+ const { start_time, end_time } = defaultQueryParams;
+ const optionsRequests = [];
+
+ state.variables.forEach(variable => {
+ if (variable.type === VARIABLE_TYPES.metric_label_values) {
+ const { prometheusEndpointPath, label } = variable.options;
+
+ const optionsRequest = backOffRequest(() =>
+ axios.get(prometheusEndpointPath, {
+ params: { start_time, end_time },
+ }),
+ )
+ .then(({ data }) => data.data)
+ .then(data => {
+ commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data });
+ })
+ .catch(() => {
+ createFlash(
+ sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), {
+ name: variable.name,
+ }),
+ );
+ });
+ optionsRequests.push(optionsRequest);
+ }
+ });
+
+ return Promise.all(optionsRequests);
+};
+
// 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 b7681012472..3aa711a0509 100644
--- a/app/assets/javascripts/monitoring/stores/getters.js
+++ b/app/assets/javascripts/monitoring/stores/getters.js
@@ -1,5 +1,9 @@
import { NOT_IN_DB_PREFIX } from '../constants';
-import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils';
+import {
+ addPrefixToCustomVariableParams,
+ addDashboardMetaDataToLink,
+ normalizeCustomDashboardPath,
+} from './utils';
const metricsIdsInPanel = panel =>
panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId);
@@ -10,10 +14,10 @@ const metricsIdsInPanel = panel =>
*
* @param {Object} state
*/
-export const selectedDashboard = state => {
+export const selectedDashboard = (state, getters) => {
const { allDashboards } = state;
return (
- allDashboards.find(d => d.path === state.currentDashboard) ||
+ allDashboards.find(d => d.path === getters.fullDashboardPath) ||
allDashboards.find(d => d.default) ||
null
);
@@ -129,8 +133,8 @@ export const linksWithMetadata = state => {
};
/**
- * Maps an variables object to an array along with stripping
- * the variable prefix.
+ * Maps a variables array to an object for replacement in
+ * prometheus queries.
*
* This method outputs an object in the below format
*
@@ -143,16 +147,29 @@ export const linksWithMetadata = state => {
* user-defined variables coming through the URL and differentiate
* from other variables used for Prometheus API endpoint.
*
- * @param {Object} variables - Custom variables provided by the user
- * @returns {Array} The custom variables array to be send to the API
+ * @param {Object} state - State containing variables provided by the user
+ * @returns {Array} The custom variables object to be send to the API
* in the format of {variables[key1]=value1, variables[key2]=value2}
*/
export const getCustomVariablesParams = state =>
- Object.keys(state.variables).reduce((acc, variable) => {
- acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value;
+ state.variables.reduce((acc, variable) => {
+ const { name, value } = variable;
+ if (value !== null) {
+ acc[addPrefixToCustomVariableParams(name)] = value;
+ }
return acc;
}, {});
+/**
+ * For a given custom dashboard file name, this method
+ * returns the full file path.
+ *
+ * @param {Object} state
+ * @returns {String} full dashboard path
+ */
+export const fullDashboardPath = state =>
+ normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath);
+
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 4593461776b..d408628fc4d 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -2,17 +2,25 @@
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_VARIABLES = 'UPDATE_VARIABLES';
+export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE';
+export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_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';
+export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD';
+
// Annotations
export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS';
export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE';
+// Dashboard validation warnings
+export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS =
+ 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS';
+export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE =
+ 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE';
+
// Git project deployments
export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA';
export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS';
@@ -34,7 +42,6 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
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';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index 2d63fdd6e34..744441c8935 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -1,10 +1,11 @@
import Vue from 'vue';
import { pick } from 'lodash';
import * as types from './mutation_types';
-import { mapToDashboardViewModel, normalizeQueryResult } from './utils';
-import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
-import { endpointKeys, initialStateKeys, metricStates } from '../constants';
+import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils';
import httpStatusCodes from '~/lib/utils/http_status';
+import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
+import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants';
+import { optionsFromSeriesData } from './variable_mapping';
/**
* Locate and return a metric in the dashboard by its id
@@ -57,8 +58,7 @@ export default {
* Dashboard panels structure and global state
*/
[types.REQUEST_METRICS_DASHBOARD](state) {
- state.emptyState = 'loading';
- state.showEmptyState = true;
+ state.emptyState = dashboardEmptyStates.LOADING;
},
[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) {
const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML);
@@ -70,12 +70,15 @@ export default {
state.links = links;
if (!state.dashboard.panelGroups.length) {
- state.emptyState = 'noData';
+ state.emptyState = dashboardEmptyStates.NO_DATA;
+ } else {
+ state.emptyState = null;
}
},
[types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) {
- state.emptyState = error ? 'unableToConnect' : 'noData';
- state.showEmptyState = true;
+ state.emptyState = error
+ ? dashboardEmptyStates.UNABLE_TO_CONNECT
+ : dashboardEmptyStates.NO_DATA;
},
[types.REQUEST_DASHBOARD_STARRING](state) {
@@ -94,6 +97,10 @@ export default {
state.isUpdatingStarredValue = false;
},
+ [types.SET_CURRENT_DASHBOARD](state, currentDashboard) {
+ state.currentDashboard = currentDashboard;
+ },
+
/**
* Deployments and environments
*/
@@ -126,6 +133,16 @@ export default {
},
/**
+ * Dashboard Validation Warnings
+ */
+ [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) {
+ state.hasDashboardValidationWarnings = hasDashboardValidationWarnings;
+ },
+ [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) {
+ state.hasDashboardValidationWarnings = false;
+ },
+
+ /**
* Individual panel/metric results
*/
[types.REQUEST_METRIC_RESULT](state, { metricId }) {
@@ -135,19 +152,18 @@ export default {
metric.state = metricStates.LOADING;
}
},
- [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) {
+ [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) {
const metric = findMetricInDashboard(metricId, state.dashboard);
metric.loading = false;
- state.showEmptyState = false;
- if (!result || result.length === 0) {
+ if (!data.result || data.result.length === 0) {
metric.state = metricStates.NO_DATA;
metric.result = null;
} else {
- const normalizedResults = result.map(normalizeQueryResult);
+ const result = normalizeQueryResponseData(data);
metric.state = metricStates.OK;
- metric.result = Object.freeze(normalizedResults);
+ metric.result = Object.freeze(result);
}
},
[types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) {
@@ -169,11 +185,7 @@ export default {
state.timeRange = timeRange;
},
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
- state.emptyState = 'gettingStarted';
- },
- [types.SET_NO_DATA_EMPTY_STATE](state) {
- state.showEmptyState = true;
- state.emptyState = 'noData';
+ state.emptyState = dashboardEmptyStates.GETTING_STARTED;
},
[types.SET_ALL_DASHBOARDS](state, dashboards) {
state.allDashboards = dashboards || [];
@@ -192,13 +204,18 @@ export default {
state.expandedPanel.group = group;
state.expandedPanel.panel = panel;
},
- [types.SET_VARIABLES](state, variables) {
- state.variables = variables;
+ [types.UPDATE_VARIABLE_VALUE](state, { name, value }) {
+ const variable = state.variables.find(v => v.name === name);
+ if (variable) {
+ Object.assign(variable, {
+ value,
+ });
+ }
},
- [types.UPDATE_VARIABLES](state, updatedVariable) {
- Object.assign(state.variables[updatedVariable.key], {
- ...state.variables[updatedVariable.key],
- value: updatedVariable.value,
- });
+ [types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) {
+ const values = optionsFromSeriesData({ label, data });
+
+ // Add new options with assign to ensure Vue reactivity
+ Object.assign(variable.options, { values });
},
};
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 8000f27c0d5..89738756ffe 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -1,5 +1,6 @@
import invalidUrl from '~/lib/utils/invalid_url';
import { timezones } from '../format_date';
+import { dashboardEmptyStates } from '../constants';
export default () => ({
// API endpoints
@@ -9,11 +10,24 @@ export default () => ({
// Dashboard request parameters
timeRange: null,
+ /**
+ * Currently selected dashboard. For custom dashboards,
+ * this could be the filename or the file path.
+ *
+ * If this is the filename and full path is required,
+ * getters.fullDashboardPath should be used.
+ */
currentDashboard: null,
// Dashboard data
- emptyState: 'gettingStarted',
- showEmptyState: true,
+ hasDashboardValidationWarnings: false,
+
+ /**
+ * {?String} If set, dashboard should display a global
+ * empty state, there is no way to interact (yet)
+ * with the dashboard.
+ */
+ emptyState: dashboardEmptyStates.GETTING_STARTED,
showErrorBanner: true,
isUpdatingStarredValue: false,
dashboard: {
@@ -39,7 +53,7 @@ export default () => ({
* User-defined custom variables are passed
* via the dashboard yml file.
*/
- variables: {},
+ variables: [],
/**
* User-defined custom links are passed
* via the dashboard yml file.
@@ -56,5 +70,16 @@ export default () => ({
// GitLab paths to other pages
projectPath: null,
+ operationsSettingsPath: '',
logsPath: invalidUrl,
+
+ // static paths
+ customDashboardBasePath: '',
+
+ // current user data
+ /**
+ * Flag that denotes if the currently logged user can access
+ * the project Settings -> Operations
+ */
+ canAccessOperationsSettings: false,
});
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 5795e756282..51562593ee8 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -2,11 +2,11 @@ import { slugify } from '~/lib/utils/text_utility';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { NOT_IN_DB_PREFIX, linkTypes } from '../constants';
import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping';
import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants';
import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range';
import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility';
+import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants';
export const gqClient = createGqClient(
{},
@@ -165,7 +165,7 @@ const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => {
* @param {Object} panel - Metrics panel
* @returns {Object}
*/
-const mapPanelToViewModel = ({
+export const mapPanelToViewModel = ({
id = null,
title = '',
type,
@@ -173,6 +173,7 @@ const mapPanelToViewModel = ({
x_label,
y_label,
y_axis = {},
+ field,
metrics = [],
links = [],
max_value,
@@ -193,6 +194,7 @@ const mapPanelToViewModel = ({
y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198
yAxis,
xAxis,
+ field,
maxValue: max_value,
links: links.map(mapLinksToViewModel),
metrics: mapToMetricsViewModel(metrics),
@@ -289,49 +291,157 @@ export const mapToDashboardViewModel = ({
}) => {
return {
dashboard,
- variables: mergeURLVariables(parseTemplatingVariables(templating)),
+ variables: mergeURLVariables(parseTemplatingVariables(templating.variables)),
links: links.map(mapLinksToViewModel),
panelGroups: panel_groups.map(mapToPanelGroupViewModel),
};
};
+// Prometheus Results Parsing
+
+const dateTimeFromUnixTime = unixTime => new Date(unixTime * 1000).toISOString();
+
+const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)];
+
+// Note: `string` value type is unused as of prometheus 2.19.
+const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value];
+
+/**
+ * Processes a scalar result.
+ *
+ * The corresponding result property has the following format:
+ *
+ * [ <unix_time>, "<scalar_value>" ]
+ *
+ * @param {array} result
+ * @returns {array}
+ */
+const normalizeScalarResult = result => [
+ {
+ metric: {},
+ value: mapScalarValue(result),
+ values: [mapScalarValue(result)],
+ },
+];
+
+/**
+ * Processes a string result.
+ *
+ * The corresponding result property has the following format:
+ *
+ * [ <unix_time>, "<string_value>" ]
+ *
+ * Note: This value type is unused as of prometheus 2.19.
+ *
+ * @param {array} result
+ * @returns {array}
+ */
+const normalizeStringResult = result => [
+ {
+ metric: {},
+ value: mapStringValue(result),
+ values: [mapStringValue(result)],
+ },
+];
+
+/**
+ * Proccesses an instant vector.
+ *
+ * Instant vectors are returned as result type `vector`.
+ *
+ * The corresponding result property has the following format:
+ *
+ * [
+ * {
+ * "metric": { "<label_name>": "<label_value>", ... },
+ * "value": [ <unix_time>, "<sample_value>" ],
+ * "values": [ [ <unix_time>, "<sample_value>" ] ]
+ * },
+ * ...
+ * ]
+ *
+ * `metric` - Key-value pairs object representing metric measured
+ * `value` - The vector result
+ * `values` - An array with a single value representing the result
+ *
+ * This method also adds the matrix version of the vector
+ * by introducing a `values` array with a single element. This
+ * allows charts to default to `values` if needed.
+ *
+ * @param {array} result
+ * @returns {array}
+ */
+const normalizeVectorResult = result =>
+ result.map(({ metric, value }) => {
+ const scalar = mapScalarValue(value);
+ // Add a single element to `values`, to support matrix
+ // style charts.
+ return { metric, value: scalar, values: [scalar] };
+ });
+
/**
- * Processes a single Range vector, part of the result
- * of type `matrix` in the form:
+ * Range vectors are returned as result type matrix.
+ *
+ * The corresponding result property has the following format:
*
* {
* "metric": { "<label_name>": "<label_value>", ... },
+ * "value": [ <unix_time>, "<sample_value>" ],
* "values": [ [ <unix_time>, "<sample_value>" ], ... ]
* },
*
+ * `metric` - Key-value pairs object representing metric measured
+ * `value` - The last (more recent) result
+ * `values` - A range of results for the metric
+ *
* See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors
*
- * @param {*} timeSeries
+ * @param {array} result
+ * @returns {object} Normalized result.
*/
-export const normalizeQueryResult = timeSeries => {
- let normalizedResult = {};
-
- if (timeSeries.values) {
- normalizedResult = {
- ...timeSeries,
- values: timeSeries.values.map(([timestamp, value]) => [
- new Date(timestamp * 1000).toISOString(),
- Number(value),
- ]),
- };
- // Check result for empty data
- normalizedResult.values = normalizedResult.values.filter(series => {
- const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined);
- return series.find(hasValue);
- });
- } else if (timeSeries.value) {
- normalizedResult = {
- ...timeSeries,
- value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])],
+const normalizeResultMatrix = result =>
+ result.map(({ metric, values }) => {
+ const mappedValues = values.map(mapScalarValue);
+ return {
+ metric,
+ value: mappedValues[mappedValues.length - 1],
+ values: mappedValues,
};
- }
+ });
- return normalizedResult;
+/**
+ * Parse response data from a Prometheus Query that comes
+ * in the format:
+ *
+ * {
+ * "resultType": "matrix" | "vector" | "scalar" | "string",
+ * "result": <value>
+ * }
+ *
+ * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats
+ *
+ * @param {object} data - Data containing results and result type.
+ * @returns {object} - A result array of metric results:
+ * [
+ * {
+ * metric: { ... },
+ * value: ['2015-07-01T20:10:51.781Z', '1'],
+ * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ],
+ * },
+ * ...
+ * ]
+ *
+ */
+export const normalizeQueryResponseData = data => {
+ const { resultType, result } = data;
+ if (resultType === 'vector') {
+ return normalizeVectorResult(result);
+ } else if (resultType === 'scalar') {
+ return normalizeScalarResult(result);
+ } else if (resultType === 'string') {
+ return normalizeStringResult(result);
+ }
+ return normalizeResultMatrix(result);
};
/**
@@ -345,7 +455,35 @@ export const normalizeQueryResult = timeSeries => {
*
* This is currently only used by getters/getCustomVariablesParams
*
- * @param {String} key Variable key that needs to be prefixed
+ * @param {String} name Variable key that needs to be prefixed
* @returns {String}
*/
-export const addPrefixToCustomVariableParams = key => `variables[${key}]`;
+export const addPrefixToCustomVariableParams = name => `variables[${name}]`;
+
+/**
+ * Normalize custom dashboard paths. This method helps support
+ * metrics dashboard to work with custom dashboard file names instead
+ * of the entire path.
+ *
+ * If dashboard is empty, it is the default dashboard.
+ * If dashboard is set, it usually is a custom dashboard unless
+ * explicitly it is set to default dashboard path.
+ *
+ * @param {String} dashboard dashboard path
+ * @param {String} dashboardPrefix custom dashboard directory prefix
+ * @returns {String} normalized dashboard path
+ */
+export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => {
+ const currDashboard = dashboard || '';
+ let dashboardPath = `${dashboardPrefix}/${currDashboard}`;
+
+ if (!currDashboard) {
+ dashboardPath = '';
+ } else if (
+ currDashboard.startsWith(dashboardPrefix) ||
+ currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX)
+ ) {
+ dashboardPath = currDashboard;
+ }
+ return dashboardPath;
+};
diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js
index c0a8150063b..9245ffdb3b9 100644
--- a/app/assets/javascripts/monitoring/stores/variable_mapping.js
+++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js
@@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({
* @param {Object} custom variable option
* @returns {Object} normalized custom variable options
*/
-const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
+const normalizeVariableValues = ({ default: defaultOpt = false, text, value = null }) => ({
default: defaultOpt,
text: text || value,
value,
@@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val
* 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
+ * @param {Object} advVariable advanced custom variable
* @returns {Object}
*/
const customAdvancedVariableParser = advVariable => {
- const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions);
- const defaultOpt = options.find(opt => opt.default === true) || options[0];
+ const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues);
+ const defaultValue = values.find(opt => opt.default === true) || values[0];
return {
type: VARIABLE_TYPES.custom,
label: advVariable.label,
- value: defaultOpt?.value,
- options,
+ options: {
+ values,
+ },
+ value: defaultValue?.value || null,
};
};
@@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => {
* @param {String} opt option from simple custom variable
* @returns {Object}
*/
-const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
+export const parseSimpleCustomValues = opt => ({ text: opt, value: opt });
/**
* Custom simple variables are rendered as dropdown elements in the dashboard
@@ -95,15 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
* @returns {Object}
*/
const customSimpleVariableParser = simpleVar => {
- const options = (simpleVar || []).map(parseSimpleCustomOptions);
+ const values = (simpleVar || []).map(parseSimpleCustomValues);
return {
type: VARIABLE_TYPES.custom,
- value: options[0].value,
label: null,
- options: options.map(normalizeCustomVariableOptions),
+ value: values[0].value || null,
+ options: {
+ values: values.map(normalizeVariableValues),
+ },
};
};
+const metricLabelValuesVariableParser = ({ label, options = {} }) => ({
+ type: VARIABLE_TYPES.metric_label_values,
+ label,
+ value: null,
+ options: {
+ prometheusEndpointPath: options.prometheus_endpoint_path || '',
+ label: options.label || null,
+ values: [], // values are initially empty
+ },
+});
+
/**
* Utility method to determine if a custom variable is
* simple or not. If its not simple, it is advanced.
@@ -123,14 +138,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar);
* @return {Function} parser method
*/
const getVariableParser = variable => {
- if (isSimpleCustomVariable(variable)) {
+ if (isString(variable)) {
+ return textSimpleVariableParser;
+ } else 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;
+ } else if (variable.type === VARIABLE_TYPES.custom) {
+ return customAdvancedVariableParser;
+ } else if (variable.type === VARIABLE_TYPES.metric_label_values) {
+ return metricLabelValuesVariableParser;
}
return () => null;
};
@@ -141,29 +158,26 @@ const getVariableParser = variable => {
* 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
+ * @param {Object} templating variables from the dashboard yml file
+ * @returns {array} An array of variables to display as inputs
*/
-export const parseTemplatingVariables = ({ variables = {} } = {}) =>
- Object.entries(variables).reduce((acc, [key, variable]) => {
+export const parseTemplatingVariables = (ymlVariables = {}) =>
+ Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => {
// get the parser
- const parser = getVariableParser(variable);
+ const parser = getVariableParser(ymlVariable);
// parse the variable
- const parsedVar = parser(variable);
+ const variable = parser(ymlVariable);
// for simple custom variable label is null and it should be
// replace with key instead
- if (parsedVar) {
- acc[key] = {
- ...parsedVar,
- label: parsedVar.label || key,
- };
+ if (variable) {
+ acc.push({
+ ...variable,
+ name,
+ label: variable.label || name,
+ });
}
return acc;
- }, {});
+ }, []);
/**
* Custom variables are defined in the dashboard yml file
@@ -181,23 +195,81 @@ export const parseTemplatingVariables = ({ 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
+ * @param {array} parsedYmlVariables - template variables from yml file
* @returns {Object}
*/
-export const mergeURLVariables = (varsFromYML = {}) => {
+export const mergeURLVariables = (parsedYmlVariables = []) => {
const varsFromURL = templatingVariablesFromUrl();
- 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];
+ parsedYmlVariables.forEach(variable => {
+ const { name } = variable;
+ if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) {
+ Object.assign(variable, { value: varsFromURL[name] });
}
});
- return variables;
+ return parsedYmlVariables;
+};
+
+/**
+ * Converts series data to options that can be added to a
+ * variable. Series data is returned from the Prometheus API
+ * `/api/v1/series`.
+ *
+ * Finds a `label` in the series data, so it can be used as
+ * a filter.
+ *
+ * For example, for the arguments:
+ *
+ * {
+ * "label": "job"
+ * "data" : [
+ * {
+ * "__name__" : "up",
+ * "job" : "prometheus",
+ * "instance" : "localhost:9090"
+ * },
+ * {
+ * "__name__" : "up",
+ * "job" : "node",
+ * "instance" : "localhost:9091"
+ * },
+ * {
+ * "__name__" : "process_start_time_seconds",
+ * "job" : "prometheus",
+ * "instance" : "localhost:9090"
+ * }
+ * ]
+ * }
+ *
+ * It returns all the different "job" values:
+ *
+ * [
+ * {
+ * "label": "node",
+ * "value": "node"
+ * },
+ * {
+ * "label": "prometheus",
+ * "value": "prometheus"
+ * }
+ * ]
+ *
+ * @param {options} options object
+ * @param {options.seriesLabel} name of the searched series label
+ * @param {options.data} series data from the series API
+ * @return {array} Options objects with the shape `{ label, value }`
+ *
+ * @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
+ */
+export const optionsFromSeriesData = ({ label, data = [] }) => {
+ const optionsSet = data.reduce((set, seriesObject) => {
+ // Use `new Set` to deduplicate options
+ if (seriesObject[label]) {
+ set.add(seriesObject[label]);
+ }
+ return set;
+ }, new Set());
+
+ return [...optionsSet].map(parseSimpleCustomValues);
};
export default {};
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 4d2927a066e..0c6fcad9dd0 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -5,6 +5,7 @@ import {
removeParams,
updateHistory,
} from '~/lib/utils/url_utility';
+import { parseBoolean } from '~/lib/utils/common_utils';
import {
timeRangeParamNames,
timeRangeFromParams,
@@ -13,6 +14,50 @@ import {
import { VARIABLE_PREFIX } from './constants';
/**
+ * Extracts the initial state and props from HTML dataset
+ * and places them in separate objects to setup bundle.
+ * @param {*} dataset
+ */
+export const stateAndPropsFromDataset = (dataset = {}) => {
+ const {
+ currentDashboard,
+ deploymentsEndpoint,
+ dashboardEndpoint,
+ dashboardsEndpoint,
+ dashboardTimezone,
+ canAccessOperationsSettings,
+ operationsSettingsPath,
+ projectPath,
+ logsPath,
+ currentEnvironmentName,
+ customDashboardBasePath,
+ ...dataProps
+ } = dataset;
+
+ // HTML attributes are always strings, parse other types.
+ dataProps.hasMetrics = parseBoolean(dataProps.hasMetrics);
+ dataProps.customMetricsAvailable = parseBoolean(dataProps.customMetricsAvailable);
+ dataProps.prometheusAlertsAvailable = parseBoolean(dataProps.prometheusAlertsAvailable);
+
+ return {
+ initState: {
+ currentDashboard,
+ deploymentsEndpoint,
+ dashboardEndpoint,
+ dashboardsEndpoint,
+ dashboardTimezone,
+ canAccessOperationsSettings,
+ operationsSettingsPath,
+ projectPath,
+ logsPath,
+ currentEnvironmentName,
+ customDashboardBasePath,
+ },
+ dataProps,
+ };
+};
+
+/**
* 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
@@ -160,8 +205,10 @@ export const removePrefixFromLabel = label =>
* @returns {Object}
*/
export const convertVariablesForURL = variables =>
- Object.keys(variables || {}).reduce((acc, key) => {
- acc[addPrefixToLabel(key)] = variables[key]?.value;
+ variables.reduce((acc, { name, value }) => {
+ if (value !== null) {
+ acc[addPrefixToLabel(name)] = value;
+ }
return acc;
}, {});