From dc180d85c5d27b875d75287846b1c9424bf1318f Mon Sep 17 00:00:00 2001 From: cong meng Date: Mon, 14 Jun 2021 12:29:41 +1200 Subject: [PATCH] Feat 4612 real time metrics for kube nodes (#4708) * feat(k8s/node): display realtime node metrics GH#4612 * feat(k8s): show observation timestamp instead of real timestamp GH#4612 Co-authored-by: Simon Meng --- app/constants.js | 2 + app/kubernetes/__module.js | 11 ++ .../nodes-datatable/nodesDatatable.html | 6 + .../nodes-datatable/nodesDatatable.js | 1 + app/kubernetes/metrics/metrics.js | 21 +++ app/kubernetes/metrics/rest.js | 4 + app/kubernetes/views/cluster/cluster.html | 1 + .../views/cluster/clusterController.js | 5 +- .../views/cluster/node/stats/stats.html | 71 +++++++++ .../views/cluster/node/stats/stats.js | 8 + .../cluster/node/stats/statsController.js | 144 ++++++++++++++++++ 11 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 app/kubernetes/views/cluster/node/stats/stats.html create mode 100644 app/kubernetes/views/cluster/node/stats/stats.js create mode 100644 app/kubernetes/views/cluster/node/stats/statsController.js diff --git a/app/constants.js b/app/constants.js index cb0e8f17f..febc848e8 100644 --- a/app/constants.js +++ b/app/constants.js @@ -30,3 +30,5 @@ angular .constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']) .constant('KUBERNETES_DEFAULT_NAMESPACE', 'default') .constant('KUBERNETES_SYSTEM_NAMESPACES', ['kube-system', 'kube-public', 'kube-node-lease', 'portainer']); + +export const PORTAINER_FADEOUT = 1500; diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index c48095d81..d867a288f 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -182,6 +182,16 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([ }, }; + const nodeStats = { + name: 'kubernetes.cluster.node.stats', + url: '/stats', + views: { + 'content@': { + component: 'kubernetesNodeStatsView', + }, + }, + }; + const dashboard = { name: 'kubernetes.dashboard', url: '/dashboard', @@ -280,6 +290,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([ $stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(deploy); $stateRegistryProvider.register(node); + $stateRegistryProvider.register(nodeStats); $stateRegistryProvider.register(resourcePools); $stateRegistryProvider.register(resourcePoolCreation); $stateRegistryProvider.register(resourcePool); diff --git a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html index 8b976a235..b5f84a99e 100644 --- a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html @@ -107,6 +107,9 @@ + + Actions + @@ -128,6 +131,9 @@ {{ item.Memory | humansize }} {{ item.Version }} {{ item.IPAddress }} + + Stats + Loading... diff --git a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js index 3c1312302..17fc80b92 100644 --- a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js +++ b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js @@ -9,5 +9,6 @@ angular.module('portainer.kubernetes').component('kubernetesNodesDatatable', { orderBy: '@', refreshCallback: '<', isAdmin: '<', + useServerMetrics: '<', }, }); diff --git a/app/kubernetes/metrics/metrics.js b/app/kubernetes/metrics/metrics.js index 6bacdbbee..391a26509 100644 --- a/app/kubernetes/metrics/metrics.js +++ b/app/kubernetes/metrics/metrics.js @@ -10,6 +10,7 @@ class KubernetesMetricsService { this.capabilitiesAsync = this.capabilitiesAsync.bind(this); this.getPodAsync = this.getPodAsync.bind(this); + this.getNodeAsync = this.getNodeAsync.bind(this); } /** @@ -27,6 +28,26 @@ class KubernetesMetricsService { return this.$async(this.capabilitiesAsync, endpointID); } + /** + * Stats of Node + * + * @param {string} nodeName + */ + async getNodeAsync(nodeName) { + try { + const params = new KubernetesCommonParams(); + params.id = nodeName; + const data = await this.KubernetesMetrics().getNode(params).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to retrieve node stats', err); + } + } + + getNode(nodeName) { + return this.$async(this.getNodeAsync, nodeName); + } + /** * Stats * diff --git a/app/kubernetes/metrics/rest.js b/app/kubernetes/metrics/rest.js index 22f071b3b..92fa6b95b 100644 --- a/app/kubernetes/metrics/rest.js +++ b/app/kubernetes/metrics/rest.js @@ -20,6 +20,10 @@ angular.module('portainer.kubernetes').factory('KubernetesMetrics', [ method: 'GET', url: podUrl, }, + getNode: { + method: 'GET', + url: `${url}/nodes/:id`, + }, } ); }; diff --git a/app/kubernetes/views/cluster/cluster.html b/app/kubernetes/views/cluster/cluster.html index 47a198c6e..d1c9f08cb 100644 --- a/app/kubernetes/views/cluster/cluster.html +++ b/app/kubernetes/views/cluster/cluster.html @@ -89,6 +89,7 @@ order-by="Name" refresh-callback="ctrl.getNodes" is-admin="ctrl.isAdmin" + use-server-metrics="ctrl.state.useServerMetrics" > diff --git a/app/kubernetes/views/cluster/clusterController.js b/app/kubernetes/views/cluster/clusterController.js index 7726834d2..6824e0738 100644 --- a/app/kubernetes/views/cluster/clusterController.js +++ b/app/kubernetes/views/cluster/clusterController.js @@ -15,7 +15,8 @@ class KubernetesClusterController { KubernetesNodeService, KubernetesApplicationService, KubernetesComponentStatusService, - KubernetesEndpointService + KubernetesEndpointService, + EndpointProvider ) { this.$async = $async; this.$state = $state; @@ -26,6 +27,7 @@ class KubernetesClusterController { this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesComponentStatusService = KubernetesComponentStatusService; this.KubernetesEndpointService = KubernetesEndpointService; + this.EndpointProvider = EndpointProvider; this.onInit = this.onInit.bind(this); this.getNodes = this.getNodes.bind(this); @@ -132,6 +134,7 @@ class KubernetesClusterController { } this.state.viewReady = true; + this.state.useServerMetrics = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.UseServerMetrics; } $onInit() { diff --git a/app/kubernetes/views/cluster/node/stats/stats.html b/app/kubernetes/views/cluster/node/stats/stats.html new file mode 100644 index 000000000..be2341dbf --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/stats.html @@ -0,0 +1,71 @@ + + Cluster > {{ ctrl.state.transition.nodeName }} > + {{ ctrl.state.transition.nodeName }} + + + + +
+ + + + Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured. + + +
+
+ + + +
+
+
+ + This view displays real-time statistics about the node {{ ctrl.state.transition.nodeName }}. + +
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+
+
+
+ + + +
+ +
+
+
+
+
+
diff --git a/app/kubernetes/views/cluster/node/stats/stats.js b/app/kubernetes/views/cluster/node/stats/stats.js new file mode 100644 index 000000000..98e362777 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/stats.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesNodeStatsView', { + templateUrl: './stats.html', + controller: 'KubernetesNodeStatsController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/cluster/node/stats/statsController.js b/app/kubernetes/views/cluster/node/stats/statsController.js new file mode 100644 index 000000000..e56424508 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/statsController.js @@ -0,0 +1,144 @@ +import angular from 'angular'; +import moment from 'moment'; +import filesizeParser from 'filesize-parser'; +import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; +import { PORTAINER_FADEOUT } from '@/constants'; + +class KubernetesNodeStatsController { + /* @ngInject */ + constructor($async, $state, $interval, $document, Notifications, KubernetesNodeService, KubernetesMetricsService, ChartService) { + this.$async = $async; + this.$state = $state; + this.$interval = $interval; + this.$document = $document; + this.Notifications = Notifications; + 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(PORTAINER_FADEOUT); + } + + 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, 0, this.memoryChart); + } + + stopRepeater() { + var repeater = this.repeater; + if (angular.isDefined(repeater)) { + this.$interval.cancel(repeater); + this.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.getNode(this.state.transition.nodeName); + if (stats) { + const memory = filesizeParser(stats.usage.memory); + const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu); + this.stats = { + read: stats.creationTimestamp, + MemoryUsage: memory, + CPUUsage: (cpu / this.nodeCPU) * 100, + }; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); + } + }); + } + + $onDestroy() { + this.stopRepeater(); + } + + async onInit() { + this.state = { + autoRefresh: false, + refreshRate: '30', + viewReady: false, + transition: { + nodeName: this.$transition$.params().name, + }, + getMetrics: true, + }; + + try { + const nodeMetrics = await this.KubernetesMetricsService.getNode(this.state.transition.nodeName); + + if (nodeMetrics) { + const node = await this.KubernetesNodeService.get(this.state.transition.nodeName); + this.nodeCPU = node.CPU || 1; + + await this.getStats(); + + if (this.state.getMetrics) { + this.$document.ready(() => { + this.initCharts(); + }); + } + } else { + this.state.getMetrics = false; + } + } catch (err) { + this.state.getMetrics = false; + this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); + } finally { + this.state.viewReady = true; + } + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default KubernetesNodeStatsController; +angular.module('portainer.kubernetes').controller('KubernetesNodeStatsController', KubernetesNodeStatsController);