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 /app | |
parent | 8e33e7cf474b61bbc684d993d86cb5aa775a01d0 (diff) | |
download | gitlab-ce-b77fe7db3e885edca14c862f362e2bbd43f0e498.tar.gz |
Add Knative metrics to Prometheus
Diffstat (limited to 'app')
23 files changed, 629 insertions, 186 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 |