diff options
author | Chris Baumbauer <cab@cabnetworks.net> | 2019-04-06 02:02:39 +0000 |
---|---|---|
committer | Mike Greiling <mike@pixelcog.com> | 2019-04-06 02:02:39 +0000 |
commit | b77fe7db3e885edca14c862f362e2bbd43f0e498 (patch) | |
tree | cd984b8bb900a6b3e37c8f6106101ba8617bf524 | |
parent | 8e33e7cf474b61bbc684d993d86cb5aa775a01d0 (diff) | |
download | gitlab-ce-b77fe7db3e885edca14c862f362e2bbd43f0e498.tar.gz |
Add Knative metrics to Prometheus
52 files changed, 1516 insertions, 320 deletions
diff --git a/app/assets/javascripts/serverless/components/area.vue b/app/assets/javascripts/serverless/components/area.vue new file mode 100644 index 00000000000..32c9d6eccb8 --- /dev/null +++ b/app/assets/javascripts/serverless/components/area.vue @@ -0,0 +1,146 @@ +<script> +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import dateFormat from 'dateformat'; +import { X_INTERVAL } from '../constants'; +import { validateGraphData } from '../utils'; + +let debouncedResize; + +export default { + components: { + GlAreaChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: validateGraphData, + }, + containerWidth: { + type: Number, + required: true, + }, + }, + data() { + return { + tooltipPopoverTitle: '', + tooltipPopoverContent: '', + width: this.containerWidth, + }; + }, + computed: { + chartData() { + return this.graphData.queries.reduce((accumulator, query) => { + accumulator[query.unit] = query.result.reduce((acc, res) => acc.concat(res.values), []); + return accumulator; + }, {}); + }, + extractTimeData() { + return this.chartData.requests.map(data => data.time); + }, + generateSeries() { + return { + name: 'Invocations', + type: 'line', + data: this.chartData.requests.map(data => [data.time, data.value]), + symbolSize: 0, + }; + }, + getInterval() { + const { result } = this.graphData.queries[0]; + + if (result.length === 0) { + return 1; + } + + const split = result[0].values.reduce( + (acc, pair) => (pair.value > acc ? pair.value : acc), + 1, + ); + + return split < X_INTERVAL ? split : X_INTERVAL; + }, + chartOptions() { + return { + xAxis: { + name: 'time', + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, 'h:MM TT'), + }, + data: this.extractTimeData, + nameTextStyle: { + padding: [18, 0, 0, 0], + }, + }, + yAxis: { + name: this.yAxisLabel, + nameTextStyle: { + padding: [0, 0, 36, 0], + }, + splitNumber: this.getInterval, + }, + legend: { + formatter: this.xAxisLabel, + }, + series: this.generateSeries, + }; + }, + xAxisLabel() { + return this.graphData.queries.map(query => query.label).join(', '); + }, + yAxisLabel() { + const [query] = this.graphData.queries; + return `${this.graphData.y_label} (${query.unit})`; + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + }, + methods: { + formatTooltipText(params) { + const [seriesData] = params.seriesData; + this.tooltipPopoverTitle = dateFormat(params.value, 'dd mmm yyyy, h:MMTT'); + this.tooltipPopoverContent = `${this.yAxisLabel}: ${seriesData.value[1]}`; + }, + onResize() { + const { width } = this.$refs.areaChart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> + +<template> + <div class="prometheus-graph"> + <div class="prometheus-graph-header"> + <h5 ref="graphTitle" class="prometheus-graph-title">{{ graphData.title }}</h5> + <div ref="graphWidgets" class="prometheus-graph-widgets"><slot></slot></div> + </div> + <gl-area-chart + ref="areaChart" + v-bind="$attrs" + :data="[]" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :width="width" + :include-legend-avg-max="false" + > + <template slot="tooltipTitle"> + {{ tooltipPopoverTitle }} + </template> + <template slot="tooltipContent"> + {{ tooltipPopoverContent }} + </template> + </gl-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 4f89ad69129..b8906cfca4e 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -1,39 +1,77 @@ <script> +import _ from 'underscore'; +import { mapState, mapActions, mapGetters } from 'vuex'; import PodBox from './pod_box.vue'; import Url from './url.vue'; +import AreaChart from './area.vue'; +import MissingPrometheus from './missing_prometheus.vue'; export default { components: { PodBox, Url, + AreaChart, + MissingPrometheus, }, props: { func: { type: Object, required: true, }, + hasPrometheus: { + type: Boolean, + required: false, + default: false, + }, + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + }, + data() { + return { + elWidth: 0, + }; }, computed: { name() { return this.func.name; }, description() { - return this.func.description; + return _.isString(this.func.description) ? this.func.description : ''; }, funcUrl() { return this.func.url; }, podCount() { - return this.func.podcount || 0; + return Number(this.func.podcount) || 0; }, + ...mapState(['graphData', 'hasPrometheusData']), + ...mapGetters(['hasPrometheusMissingData']), + }, + created() { + this.fetchMetrics({ + metricsPath: this.func.metricsUrl, + hasPrometheus: this.hasPrometheus, + }); + }, + mounted() { + this.elWidth = this.$el.clientWidth; + }, + methods: { + ...mapActions(['fetchMetrics']), }, }; </script> <template> <section id="serverless-function-details"> - <h3>{{ name }}</h3> - <div class="append-bottom-default"> + <h3 class="serverless-function-name">{{ name }}</h3> + <div class="append-bottom-default serverless-function-description"> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> </div> <url :uri="funcUrl" /> @@ -52,5 +90,13 @@ export default { </p> </div> <div v-else><p>No pods loaded at this time.</p></div> + + <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> + <missing-prometheus + v-if="!hasPrometheus || hasPrometheusMissingData" + :help-path="helpPath" + :clusters-path="clustersPath" + :missing-data="hasPrometheusMissingData" + /> </section> </template> diff --git a/app/assets/javascripts/serverless/components/function_row.vue b/app/assets/javascripts/serverless/components/function_row.vue index 773d18781fd..4b3bb078eae 100644 --- a/app/assets/javascripts/serverless/components/function_row.vue +++ b/app/assets/javascripts/serverless/components/function_row.vue @@ -1,4 +1,5 @@ <script> +import _ from 'underscore'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Url from './url.vue'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -19,6 +20,10 @@ export default { return this.func.name; }, description() { + if (!_.isString(this.func.description)) { + return ''; + } + const desc = this.func.description.split('\n'); if (desc.length > 1) { return desc[1]; diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 4bde409f906..f9b4e789563 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,5 +1,6 @@ <script> -import { GlSkeletonLoading } from '@gitlab/ui'; +import { mapState, mapActions, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; import FunctionRow from './function_row.vue'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; @@ -9,14 +10,9 @@ export default { EnvironmentRow, FunctionRow, EmptyState, - GlSkeletonLoading, + GlLoadingIcon, }, props: { - functions: { - type: Object, - required: true, - default: () => ({}), - }, installed: { type: Boolean, required: true, @@ -29,17 +25,23 @@ export default { type: String, required: true, }, - loadingData: { - type: Boolean, - required: false, - default: true, - }, - hasFunctionData: { - type: Boolean, - required: false, - default: true, + statusPath: { + type: String, + required: true, }, }, + computed: { + ...mapState(['isLoading', 'hasFunctionData']), + ...mapGetters(['getFunctions']), + }, + created() { + this.fetchFunctions({ + functionsPath: this.statusPath, + }); + }, + methods: { + ...mapActions(['fetchFunctions']), + }, }; </script> @@ -47,14 +49,16 @@ export default { <section id="serverless-functions"> <div v-if="installed"> <div v-if="hasFunctionData"> - <template v-if="loadingData"> - <div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> - </template> + <gl-loading-icon + v-if="isLoading" + :size="2" + class="prepend-top-default append-bottom-default" + /> <template v-else> <div class="groups-list-tree-container"> <ul class="content-list group-list-tree"> <environment-row - v-for="(env, index) in functions" + v-for="(env, index) in getFunctions" :key="index" :env="env" :env-name="index" diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue new file mode 100644 index 00000000000..6c19434f202 --- /dev/null +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -0,0 +1,63 @@ +<script> +import { GlButton, GlLink } from '@gitlab/ui'; +import { s__ } from '../../locale'; + +export default { + components: { + GlButton, + GlLink, + }, + props: { + clustersPath: { + type: String, + required: true, + }, + helpPath: { + type: String, + required: true, + }, + missingData: { + type: Boolean, + required: true, + }, + }, + computed: { + missingStateClass() { + return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state'; + }, + prometheusHelpPath() { + return `${this.helpPath}#prometheus-support`; + }, + description() { + return this.missingData + ? s__(`ServerlessDetails|Invocation metrics loading or not available at this time.`) + : s__( + `ServerlessDetails|Function invocation metrics require Prometheus to be installed first.`, + ); + }, + }, +}; +</script> + +<template> + <div class="row" :class="missingStateClass"> + <div class="col-12"> + <div class="text-content"> + <h4 class="state-title text-left">{{ s__(`ServerlessDetails|Invocations`) }}</h4> + <p class="state-description"> + {{ description }} + <gl-link :href="prometheusHelpPath">{{ + s__(`ServerlessDetails|More information`) + }}</gl-link + >. + </p> + + <div v-if="!missingData" class="text-left"> + <gl-button :href="clustersPath" variant="success"> + {{ s__('ServerlessDetails|Install Prometheus') }} + </gl-button> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/serverless/constants.js b/app/assets/javascripts/serverless/constants.js new file mode 100644 index 00000000000..35f77205f2c --- /dev/null +++ b/app/assets/javascripts/serverless/constants.js @@ -0,0 +1,3 @@ +export const MAX_REQUESTS = 3; // max number of times to retry + +export const X_INTERVAL = 5; // Reflects the number of verticle bars on the x-axis diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index 47a510d5fb5..2d3f086ffee 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -1,13 +1,7 @@ -import Visibility from 'visibilityjs'; import Vue from 'vue'; -import { s__ } from '../locale'; -import Flash from '../flash'; -import Poll from '../lib/utils/poll'; -import ServerlessStore from './stores/serverless_store'; -import ServerlessDetailsStore from './stores/serverless_details_store'; -import GetFunctionsService from './services/get_functions_service'; import Functions from './components/functions.vue'; import FunctionDetails from './components/function_details.vue'; +import { createStore } from './store'; export default class Serverless { constructor() { @@ -19,10 +13,12 @@ export default class Serverless { serviceUrl, serviceNamespace, servicePodcount, + serviceMetricsUrl, + prometheus, + clustersPath, + helpPath, } = document.querySelector('.js-serverless-function-details-page').dataset; const el = document.querySelector('#js-serverless-function-details'); - this.store = new ServerlessDetailsStore(); - const { store } = this; const service = { name: serviceName, @@ -31,20 +27,19 @@ export default class Serverless { url: serviceUrl, namespace: serviceNamespace, podcount: servicePodcount, + metricsUrl: serviceMetricsUrl, }; - this.store.updateDetailedFunction(service); this.functionDetails = new Vue({ el, - data() { - return { - state: store.state, - }; - }, + store: createStore(), render(createElement) { return createElement(FunctionDetails, { props: { - func: this.state.functionDetail, + func: service, + hasPrometheus: prometheus !== undefined, + clustersPath, + helpPath, }, }); }, @@ -54,95 +49,27 @@ export default class Serverless { '.js-serverless-functions-page', ).dataset; - this.service = new GetFunctionsService(statusPath); - this.knativeInstalled = installed !== undefined; - this.store = new ServerlessStore(this.knativeInstalled, clustersPath, helpPath); - this.initServerless(); - this.functionLoadCount = 0; - - if (statusPath && this.knativeInstalled) { - this.initPolling(); - } - } - } - - initServerless() { - const { store } = this; - const el = document.querySelector('#js-serverless-functions'); - - this.functions = new Vue({ - el, - data() { - return { - state: store.state, - }; - }, - render(createElement) { - return createElement(Functions, { - props: { - functions: this.state.functions, - installed: this.state.installed, - clustersPath: this.state.clustersPath, - helpPath: this.state.helpPath, - loadingData: this.state.loadingData, - hasFunctionData: this.state.hasFunctionData, - }, - }); - }, - }); - } - - initPolling() { - this.poll = new Poll({ - resource: this.service, - method: 'fetchData', - successCallback: data => this.handleSuccess(data), - errorCallback: () => Serverless.handleError(), - }); - - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } else { - this.service - .fetchData() - .then(data => this.handleSuccess(data)) - .catch(() => Serverless.handleError()); - } - - Visibility.change(() => { - if (!Visibility.hidden() && !this.destroyed) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - } - - handleSuccess(data) { - if (data.status === 200) { - this.store.updateFunctionsFromServer(data.data); - this.store.updateLoadingState(false); - } else if (data.status === 204) { - /* Time out after 3 attempts to retrieve data */ - this.functionLoadCount += 1; - if (this.functionLoadCount === 3) { - this.poll.stop(); - this.store.toggleNoFunctionData(); - } + const el = document.querySelector('#js-serverless-functions'); + this.functions = new Vue({ + el, + store: createStore(), + render(createElement) { + return createElement(Functions, { + props: { + installed: installed !== undefined, + clustersPath, + helpPath, + statusPath, + }, + }); + }, + }); } } - static handleError() { - Flash(s__('Serverless|An error occurred while retrieving serverless components')); - } - destroy() { this.destroyed = true; - if (this.poll) { - this.poll.stop(); - } - this.functions.$destroy(); this.functionDetails.$destroy(); } diff --git a/app/assets/javascripts/serverless/services/get_functions_service.js b/app/assets/javascripts/serverless/services/get_functions_service.js deleted file mode 100644 index 303b42dc66c..00000000000 --- a/app/assets/javascripts/serverless/services/get_functions_service.js +++ /dev/null @@ -1,11 +0,0 @@ -import axios from '~/lib/utils/axios_utils'; - -export default class GetFunctionsService { - constructor(endpoint) { - this.endpoint = endpoint; - } - - fetchData() { - return axios.get(this.endpoint); - } -} diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js new file mode 100644 index 00000000000..826501c9022 --- /dev/null +++ b/app/assets/javascripts/serverless/store/actions.js @@ -0,0 +1,113 @@ +import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; +import { MAX_REQUESTS } from '../constants'; + +export const requestFunctionsLoading = ({ commit }) => commit(types.REQUEST_FUNCTIONS_LOADING); +export const receiveFunctionsSuccess = ({ commit }, data) => + commit(types.RECEIVE_FUNCTIONS_SUCCESS, data); +export const receiveFunctionsNoDataSuccess = ({ commit }) => + commit(types.RECEIVE_FUNCTIONS_NODATA_SUCCESS); +export const receiveFunctionsError = ({ commit }, error) => + commit(types.RECEIVE_FUNCTIONS_ERROR, error); + +export const receiveMetricsSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_SUCCESS, data); +export const receiveMetricsNoPrometheus = ({ commit }) => + commit(types.RECEIVE_METRICS_NO_PROMETHEUS); +export const receiveMetricsNoDataSuccess = ({ commit }, data) => + commit(types.RECEIVE_METRICS_NODATA_SUCCESS, data); +export const receiveMetricsError = ({ commit }, error) => + commit(types.RECEIVE_METRICS_ERROR, error); + +export const fetchFunctions = ({ dispatch }, { functionsPath }) => { + let retryCount = 0; + + dispatch('requestFunctionsLoading'); + + backOff((next, stop) => { + axios + .get(functionsPath) + .then(response => { + if (response.status === statusCodes.NO_CONTENT) { + retryCount += 1; + if (retryCount < MAX_REQUESTS) { + next(); + } else { + stop(null); + } + } else { + stop(response.data); + } + }) + .catch(stop); + }) + .then(data => { + if (data !== null) { + dispatch('receiveFunctionsSuccess', data); + } else { + dispatch('receiveFunctionsNoDataSuccess'); + } + }) + .catch(error => { + dispatch('receiveFunctionsError', error); + createFlash(error); + }); +}; + +export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => { + let retryCount = 0; + + if (!hasPrometheus) { + dispatch('receiveMetricsNoPrometheus'); + return; + } + + backOff((next, stop) => { + axios + .get(metricsPath) + .then(response => { + if (response.status === statusCodes.NO_CONTENT) { + retryCount += 1; + if (retryCount < MAX_REQUESTS) { + next(); + } else { + dispatch('receiveMetricsNoDataSuccess'); + stop(null); + } + } else { + stop(response.data); + } + }) + .catch(stop); + }) + .then(data => { + if (data === null) { + return; + } + + const updatedMetric = data.metrics; + const queries = data.metrics.queries.map(query => ({ + ...query, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => ({ + time: new Date(timestamp * 1000).toISOString(), + value: Number(value), + })), + })), + })); + + updatedMetric.queries = queries; + dispatch('receiveMetricsSuccess', updatedMetric); + }) + .catch(error => { + dispatch('receiveMetricsError', error); + createFlash(error); + }); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js new file mode 100644 index 00000000000..071f663d9d2 --- /dev/null +++ b/app/assets/javascripts/serverless/store/getters.js @@ -0,0 +1,10 @@ +import { translate } from '../utils'; + +export const hasPrometheusMissingData = state => state.hasPrometheus && !state.hasPrometheusData; + +// Convert the function list into a k/v grouping based on the environment scope + +export const getFunctions = state => translate(state.functions); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js new file mode 100644 index 00000000000..5f72060633e --- /dev/null +++ b/app/assets/javascripts/serverless/store/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/serverless/store/mutation_types.js b/app/assets/javascripts/serverless/store/mutation_types.js new file mode 100644 index 00000000000..25b2f7ac38a --- /dev/null +++ b/app/assets/javascripts/serverless/store/mutation_types.js @@ -0,0 +1,9 @@ +export const REQUEST_FUNCTIONS_LOADING = 'REQUEST_FUNCTIONS_LOADING'; +export const RECEIVE_FUNCTIONS_SUCCESS = 'RECEIVE_FUNCTIONS_SUCCESS'; +export const RECEIVE_FUNCTIONS_NODATA_SUCCESS = 'RECEIVE_FUNCTIONS_NODATA_SUCCESS'; +export const RECEIVE_FUNCTIONS_ERROR = 'RECEIVE_FUNCTIONS_ERROR'; + +export const RECEIVE_METRICS_NO_PROMETHEUS = 'RECEIVE_METRICS_NO_PROMETHEUS'; +export const RECEIVE_METRICS_SUCCESS = 'RECEIVE_METRICS_SUCCESS'; +export const RECEIVE_METRICS_NODATA_SUCCESS = 'RECEIVE_METRICS_NODATA_SUCCESS'; +export const RECEIVE_METRICS_ERROR = 'RECEIVE_METRICS_ERROR'; diff --git a/app/assets/javascripts/serverless/store/mutations.js b/app/assets/javascripts/serverless/store/mutations.js new file mode 100644 index 00000000000..991f32a275d --- /dev/null +++ b/app/assets/javascripts/serverless/store/mutations.js @@ -0,0 +1,38 @@ +import * as types from './mutation_types'; + +export default { + [types.REQUEST_FUNCTIONS_LOADING](state) { + state.isLoading = true; + }, + [types.RECEIVE_FUNCTIONS_SUCCESS](state, data) { + state.functions = data; + state.isLoading = false; + state.hasFunctionData = true; + }, + [types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state) { + state.isLoading = false; + state.hasFunctionData = false; + }, + [types.RECEIVE_FUNCTIONS_ERROR](state, error) { + state.error = error; + state.hasFunctionData = false; + state.isLoading = false; + }, + [types.RECEIVE_METRICS_SUCCESS](state, data) { + state.isLoading = false; + state.hasPrometheusData = true; + state.graphData = data; + }, + [types.RECEIVE_METRICS_NODATA_SUCCESS](state) { + state.isLoading = false; + state.hasPrometheusData = false; + }, + [types.RECEIVE_METRICS_ERROR](state, error) { + state.hasPrometheusData = false; + state.error = error; + }, + [types.RECEIVE_METRICS_NO_PROMETHEUS](state) { + state.hasPrometheusData = false; + state.hasPrometheus = false; + }, +}; diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js new file mode 100644 index 00000000000..afc3f37d7ba --- /dev/null +++ b/app/assets/javascripts/serverless/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + error: null, + isLoading: true, + + // functions + functions: [], + hasFunctionData: true, + + // function_details + hasPrometheus: true, + hasPrometheusData: false, + graphData: {}, +}); diff --git a/app/assets/javascripts/serverless/stores/serverless_details_store.js b/app/assets/javascripts/serverless/stores/serverless_details_store.js deleted file mode 100644 index 5394d2cded1..00000000000 --- a/app/assets/javascripts/serverless/stores/serverless_details_store.js +++ /dev/null @@ -1,11 +0,0 @@ -export default class ServerlessDetailsStore { - constructor() { - this.state = { - functionDetail: {}, - }; - } - - updateDetailedFunction(func) { - this.state.functionDetail = func; - } -} diff --git a/app/assets/javascripts/serverless/stores/serverless_store.js b/app/assets/javascripts/serverless/stores/serverless_store.js deleted file mode 100644 index 816d55a03f9..00000000000 --- a/app/assets/javascripts/serverless/stores/serverless_store.js +++ /dev/null @@ -1,29 +0,0 @@ -export default class ServerlessStore { - constructor(knativeInstalled = false, clustersPath, helpPath) { - this.state = { - functions: {}, - hasFunctionData: true, - loadingData: true, - installed: knativeInstalled, - clustersPath, - helpPath, - }; - } - - updateFunctionsFromServer(upstreamFunctions = []) { - this.state.functions = upstreamFunctions.reduce((rv, func) => { - const envs = rv; - envs[func.environment_scope] = (rv[func.environment_scope] || []).concat([func]); - - return envs; - }, {}); - } - - updateLoadingState(loadingData) { - this.state.loadingData = loadingData; - } - - toggleNoFunctionData() { - this.state.hasFunctionData = false; - } -} diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js new file mode 100644 index 00000000000..8b9e96ce9aa --- /dev/null +++ b/app/assets/javascripts/serverless/utils.js @@ -0,0 +1,23 @@ +// Validate that the object coming in has valid query details and results +export const validateGraphData = data => + data.queries && + Array.isArray(data.queries) && + data.queries.filter(query => { + if (Array.isArray(query.result)) { + return query.result.filter(res => Array.isArray(res.values)).length === query.result.length; + } + + return false; + }).length === data.queries.length; + +export const translate = functions => + functions.reduce( + (acc, func) => + Object.assign(acc, { + [func.environment_scope]: (acc[func.environment_scope] || []).concat([func]), + }), + {}, + ); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/controllers/projects/serverless/functions_controller.rb b/app/controllers/projects/serverless/functions_controller.rb index 39eca10134f..8c3d141c888 100644 --- a/app/controllers/projects/serverless/functions_controller.rb +++ b/app/controllers/projects/serverless/functions_controller.rb @@ -7,19 +7,14 @@ module Projects before_action :authorize_read_cluster! - INDEX_PRIMING_INTERVAL = 15_000 - INDEX_POLLING_INTERVAL = 60_000 - def index respond_to do |format| format.json do functions = finder.execute if functions.any? - Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL) render json: serialize_function(functions) else - Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL) head :no_content end end @@ -33,6 +28,8 @@ module Projects def show @service = serialize_function(finder.service(params[:environment_id], params[:id])) + @prometheus = finder.has_prometheus?(params[:environment_id]) + return not_found if @service.nil? respond_to do |format| @@ -44,10 +41,24 @@ module Projects end end + def metrics + respond_to do |format| + format.json do + metrics = finder.invocation_metrics(params[:environment_id], params[:id]) + + if metrics.nil? + head :no_content + else + render json: metrics + end + end + end + end + private def finder - Projects::Serverless::FunctionsFinder.new(project.clusters) + Projects::Serverless::FunctionsFinder.new(project) end def serialize_function(function) diff --git a/app/finders/projects/serverless/functions_finder.rb b/app/finders/projects/serverless/functions_finder.rb index 2f2816a4a08..d9802598c64 100644 --- a/app/finders/projects/serverless/functions_finder.rb +++ b/app/finders/projects/serverless/functions_finder.rb @@ -3,8 +3,9 @@ module Projects module Serverless class FunctionsFinder - def initialize(clusters) - @clusters = clusters + def initialize(project) + @clusters = project.clusters + @project = project end def execute @@ -19,6 +20,23 @@ module Projects knative_service(environment_scope, name)&.first end + def invocation_metrics(environment_scope, name) + return unless prometheus_adapter&.can_query? + + cluster = clusters_with_knative_installed.preload_knative.find do |c| + environment_scope == c.environment_scope + end + + func = ::Serverless::Function.new(@project, name, cluster.platform_kubernetes&.actual_namespace) + prometheus_adapter.query(:knative_invocation, func) + end + + def has_prometheus?(environment_scope) + clusters_with_knative_installed.preload_knative.to_a.any? do |cluster| + environment_scope == cluster.environment_scope && cluster.application_prometheus_available? + end + end + private def knative_service(environment_scope, name) @@ -55,6 +73,12 @@ module Projects def clusters_with_knative_installed @clusters.with_knative_installed end + + # rubocop: disable CodeReuse/ServiceClass + def prometheus_adapter + @prometheus_adapter ||= ::Prometheus::AdapterService.new(@project).prometheus_adapter + end + # rubocop: enable CodeReuse/ServiceClass end end end diff --git a/app/models/serverless/function.rb b/app/models/serverless/function.rb new file mode 100644 index 00000000000..5d4f8e0c9e2 --- /dev/null +++ b/app/models/serverless/function.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Serverless + class Function + attr_accessor :name, :namespace + + def initialize(project, name, namespace) + @project = project + @name = name + @namespace = namespace + end + + def id + @project.id.to_s + "/" + @name + "/" + @namespace + end + + def self.find_by_id(id) + array = id.split("/") + project = Project.find_by_id(array[0]) + name = array[1] + namespace = array[2] + + self.new(project, name, namespace) + end + end +end diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index c98dc1a1c4a..a46f8af1466 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -32,6 +32,13 @@ module Projects service.dig('podcount') end + expose :metrics_url do |service| + project_serverless_metrics_path( + request.project, + service.dig('environment_scope'), + service.dig('metadata', 'name')) + ".json" + end + expose :created_at do |service| service.dig('metadata', 'creationTimestamp') end diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index 635580eac5c..9c69aedfbfc 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -5,7 +5,10 @@ - status_path = project_serverless_functions_path(@project, format: :json) - clusters_path = project_clusters_path(@project) -.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, help_path: help_page_path('user/project/clusters/serverless/index') } } +.serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, + installed: @installed, + clusters_path: clusters_path, + help_path: help_page_path('user/project/clusters/serverless/index') } } %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } .js-serverless-functions-notice diff --git a/app/views/projects/serverless/functions/show.html.haml b/app/views/projects/serverless/functions/show.html.haml index 29737b7014a..d1fe208ce60 100644 --- a/app/views/projects/serverless/functions/show.html.haml +++ b/app/views/projects/serverless/functions/show.html.haml @@ -1,14 +1,19 @@ - @no_container = true - @content_class = "limit-container-width" unless fluid_layout +- clusters_path = project_clusters_path(@project) +- help_path = help_page_path('user/project/clusters/serverless/index') - add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project)) - page_title @service[:name] -.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json } } +.serverless-function-details-page.js-serverless-function-details-page{ data: { service: @service.as_json, + prometheus: @prometheus, + clusters_path: clusters_path, + help_path: help_path } } + %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] } - .top-area.adjust - .serverless-function-details#js-serverless-function-details + .serverless-function-details#js-serverless-function-details .js-serverless-function-notice .flash-container diff --git a/changelogs/unreleased/knative-prometheus.yml b/changelogs/unreleased/knative-prometheus.yml new file mode 100644 index 00000000000..e24f53b7225 --- /dev/null +++ b/changelogs/unreleased/knative-prometheus.yml @@ -0,0 +1,5 @@ +--- +title: Add Knative metrics to Prometheus +merge_request: 24663 +author: Chris Baumbauer <cab@cabnetworks.net> +type: added diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml index 9bdaf1575e9..884868c6336 100644 --- a/config/prometheus/common_metrics.yml +++ b/config/prometheus/common_metrics.yml @@ -259,3 +259,13 @@ label: Pod average unit: "cores" track: canary + - title: "Knative function invocations" + y_label: "Invocations" + required_metrics: + - istio_revision_request_count + weight: 1 + queries: + - id: system_metrics_knative_function_invocation_count + query_range: 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))' + label: invocations / minute + unit: requests diff --git a/config/routes/project.rb b/config/routes/project.rb index 1cb8f331f6f..93d168fc595 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -252,7 +252,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end namespace :serverless do - get '/functions/:environment_id/:id', to: 'functions#show' + scope :functions do + get '/:environment_id/:id', to: 'functions#show' + get '/:environment_id/:id/metrics', to: 'functions#metrics', as: :metrics + end + resources :functions, only: [:index] end diff --git a/db/migrate/20190326164045_import_common_metrics_knative.rb b/db/migrate/20190326164045_import_common_metrics_knative.rb new file mode 100644 index 00000000000..340ec1e1f75 --- /dev/null +++ b/db/migrate/20190326164045_import_common_metrics_knative.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class ImportCommonMetricsKnative < ActiveRecord::Migration[5.0] + include Gitlab::Database::MigrationHelpers + + require Rails.root.join('db/importers/common_metrics_importer.rb') + + DOWNTIME = false + + def up + Importers::CommonMetricsImporter.new.execute + end + + def down + # no-op + end +end diff --git a/db/schema.rb b/db/schema.rb index 1a50c6efbc7..ca5b04e810a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20190325165127) do +ActiveRecord::Schema.define(version: 20190326164045) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/doc/user/project/clusters/serverless/img/function-details-loaded.png b/doc/user/project/clusters/serverless/img/function-details-loaded.png Binary files differnew file mode 100644 index 00000000000..34465c5c087 --- /dev/null +++ b/doc/user/project/clusters/serverless/img/function-details-loaded.png diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index b72083e85df..5b7e9ef906f 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -301,3 +301,23 @@ The second to last line, labeled **Service domain** contains the URL for the dep browser to see the app live. ![knative app](img/knative-app.png) + +## Function details + +Go to the **Operations > Serverless** page and click on one of the function +rows to bring up the function details page. + +![function_details](img/function-details-loaded.png) + +The pod count will give you the number of pods running the serverless function instances on a given cluster. + +### Prometheus support + +For the Knative function invocations to appear, +[Prometheus must be installed](../index.md#installing-applications). + +Once Prometheus is installed, a message may appear indicating that the metrics data _is +loading or is not available at this time._ It will appear upon the first access of the +page, but should go away after a few seconds. If the message does not disappear, then it +is possible that GitLab is unable to connect to the Prometheus instance running on the +cluster. diff --git a/lib/gitlab/prometheus/queries/knative_invocation_query.rb b/lib/gitlab/prometheus/queries/knative_invocation_query.rb new file mode 100644 index 00000000000..2691abe46d6 --- /dev/null +++ b/lib/gitlab/prometheus/queries/knative_invocation_query.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Gitlab + module Prometheus + module Queries + class KnativeInvocationQuery < BaseQuery + include QueryAdditionalMetrics + + def query(serverless_function_id) + PrometheusMetric + .find_by_identifier(:system_metrics_knative_function_invocation_count) + .to_query_metric.tap do |q| + q.queries[0][:result] = run_query(q.queries[0][:query_range], context(serverless_function_id)) + end + end + + protected + + def context(function_id) + function = Serverless::Function.find_by_id(function_id) + { + function_name: function.name, + kube_namespace: function.namespace + } + end + + def run_query(query, context) + query %= context + client_query_range(query, start: 8.hours.ago.to_f, stop: Time.now.to_f) + end + + def self.transform_reactive_result(result) + result[:metrics] = result.delete :data + result + end + end + end + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4e756cf2095..711baff9f9a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -7238,9 +7238,24 @@ msgstr "" msgid "Serverless" msgstr "" +msgid "ServerlessDetails|Function invocation metrics require Prometheus to be installed first." +msgstr "" + +msgid "ServerlessDetails|Install Prometheus" +msgstr "" + +msgid "ServerlessDetails|Invocation metrics loading or not available at this time." +msgstr "" + +msgid "ServerlessDetails|Invocations" +msgstr "" + msgid "ServerlessDetails|Kubernetes Pods" msgstr "" +msgid "ServerlessDetails|More information" +msgstr "" + msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity." msgstr "" @@ -7256,9 +7271,6 @@ msgstr "" msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster." msgstr "" -msgid "Serverless|An error occurred while retrieving serverless components" -msgstr "" - msgid "Serverless|Getting started with serverless" msgstr "" diff --git a/spec/controllers/projects/serverless/functions_controller_spec.rb b/spec/controllers/projects/serverless/functions_controller_spec.rb index 276cf340962..782f5f272d9 100644 --- a/spec/controllers/projects/serverless/functions_controller_spec.rb +++ b/spec/controllers/projects/serverless/functions_controller_spec.rb @@ -76,6 +76,15 @@ describe Projects::Serverless::FunctionsController do end end + describe 'GET #metrics' do + context 'invalid data' do + it 'has a bad function name' do + get :metrics, params: params({ format: :json, environment_id: "*", id: "foo" }) + expect(response).to have_gitlab_http_status(204) + end + end + end + describe 'GET #index with data', :use_clean_rails_memory_store_caching do before do stub_kubeclient_service_pods diff --git a/spec/features/projects/serverless/functions_spec.rb b/spec/features/projects/serverless/functions_spec.rb index aa71669de98..e14934b1672 100644 --- a/spec/features/projects/serverless/functions_spec.rb +++ b/spec/features/projects/serverless/functions_spec.rb @@ -50,7 +50,7 @@ describe 'Functions', :js do end it 'sees an empty listing of serverless functions' do - expect(page).to have_selector('.gl-responsive-table-row') + expect(page).to have_selector('.empty-state') end end end diff --git a/spec/finders/projects/serverless/functions_finder_spec.rb b/spec/finders/projects/serverless/functions_finder_spec.rb index 35279906854..3ad38207da4 100644 --- a/spec/finders/projects/serverless/functions_finder_spec.rb +++ b/spec/finders/projects/serverless/functions_finder_spec.rb @@ -4,6 +4,7 @@ require 'spec_helper' describe Projects::Serverless::FunctionsFinder do include KubernetesHelpers + include PrometheusHelpers include ReactiveCachingHelpers let(:user) { create(:user) } @@ -24,12 +25,12 @@ describe Projects::Serverless::FunctionsFinder do describe 'retrieve data from knative' do it 'does not have knative installed' do - expect(described_class.new(project.clusters).execute).to be_empty + expect(described_class.new(project).execute).to be_empty end context 'has knative installed' do let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } - let(:finder) { described_class.new(project.clusters) } + let(:finder) { described_class.new(project) } it 'there are no functions' do expect(finder.execute).to be_empty @@ -58,13 +59,36 @@ describe Projects::Serverless::FunctionsFinder do expect(result).not_to be_empty expect(result["metadata"]["name"]).to be_eql(cluster.project.name) end + + it 'has metrics', :use_clean_rails_memory_store_caching do + end + end + + context 'has prometheus' do + let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) } + let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } + let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) } + let(:finder) { described_class.new(project) } + + before do + allow(finder).to receive(:prometheus_adapter).and_return(prometheus_adapter) + allow(prometheus_adapter).to receive(:query).and_return(prometheus_empty_body('matrix')) + end + + it 'is available' do + expect(finder.has_prometheus?("*")).to be true + end + + it 'has query data' do + expect(finder.invocation_metrics("*", cluster.project.name)).not_to be_nil + end end end describe 'verify if knative is installed' do context 'knative is not installed' do it 'does not have knative installed' do - expect(described_class.new(project.clusters).installed?).to be false + expect(described_class.new(project).installed?).to be false end end @@ -72,7 +96,7 @@ describe Projects::Serverless::FunctionsFinder do let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } it 'does have knative installed' do - expect(described_class.new(project.clusters).installed?).to be true + expect(described_class.new(project).installed?).to be true end end end diff --git a/spec/javascripts/serverless/components/area_spec.js b/spec/javascripts/serverless/components/area_spec.js new file mode 100644 index 00000000000..2be6ac3d268 --- /dev/null +++ b/spec/javascripts/serverless/components/area_spec.js @@ -0,0 +1,121 @@ +import { shallowMount } from '@vue/test-utils'; +import Area from '~/serverless/components/area.vue'; +import { mockNormalizedMetrics } from '../mock_data'; + +describe('Area component', () => { + const mockWidgets = 'mockWidgets'; + const mockGraphData = mockNormalizedMetrics; + let areaChart; + + beforeEach(() => { + areaChart = shallowMount(Area, { + propsData: { + graphData: mockGraphData, + containerWidth: 0, + }, + slots: { + default: mockWidgets, + }, + }); + }); + + afterEach(() => { + areaChart.destroy(); + }); + + it('renders chart title', () => { + expect(areaChart.find({ ref: 'graphTitle' }).text()).toBe(mockGraphData.title); + }); + + it('contains graph widgets from slot', () => { + expect(areaChart.find({ ref: 'graphWidgets' }).text()).toBe(mockWidgets); + }); + + describe('methods', () => { + describe('formatTooltipText', () => { + const mockDate = mockNormalizedMetrics.queries[0].result[0].values[0].time; + const generateSeriesData = type => ({ + seriesData: [ + { + componentSubType: type, + value: [mockDate, 4], + }, + ], + value: mockDate, + }); + + describe('series is of line type', () => { + beforeEach(() => { + areaChart.vm.formatTooltipText(generateSeriesData('line')); + }); + + it('formats tooltip title', () => { + expect(areaChart.vm.tooltipPopoverTitle).toBe('28 Feb 2019, 11:11AM'); + }); + + it('formats tooltip content', () => { + expect(areaChart.vm.tooltipPopoverContent).toBe('Invocations (requests): 4'); + }); + }); + + it('verify default interval value of 1', () => { + expect(areaChart.vm.getInterval).toBe(1); + }); + }); + + describe('onResize', () => { + const mockWidth = 233; + + beforeEach(() => { + spyOn(Element.prototype, 'getBoundingClientRect').and.callFake(() => ({ + width: mockWidth, + })); + areaChart.vm.onResize(); + }); + + it('sets area chart width', () => { + expect(areaChart.vm.width).toBe(mockWidth); + }); + }); + }); + + describe('computed', () => { + describe('chartData', () => { + it('utilizes all data points', () => { + expect(Object.keys(areaChart.vm.chartData)).toEqual(['requests']); + expect(areaChart.vm.chartData.requests.length).toBe(2); + }); + + it('creates valid data', () => { + const data = areaChart.vm.chartData.requests; + + expect( + data.filter( + datum => new Date(datum.time).getTime() > 0 && typeof datum.value === 'number', + ).length, + ).toBe(data.length); + }); + }); + + describe('generateSeries', () => { + it('utilizes correct time data', () => { + expect(areaChart.vm.generateSeries.data).toEqual([ + ['2019-02-28T11:11:38.756Z', 0], + ['2019-02-28T11:12:38.756Z', 0], + ]); + }); + }); + + describe('xAxisLabel', () => { + it('constructs a label for the chart x-axis', () => { + expect(areaChart.vm.xAxisLabel).toBe('invocations / minute'); + }); + }); + + describe('yAxisLabel', () => { + it('constructs a label for the chart y-axis', () => { + expect(areaChart.vm.yAxisLabel).toBe('Invocations (requests)'); + }); + }); + }); +}); diff --git a/spec/javascripts/serverless/components/environment_row_spec.js b/spec/javascripts/serverless/components/environment_row_spec.js index bdf7a714910..932d712dbec 100644 --- a/spec/javascripts/serverless/components/environment_row_spec.js +++ b/spec/javascripts/serverless/components/environment_row_spec.js @@ -1,81 +1,70 @@ -import Vue from 'vue'; - import environmentRowComponent from '~/serverless/components/environment_row.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import ServerlessStore from '~/serverless/stores/serverless_store'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data'; +import { translate } from '~/serverless/utils'; -const createComponent = (env, envName) => - mountComponent(Vue.extend(environmentRowComponent), { env, envName }); +const createComponent = (localVue, env, envName) => + shallowMount(environmentRowComponent, { localVue, propsData: { env, envName } }).vm; describe('environment row component', () => { describe('default global cluster case', () => { + let localVue; let vm; beforeEach(() => { - const store = new ServerlessStore(false, '/cluster_path', 'help_path'); - store.updateFunctionsFromServer(mockServerlessFunctions); - vm = createComponent(store.state.functions['*'], '*'); + localVue = createLocalVue(); + vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*'); }); + afterEach(() => vm.$destroy()); + it('has the correct envId', () => { expect(vm.envId).toEqual('env-global'); - vm.$destroy(); }); it('is open by default', () => { expect(vm.isOpenClass).toEqual({ 'is-open': true }); - vm.$destroy(); }); it('generates correct output', () => { - expect(vm.$el.querySelectorAll('li').length).toEqual(2); expect(vm.$el.id).toEqual('env-global'); expect(vm.$el.classList.contains('is-open')).toBe(true); expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*'); - - vm.$destroy(); }); it('opens and closes correctly', () => { expect(vm.isOpen).toBe(true); vm.toggleOpen(); - Vue.nextTick(() => { - expect(vm.isOpen).toBe(false); - }); - vm.$destroy(); + expect(vm.isOpen).toBe(false); }); }); describe('default named cluster case', () => { let vm; + let localVue; beforeEach(() => { - const store = new ServerlessStore(false, '/cluster_path', 'help_path'); - store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv); - vm = createComponent(store.state.functions.test, 'test'); + localVue = createLocalVue(); + vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test'); }); + afterEach(() => vm.$destroy()); + it('has the correct envId', () => { expect(vm.envId).toEqual('env-test'); - vm.$destroy(); }); it('is open by default', () => { expect(vm.isOpenClass).toEqual({ 'is-open': true }); - vm.$destroy(); }); it('generates correct output', () => { - expect(vm.$el.querySelectorAll('li').length).toEqual(1); expect(vm.$el.id).toEqual('env-test'); expect(vm.$el.classList.contains('is-open')).toBe(true); expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test'); - - vm.$destroy(); }); }); }); diff --git a/spec/javascripts/serverless/components/function_details_spec.js b/spec/javascripts/serverless/components/function_details_spec.js new file mode 100644 index 00000000000..a29d4a296ef --- /dev/null +++ b/spec/javascripts/serverless/components/function_details_spec.js @@ -0,0 +1,113 @@ +import Vuex from 'vuex'; + +import functionDetailsComponent from '~/serverless/components/function_details.vue'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createStore } from '~/serverless/store'; + +describe('functionDetailsComponent', () => { + let localVue; + let component; + let store; + + beforeEach(() => { + localVue = createLocalVue(); + localVue.use(Vuex); + + store = createStore(); + }); + + afterEach(() => { + component.vm.$destroy(); + }); + + describe('Verify base functionality', () => { + const serviceStub = { + name: 'test', + description: 'a description', + environment: '*', + url: 'http://service.com/test', + namespace: 'test-ns', + podcount: 0, + metricsUrl: '/metrics', + }; + + it('has a name, description, URL, and no pods loaded', () => { + component = shallowMount(functionDetailsComponent, { + localVue, + store, + propsData: { + func: serviceStub, + hasPrometheus: false, + clustersPath: '/clusters', + helpPath: '/help', + }, + }); + + expect( + component.vm.$el.querySelector('.serverless-function-name').innerHTML.trim(), + ).toContain('test'); + + expect( + component.vm.$el.querySelector('.serverless-function-description').innerHTML.trim(), + ).toContain('a description'); + + expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain( + 'No pods loaded at this time.', + ); + }); + + it('has a pods loaded', () => { + serviceStub.podcount = 1; + + component = shallowMount(functionDetailsComponent, { + localVue, + store, + propsData: { + func: serviceStub, + hasPrometheus: false, + clustersPath: '/clusters', + helpPath: '/help', + }, + }); + + expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('1 pod in use'); + }); + + it('has multiple pods loaded', () => { + serviceStub.podcount = 3; + + component = shallowMount(functionDetailsComponent, { + localVue, + store, + propsData: { + func: serviceStub, + hasPrometheus: false, + clustersPath: '/clusters', + helpPath: '/help', + }, + }); + + expect(component.vm.$el.querySelector('p').innerHTML.trim()).toContain('3 pods in use'); + }); + + it('can support a missing description', () => { + serviceStub.description = null; + + component = shallowMount(functionDetailsComponent, { + localVue, + store, + propsData: { + func: serviceStub, + hasPrometheus: false, + clustersPath: '/clusters', + helpPath: '/help', + }, + }); + + expect( + component.vm.$el.querySelector('.serverless-function-description').querySelector('div') + .innerHTML.length, + ).toEqual(0); + }); + }); +}); diff --git a/spec/javascripts/serverless/components/function_row_spec.js b/spec/javascripts/serverless/components/function_row_spec.js index 6933a8f6c87..3987e1753bd 100644 --- a/spec/javascripts/serverless/components/function_row_spec.js +++ b/spec/javascripts/serverless/components/function_row_spec.js @@ -1,11 +1,9 @@ -import Vue from 'vue'; - import functionRowComponent from '~/serverless/components/function_row.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { shallowMount } from '@vue/test-utils'; import { mockServerlessFunction } from '../mock_data'; -const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func }); +const createComponent = func => shallowMount(functionRowComponent, { propsData: { func } }).vm; describe('functionRowComponent', () => { it('Parses the function details correctly', () => { @@ -13,10 +11,7 @@ describe('functionRowComponent', () => { expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name); expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image); - expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null); - expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual( - mockServerlessFunction.url, - ); + expect(vm.$el.querySelector('timeago-stub').getAttribute('time')).not.toBe(null); vm.$destroy(); }); @@ -25,8 +20,6 @@ describe('functionRowComponent', () => { const vm = createComponent(mockServerlessFunction); expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row - expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image - expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar vm.$destroy(); }); diff --git a/spec/javascripts/serverless/components/functions_spec.js b/spec/javascripts/serverless/components/functions_spec.js index 85cfe71281f..c32978ea58a 100644 --- a/spec/javascripts/serverless/components/functions_spec.js +++ b/spec/javascripts/serverless/components/functions_spec.js @@ -1,68 +1,101 @@ -import Vue from 'vue'; +import Vuex from 'vuex'; import functionsComponent from '~/serverless/components/functions.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; -import ServerlessStore from '~/serverless/stores/serverless_store'; - +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { createStore } from '~/serverless/store'; import { mockServerlessFunctions } from '../mock_data'; -const createComponent = ( - functions, - installed = true, - loadingData = true, - hasFunctionData = true, -) => { - const component = Vue.extend(functionsComponent); +describe('functionsComponent', () => { + let component; + let store; + let localVue; + + beforeEach(() => { + localVue = createLocalVue(); + localVue.use(Vuex); - return mountComponent(component, { - functions, - installed, - clustersPath: '/testClusterPath', - helpPath: '/helpPath', - loadingData, - hasFunctionData, + store = createStore(); }); -}; -describe('functionsComponent', () => { - it('should render empty state when Knative is not installed', () => { - const vm = createComponent({}, false); + afterEach(() => { + component.vm.$destroy(); + }); - expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true); - expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual( - 'Getting started with serverless', - ); + it('should render empty state when Knative is not installed', () => { + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + installed: false, + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); - vm.$destroy(); + expect(component.vm.$el.querySelector('emptystate-stub')).not.toBe(null); }); it('should render a loading component', () => { - const vm = createComponent({}); + store.dispatch('requestFunctionsLoading'); + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + installed: true, + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); - expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null); - expect(vm.$el.querySelector('div.animation-container')).not.toBe(null); + expect(component.vm.$el.querySelector('glloadingicon-stub')).not.toBe(null); }); it('should render empty state when there is no function data', () => { - const vm = createComponent({}, true, false, false); + store.dispatch('receiveFunctionsNoDataSuccess'); + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + installed: true, + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); expect( - vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'), + component.vm.$el + .querySelector('.empty-state, .js-empty-state') + .classList.contains('js-empty-state'), ).toBe(true); - expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual( + expect(component.vm.$el.querySelector('.state-title, .text-center').innerHTML.trim()).toEqual( 'No functions available', ); - - vm.$destroy(); }); it('should render the functions list', () => { - const store = new ServerlessStore(false, '/cluster_path', 'help_path'); - store.updateFunctionsFromServer(mockServerlessFunctions); - const vm = createComponent(store.state.functions, true, false); + component = shallowMount(functionsComponent, { + localVue, + store, + propsData: { + installed: true, + clustersPath: '', + helpPath: '', + statusPath: '', + }, + sync: false, + }); + + component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions); - expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null); - expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true); + return component.vm.$nextTick().then(() => { + expect(component.vm.$el.querySelector('environmentrow-stub')).not.toBe(null); + }); }); }); diff --git a/spec/javascripts/serverless/components/missing_prometheus_spec.js b/spec/javascripts/serverless/components/missing_prometheus_spec.js new file mode 100644 index 00000000000..77aca03772b --- /dev/null +++ b/spec/javascripts/serverless/components/missing_prometheus_spec.js @@ -0,0 +1,37 @@ +import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue'; +import { shallowMount } from '@vue/test-utils'; + +const createComponent = missingData => + shallowMount(missingPrometheusComponent, { + propsData: { + clustersPath: '/clusters', + helpPath: '/help', + missingData, + }, + }).vm; + +describe('missingPrometheusComponent', () => { + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + it('should render missing prometheus message', () => { + vm = createComponent(false); + + expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( + 'Function invocation metrics require Prometheus to be installed first.', + ); + + expect(vm.$el.querySelector('glbutton-stub').getAttribute('variant')).toEqual('success'); + }); + + it('should render no prometheus data message', () => { + vm = createComponent(true); + + expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( + 'Invocation metrics loading or not available at this time.', + ); + }); +}); diff --git a/spec/javascripts/serverless/components/pod_box_spec.js b/spec/javascripts/serverless/components/pod_box_spec.js new file mode 100644 index 00000000000..69ac1a2bb5f --- /dev/null +++ b/spec/javascripts/serverless/components/pod_box_spec.js @@ -0,0 +1,22 @@ +import podBoxComponent from '~/serverless/components/pod_box.vue'; +import { shallowMount } from '@vue/test-utils'; + +const createComponent = count => + shallowMount(podBoxComponent, { + propsData: { + count, + }, + }).vm; + +describe('podBoxComponent', () => { + it('should render three boxes', () => { + const count = 3; + const vm = createComponent(count); + const rects = vm.$el.querySelectorAll('rect'); + + expect(rects.length).toEqual(3); + expect(parseInt(rects[2].getAttribute('x'), 10)).toEqual(40); + + vm.$destroy(); + }); +}); diff --git a/spec/javascripts/serverless/components/url_spec.js b/spec/javascripts/serverless/components/url_spec.js index 21a879a49bb..08c3e4146b1 100644 --- a/spec/javascripts/serverless/components/url_spec.js +++ b/spec/javascripts/serverless/components/url_spec.js @@ -1,15 +1,13 @@ import Vue from 'vue'; - import urlComponent from '~/serverless/components/url.vue'; -import mountComponent from 'spec/helpers/vue_mount_component_helper'; - -const createComponent = uri => { - const component = Vue.extend(urlComponent); +import { shallowMount } from '@vue/test-utils'; - return mountComponent(component, { - uri, - }); -}; +const createComponent = uri => + shallowMount(Vue.extend(urlComponent), { + propsData: { + uri, + }, + }).vm; describe('urlComponent', () => { it('should render correctly', () => { @@ -17,9 +15,7 @@ describe('urlComponent', () => { const vm = createComponent(uri); expect(vm.$el.classList.contains('clipboard-group')).toBe(true); - expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual( - uri, - ); + expect(vm.$el.querySelector('clipboardbutton-stub').getAttribute('text')).toEqual(uri); expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri); diff --git a/spec/javascripts/serverless/mock_data.js b/spec/javascripts/serverless/mock_data.js index ecd393b174c..a2c18616324 100644 --- a/spec/javascripts/serverless/mock_data.js +++ b/spec/javascripts/serverless/mock_data.js @@ -77,3 +77,60 @@ export const mockMultilineServerlessFunction = { description: 'testfunc1\nA test service line\\nWith additional services', image: 'knative-test-container-buildtemplate', }; + +export const mockMetrics = { + success: true, + last_update: '2019-02-28T19:11:38.926Z', + metrics: { + id: 22, + title: 'Knative function invocations', + required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'], + weight: 0, + y_label: 'Invocations', + queries: [ + { + query_range: + 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))', + unit: 'requests', + label: 'invocations / minute', + result: [ + { + metric: {}, + values: [[1551352298.756, '0'], [1551352358.756, '0']], + }, + ], + }, + ], + }, +}; + +export const mockNormalizedMetrics = { + id: 22, + title: 'Knative function invocations', + required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'], + weight: 0, + y_label: 'Invocations', + queries: [ + { + query_range: + 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))', + unit: 'requests', + label: 'invocations / minute', + result: [ + { + metric: {}, + values: [ + { + time: '2019-02-28T11:11:38.756Z', + value: 0, + }, + { + time: '2019-02-28T11:12:38.756Z', + value: 0, + }, + ], + }, + ], + }, + ], +}; diff --git a/spec/javascripts/serverless/store/actions_spec.js b/spec/javascripts/serverless/store/actions_spec.js new file mode 100644 index 00000000000..602798573e9 --- /dev/null +++ b/spec/javascripts/serverless/store/actions_spec.js @@ -0,0 +1,88 @@ +import MockAdapter from 'axios-mock-adapter'; +import statusCodes from '~/lib/utils/http_status'; +import { fetchFunctions, fetchMetrics } from '~/serverless/store/actions'; +import { mockServerlessFunctions, mockMetrics } from '../mock_data'; +import axios from '~/lib/utils/axios_utils'; +import testAction from '../../helpers/vuex_action_helper'; +import { adjustMetricQuery } from '../utils'; + +describe('ServerlessActions', () => { + describe('fetchFunctions', () => { + it('should successfully fetch functions', done => { + const endpoint = '/functions'; + const mock = new MockAdapter(axios); + mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockServerlessFunctions)); + + testAction( + fetchFunctions, + { functionsPath: endpoint }, + {}, + [], + [ + { type: 'requestFunctionsLoading' }, + { type: 'receiveFunctionsSuccess', payload: mockServerlessFunctions }, + ], + () => { + mock.restore(); + done(); + }, + ); + }); + + it('should successfully retry', done => { + const endpoint = '/functions'; + const mock = new MockAdapter(axios); + mock.onGet(endpoint).reply(statusCodes.NO_CONTENT); + + testAction( + fetchFunctions, + { functionsPath: endpoint }, + {}, + [], + [{ type: 'requestFunctionsLoading' }], + () => { + mock.restore(); + done(); + }, + ); + }); + }); + + describe('fetchMetrics', () => { + it('should return no prometheus', done => { + const endpoint = '/metrics'; + const mock = new MockAdapter(axios); + mock.onGet(endpoint).reply(statusCodes.NO_CONTENT); + + testAction( + fetchMetrics, + { metricsPath: endpoint, hasPrometheus: false }, + {}, + [], + [{ type: 'receiveMetricsNoPrometheus' }], + () => { + mock.restore(); + done(); + }, + ); + }); + + it('should successfully fetch metrics', done => { + const endpoint = '/metrics'; + const mock = new MockAdapter(axios); + mock.onGet(endpoint).reply(statusCodes.OK, JSON.stringify(mockMetrics)); + + testAction( + fetchMetrics, + { metricsPath: endpoint, hasPrometheus: true }, + {}, + [], + [{ type: 'receiveMetricsSuccess', payload: adjustMetricQuery(mockMetrics) }], + () => { + mock.restore(); + done(); + }, + ); + }); + }); +}); diff --git a/spec/javascripts/serverless/store/getters_spec.js b/spec/javascripts/serverless/store/getters_spec.js new file mode 100644 index 00000000000..fb549c8f153 --- /dev/null +++ b/spec/javascripts/serverless/store/getters_spec.js @@ -0,0 +1,43 @@ +import serverlessState from '~/serverless/store/state'; +import * as getters from '~/serverless/store/getters'; +import { mockServerlessFunctions } from '../mock_data'; + +describe('Serverless Store Getters', () => { + let state; + + beforeEach(() => { + state = serverlessState; + }); + + describe('hasPrometheusMissingData', () => { + it('should return false if Prometheus is not installed', () => { + state.hasPrometheus = false; + + expect(getters.hasPrometheusMissingData(state)).toEqual(false); + }); + + it('should return false if Prometheus is installed and there is data', () => { + state.hasPrometheusData = true; + + expect(getters.hasPrometheusMissingData(state)).toEqual(false); + }); + + it('should return true if Prometheus is installed and there is no data', () => { + state.hasPrometheus = true; + state.hasPrometheusData = false; + + expect(getters.hasPrometheusMissingData(state)).toEqual(true); + }); + }); + + describe('getFunctions', () => { + it('should translate the raw function array to group the functions per environment scope', () => { + state.functions = mockServerlessFunctions; + + const funcs = getters.getFunctions(state); + + expect(Object.keys(funcs)).toContain('*'); + expect(funcs['*'].length).toEqual(2); + }); + }); +}); diff --git a/spec/javascripts/serverless/store/mutations_spec.js b/spec/javascripts/serverless/store/mutations_spec.js new file mode 100644 index 00000000000..ca3053e5c38 --- /dev/null +++ b/spec/javascripts/serverless/store/mutations_spec.js @@ -0,0 +1,86 @@ +import mutations from '~/serverless/store/mutations'; +import * as types from '~/serverless/store/mutation_types'; +import { mockServerlessFunctions, mockMetrics } from '../mock_data'; + +describe('ServerlessMutations', () => { + describe('Functions List Mutations', () => { + it('should ensure loading is true', () => { + const state = {}; + + mutations[types.REQUEST_FUNCTIONS_LOADING](state); + + expect(state.isLoading).toEqual(true); + }); + + it('should set proper state once functions are loaded', () => { + const state = {}; + + mutations[types.RECEIVE_FUNCTIONS_SUCCESS](state, mockServerlessFunctions); + + expect(state.isLoading).toEqual(false); + expect(state.hasFunctionData).toEqual(true); + expect(state.functions).toEqual(mockServerlessFunctions); + }); + + it('should ensure loading has stopped and hasFunctionData is false when there are no functions available', () => { + const state = {}; + + mutations[types.RECEIVE_FUNCTIONS_NODATA_SUCCESS](state); + + expect(state.isLoading).toEqual(false); + expect(state.hasFunctionData).toEqual(false); + expect(state.functions).toBe(undefined); + }); + + it('should ensure loading has stopped, and an error is raised', () => { + const state = {}; + + mutations[types.RECEIVE_FUNCTIONS_ERROR](state, 'sample error'); + + expect(state.isLoading).toEqual(false); + expect(state.hasFunctionData).toEqual(false); + expect(state.functions).toBe(undefined); + expect(state.error).not.toBe(undefined); + }); + }); + + describe('Function Details Metrics Mutations', () => { + it('should ensure isLoading and hasPrometheus data flags indicate data is loaded', () => { + const state = {}; + + mutations[types.RECEIVE_METRICS_SUCCESS](state, mockMetrics); + + expect(state.isLoading).toEqual(false); + expect(state.hasPrometheusData).toEqual(true); + expect(state.graphData).toEqual(mockMetrics); + }); + + it('should ensure isLoading and hasPrometheus data flags are cleared indicating no functions available', () => { + const state = {}; + + mutations[types.RECEIVE_METRICS_NODATA_SUCCESS](state); + + expect(state.isLoading).toEqual(false); + expect(state.hasPrometheusData).toEqual(false); + expect(state.graphData).toBe(undefined); + }); + + it('should properly indicate an error', () => { + const state = {}; + + mutations[types.RECEIVE_METRICS_ERROR](state, 'sample error'); + + expect(state.hasPrometheusData).toEqual(false); + expect(state.error).not.toBe(undefined); + }); + + it('should properly indicate when prometheus is installed', () => { + const state = {}; + + mutations[types.RECEIVE_METRICS_NO_PROMETHEUS](state); + + expect(state.hasPrometheus).toEqual(false); + expect(state.hasPrometheusData).toEqual(false); + }); + }); +}); diff --git a/spec/javascripts/serverless/stores/serverless_store_spec.js b/spec/javascripts/serverless/stores/serverless_store_spec.js deleted file mode 100644 index 72fd903d7d1..00000000000 --- a/spec/javascripts/serverless/stores/serverless_store_spec.js +++ /dev/null @@ -1,36 +0,0 @@ -import ServerlessStore from '~/serverless/stores/serverless_store'; -import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data'; - -describe('Serverless Functions Store', () => { - let store; - - beforeEach(() => { - store = new ServerlessStore(false, '/cluster_path', 'help_path'); - }); - - describe('#updateFunctionsFromServer', () => { - it('should pass an empty hash object', () => { - store.updateFunctionsFromServer(); - - expect(store.state.functions).toEqual({}); - }); - - it('should group functions to one global environment', () => { - const mockServerlessData = mockServerlessFunctions; - store.updateFunctionsFromServer(mockServerlessData); - - expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*'])); - expect(store.state.functions['*'].length).toEqual(2); - }); - - it('should group functions to multiple environments', () => { - const mockServerlessData = mockServerlessFunctionsDiffEnv; - store.updateFunctionsFromServer(mockServerlessData); - - expect(Object.keys(store.state.functions)).toEqual(jasmine.objectContaining(['*'])); - expect(store.state.functions['*'].length).toEqual(1); - expect(store.state.functions.test.length).toEqual(1); - expect(store.state.functions.test[0].name).toEqual('testfunc2'); - }); - }); -}); diff --git a/spec/javascripts/serverless/utils.js b/spec/javascripts/serverless/utils.js new file mode 100644 index 00000000000..5ce2e37d493 --- /dev/null +++ b/spec/javascripts/serverless/utils.js @@ -0,0 +1,20 @@ +export const adjustMetricQuery = data => { + const updatedMetric = data.metrics; + + const queries = data.metrics.queries.map(query => ({ + ...query, + result: query.result.map(result => ({ + ...result, + values: result.values.map(([timestamp, value]) => ({ + time: new Date(timestamp * 1000).toISOString(), + value: Number(value), + })), + })), + })); + + updatedMetric.queries = queries; + return updatedMetric; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb new file mode 100644 index 00000000000..7f6283715f2 --- /dev/null +++ b/spec/lib/gitlab/prometheus/queries/knative_invocation_query_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Prometheus::Queries::KnativeInvocationQuery do + include PrometheusHelpers + + let(:project) { create(:project) } + let(:serverless_func) { Serverless::Function.new(project, 'test-name', 'test-ns') } + + let(:client) { double('prometheus_client') } + subject { described_class.new(client) } + + context 'verify queries' do + before do + allow(PrometheusMetric).to receive(:find_by_identifier).and_return(create(:prometheus_metric, query: prometheus_istio_query('test-name', 'test-ns'))) + allow(client).to receive(:query_range) + end + + it 'has the query, but no data' do + results = subject.query(serverless_func.id) + + expect(results.queries[0][:query_range]).to eql('floor(sum(rate(istio_revision_request_count{destination_configuration="test-name", destination_namespace="test-ns"}[1m])*30))') + end + end +end diff --git a/spec/models/serverless/function_spec.rb b/spec/models/serverless/function_spec.rb new file mode 100644 index 00000000000..1854d5f9415 --- /dev/null +++ b/spec/models/serverless/function_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Serverless::Function do + let(:project) { create(:project) } + let(:func) { described_class.new(project, 'test', 'test-ns') } + + it 'has a proper id' do + expect(func.id).to eql("#{project.id}/test/test-ns") + expect(func.name).to eql("test") + expect(func.namespace).to eql("test-ns") + end + + it 'can decode an identifier' do + f = described_class.find_by_id("#{project.id}/testfunc/dummy-ns") + + expect(f.name).to eql("testfunc") + expect(f.namespace).to eql("dummy-ns") + end +end diff --git a/spec/support/helpers/prometheus_helpers.rb b/spec/support/helpers/prometheus_helpers.rb index 08d1d7a6059..87f825152cf 100644 --- a/spec/support/helpers/prometheus_helpers.rb +++ b/spec/support/helpers/prometheus_helpers.rb @@ -7,6 +7,10 @@ module PrometheusHelpers %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100} end + def prometheus_istio_query(function_name, kube_namespace) + %{floor(sum(rate(istio_revision_request_count{destination_configuration=\"#{function_name}\", destination_namespace=\"#{kube_namespace}\"}[1m])*30))} + end + def prometheus_ping_url(prometheus_query) query = { query: prometheus_query }.to_query |