Commit 3198867f authored by Mike Greiling's avatar Mike Greiling

Merge branch 'knative-prometheus' into 'master'

Add Knative metrics to Prometheus

See merge request gitlab-org/gitlab-ce!24663
parents 8e33e7cf b77fe7db
<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>
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>
<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];
......
<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"
......
<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 { 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();
}
......
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
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)
......
......@@ -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
# 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
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
......
......@@ -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
......
- @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
......
---
title: Add Knative metrics to Prometheus
merge_request: 24663
author: Chris Baumbauer <cab@cabnetworks.net>
type: added
......@@ -259,3 +259,13 @@
label: Pod average
unit: "cores"
track: canary
- title: "Knative function invocations"
y_label: "Invocations"
required_metrics:
- istio_revision_request_count
weight: 1
queries:
- id: system_metrics_knative_function_invocation_count
query_range: 'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))'
label: invocations / minute
unit: requests
......@@ -252,7 +252,11 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
namespace :serverless do
get '/functions/:environment_id/:id', to: 'functions#show'
scope :functions do
get '/:environment_id/:id', to: 'functions#show'
get '/:environment_id/:id/metrics', to: 'functions#metrics', as: :metrics
end
resources :functions, only: [:index]
end
......
# 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 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20190325165127) do
ActiveRecord::Schema.define(version: 20190326164045) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......
......@@ -301,3 +301,23 @@ The second to last line, labeled **Service domain** contains the URL for the dep
browser to see the app live.
![knative app](img/knative-app.png)
## Function details
Go to the **Operations > Serverless** page and click on one of the function
rows to bring up the function details page.
![function_details](img/function-details-loaded.png)
The pod count will give you the number of pods running the serverless function instances on a given cluster.
### Prometheus support
For the Knative function invocations to appear,
[Prometheus must be installed](../index.md#installing-applications).
Once Prometheus is installed, a message may appear indicating that the metrics data _is
loading or is not available at this time._ It will appear upon the first access of the
page, but should go away after a few seconds. If the message does not disappear, then it
is possible that GitLab is unable to connect to the Prometheus instance running on the
cluster.
# 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 ""
msgid "Serverless"
msgstr ""
msgid "ServerlessDetails|Function invocation metrics require Prometheus to be installed first."
msgstr ""
msgid "ServerlessDetails|Install Prometheus"
msgstr ""
msgid "ServerlessDetails|Invocation metrics loading or not available at this time."
msgstr ""
msgid "ServerlessDetails|Invocations"
msgstr ""
msgid "ServerlessDetails|Kubernetes Pods"
msgstr ""
msgid "ServerlessDetails|More information"
msgstr ""
msgid "ServerlessDetails|Number of Kubernetes pods in use over time based on necessity."
msgstr ""
......@@ -7256,9 +7271,6 @@ msgstr ""
msgid "Serverless| In order to start using functions as a service, you must first install Knative on your Kubernetes cluster."
msgstr ""
msgid "Serverless|An error occurred while retrieving serverless components"
msgstr ""
msgid "Serverless|Getting started with serverless"
msgstr ""
......
......@@ -76,6 +76,15 @@ describe Projects::Serverless::FunctionsController do
end
end
describe 'GET #metrics' do
context 'invalid data' do
it 'has a bad function name' do
get :metrics, params: params({ format: :json, environment_id: "*", id: "foo" })
expect(response).to have_gitlab_http_status(204)
end
end
end
describe 'GET #index with data', :use_clean_rails_memory_store_caching do
before do
stub_kubeclient_service_pods
......
......@@ -50,7 +50,7 @@ describe 'Functions', :js do
end
it 'sees an empty listing of serverless functions' do
expect(page).to have_selector('.gl-responsive-table-row')
expect(page).to have_selector('.empty-state')
end
end
end
......@@ -4,6 +4,7 @@ require 'spec_helper'
describe Projects::Serverless::FunctionsFinder do
include KubernetesHelpers
include PrometheusHelpers
include ReactiveCachingHelpers
let(:user) { create(:user) }
......@@ -24,12 +25,12 @@ describe Projects::Serverless::FunctionsFinder do
describe 'retrieve data from knative' do
it 'does not have knative installed' do
expect(described_class.new(project.clusters).execute).to be_empty
expect(described_class.new(project).execute).to be_empty
end
context 'has knative installed' do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let(:finder) { described_class.new(project.clusters) }
let(:finder) { described_class.new(project) }
it 'there are no functions' do
expect(finder.execute).to be_empty
......@@ -58,13 +59,36 @@ describe Projects::Serverless::FunctionsFinder do
expect(result).not_to be_empty
expect(result["metadata"]["name"]).to be_eql(cluster.project.name)
end
it 'has metrics', :use_clean_rails_memory_store_caching do
end
end
context 'has prometheus' do
let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) }
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
let!(:prometheus) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
let(:finder) { described_class.new(project) }
before do
allow(finder).to receive(:prometheus_adapter).and_return(prometheus_adapter)
allow(prometheus_adapter).to receive(:query).and_return(prometheus_empty_body('matrix'))
end
it 'is available' do
expect(finder.has_prometheus?("*")).to be true
end
it 'has query data' do
expect(finder.invocation_metrics("*", cluster.project.name)).not_to be_nil
end
end
end
describe 'verify if knative is installed' do
context 'knative is not installed' do
it 'does not have knative installed' do
expect(described_class.new(project.clusters).installed?).to be false
expect(described_class.new(project).installed?).to be false
end
end
......@@ -72,7 +96,7 @@ describe Projects::Serverless::FunctionsFinder do
let!(:knative) { create(:clusters_applications_knative, :installed, cluster: cluster) }
it 'does have knative installed' do
expect(described_class.new(project.clusters).installed?).to be true
expect(described_class.new(project).installed?).to be true
end
end
end
......
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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import ServerlessStore from '~/serverless/stores/serverless_store';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { mockServerlessFunctions, mockServerlessFunctionsDiffEnv } from '../mock_data';
import { translate } from '~/serverless/utils';
const createComponent = (env, envName) =>
mountComponent(Vue.extend(environmentRowComponent), { env, envName });
const createComponent = (localVue, env, envName) =>
shallowMount(environmentRowComponent, { localVue, propsData: { env, envName } }).vm;
describe('environment row component', () => {
describe('default global cluster case', () => {
let localVue;
let vm;
beforeEach(() => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path');
store.updateFunctionsFromServer(mockServerlessFunctions);
vm = createComponent(store.state.functions['*'], '*');
localVue = createLocalVue();
vm = createComponent(localVue, translate(mockServerlessFunctions)['*'], '*');
});
afterEach(() => vm.$destroy());
it('has the correct envId', () => {
expect(vm.envId).toEqual('env-global');
vm.$destroy();
});
it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true });
vm.$destroy();
});
it('generates correct output', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(2);
expect(vm.$el.id).toEqual('env-global');
expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('*');
vm.$destroy();
});
it('opens and closes correctly', () => {
expect(vm.isOpen).toBe(true);
vm.toggleOpen();
Vue.nextTick(() => {
expect(vm.isOpen).toBe(false);
});
vm.$destroy();
expect(vm.isOpen).toBe(false);
});
});
describe('default named cluster case', () => {
let vm;
let localVue;
beforeEach(() => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path');
store.updateFunctionsFromServer(mockServerlessFunctionsDiffEnv);
vm = createComponent(store.state.functions.test, 'test');
localVue = createLocalVue();
vm = createComponent(localVue, translate(mockServerlessFunctionsDiffEnv).test, 'test');
});
afterEach(() => vm.$destroy());
it('has the correct envId', () => {
expect(vm.envId).toEqual('env-test');
vm.$destroy();
});
it('is open by default', () => {
expect(vm.isOpenClass).toEqual({ 'is-open': true });
vm.$destroy();
});
it('generates correct output', () => {
expect(vm.$el.querySelectorAll('li').length).toEqual(1);
expect(vm.$el.id).toEqual('env-test');
expect(vm.$el.classList.contains('is-open')).toBe(true);
expect(vm.$el.querySelector('div.title').innerHTML.trim()).toEqual('test');
vm.$destroy();
});
});
});
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 mountComponent from 'spec/helpers/vue_mount_component_helper';
import { shallowMount } from '@vue/test-utils';
import { mockServerlessFunction } from '../mock_data';
const createComponent = func => mountComponent(Vue.extend(functionRowComponent), { func });
const createComponent = func => shallowMount(functionRowComponent, { propsData: { func } }).vm;
describe('functionRowComponent', () => {
it('Parses the function details correctly', () => {
......@@ -13,10 +11,7 @@ describe('functionRowComponent', () => {
expect(vm.$el.querySelector('b').innerHTML).toEqual(mockServerlessFunction.name);
expect(vm.$el.querySelector('span').innerHTML).toEqual(mockServerlessFunction.image);
expect(vm.$el.querySelector('time').getAttribute('data-original-title')).not.toBe(null);
expect(vm.$el.querySelector('div.url-text-field').innerHTML).toEqual(
mockServerlessFunction.url,
);
expect(vm.$el.querySelector('timeago-stub').getAttribute('time')).not.toBe(null);
vm.$destroy();
});
......@@ -25,8 +20,6 @@ describe('functionRowComponent', () => {
const vm = createComponent(mockServerlessFunction);
expect(vm.checkClass(vm.$el.querySelector('p'))).toBe(true); // check somewhere inside the row
expect(vm.checkClass(vm.$el.querySelector('svg'))).toBe(false); // check a button image
expect(vm.checkClass(vm.$el.querySelector('div.url-text-field'))).toBe(false); // check the url bar
vm.$destroy();
});
......
import Vue from 'vue';
import Vuex from 'vuex';
import functionsComponent from '~/serverless/components/functions.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import ServerlessStore from '~/serverless/stores/serverless_store';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { createStore } from '~/serverless/store';
import { mockServerlessFunctions } from '../mock_data';
const createComponent = (
functions,
installed = true,
loadingData = true,
hasFunctionData = true,
) => {
const component = Vue.extend(functionsComponent);
describe('functionsComponent', () => {
let component;
let store;
let localVue;
beforeEach(() => {
localVue = createLocalVue();
localVue.use(Vuex);
return mountComponent(component, {
functions,
installed,
clustersPath: '/testClusterPath',
helpPath: '/helpPath',
loadingData,
hasFunctionData,
store = createStore();
});
};
describe('functionsComponent', () => {
it('should render empty state when Knative is not installed', () => {
const vm = createComponent({}, false);
afterEach(() => {
component.vm.$destroy();
});
expect(vm.$el.querySelector('div.row').classList.contains('js-empty-state')).toBe(true);
expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
'Getting started with serverless',
);
it('should render empty state when Knative is not installed', () => {
component = shallowMount(functionsComponent, {
localVue,
store,
propsData: {
installed: false,
clustersPath: '',
helpPath: '',
statusPath: '',
},
sync: false,
});
vm.$destroy();
expect(component.vm.$el.querySelector('emptystate-stub')).not.toBe(null);
});
it('should render a loading component', () => {
const vm = createComponent({});
store.dispatch('requestFunctionsLoading');
component = shallowMount(functionsComponent, {
localVue,
store,
propsData: {
installed: true,
clustersPath: '',
helpPath: '',
statusPath: '',
},
sync: false,
});
expect(vm.$el.querySelector('.gl-responsive-table-row')).not.toBe(null);
expect(vm.$el.querySelector('div.animation-container')).not.toBe(null);
expect(component.vm.$el.querySelector('glloadingicon-stub')).not.toBe(null);
});
it('should render empty state when there is no function data', () => {
const vm = createComponent({}, true, false, false);
store.dispatch('receiveFunctionsNoDataSuccess');
component = shallowMount(functionsComponent, {
localVue,
store,
propsData: {
installed: true,
clustersPath: '',
helpPath: '',
statusPath: '',
},
sync: false,
});
expect(
vm.$el.querySelector('.empty-state, .js-empty-state').classList.contains('js-empty-state'),
component.vm.$el
.querySelector('.empty-state, .js-empty-state')
.classList.contains('js-empty-state'),
).toBe(true);
expect(vm.$el.querySelector('h4.state-title').innerHTML.trim()).toEqual(
expect(component.vm.$el.querySelector('.state-title, .text-center').innerHTML.trim()).toEqual(
'No functions available',
);
vm.$destroy();
});
it('should render the functions list', () => {
const store = new ServerlessStore(false, '/cluster_path', 'help_path');
store.updateFunctionsFromServer(mockServerlessFunctions);
const vm = createComponent(store.state.functions, true, false);
component = shallowMount(functionsComponent, {
localVue,
store,
propsData: {
installed: true,
clustersPath: '',
helpPath: '',
statusPath: '',
},
sync: false,
});
component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions);
expect(vm.$el.querySelector('div.groups-list-tree-container')).not.toBe(null);
expect(vm.$el.querySelector('#env-global').classList.contains('has-children')).toBe(true);
return component.vm.$nextTick().then(() => {
expect(component.vm.$el.querySelector('environmentrow-stub')).not.toBe(null);
});
});
});
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 urlComponent from '~/serverless/components/url.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
const createComponent = uri => {
const component = Vue.extend(urlComponent);
import { shallowMount } from '@vue/test-utils';
return mountComponent(component, {
uri,
});
};
const createComponent = uri =>
shallowMount(Vue.extend(urlComponent), {
propsData: {
uri,
},
}).vm;
describe('urlComponent', () => {
it('should render correctly', () => {
......@@ -17,9 +15,7 @@ describe('urlComponent', () => {
const vm = createComponent(uri);
expect(vm.$el.classList.contains('clipboard-group')).toBe(true);
expect(vm.$el.querySelector('.js-clipboard-btn').getAttribute('data-clipboard-text')).toEqual(
uri,
);
expect(vm.$el.querySelector('clipboardbutton-stub').getAttribute('text')).toEqual(uri);
expect(vm.$el.querySelector('.url-text-field').innerHTML).toEqual(uri);
......
......@@ -77,3 +77,60 @@ export const mockMultilineServerlessFunction = {
description: 'testfunc1\nA test service line\\nWith additional services',
image: 'knative-test-container-buildtemplate',
};
export const mockMetrics = {
success: true,
last_update: '2019-02-28T19:11:38.926Z',
metrics: {
id: 22,
title: 'Knative function invocations',
required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
weight: 0,
y_label: 'Invocations',
queries: [
{
query_range:
'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
unit: 'requests',
label: 'invocations / minute',
result: [
{
metric: {},
values: [[1551352298.756, '0'], [1551352358.756, '0']],
},
],
},
],
},
};
export const mockNormalizedMetrics = {
id: 22,
title: 'Knative function invocations',
required_metrics: ['container_memory_usage_bytes', 'container_cpu_usage_seconds_total'],
weight: 0,
y_label: 'Invocations',
queries: [
{
query_range:
'floor(sum(rate(istio_revision_request_count{destination_configuration="%{function_name}", destination_namespace="%{kube_namespace}"}[1m])*30))',
unit: 'requests',
label: 'invocations / minute',
result: [
{
metric: {},
values: [
{
time: '2019-02-28T11:11:38.756Z',
value: 0,
},
{
time: '2019-02-28T11:12:38.756Z',
value: 0,
},
],
},
],
},
],
};
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
%{avg(rate(container_cpu_usage_seconds_total{container_name!="POD",environment="#{environment_slug}"}[2m])) * 100}
end
def prometheus_istio_query(function_name, kube_namespace)
%{floor(sum(rate(istio_revision_request_count{destination_configuration=\"#{function_name}\", destination_namespace=\"#{kube_namespace}\"}[1m])*30))}
end
def prometheus_ping_url(prometheus_query)
query = { query: prometheus_query }.to_query
......
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