Commit 21b70167 authored by Chris Baumbauer's avatar Chris Baumbauer Committed by Mike Greiling
Browse files

Add Knative metrics to Prometheus

parent d8f3b1692725
<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';