Commit fe71ae5d authored by GitLab Bot's avatar GitLab Bot
Browse files

Add latest changes from gitlab-org/gitlab@master

parent e57bf97c6045
......@@ -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
......
......@@ -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 () => {};
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';
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;
}
},
};
......@@ -2,4 +2,6 @@ export default () => ({
errors: [],
externalUrl: '',
loading: true,
indexPath: '',
recentSearches: [],
});
<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"
/>
......
<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(() => {
......
......@@ -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'),
......
......@@ -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;