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 @@
-
-
-
-
-
-
-
-
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.
-
-
-
-
-
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.
-
-
-
-
-
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