diff --git a/app/docker/models/containerStats.test.ts b/app/docker/models/containerStats.test.ts new file mode 100644 index 0000000000..9cc42d483a --- /dev/null +++ b/app/docker/models/containerStats.test.ts @@ -0,0 +1,61 @@ +import { ContainerStatsViewModel } from './containerStats'; + +describe('ContainerStatsViewModel', () => { + it('extracts CPU fields correctly from Linux cgroups v2 stats', () => { + const vm = new ContainerStatsViewModel({ + read: '2024-01-01T00:00:01Z', + preread: '2024-01-01T00:00:00Z', + cpu_stats: { + cpu_usage: { total_usage: 709734856000 }, + system_cpu_usage: 16006861690000000, + online_cpus: 4, + }, + precpu_stats: { + cpu_usage: { total_usage: 709734581000 }, + system_cpu_usage: 16006857740000000, + }, + memory_stats: { usage: 0 }, + }); + + expect(vm.CurrentCPUTotalUsage).toBe(709734856000); + expect(vm.PreviousCPUTotalUsage).toBe(709734581000); + expect(vm.CurrentCPUSystemUsage).toBe(16006861690000000); + expect(vm.PreviousCPUSystemUsage).toBe(16006857740000000); + expect(vm.CPUCores).toBe(4); + expect(vm.isWindows).toBe(false); + }); + + it('prefers percpu_usage length over online_cpus for CPUCores', () => { + const vm = new ContainerStatsViewModel({ + read: '2024-01-01T00:00:01Z', + preread: '2024-01-01T00:00:00Z', + cpu_stats: { + cpu_usage: { + total_usage: 2000000, + percpu_usage: [1000000, 1000000], + }, + system_cpu_usage: 100000000, + online_cpus: 8, + }, + precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 }, + memory_stats: { usage: 0 }, + }); + + expect(vm.CPUCores).toBe(2); + }); + + it('falls back to 1 for CPUCores when percpu_usage and online_cpus are absent', () => { + const vm = new ContainerStatsViewModel({ + read: '2024-01-01T00:00:01Z', + preread: '2024-01-01T00:00:00Z', + cpu_stats: { + cpu_usage: { total_usage: 1000 }, + system_cpu_usage: 10000, + }, + precpu_stats: { cpu_usage: { total_usage: 500 }, system_cpu_usage: 9000 }, + memory_stats: { usage: 0 }, + }); + + expect(vm.CPUCores).toBe(1); + }); +}); diff --git a/app/docker/react/views/containers.ts b/app/docker/react/views/containers.ts index 442a72691c..634e109f21 100644 --- a/app/docker/react/views/containers.ts +++ b/app/docker/react/views/containers.ts @@ -10,6 +10,7 @@ import { LogView } from '@/react/docker/containers/LogView'; import { CreateView } from '@/react/docker/containers/CreateView'; import { InspectView } from '@/react/docker/containers/InspectView/InspectView'; import { ItemView } from '@/react/docker/containers/ItemView/ItemView'; +import { StatsView } from '@/react/docker/containers/StatsView/StatsView'; export const containersModule = angular .module('portainer.docker.react.views.containers', []) @@ -36,6 +37,10 @@ export const containersModule = angular 'dockerContainerInspectView', r2a(withUIRouter(withReactQuery(withCurrentUser(InspectView))), []) ) + .component( + 'containerStatsView', + r2a(withUIRouter(withReactQuery(withCurrentUser(StatsView))), []) + ) .config(config).name; /* @ngInject */ @@ -124,8 +129,7 @@ function config($stateRegistryProvider: StateRegistry) { url: '/stats', views: { 'content@': { - templateUrl: '~@/docker/views/containers/stats/containerstats.html', - controller: 'ContainerStatsController', + component: 'containerStatsView', }, }, }); diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js deleted file mode 100644 index d9889cda47..0000000000 --- a/app/docker/views/containers/stats/containerStatsController.js +++ /dev/null @@ -1,177 +0,0 @@ -import moment from 'moment'; - -angular.module('portainer.docker').controller('ContainerStatsController', [ - '$q', - '$scope', - '$transition$', - '$document', - '$interval', - 'ContainerService', - 'ChartService', - 'Notifications', - 'HttpRequestHelper', - 'endpoint', - function ($q, $scope, $transition$, $document, $interval, ContainerService, ChartService, Notifications, HttpRequestHelper, endpoint) { - $scope.state = { - refreshRate: '5', - networkStatsUnavailable: false, - ioStatsUnavailable: false, - }; - - $scope.$on('$destroy', function () { - stopRepeater(); - }); - - function stopRepeater() { - var repeater = $scope.repeater; - if (angular.isDefined(repeater)) { - $interval.cancel(repeater); - } - } - - function updateNetworkChart(stats, chart) { - if (stats.Networks.length > 0) { - var rx = stats.Networks[0].rx_bytes; - var tx = stats.Networks[0].tx_bytes; - var label = moment(stats.read).format('HH:mm:ss'); - - ChartService.UpdateNetworkChart(label, rx, tx, chart); - } - } - - function updateMemoryChart(stats, chart) { - var label = moment(stats.read).format('HH:mm:ss'); - - ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart); - } - - function updateIOChart(stats, chart) { - var label = moment(stats.read).format('HH:mm:ss'); - if (stats.noIOData !== true) { - ChartService.UpdateIOChart(label, stats.BytesRead, stats.BytesWrite, chart); - } - } - - function updateCPUChart(stats, chart) { - var label = moment(stats.read).format('HH:mm:ss'); - var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats); - - ChartService.UpdateCPUChart(label, value, chart); - } - - function calculateCPUPercentUnix(stats) { - var cpuPercent = 0.0; - var cpuDelta = stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage; - var systemDelta = stats.CurrentCPUSystemUsage - stats.PreviousCPUSystemUsage; - - if (systemDelta > 0.0 && cpuDelta > 0.0) { - cpuPercent = (cpuDelta / systemDelta) * stats.CPUCores * 100.0; - } - - return cpuPercent; - } - - function calculateCPUPercentWindows(stats) { - var possIntervals = - stats.NumProcs * parseFloat(moment(stats.read, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf() - moment(stats.preread, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf()); - var windowsCpuUsage = 0.0; - if (possIntervals > 0) { - windowsCpuUsage = parseFloat(stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage) / parseFloat(possIntervals * 100); - } - return windowsCpuUsage; - } - - $scope.changeUpdateRepeater = function () { - var networkChart = $scope.networkChart; - var cpuChart = $scope.cpuChart; - var memoryChart = $scope.memoryChart; - var ioChart = $scope.ioChart; - - stopRepeater(); - setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart); - $('#refreshRateChange').show(); - $('#refreshRateChange').fadeOut(1500); - }; - - function startChartUpdate(networkChart, cpuChart, memoryChart, ioChart) { - $q.all({ - stats: ContainerService.containerStats(endpoint.Id, $transition$.params().id), - }) - .then(function success(data) { - var stats = data.stats; - if (stats.Networks.length === 0) { - $scope.state.networkStatsUnavailable = true; - } - if (stats.noIOData === true) { - $scope.state.ioStatsUnavailable = true; - } - updateNetworkChart(stats, networkChart); - updateMemoryChart(stats, memoryChart); - updateCPUChart(stats, cpuChart); - updateIOChart(stats, ioChart); - setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart); - }) - .catch(function error(err) { - stopRepeater(); - Notifications.error('Failure', err, 'Unable to retrieve container statistics'); - }); - } - - function setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart) { - var refreshRate = $scope.state.refreshRate; - $scope.repeater = $interval(function () { - $q.all({ - stats: ContainerService.containerStats(endpoint.Id, $transition$.params().id), - }) - .then(function success(data) { - var stats = data.stats; - updateNetworkChart(stats, networkChart); - updateMemoryChart(stats, memoryChart); - updateCPUChart(stats, cpuChart); - updateIOChart(stats, ioChart); - }) - .catch(function error(err) { - stopRepeater(); - Notifications.error('Failure', err, 'Unable to retrieve container statistics'); - }); - }, refreshRate * 1000); - } - - function initCharts() { - var networkChartCtx = $('#networkChart'); - var networkChart = ChartService.CreateNetworkChart(networkChartCtx); - $scope.networkChart = networkChart; - - var cpuChartCtx = $('#cpuChart'); - var cpuChart = ChartService.CreateCPUChart(cpuChartCtx); - $scope.cpuChart = cpuChart; - - var memoryChartCtx = $('#memoryChart'); - var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx); - $scope.memoryChart = memoryChart; - - var ioChartCtx = $('#ioChart'); - var ioChart = ChartService.CreateIOChart(ioChartCtx); - $scope.ioChart = ioChart; - - startChartUpdate(networkChart, cpuChart, memoryChart, ioChart); - } - - function initView() { - HttpRequestHelper.setPortainerAgentTargetHeader($transition$.params().nodeName); - ContainerService.container(endpoint.Id, $transition$.params().id) - .then(function success(data) { - $scope.container = data; - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve container information'); - }); - - $document.ready(function () { - initCharts(); - }); - } - - initView(); - }, -]); diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html deleted file mode 100644 index 6429e3177b..0000000000 --- a/app/docker/views/containers/stats/containerstats.html +++ /dev/null @@ -1,111 +0,0 @@ - - - -
-
- - - -
-
-
- - This view displays real-time statistics about the container {{ container.Name | trimcontainername }} as well as a list of the running processes inside this - container. - -
-
-
- -
- -
- - - -
-
-
- - - Network stats are unavailable for this container. - -
-
-
-
- - - I/O stats are unavailable for this container. - -
-
-
-
-
-
-
- -
-
- - - -
- -
-
-
-
- -
- - - -
- -
-
-
-
- -
- - - -
- -
-
-
-
- -
- - - -
- -
-
-
-
-
- - diff --git a/app/kubernetes/views/applications/stats/stats.html b/app/kubernetes/views/applications/stats/stats.html deleted file mode 100644 index 1d39851379..0000000000 --- a/app/kubernetes/views/applications/stats/stats.html +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - 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. - - -
-
- -
-
-
- -
- About statistics -
-
- -
-
-
- - This view displays real-time statistics about the container {{ ctrl.state.transition.containerName | trimcontainername }}. - -
-
-
- -
- -
- - - -
-
-
- - - Network stats are unavailable for this container. - -
-
-
-
-
-
-
- -
-
- -
-
-
- -
- Memory usage -
-
- -
- -
-
-
-
-
- -
-
-
- -
- CPU usage -
-
- -
- -
-
-
-
-
diff --git a/app/kubernetes/views/applications/stats/stats.js b/app/kubernetes/views/applications/stats/stats.js index 01496a9b62..eaa71da23a 100644 --- a/app/kubernetes/views/applications/stats/stats.js +++ b/app/kubernetes/views/applications/stats/stats.js @@ -1,8 +1,9 @@ -angular.module('portainer.kubernetes').component('kubernetesApplicationStatsView', { - templateUrl: './stats.html', - controller: 'KubernetesApplicationStatsController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - }, -}); +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { ApplicationStatsView } from '@/react/kubernetes/applications/StatsView/ApplicationStatsView'; + +angular.module('portainer.kubernetes').component('kubernetesApplicationStatsView', r2a(withUIRouter(withReactQuery(withCurrentUser(ApplicationStatsView))), [])); diff --git a/app/kubernetes/views/applications/stats/statsController.js b/app/kubernetes/views/applications/stats/statsController.js deleted file mode 100644 index 6c70520509..0000000000 --- a/app/kubernetes/views/applications/stats/statsController.js +++ /dev/null @@ -1,173 +0,0 @@ -import angular from 'angular'; -import moment from 'moment'; -import _ from 'lodash-es'; -import filesizeParser from 'filesize-parser'; -import KubernetesPodConverter from '@/kubernetes/pod/converter'; -import { getMetricsForPod } from '@/react/kubernetes/metrics/metrics.ts'; -import { parseCPU } from '@/react/kubernetes/utils'; - -class KubernetesApplicationStatsController { - /* @ngInject */ - constructor($async, $state, $interval, $document, Notifications, KubernetesPodService, KubernetesNodeService, ChartService) { - this.$async = $async; - this.$state = $state; - this.$interval = $interval; - this.$document = $document; - this.Notifications = Notifications; - this.KubernetesPodService = KubernetesPodService; - this.KubernetesNodeService = KubernetesNodeService; - this.ChartService = ChartService; - - this.onInit = this.onInit.bind(this); - this.initCharts = this.initCharts.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); - } - } - - 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() { - let i = 0; - const findCharts = setInterval(() => { - let cpuChartCtx = $('#cpuChart'); - let memoryChartCtx = $('#memoryChart'); - if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) { - const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); - this.cpuChart = cpuChart; - const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); - this.memoryChart = memoryChart; - this.updateCPUChart(); - this.updateMemoryChart(); - this.setUpdateRepeater(); - clearInterval(findCharts); - return; - } - i++; - if (i >= 10) { - clearInterval(findCharts); - } - }, 200); - } - - getStats() { - return this.$async(async () => { - try { - const stats = await getMetricsForPod(this.$state.params.endpointId, 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 = 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 getMetricsForPod(this.$state.params.endpointId, 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/cluster/node/stats/stats.html b/app/kubernetes/views/cluster/node/stats/stats.html deleted file mode 100644 index 7cd0412ddf..0000000000 --- a/app/kubernetes/views/cluster/node/stats/stats.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - 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. - - -
-
- -
-
-
- -
- About statistics -
-
- -
-
-
- - This view displays real-time statistics about the node {{ ctrl.state.transition.nodeName }}. - -
-
-
- -
- -
- - - -
-
-
-
-
-
- -
-
- -
-
-
- -
- Memory usage -
-
- -
- -
-
-
-
-
- -
-
-
- -
- CPU usage -
-
- -
- -
-
-
-
-
diff --git a/app/kubernetes/views/cluster/node/stats/stats.js b/app/kubernetes/views/cluster/node/stats/stats.js index 98e3627774..27b38201e9 100644 --- a/app/kubernetes/views/cluster/node/stats/stats.js +++ b/app/kubernetes/views/cluster/node/stats/stats.js @@ -1,8 +1,9 @@ -angular.module('portainer.kubernetes').component('kubernetesNodeStatsView', { - templateUrl: './stats.html', - controller: 'KubernetesNodeStatsController', - controllerAs: 'ctrl', - bindings: { - $transition$: '<', - }, -}); +import angular from 'angular'; + +import { r2a } from '@/react-tools/react2angular'; +import { withCurrentUser } from '@/react-tools/withCurrentUser'; +import { withReactQuery } from '@/react-tools/withReactQuery'; +import { withUIRouter } from '@/react-tools/withUIRouter'; +import { NodeStatsView } from '@/react/kubernetes/cluster/NodeStatsView/NodeStatsView'; + +angular.module('portainer.kubernetes').component('kubernetesNodeStatsView', r2a(withUIRouter(withReactQuery(withCurrentUser(NodeStatsView))), [])); diff --git a/app/kubernetes/views/cluster/node/stats/statsController.js b/app/kubernetes/views/cluster/node/stats/statsController.js deleted file mode 100644 index 7142951620..0000000000 --- a/app/kubernetes/views/cluster/node/stats/statsController.js +++ /dev/null @@ -1,147 +0,0 @@ -import angular from 'angular'; -import moment from 'moment'; -import filesizeParser from 'filesize-parser'; -import { PORTAINER_FADEOUT } from '@/constants'; -import { getMetricsForNode } from '@/react/kubernetes/metrics/queries/useNodeMetricsQuery'; -import { parseCPU } from '@/react/kubernetes/utils'; - -class KubernetesNodeStatsController { - /* @ngInject */ - constructor($async, $state, $interval, $document, Notifications, KubernetesNodeService, ChartService) { - this.$async = $async; - this.$state = $state; - this.$interval = $interval; - this.$document = $document; - this.Notifications = Notifications; - this.KubernetesNodeService = KubernetesNodeService; - this.ChartService = ChartService; - - this.onInit = this.onInit.bind(this); - this.initCharts = this.initCharts.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 findCharts = setInterval(() => { - let cpuChartCtx = $('#cpuChart'); - let memoryChartCtx = $('#memoryChart'); - if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) { - const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); - this.cpuChart = cpuChart; - const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); - this.memoryChart = memoryChart; - this.updateCPUChart(); - this.updateMemoryChart(); - this.setUpdateRepeater(); - clearInterval(findCharts); - } - }, 200); - } - - getStats() { - return this.$async(async () => { - try { - const stats = await getMetricsForNode(this.$state.params.endpointId, this.state.transition.nodeName); - if (stats) { - const memory = filesizeParser(stats.usage.memory); - const cpu = parseCPU(stats.usage.cpu); - this.stats = { - read: stats.metadata.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().nodeName, - }, - getMetrics: true, - }; - - try { - const nodeMetrics = await getMetricsForNode(this.$state.params.endpointId, this.state.transition.nodeName); - - if (nodeMetrics) { - const node = await this.KubernetesNodeService.get(this.state.transition.nodeName); - this.nodeCPU = node.CPU || 1; - - await this.getStats(); - } 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; - if (this.state.getMetrics) { - this.$document.ready(() => { - this.initCharts(); - }); - } - } - } - - $onInit() { - return this.$async(this.onInit); - } -} - -export default KubernetesNodeStatsController; -angular.module('portainer.kubernetes').controller('KubernetesNodeStatsController', KubernetesNodeStatsController); diff --git a/app/portainer/services/chartService.js b/app/portainer/services/chartService.js deleted file mode 100644 index 3cb3f1d39c..0000000000 --- a/app/portainer/services/chartService.js +++ /dev/null @@ -1,277 +0,0 @@ -import Chart from 'chart.js'; -import { filesize } from 'filesize'; - -angular.module('portainer.app').factory('ChartService', [ - function ChartService() { - 'use strict'; - - // Max. number of items to display on a chart - var CHART_LIMIT = 600; - - var service = {}; - - function defaultChartOptions(pos, tooltipCallback, scalesCallback, isStacked) { - return { - animation: { duration: 0 }, - responsiveAnimationDuration: 0, - responsive: true, - tooltips: { - mode: 'index', - intersect: false, - position: pos, - callbacks: { - label: function (tooltipItem, data) { - var datasetLabel = data.datasets[tooltipItem.datasetIndex].label; - return tooltipCallback(datasetLabel, tooltipItem.yLabel); - }, - }, - }, - layout: { - padding: { - left: 15, - }, - }, - hover: { animationDuration: 0 }, - scales: { - yAxes: [ - { - stacked: isStacked, - ticks: { - beginAtZero: true, - callback: scalesCallback, - precision: 0, - }, - }, - ], - }, - }; - } - - function CreateChart(context, label, tooltipCallback, scalesCallback) { - return new Chart(context, { - type: 'line', - data: { - labels: [], - datasets: [ - { - label: label, - data: [], - fill: true, - backgroundColor: 'rgba(151,187,205,0.4)', - borderColor: 'rgba(151,187,205,0.6)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: 'rgba(151,187,205,1)', - pointRadius: 2, - borderWidth: 2, - }, - ], - }, - options: defaultChartOptions('nearest', tooltipCallback, scalesCallback), - }); - } - - function CreateMemoryChart(context, tooltipCallback, scalesCallback) { - return new Chart(context, { - type: 'line', - data: { - labels: [], - datasets: [ - { - label: 'Memory', - data: [], - fill: true, - backgroundColor: 'rgba(151,187,205,0.4)', - borderColor: 'rgba(151,187,205,0.6)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: 'rgba(151,187,205,1)', - pointRadius: 2, - borderWidth: 2, - }, - { - label: 'Cache', - data: [], - fill: true, - backgroundColor: 'rgba(255,180,174,0.4)', - borderColor: 'rgba(255,180,174,0.6)', - pointBackgroundColor: 'rgba(255,180,174,1)', - pointBorderColor: 'rgba(255,180,174,1)', - pointRadius: 2, - borderWidth: 2, - }, - ], - }, - options: defaultChartOptions('nearest', tooltipCallback, scalesCallback, true), - }); - } - - function CreateIOChart(context, tooltipCallback, scalesCallback) { - return new Chart(context, { - type: 'line', - data: { - labels: [], - datasets: [ - { - label: 'Read (Aggregate)', - data: [], - fill: true, - backgroundColor: 'rgba(151,187,205,0.4)', - borderColor: 'rgba(151,187,205,0.6)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: 'rgba(151,187,205,1)', - pointRadius: 2, - borderWidth: 2, - }, - { - label: 'Write (Aggregate)', - data: [], - fill: true, - backgroundColor: 'rgba(255,180,174,0.4)', - borderColor: 'rgba(255,180,174,0.6)', - pointBackgroundColor: 'rgba(255,180,174,1)', - pointBorderColor: 'rgba(255,180,174,1)', - pointRadius: 2, - borderWidth: 2, - }, - ], - }, - options: defaultChartOptions('nearest', tooltipCallback, scalesCallback, true), - }); - } - - service.CreateCPUChart = function (context) { - return CreateChart(context, 'CPU', percentageBasedTooltipLabel, percentageBasedAxisLabel); - }; - - service.CreateIOChart = function (context) { - return CreateIOChart(context, byteBasedTooltipLabel, byteBasedAxisLabel); - }; - - service.CreateMemoryChart = function (context) { - return CreateMemoryChart(context, byteBasedTooltipLabel, byteBasedAxisLabel); - }; - - service.CreateNetworkChart = function (context) { - return new Chart(context, { - type: 'line', - data: { - labels: [], - datasets: [ - { - label: 'RX on eth0', - data: [], - fill: false, - backgroundColor: 'rgba(151,187,205,0.4)', - borderColor: 'rgba(151,187,205,0.6)', - pointBackgroundColor: 'rgba(151,187,205,1)', - pointBorderColor: 'rgba(151,187,205,1)', - pointRadius: 2, - borderWidth: 2, - }, - { - label: 'TX on eth0', - data: [], - fill: false, - backgroundColor: 'rgba(255,180,174,0.4)', - borderColor: 'rgba(255,180,174,0.6)', - pointBackgroundColor: 'rgba(255,180,174,1)', - pointBorderColor: 'rgba(255,180,174,1)', - pointRadius: 2, - borderWidth: 2, - }, - ], - }, - options: defaultChartOptions('average', byteBasedTooltipLabel, byteBasedAxisLabel), - }); - }; - - function LimitChartItems(chart, CHART_LIMIT) { - if (chart.data.datasets[0].data.length > CHART_LIMIT) { - chart.data.labels.pop(); - chart.data.datasets[0].data.pop(); - chart.data.datasets[1].data.pop(); - } - } - - function UpdateChart(label, value, chart) { - chart.data.labels.push(label); - chart.data.datasets[0].data.push(value); - - if (chart.data.datasets[0].data.length > CHART_LIMIT) { - chart.data.labels.pop(); - chart.data.datasets[0].data.pop(); - } - - chart.update(0); - } - - service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) { - chart.data.labels.push(label); - chart.data.datasets[0].data.push(memoryValue); - - if (cacheValue) { - chart.data.datasets[1].data.push(cacheValue); - } else { - // cache values are not available for Windows - chart.data.datasets.splice(1, 1); - } - - LimitChartItems(chart); - - chart.update(0); - }; - service.UpdateCPUChart = UpdateChart; - service.UpdateIOChart = function (label, read, write, chart) { - chart.data.labels.push(label); - chart.data.datasets[0].data.push(read); - chart.data.datasets[1].data.push(write); - LimitChartItems(chart); - chart.update(0); - }; - - service.UpdateNetworkChart = function (label, rx, tx, chart) { - chart.data.labels.push(label); - chart.data.datasets[0].data.push(rx); - chart.data.datasets[1].data.push(tx); - - LimitChartItems(chart); - - chart.update(0); - }; - - function byteBasedTooltipLabel(label, value) { - var processedValue = 0; - if (value > 5) { - processedValue = filesize(value, { base: 10, round: 1 }); - } else { - processedValue = value.toFixed(1) + 'B'; - } - return label + ': ' + processedValue; - } - - function byteBasedAxisLabel(value) { - if (value > 5) { - return filesize(value, { base: 10, round: 1 }); - } - return value.toFixed(1) + 'B'; - } - - function percentageBasedAxisLabel(value) { - if (value > 1) { - return Math.round(value) + '%'; - } - return value.toFixed(1) + '%'; - } - - function percentageBasedTooltipLabel(label, value) { - var processedValue = 0; - if (value > 1) { - processedValue = Math.round(value); - } else { - processedValue = value.toFixed(1); - } - return label + ': ' + processedValue + '%'; - } - - return service; - }, -]); diff --git a/app/react/components/Charts/ChartsRecharts.stories.tsx b/app/react/components/Charts/ChartsRecharts.stories.tsx new file mode 100644 index 0000000000..606f374281 --- /dev/null +++ b/app/react/components/Charts/ChartsRecharts.stories.tsx @@ -0,0 +1,160 @@ +import { Meta, StoryObj } from '@storybook/react-webpack5'; +import { filesize } from 'filesize'; + +import { StatsLineChart } from './StatsLineChart'; + +// Same sample data as Charts.stories.tsx, combined into the object format StatsLineChart expects + +const LABELS = Array.from( + { length: 30 }, + (_, i) => `10:00:${String(i).padStart(2, '0')}` +); + +const CPU_VALUES = [ + 45, 52, 48, 61, 55, 42, 38, 65, 72, 68, 55, 49, 53, 61, 58, 44, 39, 47, 63, + 70, 65, 52, 48, 55, 60, 57, 45, 41, 50, 58, +]; + +const MEMORY_VALUES = Array.from( + { length: 30 }, + (_, i) => (512 + i * 2) * 1024 * 1024 +); +const CACHE_VALUES = Array.from( + { length: 30 }, + (_, i) => (256 + i * 0.5) * 1024 * 1024 +); + +const RX_VALUES = [ + 50_000, 120_000, 80_000, 250_000, 180_000, 90_000, 60_000, 350_000, 220_000, + 150_000, 80_000, 100_000, 200_000, 280_000, 160_000, 90_000, 70_000, 130_000, + 300_000, 240_000, 170_000, 100_000, 80_000, 150_000, 210_000, 190_000, + 120_000, 85_000, 140_000, 200_000, +]; +const TX_VALUES = [ + 10_000, 25_000, 18_000, 40_000, 30_000, 20_000, 12_000, 55_000, 35_000, + 28_000, 15_000, 20_000, 38_000, 45_000, 32_000, 18_000, 14_000, 25_000, + 50_000, 42_000, 35_000, 22_000, 16_000, 30_000, 40_000, 38_000, 25_000, + 17_000, 28_000, 40_000, +]; + +const READ_VALUES = [ + 1_000_000, 2_500_000, 1_800_000, 3_500_000, 2_200_000, 1_500_000, 800_000, + 4_000_000, 2_800_000, 2_000_000, 1_200_000, 1_500_000, 2_700_000, 3_200_000, + 2_100_000, 1_300_000, 1_000_000, 1_800_000, 3_500_000, 2_900_000, 2_200_000, + 1_600_000, 1_200_000, 2_000_000, 2_800_000, 2_500_000, 1_800_000, 1_200_000, + 2_000_000, 2_800_000, +]; +const WRITE_VALUES = [ + 500_000, 1_000_000, 800_000, 1_500_000, 900_000, 700_000, 400_000, 1_800_000, + 1_200_000, 900_000, 600_000, 700_000, 1_200_000, 1_500_000, 1_000_000, + 600_000, 500_000, 800_000, 1_500_000, 1_300_000, 1_000_000, 700_000, 600_000, + 900_000, 1_200_000, 1_100_000, 800_000, 600_000, 900_000, 1_200_000, +]; + +const DATA = LABELS.map((time, i) => ({ + time, + cpu: CPU_VALUES[i], + memory: MEMORY_VALUES[i], + cache: CACHE_VALUES[i], + rx: RX_VALUES[i], + tx: TX_VALUES[i], + ioRead: READ_VALUES[i], + ioWrite: WRITE_VALUES[i], +})); + +function formatBytes(value: number): string { + return value > 5 + ? (filesize(value, { base: 10, round: 1 }) as string) + : `${value.toFixed(1)}B`; +} + +function formatPercent(value: number): string { + return value > 1 ? `${Math.round(value)}%` : `${value.toFixed(1)}%`; +} + +const PRIMARY = '#97bbcd'; +const SECONDARY = '#ffb4ae'; + +const meta: Meta = { + title: 'Charts/Recharts', + component: StatsLineChart, +}; +export default meta; + +type Story = StoryObj; + +export const CPU: Story = { + args: { + data: DATA, + series: [{ dataKey: 'cpu', name: 'CPU', color: PRIMARY, area: true }], + yAxisFormatter: formatPercent, + }, +}; + +export const Memory: Story = { + args: { + data: DATA, + series: [ + { + dataKey: 'memory', + name: 'Memory', + color: PRIMARY, + area: true, + stackId: 'mem', + }, + { + dataKey: 'cache', + name: 'Cache', + color: SECONDARY, + area: true, + stackId: 'mem', + }, + ], + yAxisFormatter: formatBytes, + }, +}; + +export const MemoryWithoutCache: Story = { + name: 'Memory (no cache)', + args: { + data: DATA, + series: [{ dataKey: 'memory', name: 'Memory', color: PRIMARY, area: true }], + yAxisFormatter: formatBytes, + }, +}; + +export const NetworkIO: Story = { + name: 'Network I/O', + args: { + data: DATA, + series: [ + { dataKey: 'rx', name: 'RX on eth0', color: PRIMARY }, + { dataKey: 'tx', name: 'TX on eth0', color: SECONDARY }, + ], + yAxisFormatter: formatBytes, + }, +}; + +export const DiskIO: Story = { + name: 'Disk I/O', + args: { + data: DATA, + series: [ + { + dataKey: 'ioRead', + name: 'Read (Aggregate)', + color: PRIMARY, + area: true, + stackId: 'io', + }, + { + dataKey: 'ioWrite', + name: 'Write (Aggregate)', + color: SECONDARY, + area: true, + stackId: 'io', + }, + ], + yAxisFormatter: formatBytes, + }, +}; diff --git a/app/react/components/Charts/StatsLineChart.test.tsx b/app/react/components/Charts/StatsLineChart.test.tsx new file mode 100644 index 0000000000..94d71303fb --- /dev/null +++ b/app/react/components/Charts/StatsLineChart.test.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { StatsLineChart } from './StatsLineChart'; +import type { SeriesConfig } from './StatsLineChart'; + +vi.mock('recharts', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + ResponsiveContainer: ({ children }: { children: React.ReactElement }) => + React.cloneElement(children, { width: 800, height: 300 }), + }; +}); + +function yAxisFormatter(value: number): string { + return `${value}%`; +} + +const singleSeries: SeriesConfig[] = [ + { dataKey: 'cpu', name: 'CPU Usage', color: '#4e9af1' }, +]; + +const multiSeries: SeriesConfig[] = [ + { dataKey: 'cpu', name: 'CPU Usage', color: '#4e9af1' }, + { dataKey: 'mem', name: 'Memory Usage', color: '#f1a24e' }, +]; + +const stackedAreaSeries: SeriesConfig[] = [ + { + dataKey: 'rx', + name: 'Rx', + color: '#4e9af1', + area: true, + stackId: 'network', + }, + { + dataKey: 'tx', + name: 'Tx', + color: '#f14e4e', + area: true, + stackId: 'network', + }, +]; + +const sampleData = [ + { time: '10:00', cpu: 20, mem: 40 }, + { time: '10:05', cpu: 35, mem: 55 }, +]; + +describe('StatsLineChart', () => { + it('renders without crashing on empty data and shows the legend entry', () => { + render( + + ); + + expect(screen.getByText('CPU Usage')).toBeVisible(); + }); + + it('renders a legend entry for each series name', () => { + render( + + ); + + expect(screen.getByText('CPU Usage')).toBeVisible(); + expect(screen.getByText('Memory Usage')).toBeVisible(); + }); + + it('renders an SVG element', () => { + const { container } = render( + + ); + + expect(container.querySelector('svg')).not.toBeNull(); + }); + + it('applies the default height of 300px to the wrapper div', () => { + const { container } = render( + + ); + + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.style.height).toBe('300px'); + }); + + it('applies a custom height to the wrapper div', () => { + const { container } = render( + + ); + + const wrapper = container.firstElementChild as HTMLElement; + expect(wrapper.style.height).toBe('500px'); + }); + + it('renders both legend entries for stacked area series', () => { + render( + + ); + + expect(screen.getByText('Rx')).toBeVisible(); + expect(screen.getByText('Tx')).toBeVisible(); + }); + + it('renders correctly with a custom timeKey prop', () => { + const dataWithCustomKey = [ + { ts: '10:00', cpu: 20 }, + { ts: '10:05', cpu: 30 }, + ]; + + render( + + ); + + expect(screen.getByText('CPU Usage')).toBeVisible(); + }); +}); diff --git a/app/react/components/Charts/StatsLineChart.tsx b/app/react/components/Charts/StatsLineChart.tsx new file mode 100644 index 0000000000..48b14812c1 --- /dev/null +++ b/app/react/components/Charts/StatsLineChart.tsx @@ -0,0 +1,94 @@ +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; + +export type SeriesConfig = { + dataKey: string; + name: string; + color: string; + area?: boolean; + stackId?: string; +}; + +interface Props { + data: Array>; + timeKey?: string; + series: SeriesConfig[]; + yAxisFormatter: (value: number) => string; + height?: number; + yAxisDomain?: [number | string, number | string]; +} + +export function StatsLineChart({ + data, + timeKey = 'time', + series, + yAxisFormatter, + height = 300, + yAxisDomain = [0, 'auto'], +}: Props) { + return ( +
+ + + + + + + yAxisFormatter(typeof value === 'number' ? value : 0) + } + isAnimationActive={false} + /> + + {series.map((s) => + s.area ? ( + + ) : ( + + ) + )} + + +
+ ); +} diff --git a/app/react/docker/containers/StatsView/StatsView.test.tsx b/app/react/docker/containers/StatsView/StatsView.test.tsx new file mode 100644 index 0000000000..12fd52f1a3 --- /dev/null +++ b/app/react/docker/containers/StatsView/StatsView.test.tsx @@ -0,0 +1,227 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { ContainerStatsViewModel } from '@/docker/models/containerStats'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; + +import { StatsView, formatPercent, calculateCpuPercent } from './StatsView'; + +vi.mock('@uirouter/react', async (importOriginal) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: 1, id: 'container1', nodeName: undefined }, + })), +})); + +const minimalStats = { + read: '2024-01-01T00:00:01Z', + preread: '2024-01-01T00:00:00Z', + cpu_stats: { + cpu_usage: { total_usage: 2000000, percpu_usage: [1000000, 1000000] }, + system_cpu_usage: 100000000, + }, + precpu_stats: { + cpu_usage: { total_usage: 1000000 }, + system_cpu_usage: 90000000, + }, + memory_stats: { + usage: 536870912, + stats: { cache: 0 }, + limit: 2147483648, + }, + networks: { + eth0: { + rx_bytes: 100000, + tx_bytes: 10000, + rx_packets: 0, + rx_errors: 0, + rx_dropped: 0, + tx_packets: 0, + tx_errors: 0, + tx_dropped: 0, + }, + }, + blkio_stats: { + io_service_bytes_recursive: [ + { op: 'Read', value: 1000000, major: 8, minor: 0 }, + { op: 'Write', value: 500000, major: 8, minor: 0 }, + ], + }, +}; + +function addBaseHandlers() { + server.use( + http.get('/api/endpoints/1/docker/containers/container1/json', () => + HttpResponse.json({ Name: '/container1', Id: 'container1' }) + ), + http.get('/api/endpoints/1/docker/containers/container1/top', () => + HttpResponse.json({ Processes: [], Titles: [] }) + ) + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + addBaseHandlers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function renderComponent() { + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(StatsView)) + ); + return render(); +} + +describe('formatPercent', () => { + it('rounds to the nearest integer for values >= 1', () => { + expect(formatPercent(20)).toBe('20%'); + expect(formatPercent(1.6)).toBe('2%'); + }); + + it('formats to one decimal place for values between 0.1 and 1', () => { + expect(formatPercent(0.5)).toBe('0.5%'); + expect(formatPercent(0.1)).toBe('0.1%'); + }); + + it('formats to two decimal places for values below 0.1', () => { + expect(formatPercent(0.028)).toBe('0.03%'); + expect(formatPercent(0)).toBe('0.00%'); + }); +}); + +// Real-world fixture from Docker API: cpu_stats then precpu_stats +const realWorldStats = new ContainerStatsViewModel({ + read: '2024-01-01T00:00:01Z', + preread: '2024-01-01T00:00:00Z', + cpu_stats: { + cpu_usage: { total_usage: 709734856000 }, + system_cpu_usage: 16006861690000000, + online_cpus: 4, + }, + precpu_stats: { + cpu_usage: { total_usage: 709734581000 }, + system_cpu_usage: 16006857740000000, + }, + memory_stats: { usage: 0 }, +}); + +describe('calculateCpuPercent', () => { + it('computes the correct percentage from real-world cgroups v2 stats', () => { + // cpuDelta=275000, systemDelta=3950000000, cores=4 → ~0.028% + expect(calculateCpuPercent(realWorldStats)).toBeCloseTo(0.0278, 3); + }); + + it('returns 0 when cpu and system deltas are both zero', () => { + const idleStats = new ContainerStatsViewModel({ + read: '2024-01-01T00:00:01Z', + preread: '2024-01-01T00:00:00Z', + cpu_stats: { + cpu_usage: { total_usage: 1000000 }, + system_cpu_usage: 100000000, + online_cpus: 2, + }, + precpu_stats: { + cpu_usage: { total_usage: 1000000 }, + system_cpu_usage: 100000000, + }, + memory_stats: { usage: 0 }, + }); + + expect(calculateCpuPercent(idleStats)).toBe(0); + }); +}); + +describe('StatsView', () => { + it('renders the page header "Container statistics"', () => { + renderComponent(); + + expect(screen.getByText('Container statistics')).toBeInTheDocument(); + }); + + it('renders the refresh rate select with the correct options', () => { + renderComponent(); + + const select = screen.getByRole('combobox', { name: /refresh rate/i }); + expect(select).toBeInTheDocument(); + + const options = Array.from(select.querySelectorAll('option')).map( + (o) => o.textContent + ); + expect(options).toEqual(['1s', '3s', '5s', '10s', '30s', '60s']); + }); + + it('shows "Unable to retrieve container statistics" error panel when stats fetch returns 500', async () => { + server.use( + http.get('/api/endpoints/1/docker/containers/container1/stats', () => + HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('Unable to retrieve container statistics') + ).toBeInTheDocument(); + }); + }); + + it('shows "Network stats are unavailable" message when stats have empty networks', async () => { + server.use( + http.get('/api/endpoints/1/docker/containers/container1/stats', () => + HttpResponse.json({ ...minimalStats, networks: {} }) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('Network stats are unavailable for this container.') + ).toBeInTheDocument(); + }); + }); + + it('does not render the Network chart widget when networkUnavailable is true', async () => { + server.use( + http.get('/api/endpoints/1/docker/containers/container1/stats', () => + HttpResponse.json({ ...minimalStats, networks: {} }) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('Network stats are unavailable for this container.') + ).toBeInTheDocument(); + }); + + expect( + screen.queryByText('Network usage (aggregate)') + ).not.toBeInTheDocument(); + }); + + it('shows "I/O stats are unavailable" when stats have blkio_stats: undefined', async () => { + server.use( + http.get('/api/endpoints/1/docker/containers/container1/stats', () => + HttpResponse.json({ ...minimalStats, blkio_stats: undefined }) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('I/O stats are unavailable for this container.') + ).toBeInTheDocument(); + }); + }); +}); diff --git a/app/react/docker/containers/StatsView/StatsView.tsx b/app/react/docker/containers/StatsView/StatsView.tsx new file mode 100644 index 0000000000..a709df9d63 --- /dev/null +++ b/app/react/docker/containers/StatsView/StatsView.tsx @@ -0,0 +1,344 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import { filesize } from 'filesize'; +import moment from 'moment'; +import { useEffect, useRef, useState } from 'react'; + +import { trimContainerName } from '@/docker/filters/utils'; +import { ContainerStatsViewModel } from '@/docker/models/containerStats'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { StatsLineChart } from '@/react/components/Charts/StatsLineChart'; + +import { InformationPanel } from '@@/InformationPanel'; +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; + +import { containerStats } from '../queries/useContainerStats'; +import { + useContainer, + ContainerDetailsResponse, +} from '../queries/useContainer'; + +import { ProcessesDatatable } from './ProcessesDatatable'; + +const CHART_LIMIT = 600; +const REFRESH_RATES = [1, 3, 5, 10, 30, 60] as const; + +const PRIMARY = '#97bbcd'; +const SECONDARY = '#ffb4ae'; + +type ChartPoint = { + time: string; + cpu: number; + memory: number; + cache: number; + rx: number; + tx: number; + ioRead: number; + ioWrite: number; +}; + +function formatBytes(value: number): string { + return value > 5 + ? filesize(value, { base: 10, round: 1 }) + : `${value.toFixed(1)}B`; +} + +export function formatPercent(value: number): string { + if (value >= 1) return `${Math.round(value)}%`; + if (value >= 0.1) return `${value.toFixed(1)}%`; + return `${value.toFixed(2)}%`; +} + +export function calculateCpuPercent(stats: ContainerStatsViewModel): number { + if (stats.isWindows) { + const readMs = new Date(stats.read).getTime(); + const prereadMs = new Date(stats.preread).getTime(); + const possIntervals = stats.NumProcs * (readMs - prereadMs); + if (possIntervals > 0) { + return ( + (stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage) / + (possIntervals * 100) + ); + } + return 0; + } + const cpuDelta = stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage; + const systemDelta = + stats.CurrentCPUSystemUsage - stats.PreviousCPUSystemUsage; + if (systemDelta > 0 && cpuDelta > 0) { + return (cpuDelta / systemDelta) * stats.CPUCores * 100; + } + return 0; +} + +export function StatsView() { + const environmentId = useEnvironmentId(); + const { + params: { id: containerId, nodeName }, + } = useCurrentStateAndParams(); + + const [refreshRate, setRefreshRate] = useState(5); + const [chartData, setChartData] = useState([]); + const [networkUnavailable, setNetworkUnavailable] = useState(false); + const [ioUnavailable, setIoUnavailable] = useState(false); + const [fetchError, setFetchError] = useState(null); + + const networkUnavailableRef = useRef(false); + const ioUnavailableRef = useRef(false); + + const containerQuery = useContainer({ + environmentId, + containerId, + nodeName, + }); + const containerName = trimContainerName(containerQuery.data?.Name); + + useEffect(() => { + let active = true; + let intervalId: ReturnType | null = null; + + async function doFetch() { + try { + const raw = await containerStats(environmentId, containerId, nodeName); + if (!active) return; + + const stats = new ContainerStatsViewModel(raw); + + if (!networkUnavailableRef.current && stats.Networks.length === 0) { + networkUnavailableRef.current = true; + setNetworkUnavailable(true); + } + if (!ioUnavailableRef.current && stats.noIOdata) { + ioUnavailableRef.current = true; + setIoUnavailable(true); + } + + const point: ChartPoint = { + time: moment(stats.read).format('HH:mm:ss'), + cpu: calculateCpuPercent(stats), + memory: stats.MemoryUsage, + cache: stats.MemoryCache, + rx: stats.Networks[0]?.rx_bytes ?? 0, + tx: stats.Networks[0]?.tx_bytes ?? 0, + ioRead: stats.BytesRead, + ioWrite: stats.BytesWrite, + }; + + setFetchError(null); + setChartData((prev) => { + const next = [...prev, point]; + return next.length > CHART_LIMIT ? next.slice(1) : next; + }); + } catch (err) { + if (!active) return; + setFetchError( + err instanceof Error + ? err.message + : 'Unable to retrieve container statistics' + ); + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + } + } + + doFetch(); + intervalId = setInterval(doFetch, refreshRate * 1000); + return () => { + active = false; + if (intervalId) { + clearInterval(intervalId); + } + }; + }, [environmentId, containerId, nodeName, refreshRate]); + + return ( + <> + + +
+
+ + + +
+
+
+ + This view displays real-time statistics about the + container {containerName} as well as a list of the + running processes inside this container. + +
+
+
+ +
+ +
+
+ {networkUnavailable && ( +
+
+ + Network stats are unavailable for this container. + +
+
+ )} + {ioUnavailable && ( +
+
+ + I/O stats are unavailable for this container. + +
+
+ )} +
+
+
+
+
+ + {fetchError && ( + + {fetchError} + + )} + +
+
+ + + + + + +
+ +
+ + + + + + +
+ + {!networkUnavailable && ( +
+ + + + + + +
+ )} + + {!ioUnavailable && ( +
+ + + + + + +
+ )} +
+ + + + ); +} diff --git a/app/react/docker/containers/queries/useContainerStats.test.ts b/app/react/docker/containers/queries/useContainerStats.test.ts new file mode 100644 index 0000000000..91fbf00858 --- /dev/null +++ b/app/react/docker/containers/queries/useContainerStats.test.ts @@ -0,0 +1,66 @@ +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; + +import { containerStats } from './useContainerStats'; + +describe('containerStats', () => { + it('returns stats data on a successful request', async () => { + const statsData = { read: '2024-01-01T00:00:00Z' }; + server.use( + http.get('/api/endpoints/1/docker/containers/abc/stats', () => + HttpResponse.json(statsData) + ) + ); + + const result = await containerStats(1, 'abc'); + + expect(result).toEqual(statsData); + }); + + it('sends X-PortainerAgent-Target header when nodeName is provided', async () => { + let capturedRequest: Request | undefined; + server.use( + http.get( + '/api/endpoints/1/docker/containers/abc/stats', + ({ request }) => { + capturedRequest = request; + return HttpResponse.json({}); + } + ) + ); + + await containerStats(1, 'abc', 'node1'); + + expect(capturedRequest?.headers.get('x-portaineragent-target')).toBe( + 'node1' + ); + }); + + it('does not send X-PortainerAgent-Target header when nodeName is undefined', async () => { + let capturedRequest: Request | undefined; + server.use( + http.get( + '/api/endpoints/1/docker/containers/abc/stats', + ({ request }) => { + capturedRequest = request; + return HttpResponse.json({}); + } + ) + ); + + await containerStats(1, 'abc', undefined); + + expect(capturedRequest?.headers.get('x-portaineragent-target')).toBeNull(); + }); + + it('throws when the request fails', async () => { + server.use( + http.get('/api/endpoints/1/docker/containers/abc/stats', () => + HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }) + ) + ); + + await expect(containerStats(1, 'abc')).rejects.toThrow(); + }); +}); diff --git a/app/react/docker/containers/queries/useContainerStats.ts b/app/react/docker/containers/queries/useContainerStats.ts index eb1bac0b32..bee02d7952 100644 --- a/app/react/docker/containers/queries/useContainerStats.ts +++ b/app/react/docker/containers/queries/useContainerStats.ts @@ -2,6 +2,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types'; import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; import { buildDockerProxyUrl } from '../../proxy/queries/buildDockerProxyUrl'; +import { withAgentTargetHeader } from '../../proxy/queries/utils'; import { ContainerId } from '../types'; /** @@ -33,12 +34,16 @@ export type ContainerStats = { */ export async function containerStats( environmentId: EnvironmentId, - id: ContainerId + id: ContainerId, + nodeName?: string ) { try { const { data } = await axios.get( buildDockerProxyUrl(environmentId, 'containers', id, 'stats'), - { params: { stream: false } } + { + params: { stream: false }, + headers: { ...withAgentTargetHeader(nodeName) }, + } ); return data; } catch (err) { diff --git a/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx b/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx new file mode 100644 index 0000000000..bee289ffda --- /dev/null +++ b/app/react/kubernetes/applications/StatsView/ApplicationStatsView.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; + +import { ApplicationStatsView } from './ApplicationStatsView'; + +vi.mock('@uirouter/react', async (importOriginal) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { + endpointId: 1, + namespace: 'default', + name: 'my-app', + pod: 'my-pod', + container: 'my-container', + }, + })), +})); + +const podMetricsSuccess = { + timestamp: '2024-01-01T00:00:00Z', + containers: [ + { + name: 'my-container', + usage: { cpu: '100m', memory: '128Mi' }, + }, + ], +}; + +function addBaseHandlers() { + server.use( + http.get( + '/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/my-pod', + () => HttpResponse.json({ spec: { nodeName: 'node1' } }) + ), + http.get('/api/endpoints/1/kubernetes/api/v1/nodes/node1', () => + HttpResponse.json({ status: { allocatable: { cpu: '4' } } }) + ) + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + addBaseHandlers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function renderComponent() { + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(ApplicationStatsView)) + ); + return render(); +} + +describe('ApplicationStatsView', () => { + it('renders the page header "Application stats"', () => { + server.use( + http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () => + HttpResponse.json(podMetricsSuccess) + ) + ); + + renderComponent(); + + expect(screen.getByText('Application stats')).toBeInTheDocument(); + }); + + it('shows "Unable to retrieve container metrics" panel when pod metrics fetch returns 500', async () => { + server.use( + http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () => + HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('Unable to retrieve container metrics') + ).toBeInTheDocument(); + }); + }); + + it('shows the refresh rate select when metrics are available', async () => { + server.use( + http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () => + HttpResponse.json(podMetricsSuccess) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByRole('combobox', { name: /refresh rate/i }) + ).toBeInTheDocument(); + }); + }); + + it('does not show the unavailable panel when metrics fetch succeeds', async () => { + server.use( + http.get('/api/kubernetes/1/metrics/pods/namespace/default/my-pod', () => + HttpResponse.json(podMetricsSuccess) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByRole('combobox', { name: /refresh rate/i }) + ).toBeInTheDocument(); + }); + + expect( + screen.queryByText('Unable to retrieve container metrics') + ).not.toBeInTheDocument(); + }); +}); diff --git a/app/react/kubernetes/applications/StatsView/ApplicationStatsView.tsx b/app/react/kubernetes/applications/StatsView/ApplicationStatsView.tsx new file mode 100644 index 0000000000..a1a484e4d9 --- /dev/null +++ b/app/react/kubernetes/applications/StatsView/ApplicationStatsView.tsx @@ -0,0 +1,284 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import filesizeParser from 'filesize-parser'; +import { filesize } from 'filesize'; +import moment from 'moment'; +import { useEffect, useRef, useState } from 'react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { getMetricsForPod } from '@/react/kubernetes/metrics/metrics'; +import { useNodeQuery } from '@/react/kubernetes/cluster/queries/useNodeQuery'; +import { parseCPU } from '@/react/kubernetes/utils'; +import { StatsLineChart } from '@/react/components/Charts/StatsLineChart'; + +import { InformationPanel } from '@@/InformationPanel'; +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; + +import { getPod } from './getPod'; + +const CHART_LIMIT = 600; +const REFRESH_RATES = [30, 60] as const; + +const PRIMARY = '#97bbcd'; +const SECONDARY = '#ffb4ae'; + +type ChartPoint = { + time: string; + cpu: number; + memory: number; +}; + +type MetricsState = 'checking' | 'available' | 'unavailable'; + +function formatBytes(value: number): string { + return value > 5 + ? filesize(value, { base: 10, round: 1 }) + : `${value.toFixed(1)}B`; +} + +function formatPercent(value: number): string { + return value > 1 ? `${Math.round(value)}%` : `${value.toFixed(1)}%`; +} + +export function ApplicationStatsView() { + const environmentId = useEnvironmentId(); + const { + params: { + namespace, + name: applicationName, + pod: podName, + container: containerName, + }, + } = useCurrentStateAndParams(); + + const [refreshRate, setRefreshRate] = useState(30); + const [chartData, setChartData] = useState([]); + const [metricsState, setMetricsState] = useState('checking'); + + // Get pod to find which node it runs on (needed for CPU% calculation) + const [podNodeName, setPodNodeName] = useState(undefined); + useEffect(() => { + async function fetchPod() { + try { + const pod = await getPod(environmentId, namespace, podName); + setPodNodeName(pod.spec?.nodeName ?? undefined); + } catch { + // pod fetch failure is non-critical; node CPU falls back to 1 + } + } + void fetchPod(); + }, [environmentId, namespace, podName]); + + const nodeQuery = useNodeQuery(environmentId, podNodeName ?? '', { + enabled: !!podNodeName, + select: (node) => parseCPU(node.status?.allocatable?.cpu ?? '') || 1, + }); + const nodeCPU = nodeQuery.data ?? 1; + + useEffect(() => { + if (nodeQuery.data !== undefined) { + setChartData([]); + } + }, [nodeQuery.data]); + + const metricsCheckedRef = useRef(false); + + useEffect(() => { + metricsCheckedRef.current = false; + + let active = true; + let intervalId: ReturnType | null = null; + + async function doFetch() { + if (!metricsCheckedRef.current) { + setMetricsState('checking'); + } + try { + const metrics = await getMetricsForPod( + environmentId, + namespace, + podName + ); + if (!active) return; + + if (!metricsCheckedRef.current) { + metricsCheckedRef.current = true; + setMetricsState('available'); + } + + const metricsData = metrics as { + timestamp?: string; + containers?: Array<{ + name: string; + usage: { cpu: string; memory: string }; + }>; + }; + const container = metricsData.containers?.find( + (c) => c.name === containerName + ); + + if (container && metricsData.timestamp) { + const memory = filesizeParser(container.usage.memory); + const cpu = parseCPU(container.usage.cpu); + const time = moment(metricsData.timestamp).format('HH:mm:ss'); + + setChartData((prev) => { + const point: ChartPoint = { + time, + cpu: (cpu / nodeCPU) * 100, + memory, + }; + const next = [...prev, point]; + return next.length > CHART_LIMIT ? next.slice(1) : next; + }); + } + } catch { + if (!active) return; + if (!metricsCheckedRef.current) { + metricsCheckedRef.current = true; + setMetricsState('unavailable'); + } + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + } + } + + doFetch(); + intervalId = setInterval(doFetch, refreshRate * 1000); + + return () => { + active = false; + if (intervalId) clearInterval(intervalId); + }; + }, [environmentId, namespace, podName, containerName, nodeCPU, refreshRate]); + + return ( + <> + + + {metricsState === 'unavailable' && ( + + + 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. + + + )} + + {metricsState === 'available' && ( + <> +
+
+ + + +
+
+
+ + This view displays real-time statistics about the + container {containerName}. + +
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+ + + + + + +
+ +
+ + + + + + +
+
+ + )} + + ); +} diff --git a/app/react/kubernetes/applications/StatsView/getPod.test.ts b/app/react/kubernetes/applications/StatsView/getPod.test.ts new file mode 100644 index 0000000000..05f3e240be --- /dev/null +++ b/app/react/kubernetes/applications/StatsView/getPod.test.ts @@ -0,0 +1,39 @@ +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; + +import { getPod } from './getPod'; + +describe('getPod', () => { + it('returns pod data when the request succeeds', async () => { + const podData = { + metadata: { name: 'my-pod' }, + spec: { nodeName: 'node1' }, + }; + server.use( + http.get( + '/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/my-pod', + () => HttpResponse.json(podData) + ) + ); + + const result = await getPod(1, 'default', 'my-pod'); + + expect(result).toEqual(podData); + }); + + it('throws when the request fails', async () => { + server.use( + http.get( + '/api/endpoints/1/kubernetes/api/v1/namespaces/default/pods/my-pod', + () => + HttpResponse.json( + { message: 'Internal Server Error' }, + { status: 500 } + ) + ) + ); + + await expect(getPod(1, 'default', 'my-pod')).rejects.toThrow(); + }); +}); diff --git a/app/react/kubernetes/applications/StatsView/getPod.ts b/app/react/kubernetes/applications/StatsView/getPod.ts new file mode 100644 index 0000000000..458934cafd --- /dev/null +++ b/app/react/kubernetes/applications/StatsView/getPod.ts @@ -0,0 +1,21 @@ +import { Pod } from 'kubernetes-types/core/v1'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios from '@/portainer/services/axios/axios'; + +import { parseKubernetesAxiosError } from '../../axiosError'; + +export async function getPod( + environmentId: EnvironmentId, + namespace: string, + podName: string +) { + try { + const { data } = await axios.get( + `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods/${podName}` + ); + return data; + } catch (e) { + throw parseKubernetesAxiosError(e, `Unable to retrieve pod '${podName}'`); + } +} diff --git a/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx b/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx new file mode 100644 index 0000000000..5ce669685c --- /dev/null +++ b/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { http, HttpResponse } from 'msw'; + +import { server } from '@/setup-tests/server'; +import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; +import { withUserProvider } from '@/react/test-utils/withUserProvider'; +import { withTestRouter } from '@/react/test-utils/withRouter'; + +import { NodeStatsView } from './NodeStatsView'; + +vi.mock('@uirouter/react', async (importOriginal) => ({ + ...(await importOriginal()), + useCurrentStateAndParams: vi.fn(() => ({ + params: { endpointId: 1, nodeName: 'my-node' }, + })), +})); + +const nodeMetricsSuccess = { + metadata: { creationTimestamp: '2024-01-01T00:00:00Z' }, + usage: { cpu: '250m', memory: '512Mi' }, +}; + +function addBaseHandlers() { + server.use( + http.get('/api/endpoints/1/kubernetes/api/v1/nodes/my-node', () => + HttpResponse.json({ status: { allocatable: { cpu: '4' } } }) + ) + ); +} + +beforeEach(() => { + vi.useFakeTimers(); + addBaseHandlers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function renderComponent() { + const Wrapped = withTestQueryProvider( + withUserProvider(withTestRouter(NodeStatsView)) + ); + return render(); +} + +describe('NodeStatsView', () => { + it('renders the page header "Node stats"', () => { + server.use( + http.get('/api/kubernetes/1/metrics/nodes/my-node', () => + HttpResponse.json(nodeMetricsSuccess) + ) + ); + + renderComponent(); + + expect(screen.getByText('Node stats')).toBeInTheDocument(); + }); + + it('shows "Unable to retrieve node metrics" panel when metrics fetch returns 500', async () => { + server.use( + http.get('/api/kubernetes/1/metrics/nodes/my-node', () => + HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 }) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByText('Unable to retrieve node metrics') + ).toBeInTheDocument(); + }); + }); + + it('shows the refresh rate select when metrics are available', async () => { + server.use( + http.get('/api/kubernetes/1/metrics/nodes/my-node', () => + HttpResponse.json(nodeMetricsSuccess) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByRole('combobox', { name: /refresh rate/i }) + ).toBeInTheDocument(); + }); + }); + + it('does not show the unavailable panel when metrics fetch succeeds', async () => { + server.use( + http.get('/api/kubernetes/1/metrics/nodes/my-node', () => + HttpResponse.json(nodeMetricsSuccess) + ) + ); + + renderComponent(); + + await waitFor(() => { + expect( + screen.getByRole('combobox', { name: /refresh rate/i }) + ).toBeInTheDocument(); + }); + + expect( + screen.queryByText('Unable to retrieve node metrics') + ).not.toBeInTheDocument(); + }); +}); diff --git a/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.tsx b/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.tsx new file mode 100644 index 0000000000..8455bfa659 --- /dev/null +++ b/app/react/kubernetes/cluster/NodeStatsView/NodeStatsView.tsx @@ -0,0 +1,231 @@ +import { useCurrentStateAndParams } from '@uirouter/react'; +import filesizeParser from 'filesize-parser'; +import { filesize } from 'filesize'; +import moment from 'moment'; +import { useEffect, useRef, useState } from 'react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { getMetricsForNode } from '@/react/kubernetes/metrics/queries/useNodeMetricsQuery'; +import { useNodeQuery } from '@/react/kubernetes/cluster/queries/useNodeQuery'; +import { parseCPU } from '@/react/kubernetes/utils'; +import { StatsLineChart } from '@/react/components/Charts/StatsLineChart'; + +import { InformationPanel } from '@@/InformationPanel'; +import { PageHeader } from '@@/PageHeader'; +import { Widget, WidgetBody, WidgetTitle } from '@@/Widget'; + +const CHART_LIMIT = 600; +const REFRESH_RATES = [30, 60] as const; + +const PRIMARY = '#97bbcd'; +const SECONDARY = '#ffb4ae'; + +type ChartPoint = { + time: string; + cpu: number; + memory: number; +}; + +type MetricsState = 'checking' | 'available' | 'unavailable'; + +function formatBytes(value: number): string { + return value > 5 + ? filesize(value, { base: 10, round: 1 }) + : `${value.toFixed(1)}B`; +} + +function formatPercent(value: number): string { + return value > 1 ? `${Math.round(value)}%` : `${value.toFixed(1)}%`; +} + +export function NodeStatsView() { + const environmentId = useEnvironmentId(); + const { + params: { nodeName }, + } = useCurrentStateAndParams(); + + const [refreshRate, setRefreshRate] = useState(30); + const [chartData, setChartData] = useState([]); + const [metricsState, setMetricsState] = useState('checking'); + + const nodeQuery = useNodeQuery(environmentId, nodeName, { + select: (node) => parseCPU(node.status?.allocatable?.cpu ?? '') || 1, + }); + const nodeCPU = nodeQuery.data ?? 1; + + const metricsCheckedRef = useRef(false); + + useEffect(() => { + metricsCheckedRef.current = false; + setMetricsState('checking'); + + let active = true; + let intervalId: ReturnType | null = null; + + async function doFetch() { + try { + const metrics = await getMetricsForNode(environmentId, nodeName); + if (!active) return; + + if (!metricsCheckedRef.current) { + metricsCheckedRef.current = true; + setMetricsState('available'); + } + + if (metrics) { + const memory = filesizeParser(metrics.usage.memory); + const cpu = parseCPU(metrics.usage.cpu); + const time = moment(metrics.metadata.creationTimestamp).format( + 'HH:mm:ss' + ); + + setChartData((prev) => { + const point: ChartPoint = { + time, + cpu: (cpu / nodeCPU) * 100, + memory, + }; + const next = [...prev, point]; + return next.length > CHART_LIMIT ? next.slice(1) : next; + }); + } + } catch { + if (!active) return; + if (!metricsCheckedRef.current) { + metricsCheckedRef.current = true; + setMetricsState('unavailable'); + } + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + } + } + + doFetch(); + intervalId = setInterval(doFetch, refreshRate * 1000); + + return () => { + active = false; + if (intervalId) clearInterval(intervalId); + }; + }, [environmentId, nodeName, nodeCPU, refreshRate]); + + return ( + <> + + + {metricsState === 'unavailable' && ( + + + 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. + + + )} + + {metricsState === 'available' && ( + <> +
+
+ + + +
+
+
+ + This view displays real-time statistics about the node{' '} + {nodeName}. + +
+
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+ + + + + + +
+ +
+ + + + + + +
+
+ + )} + + ); +} diff --git a/package.json b/package.json index fe3bbaaf4e..16aed33f72 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "buffer": "^6.0.3", "c8": "^9.1.0", "chardet": "^1.4.0", - "chart.js": "^2.9.4", + "recharts": "^3.8.1", "class-variance-authority": "^0.7.0", "clsx": "^1.1.1", "codemirror": "^6.0.1", @@ -259,7 +259,12 @@ "@types/node": "^25", "ngtemplate-loader>loader-utils": "^1.4.2", "braces": "^3.0.3", - "shell-quote": "^1.8.4" + "shell-quote": "^1.8.4", + "recharts>@reduxjs/toolkit": "^1.9.7", + "recharts>react-redux": "^8.1.3", + "recharts>redux": "^4.2.1", + "recharts>react": "^16.8.x", + "recharts>react-dom": "^16.8.x" }, "configDependencies": { "@pnpm/plugin-types-fixer": "0.1.0+sha512-bLww63gRHi7siYTqFJb5qNdcXadU0jv20Et6z5AryMZ7FlLolbEJOrXLpg8+amQZNHHNW1dfFUBGVw/9ezQbFg==" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cfe69e3f3..d2beb21a68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,11 @@ overrides: ngtemplate-loader>loader-utils: ^1.4.2 braces: ^3.0.3 shell-quote: ^1.8.4 + recharts>@reduxjs/toolkit: ^1.9.7 + recharts>react-redux: ^8.1.3 + recharts>redux: ^4.2.1 + recharts>react: ^16.8.x + recharts>react-dom: ^16.8.x importers: @@ -177,9 +182,6 @@ importers: chardet: specifier: ^1.4.0 version: 1.4.0 - chart.js: - specifier: ^2.9.4 - version: 2.9.4 class-variance-authority: specifier: ^0.7.0 version: 0.7.1 @@ -303,6 +305,9 @@ importers: react-select-async-paginate: specifier: ^0.7.11 version: 0.7.11(@types/react@17.0.75)(react-select@5.10.2(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(react@17.0.2) + recharts: + specifier: ^3.8.1 + version: 3.8.1(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react-is@17.0.2)(react@17.0.2)(redux@4.2.1) sanitize-html: specifier: ^2.17.0 version: 2.17.0 @@ -338,7 +343,7 @@ importers: version: 4.3.6 zustand: specifier: ^4.1.1 - version: 4.1.1(react@17.0.2) + version: 4.1.1(immer@11.1.8)(react@17.0.2) devDependencies: '@apidevtools/swagger-cli': specifier: ^4.0.4 @@ -2520,6 +2525,17 @@ packages: react: ^16.8.0 || 17.x react-dom: ^16.8.0 || 17.x + '@reduxjs/toolkit@1.9.7': + resolution: {integrity: sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 + react-redux: ^7.2.1 || ^8.0.2 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + '@rolldown/binding-android-arm64@1.0.3': resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3188,6 +3204,33 @@ packages: '@types/connect@3.4.35': resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==} + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -3349,6 +3392,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.3': + resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} + '@types/uuid@3.4.13': resolution: {integrity: sha512-pAeZeUbLE4Z9Vi9wsWV2bYPTweEHeJJy0G4pEjOA/FSvy1Ad5U5Km8iDV6TKre1mjBiVNfAdVHKruP8bAh4Q5A==} @@ -4269,15 +4315,6 @@ packages: chardet@1.4.0: resolution: {integrity: sha512-NpwMDdSIprbYx1CLnfbxEIarI0Z+s9MssEgggMNheGM+WD68yOhV7IEA/3r6tr0yTRgQD0HuZJDw32s99i6L+A==} - chart.js@2.9.4: - resolution: {integrity: sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==} - - chartjs-color-string@0.6.0: - resolution: {integrity: sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==} - - chartjs-color@2.4.1: - resolution: {integrity: sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==} - check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -4375,16 +4412,10 @@ packages: codemirror@6.0.1: resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==} - color-convert@1.9.3: - resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} - color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} - color-name@1.1.3: - resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} - color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} @@ -4599,6 +4630,50 @@ packages: csstype@3.0.10: resolution: {integrity: sha512-2u44ZG2OcNUO9HDp/Jl8C07x6pU/eTR3ncV91SiK3dhG9TWvRVsCoJw14Ckx5DgWkzGA3waZWO3d7pgqpUI/XA==} + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -4654,6 +4729,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -4935,6 +5013,9 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es-toolkit@1.48.1: + resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} + es6-promise@3.3.1: resolution: {integrity: sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==} @@ -5652,6 +5733,15 @@ packages: engines: {node: '>=0.10.0'} hasBin: true + immer@10.2.0: + resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==} + + immer@11.1.8: + resolution: {integrity: sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==} + + immer@9.0.21: + resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==} + import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -5682,6 +5772,10 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + interpret@3.1.1: resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==} engines: {node: '>=10.13.0'} @@ -7310,12 +7404,36 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react-json-view-lite@1.5.0: resolution: {integrity: sha512-nWqA1E4jKPklL2jvHWs6s+7Na0qNgw9HCP6xehdQJeg6nPBTFZgGwyko9Q0oj+jQWKTTVRS30u0toM5wiuL3iw==} engines: {node: '>=14'} peerDependencies: react: ^16.13.1 || ^17.0.0 || ^18.0.0 + react-redux@8.1.3: + resolution: {integrity: sha512-n0ZrutD7DaX/j9VscF+uTALI3oUPa/pO4Z3soOBIjuRn/FzVu6aehhysxZCLi6y7duMf52WNZGMl7CtuK5EnRw==} + peerDependencies: + '@types/react': ^17.0.37 + '@types/react-dom': ^17.0.11 + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + react-native: '>=0.59' + redux: ^4 || ^5.0.0-beta.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + react-dom: + optional: true + react-native: + optional: true + redux: + optional: true + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -7404,6 +7522,14 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recharts@3.8.1: + resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.8.x + react-dom: ^16.8.x + react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + rechoir@0.8.0: resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==} engines: {node: '>= 10.13.0'} @@ -7412,6 +7538,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redux-thunk@2.4.2: + resolution: {integrity: sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==} + peerDependencies: + redux: ^4 + + redux@4.2.1: + resolution: {integrity: sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -7483,6 +7617,12 @@ packages: requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + reselect@4.1.8: + resolution: {integrity: sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==} + + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'} @@ -8436,6 +8576,9 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + victory-vendor@37.3.6: + resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==} + vite-plugin-svgr@4.5.0: resolution: {integrity: sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==} peerDependencies: @@ -10882,6 +11025,16 @@ snapshots: react: 17.0.2 react-dom: 17.0.2(react@17.0.2) + '@reduxjs/toolkit@1.9.7(react-redux@8.1.3(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(redux@4.2.1))(react@17.0.2)': + dependencies: + immer: 9.0.21 + redux: 4.2.1 + redux-thunk: 2.4.2(redux@4.2.1) + reselect: 4.1.8 + optionalDependencies: + react: 17.0.2 + react-redux: 8.1.3(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(redux@4.2.1) + '@rolldown/binding-android-arm64@1.0.3': optional: true @@ -11550,6 +11703,30 @@ snapshots: dependencies: '@types/node': 25.0.3 + '@types/d3-array@3.2.2': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + '@types/deep-eql@4.0.2': {} '@types/doctrine@0.0.9': {} @@ -11721,6 +11898,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.3': {} + '@types/uuid@3.4.13': {} '@types/ws@8.18.1': @@ -12757,20 +12936,6 @@ snapshots: chardet@1.4.0: {} - chart.js@2.9.4: - dependencies: - chartjs-color: 2.4.1 - moment: 2.30.1 - - chartjs-color-string@0.6.0: - dependencies: - color-name: 1.1.4 - - chartjs-color@2.4.1: - dependencies: - chartjs-color-string: 0.6.0 - color-convert: 1.9.3 - check-error@2.1.3: {} chokidar@3.6.0: @@ -12891,16 +13056,10 @@ snapshots: '@codemirror/state': 6.6.0 '@codemirror/view': 6.43.0 - color-convert@1.9.3: - dependencies: - color-name: 1.1.3 - color-convert@2.0.1: dependencies: color-name: 1.1.4 - color-name@1.1.3: {} - color-name@1.1.4: {} color-support@1.1.3: {} @@ -13142,6 +13301,44 @@ snapshots: csstype@3.0.10: {} + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-color@3.1.0: {} + + d3-ease@3.0.1: {} + + d3-format@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@3.1.0: {} + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + damerau-levenshtein@1.0.8: {} data-urls@5.0.0: @@ -13185,6 +13382,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} + decimal.js@10.4.3: {} dedent@0.7.0: {} @@ -13535,6 +13734,8 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es-toolkit@1.48.1: {} + es6-promise@3.3.1: {} esbuild@0.27.3: @@ -14417,6 +14618,13 @@ snapshots: image-size@0.5.5: optional: true + immer@10.2.0: {} + + immer@11.1.8: + optional: true + + immer@9.0.21: {} + import-fresh@3.3.0: dependencies: parent-module: 1.0.1 @@ -14446,6 +14654,8 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + internmap@2.0.3: {} + interpret@3.1.1: {} ipaddr.js@1.9.1: {} @@ -16078,10 +16288,27 @@ snapshots: react-is@17.0.2: {} + react-is@18.3.1: {} + react-json-view-lite@1.5.0(react@17.0.2): dependencies: react: 17.0.2 + react-redux@8.1.3(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(redux@4.2.1): + dependencies: + '@babel/runtime': 7.26.0 + '@types/hoist-non-react-statics': 3.3.7(@types/react@17.0.75) + '@types/use-sync-external-store': 0.0.3 + hoist-non-react-statics: 3.3.2 + react: 17.0.2 + react-is: 18.3.1 + use-sync-external-store: 1.6.0(react@17.0.2) + optionalDependencies: + '@types/react': 17.0.75 + '@types/react-dom': 17.0.25 + react-dom: 17.0.2(react@17.0.2) + redux: 4.2.1 + react-remove-scroll-bar@2.3.8(@types/react@17.0.75)(react@17.0.2): dependencies: react: 17.0.2 @@ -16206,6 +16433,28 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recharts@3.8.1(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react-is@17.0.2)(react@17.0.2)(redux@4.2.1): + dependencies: + '@reduxjs/toolkit': 1.9.7(react-redux@8.1.3(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(redux@4.2.1))(react@17.0.2) + clsx: 2.1.1 + decimal.js-light: 2.5.1 + es-toolkit: 1.48.1 + eventemitter3: 5.0.4 + immer: 10.2.0 + react: 17.0.2 + react-dom: 17.0.2(react@17.0.2) + react-is: 17.0.2 + react-redux: 8.1.3(@types/react-dom@17.0.25)(@types/react@17.0.75)(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(redux@4.2.1) + reselect: 5.1.1 + tiny-invariant: 1.3.3 + use-sync-external-store: 1.6.0(react@17.0.2) + victory-vendor: 37.3.6 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - react-native + - redux + rechoir@0.8.0: dependencies: resolve: 1.22.11 @@ -16215,6 +16464,14 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redux-thunk@2.4.2(redux@4.2.1): + dependencies: + redux: 4.2.1 + + redux@4.2.1: + dependencies: + '@babel/runtime': 7.26.0 + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -16297,6 +16554,10 @@ snapshots: requires-port@1.0.0: {} + reselect@4.1.8: {} + + reselect@5.1.1: {} + resolve-cwd@3.0.0: dependencies: resolve-from: 5.0.0 @@ -17381,6 +17642,23 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.2 + victory-vendor@37.3.6: + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-ease': 3.0.2 + '@types/d3-interpolate': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-timer': 3.0.2 + d3-array: 3.2.4 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-timer: 3.0.1 + vite-plugin-svgr@4.5.0(rollup@4.54.0)(vite@8.0.16(@types/node@25.0.3)(esbuild@0.27.3)(jiti@2.7.0)(less@4.4.2)(terser@5.44.1)(yaml@1.10.2)): dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.54.0) @@ -17826,10 +18104,11 @@ snapshots: zod@4.3.6: {} - zustand@4.1.1(react@17.0.2): + zustand@4.1.1(immer@11.1.8)(react@17.0.2): dependencies: use-sync-external-store: 1.2.0(react@17.0.2) optionalDependencies: + immer: 11.1.8 react: 17.0.2 zwitch@2.0.4: {}