diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js
index 523f33d12..c48095d81 100644
--- a/app/kubernetes/__module.js
+++ b/app/kubernetes/__module.js
@@ -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);
diff --git a/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.html b/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.html
index e441ca663..38a982933 100644
--- a/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.html
+++ b/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.html
@@ -131,6 +131,13 @@
{{ item.CreationDate | getisodate }}
+
+ Stats
+
Logs
Console
diff --git a/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.js b/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.js
index f7b31912d..7e096228b 100644
--- a/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.js
+++ b/app/kubernetes/components/datatables/application/containers-datatable/containersDatatable.js
@@ -9,5 +9,6 @@ angular.module('portainer.kubernetes').component('kubernetesContainersDatatable'
orderBy: '@',
refreshCallback: '<',
isPod: '<',
+ useServerMetrics: '<',
},
});
diff --git a/app/kubernetes/helpers/resourceReservationHelper.js b/app/kubernetes/helpers/resourceReservationHelper.js
index 3a24e62be..2ed667595 100644
--- a/app/kubernetes/helpers/resourceReservationHelper.js
+++ b/app/kubernetes/helpers/resourceReservationHelper.js
@@ -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;
}
diff --git a/app/kubernetes/metrics/metrics.js b/app/kubernetes/metrics/metrics.js
new file mode 100644
index 000000000..6bacdbbee
--- /dev/null
+++ b/app/kubernetes/metrics/metrics.js
@@ -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);
diff --git a/app/kubernetes/metrics/rest.js b/app/kubernetes/metrics/rest.js
new file mode 100644
index 000000000..22f071b3b
--- /dev/null
+++ b/app/kubernetes/metrics/rest.js
@@ -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,
+ },
+ }
+ );
+ };
+ },
+]);
diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html
index 8b7221239..e2e6efd0e 100644
--- a/app/kubernetes/views/applications/edit/application.html
+++ b/app/kubernetes/views/applications/edit/application.html
@@ -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"
>
diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js
index ac3f97442..1c07e0b44 100644
--- a/app/kubernetes/views/applications/edit/applicationController.js
+++ b/app/kubernetes/views/applications/edit/applicationController.js
@@ -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');
diff --git a/app/kubernetes/views/applications/stats/stats.html b/app/kubernetes/views/applications/stats/stats.html
new file mode 100644
index 000000000..7a39035dd
--- /dev/null
+++ b/app/kubernetes/views/applications/stats/stats.html
@@ -0,0 +1,82 @@
+
+ Resource pools >
+ {{ ctrl.state.transition.namespace }} >
+ Applications >
+ {{
+ ctrl.state.transition.applicationName
+ }}
+ > Pods > {{ ctrl.state.transition.podName }} > Containers > {{ ctrl.state.transition.containerName }} > Stats
+
+
+
+
+
+
+
+
+ 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.
+
+
+
+
+
+
diff --git a/app/kubernetes/views/applications/stats/stats.js b/app/kubernetes/views/applications/stats/stats.js
new file mode 100644
index 000000000..01496a9b6
--- /dev/null
+++ b/app/kubernetes/views/applications/stats/stats.js
@@ -0,0 +1,8 @@
+angular.module('portainer.kubernetes').component('kubernetesApplicationStatsView', {
+ templateUrl: './stats.html',
+ controller: 'KubernetesApplicationStatsController',
+ controllerAs: 'ctrl',
+ bindings: {
+ $transition$: '<',
+ },
+});
diff --git a/app/kubernetes/views/applications/stats/statsController.js b/app/kubernetes/views/applications/stats/statsController.js
new file mode 100644
index 000000000..01f3458a7
--- /dev/null
+++ b/app/kubernetes/views/applications/stats/statsController.js
@@ -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);
diff --git a/app/kubernetes/views/configure/configure.html b/app/kubernetes/views/configure/configure.html
index a94bcc2e0..98f86b0d1 100644
--- a/app/kubernetes/views/configure/configure.html
+++ b/app/kubernetes/views/configure/configure.html
@@ -196,7 +196,27 @@
Enable features using metrics server
-
+
+
+
+
+
+ Checking metrics API...
+
+
+ Successfully reached metrics API
+
+
+ Unable to reach metrics API, make sure metrics server is properly deployed inside
+ that cluster.
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js
index a66bf5a19..6cef0781d 100644
--- a/app/kubernetes/views/configure/configureController.js
+++ b/app/kubernetes/views/configure/configureController.js
@@ -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 = {