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. + + +
+
+ + + +
+
+
+ + This view displays real-time statistics about the container {{ ctrl.state.transition.containerName | trimcontainername }}. + +
+
+
+ +
+ +
+ + + +
+
+
+ Network stats are unavailable for this container. +
+
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+
+
+
+ + + +
+ +
+
+
+
+
+
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 @@ - + + +
+ 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 = {