mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 09:04:48 +00:00
refactor(charts): migrate to rechart charts in UI [C9S-271] (#2990)
This commit is contained in:
committed by
GitHub
parent
61e742a66a
commit
2b7b8b492e
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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
@@ -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=="
|
||||
|
||||
Generated
+319
-40
@@ -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: {}
|
||||
|
||||
Reference in New Issue
Block a user