mirror of https://github.com/portainer/portainer
feat(k8s/container): realtime metrics (#4416)
* feat(k8s/container): metrics layout * feat(k8s/container): memory graph * feat(k8s/container): cpu usage percent * feat(k8s/metrics): metrics api validation to enable metrics server * feat(k8s/pods): update error metrics view * feat(k8s/container): improve stopRepeater function * feat(k8s/pods): display empty view instead of empty graphs * feat(k8s/pods): fix CPU usage * feat(k8s/configure): fix the metrics server test * feat(k8s/pod): fix cpu issue * feat(k8s/pod): fix toaster for non register pods in metrics server * feat(k8s/service): remove options before 30 secondes for refresh rate * feat(k8s/pod): fix default value for the refresh rate * feat(k8s/pod): fix rebasepull/5027/head
parent
befccacc27
commit
d99358ea8e
|
@ -100,6 +100,16 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
|
|||
},
|
||||
};
|
||||
|
||||
const applicationStats = {
|
||||
name: 'kubernetes.applications.application.stats',
|
||||
url: '/:pod/:container/stats',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesApplicationStatsView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const stacks = {
|
||||
name: 'kubernetes.stacks',
|
||||
url: '/stacks',
|
||||
|
@ -259,6 +269,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([
|
|||
$stateRegistryProvider.register(applicationEdit);
|
||||
$stateRegistryProvider.register(applicationConsole);
|
||||
$stateRegistryProvider.register(applicationLogs);
|
||||
$stateRegistryProvider.register(applicationStats);
|
||||
$stateRegistryProvider.register(stacks);
|
||||
$stateRegistryProvider.register(stack);
|
||||
$stateRegistryProvider.register(stackLogs);
|
||||
|
|
|
@ -131,6 +131,13 @@
|
|||
</td>
|
||||
<td>{{ item.CreationDate | getisodate }}</td>
|
||||
<td>
|
||||
<a
|
||||
ng-if="item.Status === 'Running' && $ctrl.useServerMetrics"
|
||||
ui-sref="kubernetes.applications.application.stats({ pod: item.PodName, container: item.Name })"
|
||||
style="margin-right: 10px;"
|
||||
>
|
||||
<i class="fa fa-chart-area" aria-hidden="true"></i> Stats
|
||||
</a>
|
||||
<a ui-sref="kubernetes.applications.application.logs({ pod: item.PodName, container: item.Name })"> <i class="fa fa-file-alt" aria-hidden="true"></i> Logs </a>
|
||||
<a ng-if="item.Status === 'Running'" ui-sref="kubernetes.applications.application.console({ pod: item.PodName, container: item.Name })" style="margin-left: 10px;">
|
||||
<i class="fa fa-terminal" aria-hidden="true"></i> Console
|
||||
|
|
|
@ -9,5 +9,6 @@ angular.module('portainer.kubernetes').component('kubernetesContainersDatatable'
|
|||
orderBy: '@',
|
||||
refreshCallback: '<',
|
||||
isPod: '<',
|
||||
useServerMetrics: '<',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -29,6 +29,8 @@ class KubernetesResourceReservationHelper {
|
|||
let res = parseInt(cpu, 10);
|
||||
if (_.endsWith(cpu, 'm')) {
|
||||
res /= 1000;
|
||||
} else if (_.endsWith(cpu, 'n')) {
|
||||
res /= 1000000000;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import angular from 'angular';
|
||||
import PortainerError from 'Portainer/error';
|
||||
import { KubernetesCommonParams } from 'Kubernetes/models/common/params';
|
||||
|
||||
class KubernetesMetricsService {
|
||||
/* @ngInject */
|
||||
constructor($async, KubernetesMetrics) {
|
||||
this.$async = $async;
|
||||
this.KubernetesMetrics = KubernetesMetrics;
|
||||
|
||||
this.capabilitiesAsync = this.capabilitiesAsync.bind(this);
|
||||
this.getPodAsync = this.getPodAsync.bind(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET
|
||||
*/
|
||||
async capabilitiesAsync(endpointID) {
|
||||
try {
|
||||
await this.KubernetesMetrics().capabilities({ endpointId: endpointID }).$promise;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve metrics', err);
|
||||
}
|
||||
}
|
||||
|
||||
capabilities(endpointID) {
|
||||
return this.$async(this.capabilitiesAsync, endpointID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stats
|
||||
*
|
||||
* @param {string} namespace
|
||||
* @param {string} podName
|
||||
*/
|
||||
async getPodAsync(namespace, podName) {
|
||||
try {
|
||||
const params = new KubernetesCommonParams();
|
||||
params.id = podName;
|
||||
const data = await this.KubernetesMetrics(namespace).getPod(params).$promise;
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw new PortainerError('Unable to retrieve pod stats', err);
|
||||
}
|
||||
}
|
||||
|
||||
getPod(namespace, podName) {
|
||||
return this.$async(this.getPodAsync, namespace, podName);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesMetricsService;
|
||||
angular.module('portainer.kubernetes').service('KubernetesMetricsService', KubernetesMetricsService);
|
|
@ -0,0 +1,27 @@
|
|||
angular.module('portainer.kubernetes').factory('KubernetesMetrics', [
|
||||
'$resource',
|
||||
'API_ENDPOINT_ENDPOINTS',
|
||||
'EndpointProvider',
|
||||
function KubernetesMetrics($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
|
||||
'use strict';
|
||||
return function (namespace) {
|
||||
const url = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/apis/metrics.k8s.io/v1beta1';
|
||||
const podUrl = `${url}${namespace ? '/namespaces/:namespace' : ''}/pods/:id`;
|
||||
|
||||
return $resource(
|
||||
url,
|
||||
{
|
||||
endpointId: EndpointProvider.endpointID,
|
||||
namespace: namespace,
|
||||
},
|
||||
{
|
||||
capabilities: { method: 'GET' },
|
||||
getPod: {
|
||||
method: 'GET',
|
||||
url: podUrl,
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
},
|
||||
]);
|
|
@ -587,6 +587,7 @@
|
|||
table-key="kubernetes.application.containers"
|
||||
is-pod="ctrl.application.ApplicationType === ctrl.KubernetesApplicationTypes.POD"
|
||||
order-by="{{ ctrl.application.ApplicationType === ctrl.KubernetesApplicationTypes.POD ? 'Name' : 'PodName' }}"
|
||||
use-server-metrics="ctrl.state.useServerMetrics"
|
||||
>
|
||||
</kubernetes-containers-datatable>
|
||||
</div>
|
||||
|
|
|
@ -106,7 +106,8 @@ class KubernetesApplicationController {
|
|||
KubernetesStackService,
|
||||
KubernetesPodService,
|
||||
KubernetesNodeService,
|
||||
KubernetesNamespaceHelper
|
||||
KubernetesNamespaceHelper,
|
||||
EndpointProvider
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
|
@ -125,6 +126,8 @@ class KubernetesApplicationController {
|
|||
|
||||
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
|
||||
this.KubernetesApplicationTypes = KubernetesApplicationTypes;
|
||||
this.EndpointProvider = EndpointProvider;
|
||||
|
||||
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
|
||||
this.KubernetesServiceTypes = KubernetesServiceTypes;
|
||||
this.KubernetesPodContainerTypes = KubernetesPodContainerTypes;
|
||||
|
@ -335,6 +338,7 @@ class KubernetesApplicationController {
|
|||
placementWarning: false,
|
||||
expandedNote: false,
|
||||
useIngress: false,
|
||||
useServerMetrics: this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.UseServerMetrics,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('application');
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
<kubernetes-view-header title="Application stats" state="kubernetes.applications.application.stats" view-ready="ctrl.state.viewReady">
|
||||
<a ui-sref="kubernetes.resourcePools">Resource pools</a> >
|
||||
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: ctrl.state.transition.namespace })">{{ ctrl.state.transition.namespace }}</a> >
|
||||
<a ui-sref="kubernetes.applications">Applications</a> >
|
||||
<a ui-sref="kubernetes.applications.application({ name: ctrl.state.transition.applicationName, namespace: ctrl.state.transition.namespace })">{{
|
||||
ctrl.state.transition.applicationName
|
||||
}}</a>
|
||||
> Pods > {{ ctrl.state.transition.podName }} > Containers > {{ ctrl.state.transition.containerName }} > Stats
|
||||
</kubernetes-view-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
|
||||
<span class="small text-muted">
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
|
||||
configured.
|
||||
</span>
|
||||
</information-panel>
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info-circle" title-text="About statistics"> </rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left">
|
||||
Refresh rate
|
||||
</label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<span>
|
||||
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted"> <i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i> Network stats are unavailable for this container. </span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="Memory usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-chart-area" title-text="CPU usage"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative;">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,8 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesApplicationStatsView', {
|
||||
templateUrl: './stats.html',
|
||||
controller: 'KubernetesApplicationStatsController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
$transition$: '<',
|
||||
},
|
||||
});
|
|
@ -0,0 +1,164 @@
|
|||
import angular from 'angular';
|
||||
import moment from 'moment';
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import KubernetesPodConverter from 'Kubernetes/pod/converter';
|
||||
|
||||
class KubernetesApplicationStatsController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $interval, $document, Notifications, KubernetesPodService, KubernetesNodeService, KubernetesMetricsService, ChartService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.$interval = $interval;
|
||||
this.$document = $document;
|
||||
this.Notifications = Notifications;
|
||||
this.KubernetesPodService = KubernetesPodService;
|
||||
this.KubernetesNodeService = KubernetesNodeService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
this.ChartService = ChartService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
}
|
||||
|
||||
changeUpdateRepeater() {
|
||||
var cpuChart = this.cpuChart;
|
||||
var memoryChart = this.memoryChart;
|
||||
|
||||
this.stopRepeater();
|
||||
this.setUpdateRepeater(cpuChart, memoryChart);
|
||||
$('#refreshRateChange').show();
|
||||
$('#refreshRateChange').fadeOut(1500);
|
||||
}
|
||||
|
||||
updateCPUChart() {
|
||||
const label = moment(this.stats.read).format('HH:mm:ss');
|
||||
|
||||
this.ChartService.UpdateCPUChart(label, this.stats.CPUUsage, this.cpuChart);
|
||||
}
|
||||
|
||||
updateMemoryChart() {
|
||||
const label = moment(this.stats.read).format('HH:mm:ss');
|
||||
|
||||
this.ChartService.UpdateMemoryChart(label, this.stats.MemoryUsage, this.stats.MemoryCache, this.memoryChart);
|
||||
}
|
||||
|
||||
stopRepeater() {
|
||||
var repeater = this.repeater;
|
||||
if (angular.isDefined(repeater)) {
|
||||
this.$interval.cancel(repeater);
|
||||
repeater = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
setUpdateRepeater() {
|
||||
const refreshRate = this.state.refreshRate;
|
||||
|
||||
this.repeater = this.$interval(async () => {
|
||||
try {
|
||||
await this.getStats();
|
||||
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
} catch (error) {
|
||||
this.stopRepeater();
|
||||
this.Notifications.error('Failure', error);
|
||||
}
|
||||
}, refreshRate * 1000);
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
const cpuChartCtx = $('#cpuChart');
|
||||
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
|
||||
this.cpuChart = cpuChart;
|
||||
|
||||
const memoryChartCtx = $('#memoryChart');
|
||||
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
this.memoryChart = memoryChart;
|
||||
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
this.setUpdateRepeater();
|
||||
}
|
||||
|
||||
getStats() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const stats = await this.KubernetesMetricsService.getPod(this.state.transition.namespace, this.state.transition.podName);
|
||||
const container = _.find(stats.containers, { name: this.state.transition.containerName });
|
||||
if (container) {
|
||||
const memory = filesizeParser(container.usage.memory);
|
||||
const cpu = KubernetesResourceReservationHelper.parseCPU(container.usage.cpu);
|
||||
this.stats = {
|
||||
read: stats.timestamp,
|
||||
preread: '',
|
||||
MemoryCache: 0,
|
||||
MemoryUsage: memory,
|
||||
NumProcs: '',
|
||||
isWindows: false,
|
||||
PreviousCPUTotalUsage: 0,
|
||||
CPUUsage: (cpu / this.nodeCPU) * 100,
|
||||
CPUCores: 0,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve application stats');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$onDestroy() {
|
||||
this.stopRepeater();
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
autoRefresh: false,
|
||||
refreshRate: '30',
|
||||
viewReady: false,
|
||||
transition: {
|
||||
podName: this.$transition$.params().pod,
|
||||
containerName: this.$transition$.params().container,
|
||||
namespace: this.$transition$.params().namespace,
|
||||
applicationName: this.$transition$.params().name,
|
||||
},
|
||||
getMetrics: false,
|
||||
};
|
||||
|
||||
try {
|
||||
await this.KubernetesMetricsService.getPod(this.state.transition.namespace, this.state.transition.podName);
|
||||
} catch (error) {
|
||||
this.state.getMetrics = false;
|
||||
this.state.viewReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const podRaw = await this.KubernetesPodService.get(this.state.transition.namespace, this.state.transition.podName);
|
||||
const pod = KubernetesPodConverter.apiToModel(podRaw.Raw);
|
||||
if (pod) {
|
||||
const node = await this.KubernetesNodeService.get(pod.Node);
|
||||
this.nodeCPU = node.CPU;
|
||||
} else {
|
||||
throw new Error('Unable to find pod');
|
||||
}
|
||||
await this.getStats();
|
||||
this.state.getMetrics = true;
|
||||
|
||||
this.$document.ready(() => {
|
||||
this.initCharts();
|
||||
});
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve application stats');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesApplicationStatsController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesApplicationStatsController', KubernetesApplicationStatsController);
|
|
@ -196,7 +196,27 @@
|
|||
<label class="control-label text-left">
|
||||
Enable features using metrics server
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;"> <input type="checkbox" ng-model="ctrl.formValues.UseServerMetrics" /><i></i> </label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="ctrl.formValues.UseServerMetrics" ng-change="ctrl.enableMetricsServer()" /><i></i>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-if="ctrl.state.metrics.pending && ctrl.state.metrics.userClick" class="col-sm-12 small text-muted" style="margin-top: 5px;">
|
||||
Checking metrics API... <i class="fa fa-spinner fa-spin" style="margin-left: 2px;"></i>
|
||||
</div>
|
||||
<div
|
||||
ng-if="!ctrl.state.metrics.pending && ctrl.state.metrics.isServerRunning && ctrl.state.metrics.userClick"
|
||||
class="col-sm-12 small text-muted"
|
||||
style="margin-top: 5px;"
|
||||
>
|
||||
<i class="fa fa-check green-icon" aria-hidden="true" style="margin-right: 2px;"></i> Successfully reached metrics API
|
||||
</div>
|
||||
<div
|
||||
ng-if="!ctrl.state.metrics.pending && !ctrl.state.metrics.isServerRunning && ctrl.state.metrics.userClick"
|
||||
class="col-sm-12 small text-muted"
|
||||
style="margin-top: 5px;"
|
||||
>
|
||||
<i class="fa fa-times red-icon" aria-hidden="true" style="margin-right: 2px;"></i> Unable to reach metrics API, make sure metrics server is properly deployed inside
|
||||
that cluster.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -28,7 +28,8 @@ class KubernetesConfigureController {
|
|||
ModalService,
|
||||
KubernetesNamespaceHelper,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesIngressService
|
||||
KubernetesIngressService,
|
||||
KubernetesMetricsService
|
||||
) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
|
@ -41,6 +42,7 @@ class KubernetesConfigureController {
|
|||
this.KubernetesNamespaceHelper = KubernetesNamespaceHelper;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.KubernetesIngressService = KubernetesIngressService;
|
||||
this.KubernetesMetricsService = KubernetesMetricsService;
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
|
||||
|
@ -161,6 +163,27 @@ class KubernetesConfigureController {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
enableMetricsServer() {
|
||||
if (this.formValues.UseServerMetrics) {
|
||||
this.state.metrics.userClick = true;
|
||||
this.state.metrics.pending = true;
|
||||
this.KubernetesMetricsService.capabilities(this.endpoint.Id)
|
||||
.then(() => {
|
||||
this.state.metrics.isServerRunning = true;
|
||||
this.state.metrics.pending = false;
|
||||
this.formValues.UseServerMetrics = true;
|
||||
})
|
||||
.catch(() => {
|
||||
this.state.metrics.isServerRunning = false;
|
||||
this.state.metrics.pending = false;
|
||||
this.formValues.UseServerMetrics = false;
|
||||
});
|
||||
} else {
|
||||
this.state.metrics.userClick = false;
|
||||
this.formValues.UseServerMetrics = false;
|
||||
}
|
||||
}
|
||||
|
||||
async configureAsync() {
|
||||
try {
|
||||
|
@ -222,6 +245,11 @@ class KubernetesConfigureController {
|
|||
duplicates: {
|
||||
ingressClasses: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
metrics: {
|
||||
pending: false,
|
||||
isServerRunning: false,
|
||||
userClick: false,
|
||||
},
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
|
|
Loading…
Reference in New Issue