Commit b77fe7db authored by Chris Baumbauer's avatar Chris Baumbauer Committed by Mike Greiling

Add Knative metrics to Prometheus

parent 8e33e7cf
<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>
<script> <script>
import _ from 'underscore';
import { mapState, mapActions, mapGetters } from 'vuex';
import PodBox from './pod_box.vue'; import PodBox from './pod_box.vue';
import Url from './url.vue'; import Url from './url.vue';
import AreaChart from './area.vue';
import MissingPrometheus from './missing_prometheus.vue';
export default { export default {
components: { components: {
PodBox, PodBox,
Url, Url,
AreaChart,
MissingPrometheus,
}, },
props: { props: {
func: { func: {
type: Object, type: Object,
required: true, required: true,
}, },
hasPrometheus: {
type: Boolean,
required: false,
default: false,
},
clustersPath: {
type: String,
required: true,
},
helpPath: {
type: String,
required: true,
},
},
data() {
return {
elWidth: 0,
};
}, },
computed: { computed: {
name() { name() {
return this.func.name; return this.func.name;
}, },
description() { description() {
return this.func.description; return _.isString(this.func.description) ? this.func.description : '';
}, },
funcUrl() { funcUrl() {
return this.func.url; return this.func.url;
}, },
podCount() { 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> </script>
<template> <template>
<section id="serverless-function-details"> <section id="serverless-function-details">
<h3>{{ name }}</h3> <h3 class="serverless-function-name">{{ name }}</h3>
<div class="append-bottom-default"> <div class="append-bottom-default serverless-function-description">
<div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div> <div v-for="(line, index) in description.split('\n')" :key="index">{{ line }}</div>
</div> </div>
<url :uri="funcUrl" /> <url :uri="funcUrl" />
...@@ -52,5 +90,13 @@ export default { ...@@ -52,5 +90,13 @@ export default {
</p> </p>
</div> </div>
<div v-else><p>No pods loaded at this time.</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> </section>
</template> </template>
<script> <script>
import _ from 'underscore';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
import Url from './url.vue'; import Url from './url.vue';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
...@@ -19,6 +20,10 @@ export default { ...@@ -19,6 +20,10 @@ export default {
return this.func.name; return this.func.name;
}, },
description() { description() {
if (!_.isString(this.func.description)) {
return '';
}
const desc = this.func.description.split('\n'); const desc = this.func.description.split('\n');
if (desc.length > 1) { if (desc.length > 1) {
return desc[1]; return desc[1];
......
<script> <script>
import { GlSkeletonLoading } from '@gitlab/ui'; import { mapState, mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import FunctionRow from './function_row.vue'; import FunctionRow from './function_row.vue';
import EnvironmentRow from './environment_row.vue'; import EnvironmentRow from './environment_row.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
...@@ -9,14 +10,9 @@ export default { ...@@ -9,14 +10,9 @@ export default {
EnvironmentRow, EnvironmentRow,
FunctionRow, FunctionRow,
EmptyState, EmptyState,
GlSkeletonLoading, GlLoadingIcon,
}, },
props: { props: {
functions: {
type: Object,
required: true,
default: () => ({}),
},
installed: { installed: {
type: Boolean, type: Boolean,
required: true, required: true,
...@@ -29,16 +25,22 @@ export default { ...@@ -29,16 +25,22 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
loadingData: { statusPath: {
type: Boolean, type: String,
required: false, required: true,
default: true,
}, },
hasFunctionData: {
type: Boolean,
required: false,
default: true,
}, },
computed: {
...mapState(['isLoading', 'hasFunctionData']),
...mapGetters(['getFunctions']),
},
created() {
this.fetchFunctions({
functionsPath: this.statusPath,
});
},
methods: {
...mapActions(['fetchFunctions']),
}, },
}; };
</script> </script>
...@@ -47,14 +49,16 @@ export default { ...@@ -47,14 +49,16 @@ export default {
<section id="serverless-functions"> <section id="serverless-functions">
<div v-if="installed"> <div v-if="installed">
<div v-if="hasFunctionData"> <div v-if="hasFunctionData">
<template v-if="loadingData"> <gl-loading-icon
<div v-for="j in 3" :key="j" class="gl-responsive-table-row"><gl-skeleton-loading /></div> v-if="isLoading"
</template> :size="2"
class="prepend-top-default append-bottom-default"
/>
<template v-else> <template v-else>
<div class="groups-list-tree-container"> <div class="groups-list-tree-container">
<ul class="content-list group-list-tree"> <ul class="content-list group-list-tree">
<environment-row <environment-row
v-for="(env, index) in functions" v-for="(env, index) in getFunctions"
:key="index" :key="index"
:env="env" :env="env"
:env-name="index" :env-name="index"
......
<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>
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
import Visibility from 'visibilityjs';
import Vue from 'vue'; 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 Functions from './components/functions.vue';
import FunctionDetails from './components/function_details.vue'; import FunctionDetails from './components/function_details.vue';
import { createStore } from './store';
export default class Serverless { export default class Serverless {
constructor() { constructor() {
...@@ -19,10 +13,12 @@ export default class Serverless { ...@@ -19,10 +13,12 @@ export default class Serverless {
serviceUrl, serviceUrl,
serviceNamespace, serviceNamespace,
servicePodcount, servicePodcount,
serviceMetricsUrl,
prometheus,
clustersPath,
helpPath,
} = document.querySelector('.js-serverless-function-details-page').dataset; } = document.querySelector('.js-serverless-function-details-page').dataset;
const el = document.querySelector('#js-serverless-function-details'); const el = document.querySelector('#js-serverless-function-details');
this.store = new ServerlessDetailsStore();
const { store } = this;
const service = { const service = {
name: serviceName, name: serviceName,
...@@ -31,20 +27,19 @@ export default class Serverless { ...@@ -31,20 +27,19 @@ export default class Serverless {
url: serviceUrl, url: serviceUrl,
namespace: serviceNamespace, namespace: serviceNamespace,
podcount: servicePodcount, podcount: servicePodcount,
metricsUrl: serviceMetricsUrl,
}; };
this.store.updateDetailedFunction(service);
this.functionDetails = new Vue({ this.functionDetails = new Vue({
el, el,
data() { store: createStore(),
return {
state: store.state,
};
},
render(createElement) { render(createElement) {
return createElement(FunctionDetails, { return createElement(FunctionDetails, {
props: { props: {
func: this.state.functionDetail, func: service,
hasPrometheus: prometheus !== undefined,
clustersPath,
helpPath,
}, },
}); });
}, },
...@@ -54,95 +49,27 @@ export default class Serverless { ...@@ -54,95 +49,27 @@ export default class Serverless {
'.js-serverless-functions-page', '.js-serverless-functions-page',
).dataset; ).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'); const el = document.querySelector('#js-serverless-functions');
this.functions = new Vue({ this.functions = new Vue({
el, el,
data() { store: createStore(),
return {
state: store.state,
};
},
render(createElement) { render(createElement) {
return createElement(Functions, { return createElement(Functions, {
props: { props: {
functions: this.state.functions, installed: installed !== undefined,
installed: this.state.installed, clustersPath,
clustersPath: this.state.clustersPath, helpPath,
helpPath: this.state.helpPath, statusPath,
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();
}
}
}
static handleError() {
Flash(s__('Serverless|An error occurred while retrieving serverless components'));
} }
destroy() { destroy() {
this.destroyed = true; this.destroyed = true;
if (this.poll) {
this.poll.stop();
}
this.functions.$destroy(); this.functions.$destroy();
this.functionDetails.$destroy(); this.functionDetails.$destroy();
} }
......
import axios from '~/lib/utils/axios_utils';
export default class GetFunctionsService {
constructor(endpoint) {
this.endpoint = endpoint;
}
fetchData() {
return axios.get(this.endpoint);
}
}
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 () => {};
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 () => {};
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();
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';
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;
},
};
export default () => ({
error: null,
isLoading: true,
// functions
functions: [],
hasFunctionData: true,
// function_details
hasPrometheus: true,
hasPrometheusData: false,
graphData: {},
});
export default class ServerlessDetailsStore {
constructor() {
this.state = {
functionDetail: {},
};
}
updateDetailedFunction(func) {
this.state.functionDetail = func;
}
}
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;
}
}
// 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 () => {};
...@@ -7,19 +7,14 @@ module Projects ...@@ -7,19 +7,14 @@ module Projects
before_action :authorize_read_cluster! before_action :authorize_read_cluster!
INDEX_PRIMING_INTERVAL = 15_000
INDEX_POLLING_INTERVAL = 60_000
def index def index
respond_to do |format| respond_to do |format|
format.json do format.json do
functions = finder.execute functions = finder.execute
if functions.any? if functions.any?
Gitlab::PollingInterval.set_header(response, interval: INDEX_POLLING_INTERVAL)
render json: serialize_function(functions) render json: serialize_function(functions)
else else
Gitlab::PollingInterval.set_header(response, interval: INDEX_PRIMING_INTERVAL)
head :no_content head :no_content
end end
end end
...@@ -33,6 +28,8 @@ module Projects ...@@ -33,6 +28,8 @@ module Projects
def show def show
@service = serialize_function(finder.service(params[:environment_id], params[:id])) @service = serialize_function(finder.service(params[:environment_id], params[:id]))
@prometheus = finder.has_prometheus?(params[:environment_id])
return not_found if @service.nil? return not_found if @service.nil?
respond_to do |format| respond_to do |format|
...@@ -44,10 +41,24 @@ module Projects ...@@ -44,10 +41,24 @@ module Projects
end end
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 private
def finder def finder
Projects::Serverless::FunctionsFinder.new(project.clusters) Projects::Serverless::FunctionsFinder.new(project)
end end
def serialize_function(function) def serialize_function(function)
......
...@@ -3,8 +3,9 @@ ...@@ -3,8 +3,9 @@
module Projects module Projects
module Serverless module Serverless
class FunctionsFinder class FunctionsFinder
def initialize(clusters) def initialize(project)
@clusters = clusters @clusters = project.clusters
@project = project
end end
def execute def execute
...@@ -19,6 +20,23 @@ module Projects ...@@ -19,6 +20,23 @@ module Projects
knative_service(environment_scope, name)&.first knative_service(environment_scope, name)&.first
end 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 private
def knative_service(environment_scope, name) def knative_service(environment_scope, name)
...@@ -55,6 +73,12 @@ module Projects ...@@ -55,6 +73,12 @@ module Projects
def clusters_with_knative_installed def clusters_with_knative_installed
@clusters.with_knative_installed @clusters.with_knative_installed
end end
# rubocop: disable CodeReuse/ServiceClass
def prometheus_adapter
@prometheus_adapter ||= ::Prometheus::AdapterService.new(@project).prometheus_adapter
end
# rubocop: enable CodeReuse/ServiceClass
end end
end end
end end
# 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
...@@ -32,6 +32,13 @@ module Projects ...@@ -32,6 +32,13 @@ module Projects
service.dig('podcount') service.dig('podcount')
end 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| expose :created_at do |service|
service.dig('metadata', 'creationTimestamp') service.dig('metadata', 'creationTimestamp')
end end
......
...@@ -5,7 +5,10 @@ ...@@ -5,7 +5,10 @@
- status_path = project_serverless_functions_path(@project, format: :json) - status_path = project_serverless_functions_path(@project, format: :json)
- clusters_path = project_clusters_path(@project) - 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)] } %div{ class: [container_class, ('limit-container-width' unless fluid_layout)] }
.js-serverless-functions-notice .js-serverless-functions-notice
......
- @no_container = true - @no_container = true
- @content_class = "limit-container-width" unless fluid_layout - @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)) - add_to_breadcrumbs('Serverless', project_serverless_functions_path(@project))
- page_title @service[:name] - 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)] } %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 .js-serverless-function-notice
......
---
title: Add Knative metrics to Prometheus
merge_request: 24663
author: Chris Baumbauer <cab@cabnetworks.net>
type: added
...@@ -259,3 +259,13 @@ ...@@ -259,3 +259,13 @@
label: Pod average label: Pod average
unit: "cores" unit: "cores"
track: canary 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
...@@ -252,7 +252,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -252,7 +252,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end end
namespace :serverless do 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] resources :functions, only: [:index]
end end
......
# 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
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
......
...@@ -301,3 +301,23 @@ The second to last line, labeled **Service domain** contains the URL for the dep ...@@ -301,3 +301,23 @@ The second to last line, labeled **Service domain** contains the URL for the dep
browser to see the app live. browser to see the app live.
![knative app](img/knative-app.png) ![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.
# 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
...@@ -7238,9 +7238,24 @@ msgstr "" ...@@ -7238,9 +7238,24 @@ msgstr ""
msgid "Serverless" msgid "Serverless"
msgstr "" 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" msgid "ServerlessDetails|Kubernetes Pods"
msgstr "" msgstr ""
msgid "ServerlessDetails|More information"
msgstr ""
msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity." msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity."
msgstr "" msgstr ""
...@@ -7256,9 +7271,6 @@ 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." msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr "" msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Getting started with serverless" msgid "Serverless|Getting started with serverless"
msgstr "" msgstr ""
......
...@@ -76,6 +76,15 @@ describe Projects::Serverless::FunctionsController do ...@@ -76,6 +76,15 @@ describe Projects::Serverless::FunctionsController do
end end
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 describe 'GET #index with data', :use_clean_rails_memory_store_caching do
before do before do
stub_kubeclient_service_pods stub_kubeclient_service_pods
......
...@@ -50,7 +50,7 @@ describe 'Functions', :js do ...@@ -50,7 +50,7 @@ describe 'Functions', :js do
end end
it 'sees an empty listing of serverless functions' do 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 end
end end
...@@ -4,6 +4,7 @@ require 'spec_helper' ...@@ -4,6 +4,7 @@ require 'spec_helper'
describe Projects::Serverless::FunctionsFinder do describe Projects::Serverless::FunctionsFinder do
include KubernetesHelpers include KubernetesHelpers
include PrometheusHelpers
include ReactiveCachingHelpers include ReactiveCachingHelpers
let(:user) { create(:user) } let(:user) { create(:user) }
...@@ -24,12 +25,12 @@ describe Projects::Serverless::FunctionsFinder do ...@@ -24,12 +25,12 @@ describe Projects::Serverless::FunctionsFinder do
describe 'retrieve data from knative' do describe 'retrieve data from knative' do
it 'does not have knative installed' 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 end
context 'has knative installed' do context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } 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 it 'there are no functions' do
expect(finder.execute).to be_empty expect(finder.execute).to be_empty
...@@ -58,13 +59,36 @@ describe Projects::Serverless::FunctionsFinder do ...@@ -58,13 +59,36 @@ describe Projects::Serverless::FunctionsFinder do
expect(result).not_to be_empty expect(result).not_to be_empty
expect(result["metadata"]["name"]).to be_eql(cluster.project.name) expect(result["metadata"]["name"]).to be_eql(cluster.project.name)
end 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
end end
describe 'verify if knative is installed' do describe 'verify if knative is installed' do
context 'knative is not installed' do context 'knative is not installed' do
it 'does not have knative 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
end end
...@@ -72,7 +96,7 @@ describe Projects::Serverless::FunctionsFinder do ...@@ -72,7 +96,7 @@ describe Projects::Serverless::FunctionsFinder do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) } let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
it 'does have knative installed' do 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 end
end end
......
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)');
});
});
});
});
import Vue from 'vue';
import environmentRowComponent from '~/serverless/components/environment_row.vue'; import environmentRowComponent from '~/serverless/components/environment_row.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import ServerlessStore from '~/serverless/stores/serverless_store';
import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data'; import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
import { translate } from '~/serverless/utils';
const createComponent = (env, envName) => const createComponent = (localVue, env, envName) =>
mountComponent(Vue.extend(environmentRowComponent), { env, envName }); shallowMount(environmentRowComponent, { localVue, propsData: { env, envName } }).vm;
describe('environment row component', () => { describe('environment row component', () => {
describe('default global cluster case', () => { describe('default global cluster case', () => {
let localVue;
let vm; let vm;
beforeEach(() => { beforeEach(() => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path'); localVue = createLocalVue();
store.updateFunctionsFromServer(mockServerlessFunctions); vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*');
vm = createComponent(store.state.functions['*'], '*');
}); });
afterEach(() => vm.$destroy());
it('has the correct envId', () => { it('has the correct envId', () => {
expect(vm.envId).toEqual('env-global'); expect(vm.envId).toEqual('env-global');
vm.$destroy();
}); });
it('is open by default', () => { it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true }); expect(vm.isOpenClass).toEqual({ 'is-open': true });
vm.$destroy();
}); });
it('generates correct output', () => { it('generates correct output', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(2);
expect(vm.$el.id).toEqual('env-global'); expect(vm.$el.id).toEqual('env-global');
expect(vm.$el.classList.contains('is-open')).toBe(true); expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*'); expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
vm.$destroy();
}); });
it('opens and closes correctly', () => { it('opens and closes correctly', () => {
expect(vm.isOpen).toBe(true); expect(vm.isOpen).toBe(true);
vm.toggleOpen(); vm.toggleOpen();
Vue.nextTick(() => {
expect(vm.isOpen).toBe(false);
});
vm.$destroy(); expect(vm.isOpen).toBe(false);
}); });
}); });
describe('default named cluster case', () => { describe('default named cluster case', () => {
let vm; let vm;
let localVue;
beforeEach(() => { beforeEach(() => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path'); localVue = createLocalVue();
store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv); vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test');
vm = createComponent(store.state.functions.test, 'test');
}); });
afterEach(() => vm.$destroy());
it('has the correct envId', () => { it('has the correct envId', () => {
expect(vm.envId).toEqual('env-test'); expect(vm.envId).toEqual('env-test');
vm.$destroy();
}); });
it('is open by default', () => { it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true }); expect(vm.isOpenClass).toEqual({ 'is-open': true });
vm.$destroy();
}); });
it('generates correct output', () => { it('generates correct output', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(1);
expect(vm.$el.id).toEqual('env-test'); expect(vm.$el.id).toEqual('env-test');
expect(vm.$el.classList.contains('is-open')).toBe(true); expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test'); expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
vm.$destroy();
}); });
}); });
}); });
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);
});
});
});
import Vue from 'vue';
import functionRowComponent from '~/serverless/components/function_row.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'; import { mockServerlessFunction } from '../mock_data';
const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func }); const createComponent = func => shallowMount(functionRowComponent, { propsData: { func } }).vm;
describe('functionRowComponent', () => { describe('functionRowComponent', () => {
it('Parses the function details correctly', () => { it('Parses the function details correctly', () => {
...@@ -13,10 +11,7 @@ describe('functionRowComponent', () => { ...@@ -13,10 +11,7 @@ describe('functionRowComponent', () => {
expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name); expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image); 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('timeago-stub').getAttribute('time')).not.toBe(null);
expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
mockServerlessFunction.url,
);
vm.$destroy(); vm.$destroy();
}); });
...@@ -25,8 +20,6 @@ describe('functionRowComponent', () => { ...@@ -25,8 +20,6 @@ describe('functionRowComponent', () => {
const vm = createComponent(mockServerlessFunction); const vm = createComponent(mockServerlessFunction);
expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row 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(); vm.$destroy();
}); });
......
import Vue from 'vue'; import Vuex from 'vuex';
import functionsComponent from '~/serverless/components/functions.vue'; import functionsComponent from '~/serverless/components/functions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { createLocalVue, shallowMount } from '@vue/test-utils';
import ServerlessStore from '~/serverless/stores/serverless_store'; import { createStore } from '~/serverless/store';
import { mockServerlessFunctions } from '../mock_data'; import { mockServerlessFunctions } from '../mock_data';
const createComponent = ( describe('functionsComponent', () => {
functions, let component;
installed = true, let store;
loadingData = true, let localVue;
hasFunctionData = true,
) => { beforeEach(() => {
const component = Vue.extend(functionsComponent); localVue = createLocalVue();
localVue.use(Vuex);
return mountComponent(component, { store = createStore();
functions,
installed,
clustersPath: '/testClusterPath',
helpPath: '/helpPath',
loadingData,
hasFunctionData,
}); });
};
describe('functionsComponent', () => { afterEach(() => {
it('should render empty state when Knative is not installed', () => { component.vm.$destroy();
const vm = createComponent({}, false); });
expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true); it('should render empty state when Knative is not installed', () => {
expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual( component = shallowMount(functionsComponent, {
'Getting started with serverless', 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', () => { 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(component.vm.$el.querySelector('glloadingicon-stub')).not.toBe(null);
expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
}); });
it('should render empty state when there is no function data', () => { 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( 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); ).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', 'No functions available',
); );
vm.$destroy();
}); });
it('should render the functions list', () => { it('should render the functions list', () => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path'); component = shallowMount(functionsComponent, {
store.updateFunctionsFromServer(mockServerlessFunctions); localVue,
const vm = createComponent(store.state.functions, true, false); 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); return component.vm.$nextTick().then(() => {
expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true); expect(component.vm.$el.querySelector('environmentrow-stub')).not.toBe(null);
});
}); });
}); });
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.',
);
});
});
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();
});
});
import Vue from 'vue'; import Vue from 'vue';
import urlComponent from '~/serverless/components/url.vue'; import urlComponent from '~/serverless/components/url.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { shallowMount } from '@vue/test-utils';
const createComponent = uri => {
const component = Vue.extend(urlComponent);
return mountComponent(component, { const createComponent = uri =>
shallowMount(Vue.extend(urlComponent), {
propsData: {
uri, uri,
}); },
}; }).vm;
describe('urlComponent', () => { describe('urlComponent', () => {
it('should render correctly', () => { it('should render correctly', () => {
...@@ -17,9 +15,7 @@ describe('urlComponent', () => { ...@@ -17,9 +15,7 @@ describe('urlComponent', () => {
const vm = createComponent(uri); const vm = createComponent(uri);
expect(vm.$el.classList.contains('clipboard-group')).toBe(true); expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual( expect(vm.$el.querySelector('clipboardbutton-stub').getAttribute('text')).toEqual(uri);
uri,
);
expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri); expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
......
...@@ -77,3 +77,60 @@ export const mockMultilineServerlessFunction = { ...@@ -77,3 +77,60 @@ export const mockMultilineServerlessFunction = {
description: 'testfunc1\nA test service line\\nWith additional services', description: 'testfunc1\nA test service line\\nWith additional services',
image: 'knative-test-container-buildtemplate', 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,
},
],
},
],
},
],
};
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();
},
);
});
});
});
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);
});
});
});
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);
});
});
});
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');
});
});
});
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 () => {};
# 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
# 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
...@@ -7,6 +7,10 @@ module PrometheusHelpers ...@@ -7,6 +7,10 @@ module PrometheusHelpers
%{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100} %{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100}
end 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) def prometheus_ping_url(prometheus_query)
query = { query: prometheus_query }.to_query query = { query: prometheus_query }.to_query
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment