summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorChris Baumbauer <cab@cabnetworks.net>2019-04-06 02:02:39 +0000
committerMike Greiling <mike@pixelcog.com>2019-04-06 02:02:39 +0000
commitb77fe7db3e885edca14c862f362e2bbd43f0e498 (patch)
treecd984b8bb900a6b3e37c8f6106101ba8617bf524 /app
parent8e33e7cf474b61bbc684d993d86cb5aa775a01d0 (diff)
downloadgitlab-ce-b77fe7db3e885edca14c862f362e2bbd43f0e498.tar.gz
Add Knative metrics to Prometheus
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/serverless/components/area.vue146
-rw-r--r--app/assets/javascripts/serverless/components/function_details.vue54
-rw-r--r--app/assets/javascripts/serverless/components/function_row.vue5
-rw-r--r--app/assets/javascripts/serverless/components/functions.vue44
-rw-r--r--app/assets/javascripts/serverless/components/missing_prometheus.vue63
-rw-r--r--app/assets/javascripts/serverless/constants.js3
-rw-r--r--app/assets/javascripts/serverless/serverless_bundle.js125
-rw-r--r--app/assets/javascripts/serverless/services/get_functions_service.js11
-rw-r--r--app/assets/javascripts/serverless/store/actions.js113
-rw-r--r--app/assets/javascripts/serverless/store/getters.js10
-rw-r--r--app/assets/javascripts/serverless/store/index.js18
-rw-r--r--app/assets/javascripts/serverless/store/mutation_types.js9
-rw-r--r--app/assets/javascripts/serverless/store/mutations.js38
-rw-r--r--app/assets/javascripts/serverless/store/state.js13
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_details_store.js11
-rw-r--r--app/assets/javascripts/serverless/stores/serverless_store.js29
-rw-r--r--app/assets/javascripts/serverless/utils.js23
-rw-r--r--app/controllers/projects/serverless/functions_controller.rb23
-rw-r--r--app/finders/projects/serverless/functions_finder.rb28
-rw-r--r--app/models/serverless/function.rb26
-rw-r--r--app/serializers/projects/serverless/service_entity.rb7
-rw-r--r--app/views/projects/serverless/functions/index.html.haml5
-rw-r--r--app/views/projects/serverless/functions/show.html.haml11
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