diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 15:26:25 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 15:26:25 +0300 |
commit | a09983ae35713f5a2bbb100981116d31ce99826e (patch) | |
tree | 2ee2af7bd104d57086db360a7e6d8c9d5d43667a /app/assets/javascripts/monitoring | |
parent | 18c5ab32b738c0b6ecb4d0df3994000482f34bd8 (diff) |
Add latest changes from gitlab-org/gitlab@13-2-stable-ee
Diffstat (limited to 'app/assets/javascripts/monitoring')
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; }, {}); |