diff options
Diffstat (limited to 'app/assets')
20 files changed, 375 insertions, 144 deletions
diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 9d8e5396dea..5cd68687329 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -3,11 +3,17 @@ import { mapActions, mapState } from 'vuex'; import { GlEmptyState, GlButton, + GlIcon, GlLink, GlLoadingIcon, GlTable, - GlSearchBoxByClick, + GlFormInput, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlTooltipDirective, } from '@gitlab/ui'; +import AccessorUtils from '~/lib/utils/accessor'; import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; @@ -24,14 +30,19 @@ export default { components: { GlEmptyState, GlButton, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlIcon, GlLink, GlLoadingIcon, GlTable, - GlSearchBoxByClick, + GlFormInput, Icon, TimeAgo, }, directives: { + GlTooltip: GlTooltipDirective, TrackEvent: TrackEventDirective, }, props: { @@ -56,13 +67,14 @@ export default { required: true, }, }, + hasLocalStorage: AccessorUtils.isLocalStorageAccessSafe(), data() { return { errorSearchQuery: '', }; }, computed: { - ...mapState('list', ['errors', 'externalUrl', 'loading']), + ...mapState('list', ['errors', 'externalUrl', 'loading', 'recentSearches']), }, created() { if (this.errorTrackingEnabled) { @@ -70,9 +82,23 @@ export default { } }, methods: { - ...mapActions('list', ['startPolling', 'restartPolling']), + ...mapActions('list', [ + 'startPolling', + 'restartPolling', + 'addRecentSearch', + 'clearRecentSearches', + 'loadRecentSearches', + 'setIndexPath', + ]), filterErrors() { - this.startPolling(`${this.indexPath}?search_term=${this.errorSearchQuery}`); + const searchTerm = this.errorSearchQuery.trim(); + this.addRecentSearch(searchTerm); + + this.startPolling(`${this.indexPath}?search_term=${searchTerm}`); + }, + setSearchText(text) { + this.errorSearchQuery = text; + this.filterErrors(); }, trackViewInSentryOptions, getDetailsLink(errorId) { @@ -85,81 +111,119 @@ export default { <template> <div> <div v-if="errorTrackingEnabled"> - <div> - <div class="d-flex flex-row justify-content-around bg-secondary border"> - <gl-search-box-by-click - v-model="errorSearchQuery" - class="col-lg-10 m-3 p-0" - :placeholder="__('Search or filter results...')" - type="search" - autofocus - @submit="filterErrors" - /> - <gl-button - v-track-event="trackViewInSentryOptions(externalUrl)" - class="m-3" - variant="primary" - :href="externalUrl" - target="_blank" + <div class="d-flex flex-row justify-content-around bg-secondary border p-3"> + <div class="filtered-search-box"> + <gl-dropdown + :text="__('Recent searches')" + class="filtered-search-history-dropdown-wrapper d-none d-md-block" + toggle-class="filtered-search-history-dropdown-toggle-button" + :disabled="loading" > - {{ __('View in Sentry') }} - <icon name="external-link" class="flex-shrink-0" /> - </gl-button> - </div> - - <div v-if="loading" class="py-3"> - <gl-loading-icon size="md" /> + <div v-if="!$options.hasLocalStorage" class="px-3"> + {{ __('This feature requires local storage to be enabled') }} + </div> + <template v-else-if="recentSearches.length > 0"> + <gl-dropdown-item + v-for="searchQuery in recentSearches" + :key="searchQuery" + @click="setSearchText(searchQuery)" + >{{ searchQuery }}</gl-dropdown-item + > + <gl-dropdown-divider /> + <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches">{{ + __('Clear recent searches') + }}</gl-dropdown-item> + </template> + <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> + </gl-dropdown> + <div class="filtered-search-input-container flex-fill"> + <gl-form-input + v-model="errorSearchQuery" + class="pl-2 filtered-search" + :disabled="loading" + :placeholder="__('Search or filter results…')" + autofocus + @keyup.enter.native="filterErrors" + /> + </div> + <div class="gl-search-box-by-type-right-icons"> + <gl-button + v-if="errorSearchQuery.length > 0" + v-gl-tooltip.hover + :title="__('Clear')" + class="clear-search text-secondary" + name="clear" + @click="errorSearchQuery = ''" + > + <gl-icon name="close" :size="12" /> + </gl-button> + </div> </div> - <gl-table - v-else - class="mt-3" - :items="errors" - :fields="$options.fields" - :show-empty="true" - fixed - stacked="sm" + <gl-button + v-track-event="trackViewInSentryOptions(externalUrl)" + class="ml-3" + variant="primary" + :href="externalUrl" + target="_blank" > - <template slot="HEAD_events" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="HEAD_users" slot-scope="data"> - <div class="text-md-right">{{ data.label }}</div> - </template> - <template slot="error" slot-scope="errors"> - <div class="d-flex flex-column"> - <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)"> - <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - </gl-link> - <span class="text-secondary text-truncate"> - {{ errors.item.culprit }} - </span> - </div> - </template> + {{ __('View in Sentry') }} + <icon name="external-link" class="flex-shrink-0" /> + </gl-button> + </div> - <template slot="events" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.count }}</div> - </template> + <div v-if="loading" class="py-3"> + <gl-loading-icon size="md" /> + </div> - <template slot="users" slot-scope="errors"> - <div class="text-md-right">{{ errors.item.userCount }}</div> - </template> + <gl-table + v-else + class="mt-3" + :items="errors" + :fields="$options.fields" + :show-empty="true" + fixed + stacked="sm" + > + <template slot="HEAD_events" slot-scope="data"> + <div class="text-md-right">{{ data.label }}</div> + </template> + <template slot="HEAD_users" slot-scope="data"> + <div class="text-md-right">{{ data.label }}</div> + </template> + <template slot="error" slot-scope="errors"> + <div class="d-flex flex-column"> + <gl-link class="d-flex text-dark" :href="getDetailsLink(errors.item.id)"> + <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> + </gl-link> + <span class="text-secondary text-truncate"> + {{ errors.item.culprit }} + </span> + </div> + </template> - <template slot="lastSeen" slot-scope="errors"> - <div class="d-flex align-items-center"> - <time-ago :time="errors.item.lastSeen" class="text-secondary" /> - </div> - </template> - <template slot="empty"> - <div ref="empty"> - {{ __('No errors to display.') }} - <gl-link class="js-try-again" @click="restartPolling"> - {{ __('Check again') }} - </gl-link> - </div> - </template> - </gl-table> - </div> + <template slot="events" slot-scope="errors"> + <div class="text-md-right">{{ errors.item.count }}</div> + </template> + + <template slot="users" slot-scope="errors"> + <div class="text-md-right">{{ errors.item.userCount }}</div> + </template> + + <template slot="lastSeen" slot-scope="errors"> + <div class="d-flex align-items-center"> + <time-ago :time="errors.item.lastSeen" class="text-secondary" /> + </div> + </template> + <template slot="empty"> + <div ref="empty"> + {{ __('No errors to display.') }} + <gl-link class="js-try-again" @click="restartPolling"> + {{ __('Check again') }} + </gl-link> + </div> + </template> + </gl-table> </div> <div v-else-if="userCanEnableErrorTracking"> <gl-empty-state diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 401fef5983e..13b15549d81 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -51,4 +51,20 @@ export function restartPolling({ commit }) { if (eTagPoll) eTagPoll.restart(); } +export function setIndexPath({ commit }, path) { + commit(types.SET_INDEX_PATH, path); +} + +export function loadRecentSearches({ commit }) { + commit(types.LOAD_RECENT_SEARCHES); +} + +export function addRecentSearch({ commit }, searchQuery) { + commit(types.ADD_RECENT_SEARCH, searchQuery); +} + +export function clearRecentSearches({ commit }) { + commit(types.CLEAR_RECENT_SEARCHES); +} + export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index f9d77a6b08e..4199e8d5cda 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js @@ -1,3 +1,7 @@ export const SET_ERRORS = 'SET_ERRORS'; export const SET_EXTERNAL_URL = 'SET_EXTERNAL_URL'; +export const SET_INDEX_PATH = 'SET_INDEX_PATH'; export const SET_LOADING = 'SET_LOADING'; +export const ADD_RECENT_SEARCH = 'ADD_RECENT_SEARCH'; +export const CLEAR_RECENT_SEARCHES = 'CLEAR_RECENT_SEARCHES'; +export const LOAD_RECENT_SEARCHES = 'LOAD_RECENT_SEARCHES'; diff --git a/app/assets/javascripts/error_tracking/store/list/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index e4bd81db9c9..18404d3b0af 100644 --- a/app/assets/javascripts/error_tracking/store/list/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js @@ -1,5 +1,6 @@ import * as types from './mutation_types'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import AccessorUtils from '~/lib/utils/accessor'; export default { [types.SET_ERRORS](state, data) { @@ -11,4 +12,39 @@ export default { [types.SET_LOADING](state, loading) { state.loading = loading; }, + [types.SET_INDEX_PATH](state, path) { + state.indexPath = path; + }, + [types.ADD_RECENT_SEARCH](state, searchTerm) { + if (searchTerm.length === 0) { + return; + } + // remove any existing item, then add it to the start of the list + const recentSearches = state.recentSearches.filter(s => s !== searchTerm); + recentSearches.unshift(searchTerm); + // only keep the last 5 + state.recentSearches = recentSearches.slice(0, 5); + + if (AccessorUtils.isLocalStorageAccessSafe()) { + localStorage.setItem( + `recent-searches${state.indexPath}`, + JSON.stringify(state.recentSearches), + ); + } + }, + [types.CLEAR_RECENT_SEARCHES](state) { + state.recentSearches = []; + if (AccessorUtils.isLocalStorageAccessSafe()) { + localStorage.removeItem(`recent-searches${state.indexPath}`); + } + }, + [types.LOAD_RECENT_SEARCHES](state) { + const recentSearches = localStorage.getItem(`recent-searches${state.indexPath}`) || []; + try { + state.recentSearches = JSON.parse(recentSearches); + } catch (e) { + state.recentSearches = []; + throw e; + } + }, }; diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js index d371350ef0e..f1f0369e5f3 100644 --- a/app/assets/javascripts/error_tracking/store/list/state.js +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -2,4 +2,6 @@ export default () => ({ errors: [], externalUrl: '', loading: true, + indexPath: '', + recentSearches: [], }); diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index fb6f5dc73b8..2a9321f6733 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import { GlButton, @@ -99,6 +99,10 @@ export default { type: String, required: true, }, + emptyNoDataSmallSvgPath: { + type: String, + required: true, + }, emptyUnableToConnectSvgPath: { type: String, required: true, @@ -176,11 +180,11 @@ export default { 'showEmptyState', 'environments', 'deploymentData', - 'metricsWithData', 'useDashboardEndpoint', 'allDashboards', 'additionalPanelTypesEnabled', ]), + ...mapGetters('monitoringDashboard', ['metricsWithData']), firstDashboard() { return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 ? this.allDashboards[0] @@ -280,13 +284,8 @@ export default { submitCustomMetricsForm() { this.$refs.customMetricsForm.submit(); }, - chartsWithData(panels) { - return panels.filter(panel => - panel.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), - ); - }, groupHasData(group) { - return this.chartsWithData(group.panels).length > 0; + return this.metricsWithData(group.key).length > 0; }, onDateTimePickerApply(timeWindowUrlParams) { return redirectTo(mergeUrlParams(timeWindowUrlParams, window.location.href)); @@ -447,42 +446,61 @@ export default { :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" - :collapse-group="groupHasData(groupData)" + :collapse-group="!groupHasData(groupData)" > - <vue-draggable - :value="groupData.panels" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" - @input="updatePanels(groupData.key, $event)" - > - <div - v-for="(graphData, graphIndex) in groupData.panels" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" + <div v-if="groupHasData(groupData)"> + <vue-draggable + :value="groupData.panels" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updatePanels(groupData.key, $event)" > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removePanel(groupData.key, groupData.panels, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> + <div + v-for="(graphData, graphIndex) in groupData.panels" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" + > + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removePanel(groupData.key, groupData.panels, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> + </div> - <panel-type - :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" - :graph-data="graphData" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <panel-type + :clipboard-text=" + generateLink(groupData.group, graphData.title, graphData.y_label) + " + :graph-data="graphData" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" + /> + </div> </div> - </div> - </vue-draggable> + </vue-draggable> + </div> + <div v-else class="py-5 col col-sm-10 col-md-8 col-lg-7 col-xl-6"> + <empty-state + ref="empty-group" + selected-state="noDataGroup" + :documentation-path="documentationPath" + :settings-path="settingsPath" + :clusters-path="clustersPath" + :empty-getting-started-svg-path="emptyGettingStartedSvgPath" + :empty-loading-svg-path="emptyLoadingSvgPath" + :empty-no-data-svg-path="emptyNoDataSvgPath" + :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath" + :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" + :compact="true" + /> + </div> </graph-group> </div> <empty-state @@ -494,6 +512,7 @@ export default { :empty-getting-started-svg-path="emptyGettingStartedSvgPath" :empty-loading-svg-path="emptyLoadingSvgPath" :empty-no-data-svg-path="emptyNoDataSvgPath" + :empty-no-data-small-svg-path="emptyNoDataSmallSvgPath" :empty-unable-to-connect-svg-path="emptyUnableToConnectSvgPath" :compact="smallEmptyState" /> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index a5c933a0071..dae1fbad547 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -1,5 +1,5 @@ <script> -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; import GraphGroup from './graph_group.vue'; @@ -35,7 +35,8 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), + ...mapState('monitoringDashboard', ['dashboard']), + ...mapGetters('monitoringDashboard', ['metricsWithData']), charts() { if (!this.dashboard || !this.dashboard.panel_groups) { return []; @@ -73,7 +74,7 @@ export default { 'setShowErrorBanner', ]), chartHasData(chart) { - return chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)); + return chart.metrics.some(metric => this.metricsWithData().includes(metric.metric_id)); }, onSidebarMutation() { setTimeout(() => { diff --git a/app/assets/javascripts/monitoring/components/empty_state.vue b/app/assets/javascripts/monitoring/components/empty_state.vue index 1bb40447a3e..ab8c9712ce4 100644 --- a/app/assets/javascripts/monitoring/components/empty_state.vue +++ b/app/assets/javascripts/monitoring/components/empty_state.vue @@ -37,6 +37,10 @@ export default { type: String, required: true, }, + emptyNoDataSmallSvgPath: { + type: String, + required: true, + }, emptyUnableToConnectSvgPath: { type: String, required: true, @@ -80,6 +84,11 @@ export default { secondaryButtonText: '', secondaryButtonPath: '', }, + noDataGroup: { + svgUrl: this.emptyNoDataSmallSvgPath, + title: __('No data to display'), + description: __('The data source is connected, but there is no data to display.'), + }, unableToConnect: { svgUrl: this.emptyUnableToConnectSvgPath, title: __('Unable to connect to Prometheus server'), diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index e01324372a7..5a7981b6534 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -15,31 +15,44 @@ export default { required: false, default: true, }, + /** + * Initial value of collapse on mount. + */ collapseGroup: { type: Boolean, - required: true, + required: false, + default: false, }, }, data() { return { - showGroup: true, + isCollapsed: this.collapseGroup, }; }, computed: { caretIcon() { - return this.collapseGroup && this.showGroup ? 'angle-down' : 'angle-right'; + return this.isCollapsed ? 'angle-right' : 'angle-down'; + }, + }, + watch: { + collapseGroup(val) { + // Respond to changes in collapseGroup but do not + // collapse it once was opened by the user. + if (this.showPanels && !val) { + this.isCollapsed = false; + } }, }, methods: { collapse() { - this.showGroup = !this.showGroup; + this.isCollapsed = !this.isCollapsed; }, }, }; </script> <template> - <div v-if="showPanels" class="card prometheus-panel"> + <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> <a role="button" class="js-graph-group-toggle" @click="collapse"> @@ -47,12 +60,12 @@ export default { </a> </div> <div - v-if="collapseGroup" - v-show="collapseGroup && showGroup" + v-show="!isCollapsed" + ref="graph-group-content" class="card-body prometheus-graph-group p-0" > <slot></slot> </div> </div> - <div v-else class="prometheus-graph-group"><slot></slot></div> + <div v-else ref="graph-group-content" class="prometheus-graph-group"><slot></slot></div> </template> diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 3612e4d173f..268d9d636b1 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -4,7 +4,7 @@ import createFlash from '~/flash'; import trackDashboardLoad from '../monitoring_tracking_helper'; import statusCodes from '../../lib/utils/http_status'; import { backOff } from '../../lib/utils/common_utils'; -import { s__ } from '../../locale'; +import { s__, sprintf } from '../../locale'; const TWO_MINUTES = 120000; @@ -74,17 +74,21 @@ export const fetchDashboard = ({ state, dispatch }, params) => { return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) .then(response => dispatch('receiveMetricsDashboardSuccess', { response, params })) - .then(() => { - const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; - return trackDashboardLoad({ - label: `${dashboardType}_metrics_dashboard`, - value: state.metricsWithData.length, - }); - }) - .catch(error => { - dispatch('receiveMetricsDashboardFailure', error); - if (state.setShowErrorBanner) { - createFlash(s__('Metrics|There was an error while retrieving metrics')); + .catch(e => { + dispatch('receiveMetricsDashboardFailure', e); + if (state.showErrorBanner) { + if (e.response.data && e.response.data.message) { + const { message } = e.response.data; + createFlash( + sprintf( + s__('Metrics|There was an error while retrieving metrics. %{message}'), + { message }, + false, + ), + ); + } else { + createFlash(s__('Metrics|There was an error while retrieving metrics')); + } } }); }; @@ -126,7 +130,7 @@ export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { }); }; -export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { +export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, params) => { commit(types.REQUEST_METRICS_DATA); const promises = []; @@ -140,9 +144,11 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { return Promise.all(promises) .then(() => { - if (state.metricsWithData.length === 0) { - commit(types.SET_NO_DATA_EMPTY_STATE); - } + const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; + trackDashboardLoad({ + label: `${dashboardType}_metrics_dashboard`, + value: getters.metricsWithData().length, + }); }) .catch(() => { createFlash(s__(`Metrics|There was an error while retrieving metrics`), 'warning'); diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js new file mode 100644 index 00000000000..3eddd52705d --- /dev/null +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -0,0 +1,32 @@ +const metricsIdsInPanel = panel => + panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); + +/** + * Getter to obtain the list of metric ids that have data + * + * Useful to understand which parts of the dashboard should + * be displayed. It is a Vuex Method-Style Access getter. + * + * @param {Object} state + * @returns {Function} A function that returns an array of + * metrics in the dashboard that contain results, optionally + * filtered by group key. + */ +export const metricsWithData = state => groupKey => { + let groups = state.dashboard.panel_groups; + if (groupKey) { + groups = groups.filter(group => group.key === groupKey); + } + + const res = []; + groups.forEach(group => { + group.panels.forEach(panel => { + res.push(...metricsIdsInPanel(panel)); + }); + }); + + return res; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/index.js b/app/assets/javascripts/monitoring/stores/index.js index d58398c54ae..c1c466b7cf0 100644 --- a/app/assets/javascripts/monitoring/stores/index.js +++ b/app/assets/javascripts/monitoring/stores/index.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import state from './state'; @@ -12,6 +13,7 @@ export const createStore = () => monitoringDashboard: { namespaced: true, actions, + getters, mutations, state, }, diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index bfa76aa7cea..db5ec4e9e2b 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -67,7 +67,6 @@ export default { group.panels.forEach(panel => { panel.metrics.forEach(metric => { if (metric.metric_id === metricId) { - state.metricsWithData.push(metricId); // ensure dates/numbers are correctly formatted for charts const normalizedResults = result.map(normalizeQueryResult); Vue.set(metric, 'result', Object.freeze(normalizedResults)); diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e3300967022..88f333aeb80 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -13,7 +13,6 @@ export default () => ({ }, deploymentData: [], environments: [], - metricsWithData: [], allDashboards: [], currentDashboard: null, projectPath: null, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 4ff8447485f..42db1935123 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -28,6 +28,10 @@ export default { type: Object, required: true, }, + pipelineCoverageDelta: { + type: String, + required: false, + }, // This prop needs to be camelCase, html attributes are case insensive // https://vuejs.org/v2/guide/components.html#camelCase-vs-kebab-case hasCi: { @@ -92,6 +96,16 @@ export default { showSourceBranch() { return Boolean(this.pipeline.ref.branch); }, + coverageDeltaClass() { + const delta = this.pipelineCoverageDelta; + if (delta && parseFloat(delta) > 0) { + return 'text-success'; + } + if (delta && parseFloat(delta) < 0) { + return 'text-danger'; + } + return ''; + }, }, }; </script> @@ -142,6 +156,14 @@ export default { </div> <div v-if="pipeline.coverage" class="coverage"> {{ s__('Pipeline|Coverage') }} {{ pipeline.coverage }}% + + <span + v-if="pipelineCoverageDelta" + class="js-pipeline-coverage-delta" + :class="coverageDeltaClass" + > + ({{ pipelineCoverageDelta }}%) + </span> </div> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index c8b26889076..90fb254ecca 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -76,6 +76,7 @@ export default { <mr-widget-container> <mr-widget-pipeline :pipeline="pipeline" + :pipeline-coverage-delta="mr.pipelineCoverageDelta" :ci-status="mr.ciStatus" :has-ci="mr.hasCI" :source-branch="branch" diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 1a25edee9b8..c7949fa264e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -42,6 +42,7 @@ export default class MergeRequestStore { this.commitsCount = data.commits_count; this.divergedCommitsCount = data.diverged_commits_count; this.pipeline = data.pipeline || {}; + this.pipelineCoverageDelta = data.pipeline_coverage_delta; this.mergePipeline = data.merge_pipeline || {}; this.deployments = this.deployments || data.deployments || []; this.postMergeDeployments = this.postMergeDeployments || []; diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 9a15505dd25..4cbb2f5ba71 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -515,6 +515,12 @@ img.emoji { cursor: pointer; } +// this needs to use "!important" due to some very specific styles +// around buttons +.cursor-default { + cursor: default !important; +} + // Make buttons/dropdowns full-width on mobile .full-width-mobile { @include media-breakpoint-down(xs) { diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 2d826064569..1c252584047 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -214,8 +214,8 @@ padding-left: 0; height: $input-height - 2; line-height: inherit; - border-color: transparent; + &, &:focus, &:hover { outline: none; diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 154e505f7a4..e20e58e21cf 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -67,7 +67,6 @@ .prometheus-graph-group { display: flex; flex-wrap: wrap; - margin-top: $gl-padding-8; } .prometheus-graph { |