refactor(charts): migrate to rechart charts in UI [C9S-271] (#2990)

This commit is contained in:
bernard-portainer
2026-07-20 10:36:44 +12:00
committed by GitHub
parent 61e742a66a
commit 2b7b8b492e
26 changed files with 2267 additions and 1163 deletions
+61
View File
@@ -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);
});
});
+6 -2
View File
@@ -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',
},
},
});
@@ -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();
},
]);
@@ -1,111 +0,0 @@
<page-header
title="'Container statistics'"
breadcrumbs="[
{ label:'Containers', link:'docker.containers' },
{
label:(container.Name | trimcontainername),
link: 'docker.containers.container',
linkParams: { id: container.Id },
}, 'Stats']"
>
</page-header>
<div class="row">
<div class="col-md-12">
<rd-widget>
<rd-widget-header icon="info" title-text="About statistics"> </rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the container <b>{{ container.Name | trimcontainername }}</b> as well as a list of the running processes inside this
container.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control" data-cy="docker-containers-stats-refresh-rate">
<option value="1">1s</option>
<option value="3">3s</option>
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
<div class="form-group" ng-if="state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
</div>
<div class="form-group" ng-if="state.ioStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
I/O stats are unavailable for this container.
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-6 col-md-6 col-sm-12">
<rd-widget>
<rd-widget-header icon="bar-chart" title-text="Memory usage"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-6 col-sm-12">
<rd-widget>
<rd-widget-header icon="bar-chart" title-text="CPU usage"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-6 col-sm-12" ng-if="!state.networkStatsUnavailable">
<rd-widget>
<rd-widget-header icon="bar-chart" title-text="Network usage (aggregate)"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="networkChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-6 col-sm-12" ng-if="!state.ioStatsUnavailable">
<rd-widget>
<rd-widget-header icon="bar-chart" title-text="I/O usage (aggregate)"></rd-widget-header>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="ioChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<docker-container-processes-datatable></docker-container-processes-datatable>
@@ -1,118 +0,0 @@
<page-header
ng-if="ctrl.state.viewReady"
title="'Application stats'"
breadcrumbs="[
{ label:'Namespaces', link:'kubernetes.resourcePools' },
{
label:ctrl.state.transition.namespace,
link: 'kubernetes.resourcePools.resourcePool',
linkParams:{ id: ctrl.state.transition.namespace }
},
{ label:'Applications', link:'kubernetes.applications' },
{
label:ctrl.state.transition.applicationName,
link: 'kubernetes.applications.application',
linkParams:{ name: ctrl.state.transition.applicationName, namespace: ctrl.state.transition.namespace }
},
'Pods',
ctrl.state.transition.podName,
'Containers',
ctrl.state.transition.containerName,
'Stats'
]"
reload="true"
>
</page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
<span class="small text-warning vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
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.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-warning">
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
>.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control" data-cy="app-stats-refresh-rate">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
</span>
</div>
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
<div class="col-sm-12">
<span class="small text-muted">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
Network stats are unavailable for this container.
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-container" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -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))), []));
@@ -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);
@@ -1,98 +0,0 @@
<page-header
ng-if="ctrl.state.viewReady"
title="'Node stats'"
breadcrumbs="[
{ label:'Cluster', link:'kubernetes.cluster' },
{
label:ctrl.state.transition.nodeName,
link: 'kubernetes.cluster.node',
linkParams:{nodeName: ctrl.state.transition.nodeName}
},
ctrl.state.transition.nodeName,
]"
reload="true"
></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
<span class="small text-muted vertical-center">
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
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.
</span>
</information-panel>
<div class="row" ng-if="ctrl.state.getMetrics">
<div class="col-md-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'info'"></pr-icon>
</div>
<span class="vertical-center"> About statistics </span>
</div>
</div>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted">
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
>.
</span>
</div>
</div>
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control" data-cy="node-stats-refresh-rate">
<option value="30">30s</option>
<option value="60">60s</option>
</select>
</div>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-show="ctrl.state.getMetrics">
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-memory'"></pr-icon>
</div>
<span class="vertical-center"> Memory usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="memoryChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6 col-md-12 col-sm-12">
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'cpu'"></pr-icon>
</div>
<span class="vertical-center"> CPU usage </span>
</div>
</div>
<rd-widget-body>
<div class="chart-node" style="position: relative">
<canvas id="cpuChart" width="770" height="300"></canvas>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -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))), []));
@@ -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);
-277
View File
@@ -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;
},
]);
@@ -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<typeof StatsLineChart> = {
title: 'Charts/Recharts',
component: StatsLineChart,
};
export default meta;
type Story = StoryObj<typeof StatsLineChart>;
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,
},
};
@@ -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<typeof import('recharts')>();
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(
<StatsLineChart
data={[]}
series={singleSeries}
yAxisFormatter={yAxisFormatter}
/>
);
expect(screen.getByText('CPU Usage')).toBeVisible();
});
it('renders a legend entry for each series name', () => {
render(
<StatsLineChart
data={sampleData}
series={multiSeries}
yAxisFormatter={yAxisFormatter}
/>
);
expect(screen.getByText('CPU Usage')).toBeVisible();
expect(screen.getByText('Memory Usage')).toBeVisible();
});
it('renders an SVG element', () => {
const { container } = render(
<StatsLineChart
data={sampleData}
series={singleSeries}
yAxisFormatter={yAxisFormatter}
/>
);
expect(container.querySelector('svg')).not.toBeNull();
});
it('applies the default height of 300px to the wrapper div', () => {
const { container } = render(
<StatsLineChart
data={sampleData}
series={singleSeries}
yAxisFormatter={yAxisFormatter}
/>
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.height).toBe('300px');
});
it('applies a custom height to the wrapper div', () => {
const { container } = render(
<StatsLineChart
data={sampleData}
series={singleSeries}
yAxisFormatter={yAxisFormatter}
height={500}
/>
);
const wrapper = container.firstElementChild as HTMLElement;
expect(wrapper.style.height).toBe('500px');
});
it('renders both legend entries for stacked area series', () => {
render(
<StatsLineChart
data={sampleData}
series={stackedAreaSeries}
yAxisFormatter={yAxisFormatter}
/>
);
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(
<StatsLineChart
data={dataWithCustomKey}
timeKey="ts"
series={[{ dataKey: 'cpu', name: 'CPU Usage', color: '#4e9af1' }]}
yAxisFormatter={yAxisFormatter}
/>
);
expect(screen.getByText('CPU Usage')).toBeVisible();
});
});
@@ -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<Record<string, string | number>>;
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 (
<div style={{ height }}>
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={data}
margin={{ top: 5, right: 20, left: 10, bottom: 5 }}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={timeKey} tick={{ fontSize: 11 }} />
<YAxis
domain={yAxisDomain}
tickFormatter={yAxisFormatter}
tick={{ fontSize: 11 }}
width={75}
/>
<Tooltip
formatter={(value) =>
yAxisFormatter(typeof value === 'number' ? value : 0)
}
isAnimationActive={false}
/>
<Legend />
{series.map((s) =>
s.area ? (
<Area
key={s.dataKey}
type="monotone"
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
fill={s.color}
fillOpacity={0.3}
stackId={s.stackId}
dot={false}
isAnimationActive={false}
activeDot={{ r: 3 }}
strokeWidth={2}
/>
) : (
<Line
key={s.dataKey}
type="monotone"
dataKey={s.dataKey}
name={s.name}
stroke={s.color}
dot={false}
isAnimationActive={false}
activeDot={{ r: 3 }}
strokeWidth={2}
/>
)
)}
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
@@ -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<object>()),
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(<Wrapped />);
}
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();
});
});
});
@@ -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<ChartPoint[]>([]);
const [networkUnavailable, setNetworkUnavailable] = useState(false);
const [ioUnavailable, setIoUnavailable] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const networkUnavailableRef = useRef(false);
const ioUnavailableRef = useRef(false);
const containerQuery = useContainer<ContainerDetailsResponse>({
environmentId,
containerId,
nodeName,
});
const containerName = trimContainerName(containerQuery.data?.Name);
useEffect(() => {
let active = true;
let intervalId: ReturnType<typeof setInterval> | 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 (
<>
<PageHeader
title="Container statistics"
breadcrumbs={[
{ label: 'Containers', link: 'docker.containers' },
{
label: containerName || containerId,
link: 'docker.containers.container',
linkParams: { id: containerId },
},
'Stats',
]}
/>
<div className="row">
<div className="col-md-12">
<Widget>
<WidgetTitle icon="info" title="About statistics" />
<WidgetBody>
<form className="form-horizontal">
<div className="form-group">
<div className="col-sm-12">
<span className="small text-muted">
This view displays real-time statistics about the
container <b>{containerName}</b> as well as a list of the
running processes inside this container.
</span>
</div>
</div>
<div className="form-group">
<label
htmlFor="refreshRate"
className="col-sm-3 col-md-2 control-label text-left"
>
Refresh rate
</label>
<div className="col-sm-3 col-md-2">
<select
id="refreshRate"
className="form-control"
value={refreshRate}
onChange={(e) => setRefreshRate(Number(e.target.value))}
data-cy="docker-containers-stats-refresh-rate"
>
{REFRESH_RATES.map((r) => (
<option key={r} value={r}>
{r}s
</option>
))}
</select>
</div>
</div>
{networkUnavailable && (
<div className="form-group">
<div className="col-sm-12">
<span className="small text-muted">
Network stats are unavailable for this container.
</span>
</div>
</div>
)}
{ioUnavailable && (
<div className="form-group">
<div className="col-sm-12">
<span className="small text-muted">
I/O stats are unavailable for this container.
</span>
</div>
</div>
)}
</form>
</WidgetBody>
</Widget>
</div>
</div>
{fetchError && (
<InformationPanel title="Unable to retrieve container statistics">
<span className="small text-danger">{fetchError}</span>
</InformationPanel>
)}
<div className="row">
<div className="col-lg-6 col-md-6 col-sm-12">
<Widget>
<WidgetTitle icon="bar-chart-2" title="Memory usage" />
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{
dataKey: 'memory',
name: 'Memory',
color: PRIMARY,
area: true,
stackId: 'mem',
},
{
dataKey: 'cache',
name: 'Cache',
color: SECONDARY,
area: true,
stackId: 'mem',
},
]}
yAxisFormatter={formatBytes}
yAxisDomain={['auto', 'auto']}
/>
</WidgetBody>
</Widget>
</div>
<div className="col-lg-6 col-md-6 col-sm-12">
<Widget>
<WidgetTitle icon="bar-chart-2" title="CPU usage" />
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{
dataKey: 'cpu',
name: 'CPU',
color: PRIMARY,
area: true,
},
]}
yAxisFormatter={formatPercent}
/>
</WidgetBody>
</Widget>
</div>
{!networkUnavailable && (
<div className="col-lg-6 col-md-6 col-sm-12">
<Widget>
<WidgetTitle
icon="bar-chart-2"
title="Network usage (aggregate)"
/>
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{ dataKey: 'rx', name: 'RX on eth0', color: PRIMARY },
{ dataKey: 'tx', name: 'TX on eth0', color: SECONDARY },
]}
yAxisFormatter={formatBytes}
/>
</WidgetBody>
</Widget>
</div>
)}
{!ioUnavailable && (
<div className="col-lg-6 col-md-6 col-sm-12">
<Widget>
<WidgetTitle icon="bar-chart-2" title="I/O usage (aggregate)" />
<WidgetBody>
<StatsLineChart
data={chartData}
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}
/>
</WidgetBody>
</Widget>
</div>
)}
</div>
<ProcessesDatatable />
</>
);
}
@@ -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();
});
});
@@ -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) {
@@ -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<object>()),
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(<Wrapped />);
}
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();
});
});
@@ -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<ChartPoint[]>([]);
const [metricsState, setMetricsState] = useState<MetricsState>('checking');
// Get pod to find which node it runs on (needed for CPU% calculation)
const [podNodeName, setPodNodeName] = useState<string | undefined>(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<typeof setInterval> | 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 (
<>
<PageHeader
title="Application stats"
breadcrumbs={[
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
{
label: namespace,
link: 'kubernetes.resourcePools.resourcePool',
linkParams: { id: namespace },
},
{ label: 'Applications', link: 'kubernetes.applications' },
{
label: applicationName,
link: 'kubernetes.applications.application',
linkParams: { name: applicationName, namespace },
},
'Pods',
podName,
'Containers',
containerName,
'Stats',
]}
/>
{metricsState === 'unavailable' && (
<InformationPanel title="Unable to retrieve container metrics">
<span className="small text-warning">
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.
</span>
</InformationPanel>
)}
{metricsState === 'available' && (
<>
<div className="row">
<div className="col-md-12">
<Widget>
<WidgetTitle icon="info" title="About statistics" />
<WidgetBody>
<form className="form-horizontal">
<div className="form-group">
<div className="col-sm-12">
<span className="small text-warning">
This view displays real-time statistics about the
container <b>{containerName}</b>.
</span>
</div>
</div>
<div className="form-group">
<label
htmlFor="refreshRate"
className="col-sm-3 col-md-2 control-label text-left"
>
Refresh rate
</label>
<div className="col-sm-3 col-md-2">
<select
id="refreshRate"
className="form-control"
value={refreshRate}
onChange={(e) =>
setRefreshRate(Number(e.target.value))
}
data-cy="app-stats-refresh-rate"
>
{REFRESH_RATES.map((r) => (
<option key={r} value={r}>
{r}s
</option>
))}
</select>
</div>
</div>
</form>
</WidgetBody>
</Widget>
</div>
</div>
<div className="row">
<div className="col-lg-6 col-md-12">
<Widget>
<WidgetTitle icon="svg-memory" title="Memory usage" />
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{
dataKey: 'memory',
name: 'Memory',
color: PRIMARY,
area: true,
},
]}
yAxisFormatter={formatBytes}
/>
</WidgetBody>
</Widget>
</div>
<div className="col-lg-6 col-md-12">
<Widget>
<WidgetTitle icon="cpu" title="CPU usage" />
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{
dataKey: 'cpu',
name: 'CPU',
color: SECONDARY,
area: true,
},
]}
yAxisFormatter={formatPercent}
/>
</WidgetBody>
</Widget>
</div>
</div>
</>
)}
</>
);
}
@@ -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();
});
});
@@ -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<Pod>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/pods/${podName}`
);
return data;
} catch (e) {
throw parseKubernetesAxiosError(e, `Unable to retrieve pod '${podName}'`);
}
}
@@ -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<object>()),
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(<Wrapped />);
}
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();
});
});
@@ -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<ChartPoint[]>([]);
const [metricsState, setMetricsState] = useState<MetricsState>('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<typeof setInterval> | 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 (
<>
<PageHeader
title="Node stats"
breadcrumbs={[
{ label: 'Cluster', link: 'kubernetes.cluster' },
{
label: nodeName,
link: 'kubernetes.cluster.node',
linkParams: { nodeName },
},
nodeName,
]}
/>
{metricsState === 'unavailable' && (
<InformationPanel title="Unable to retrieve node metrics">
<span className="small text-muted">
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.
</span>
</InformationPanel>
)}
{metricsState === 'available' && (
<>
<div className="row">
<div className="col-md-12">
<Widget>
<WidgetTitle icon="info" title="About statistics" />
<WidgetBody>
<form className="form-horizontal">
<div className="form-group">
<div className="col-sm-12">
<span className="small text-muted">
This view displays real-time statistics about the node{' '}
<b>{nodeName}</b>.
</span>
</div>
</div>
<div className="form-group">
<label
htmlFor="refreshRate"
className="col-sm-3 col-md-2 control-label text-left"
>
Refresh rate
</label>
<div className="col-sm-3 col-md-2">
<select
id="refreshRate"
className="form-control"
value={refreshRate}
onChange={(e) =>
setRefreshRate(Number(e.target.value))
}
data-cy="node-stats-refresh-rate"
>
{REFRESH_RATES.map((r) => (
<option key={r} value={r}>
{r}s
</option>
))}
</select>
</div>
</div>
</form>
</WidgetBody>
</Widget>
</div>
</div>
<div className="row">
<div className="col-lg-6 col-md-12">
<Widget>
<WidgetTitle icon="cpu" title="Memory usage" />
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{
dataKey: 'memory',
name: 'Memory',
color: PRIMARY,
area: true,
},
]}
yAxisFormatter={formatBytes}
/>
</WidgetBody>
</Widget>
</div>
<div className="col-lg-6 col-md-12">
<Widget>
<WidgetTitle icon="cpu" title="CPU usage" />
<WidgetBody>
<StatsLineChart
data={chartData}
series={[
{
dataKey: 'cpu',
name: 'CPU',
color: SECONDARY,
area: true,
},
]}
yAxisFormatter={formatPercent}
/>
</WidgetBody>
</Widget>
</div>
</div>
</>
)}
</>
);
}
+7 -2
View File
@@ -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=="
+319 -40
View File
@@ -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: {}