mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
chore(react): Convert cluster details to react CE (#466)
This commit is contained in:
parent
dd98097897
commit
7759d762ab
24 changed files with 829 additions and 345 deletions
|
@ -22,6 +22,7 @@ import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
|
||||||
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
||||||
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
||||||
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
||||||
|
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.kubernetes.react.views', [])
|
.module('portainer.kubernetes.react.views', [])
|
||||||
|
@ -78,6 +79,10 @@ export const viewsModule = angular
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'kubernetesClusterView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'kubernetesConfigureView',
|
'kubernetesConfigureView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
<page-header ng-if="ctrl.state.viewReady" title="'Cluster'" breadcrumbs="['Cluster information']" reload="true"></page-header>
|
|
||||||
|
|
||||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
|
||||||
|
|
||||||
<div ng-if="ctrl.state.viewReady">
|
|
||||||
<div class="row" ng-if="ctrl.isAdmin">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<!-- resource-reservation -->
|
|
||||||
<form class="form-horizontal" ng-if="ctrl.resourceReservation">
|
|
||||||
<kubernetes-resource-reservation
|
|
||||||
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
|
||||||
cpu-reservation="ctrl.resourceReservation.CPU"
|
|
||||||
cpu-limit="ctrl.CPULimit"
|
|
||||||
memory-reservation="ctrl.resourceReservation.Memory"
|
|
||||||
memory-limit="ctrl.MemoryLimit"
|
|
||||||
display-usage="ctrl.hasResourceUsageAccess()"
|
|
||||||
cpu-usage="ctrl.resourceUsage.CPU"
|
|
||||||
memory-usage="ctrl.resourceUsage.Memory"
|
|
||||||
>
|
|
||||||
</kubernetes-resource-reservation>
|
|
||||||
</form>
|
|
||||||
<!-- !resource-reservation -->
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<kube-nodes-datatable></kube-nodes-datatable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -1,8 +0,0 @@
|
||||||
angular.module('portainer.kubernetes').component('kubernetesClusterView', {
|
|
||||||
templateUrl: './cluster.html',
|
|
||||||
controller: 'KubernetesClusterController',
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
bindings: {
|
|
||||||
endpoint: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -1,139 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
import _ from 'lodash-es';
|
|
||||||
import filesizeParser from 'filesize-parser';
|
|
||||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
|
||||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
|
||||||
import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts';
|
|
||||||
|
|
||||||
class KubernetesClusterController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async, $state, Notifications, LocalStorage, Authentication, KubernetesNodeService, KubernetesApplicationService, KubernetesEndpointService, EndpointService) {
|
|
||||||
this.$async = $async;
|
|
||||||
this.$state = $state;
|
|
||||||
this.Authentication = Authentication;
|
|
||||||
this.Notifications = Notifications;
|
|
||||||
this.LocalStorage = LocalStorage;
|
|
||||||
this.KubernetesNodeService = KubernetesNodeService;
|
|
||||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
|
||||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
|
||||||
this.EndpointService = EndpointService;
|
|
||||||
|
|
||||||
this.onInit = this.onInit.bind(this);
|
|
||||||
this.getNodes = this.getNodes.bind(this);
|
|
||||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
|
||||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
|
||||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
|
||||||
this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEndpointsAsync() {
|
|
||||||
try {
|
|
||||||
const endpoints = await this.KubernetesEndpointService.get();
|
|
||||||
const systemEndpoints = _.filter(endpoints, { Namespace: 'kube-system' });
|
|
||||||
this.systemEndpoints = _.filter(systemEndpoints, (ep) => ep.HolderIdentity);
|
|
||||||
|
|
||||||
const kubernetesEndpoint = _.find(endpoints, { Name: 'kubernetes' });
|
|
||||||
if (kubernetesEndpoint && kubernetesEndpoint.Subsets) {
|
|
||||||
const ips = _.flatten(_.map(kubernetesEndpoint.Subsets, 'Ips'));
|
|
||||||
_.forEach(this.nodes, (node) => {
|
|
||||||
node.Api = _.includes(ips, node.IPAddress);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve environments');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getEndpoints() {
|
|
||||||
return this.$async(this.getEndpointsAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNodesAsync() {
|
|
||||||
try {
|
|
||||||
const nodes = await this.KubernetesNodeService.get();
|
|
||||||
_.forEach(nodes, (node) => (node.Memory = filesizeParser(node.Memory)));
|
|
||||||
this.nodes = nodes;
|
|
||||||
this.CPULimit = _.reduce(this.nodes, (acc, node) => node.CPU + acc, 0);
|
|
||||||
this.CPULimit = Math.round(this.CPULimit * 10000) / 10000;
|
|
||||||
this.MemoryLimit = _.reduce(this.nodes, (acc, node) => KubernetesResourceReservationHelper.megaBytesValue(node.Memory) + acc, 0);
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve nodes');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getNodes() {
|
|
||||||
return this.$async(this.getNodesAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getApplicationsAsync() {
|
|
||||||
try {
|
|
||||||
this.state.applicationsLoading = true;
|
|
||||||
|
|
||||||
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
|
||||||
this.resourceReservation = new KubernetesResourceReservation();
|
|
||||||
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest / 1000);
|
|
||||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
|
|
||||||
|
|
||||||
if (this.hasResourceUsageAccess()) {
|
|
||||||
await this.getResourceUsage(this.endpoint.Id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
|
||||||
} finally {
|
|
||||||
this.state.applicationsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getApplications() {
|
|
||||||
return this.$async(this.getApplicationsAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getResourceUsage(endpointId) {
|
|
||||||
try {
|
|
||||||
const nodeMetrics = await getMetricsForAllNodes(endpointId);
|
|
||||||
const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
|
|
||||||
const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
|
|
||||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
|
||||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
|
||||||
return total;
|
|
||||||
}, new KubernetesResourceReservation());
|
|
||||||
this.resourceUsage = clusterResourceUsage;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve cluster resource usage');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if resource usage stats can be displayed
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
hasResourceUsageAccess() {
|
|
||||||
return this.isAdmin && this.state.useServerMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onInit() {
|
|
||||||
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
|
||||||
this.isAdmin = this.Authentication.isAdmin();
|
|
||||||
const useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
applicationsLoading: true,
|
|
||||||
viewReady: false,
|
|
||||||
useServerMetrics,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.getNodes();
|
|
||||||
if (this.isAdmin) {
|
|
||||||
await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.viewReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
return this.$async(this.onInit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesClusterController;
|
|
||||||
angular.module('portainer.kubernetes').controller('KubernetesClusterController', KubernetesClusterController);
|
|
|
@ -1,4 +1,5 @@
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { QueryObserverResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Team } from '@/react/portainer/users/teams/types';
|
import { Team } from '@/react/portainer/users/teams/types';
|
||||||
import { Role, User, UserId } from '@/portainer/users/types';
|
import { Role, User, UserId } from '@/portainer/users/types';
|
||||||
|
@ -134,3 +135,38 @@ export function createMockEnvironment(): Environment {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMockQueryResult<TData, TError = unknown>(
|
||||||
|
data: TData,
|
||||||
|
overrides?: Partial<QueryObserverResult<TData, TError>>
|
||||||
|
) {
|
||||||
|
const defaultResult = {
|
||||||
|
data,
|
||||||
|
dataUpdatedAt: 0,
|
||||||
|
error: null,
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
isError: false,
|
||||||
|
isFetched: true,
|
||||||
|
isFetchedAfterMount: true,
|
||||||
|
isFetching: false,
|
||||||
|
isInitialLoading: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingError: false,
|
||||||
|
isPaused: false,
|
||||||
|
isPlaceholderData: false,
|
||||||
|
isPreviousData: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
isRefetching: false,
|
||||||
|
isStale: false,
|
||||||
|
isSuccess: true,
|
||||||
|
refetch: async () => defaultResult,
|
||||||
|
remove: () => {},
|
||||||
|
status: 'success',
|
||||||
|
fetchStatus: 'idle',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...defaultResult, ...overrides };
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { Tooltip } from '@@/Tip/Tooltip';
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
@ -10,10 +10,11 @@ export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
|
dataCy?: string;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
size?: Size;
|
size?: Size;
|
||||||
tooltip?: ComponentProps<typeof Tooltip>['message'];
|
tooltip?: ReactNode;
|
||||||
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
|
setTooltipHtmlMessage?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
errors?: ReactNode;
|
errors?: ReactNode;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
@ -24,6 +25,7 @@ export interface Props {
|
||||||
|
|
||||||
export function FormControl({
|
export function FormControl({
|
||||||
inputId,
|
inputId,
|
||||||
|
dataCy,
|
||||||
label,
|
label,
|
||||||
size = 'small',
|
size = 'small',
|
||||||
tooltip = '',
|
tooltip = '',
|
||||||
|
@ -42,6 +44,7 @@ export function FormControl({
|
||||||
'form-group',
|
'form-group',
|
||||||
'after:clear-both after:table after:content-[""]' // to fix issues with float
|
'after:clear-both after:table after:content-[""]' // to fix issues with float
|
||||||
)}
|
)}
|
||||||
|
data-cy={dataCy}
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
htmlFor={inputId}
|
htmlFor={inputId}
|
||||||
|
@ -56,10 +59,15 @@ export function FormControl({
|
||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className={sizeClassChildren(size)}>
|
<div className={clsx('flex flex-col', sizeClassChildren(size))}>
|
||||||
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
|
{isLoading && (
|
||||||
|
// 34px height to reduce layout shift when loading is complete
|
||||||
|
<div className="h-[34px] flex items-center">
|
||||||
|
<InlineLoader>{loadingText}</InlineLoader>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isLoading && children}
|
{!isLoading && children}
|
||||||
{errors && <FormError>{errors}</FormError>}
|
{!!errors && !isLoading && <FormError>{errors}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,214 @@
|
||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import { HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { server, http } from '@/setup-tests/server';
|
||||||
|
import {
|
||||||
|
createMockEnvironment,
|
||||||
|
createMockQueryResult,
|
||||||
|
} from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
|
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||||
|
|
||||||
|
const mockUseAuthorizations = vi.fn();
|
||||||
|
const mockUseEnvironmentId = vi.fn(() => 3);
|
||||||
|
const mockUseCurrentEnvironment = vi.fn();
|
||||||
|
|
||||||
|
// Set up mock implementations for hooks
|
||||||
|
vi.mock('@/react/hooks/useUser', () => ({
|
||||||
|
useAuthorizations: () => mockUseAuthorizations(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useCurrentEnvironment', () => ({
|
||||||
|
useCurrentEnvironment: () => mockUseCurrentEnvironment(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const Wrapped = withTestQueryProvider(ClusterResourceReservation);
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClusterResourceReservation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set the return values for the hooks
|
||||||
|
mockUseAuthorizations.mockReturnValue({
|
||||||
|
authorized: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseEnvironmentId.mockReturnValue(3);
|
||||||
|
|
||||||
|
const mockEnvironment = createMockEnvironment();
|
||||||
|
mockEnvironment.Kubernetes.Configuration.UseServerMetrics = true;
|
||||||
|
mockUseCurrentEnvironment.mockReturnValue(
|
||||||
|
createMockQueryResult(mockEnvironment)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup default mock responses
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/api/v1/nodes', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
status: {
|
||||||
|
allocatable: {
|
||||||
|
cpu: '4',
|
||||||
|
memory: '8Gi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/kubernetes/3/metrics/nodes', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
usage: {
|
||||||
|
cpu: '2',
|
||||||
|
memory: '4Gi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/kubernetes/3/metrics/applications_resources', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
CpuRequest: 1000,
|
||||||
|
MemoryRequest: '2Gi',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display resource limits, reservations and usage when all APIs respond successfully', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-usage')).findByText(
|
||||||
|
'4294 / 8589 MB - 50%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-usage')).findByText(
|
||||||
|
'2 / 4 - 50%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display resource usage if user does not have K8sClusterNodeR authorization', async () => {
|
||||||
|
mockUseAuthorizations.mockReturnValue({
|
||||||
|
authorized: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Should only show reservation bars
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Usage bars should not be present
|
||||||
|
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display resource usage if metrics server is not enabled', async () => {
|
||||||
|
const disabledMetricsEnvironment = createMockEnvironment();
|
||||||
|
disabledMetricsEnvironment.Kubernetes.Configuration.UseServerMetrics =
|
||||||
|
false;
|
||||||
|
mockUseCurrentEnvironment.mockReturnValue(
|
||||||
|
createMockQueryResult(disabledMetricsEnvironment)
|
||||||
|
);
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Should only show reservation bars
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Usage bars should not be present
|
||||||
|
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display warning if metrics server is enabled but usage query fails', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock console.error so test logs are not polluted
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-usage')).findByText(
|
||||||
|
'0 / 8589 MB - 0%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-usage')).findByText(
|
||||||
|
'0 / 4 - 0%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Should show the warning message
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
/Resource usage is not currently available as Metrics Server is not responding/
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { Widget, WidgetBody } from '@/react/components/Widget';
|
||||||
|
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||||
|
|
||||||
|
import { useClusterResourceReservationData } from './useClusterResourceReservationData';
|
||||||
|
|
||||||
|
export function ClusterResourceReservation() {
|
||||||
|
// Load all data required for this component
|
||||||
|
const {
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
isLoading,
|
||||||
|
displayResourceUsage,
|
||||||
|
resourceUsage,
|
||||||
|
resourceReservation,
|
||||||
|
displayWarning,
|
||||||
|
} = useClusterResourceReservationData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<WidgetBody>
|
||||||
|
<ResourceReservation
|
||||||
|
isLoading={isLoading}
|
||||||
|
displayResourceUsage={displayResourceUsage}
|
||||||
|
resourceReservation={resourceReservation}
|
||||||
|
resourceUsage={resourceUsage}
|
||||||
|
cpuLimit={cpuLimit}
|
||||||
|
memoryLimit={memoryLimit}
|
||||||
|
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
||||||
|
displayWarning={displayWarning}
|
||||||
|
warningMessage="Resource usage is not currently available as Metrics Server is not responding. If you've recently upgraded, Metrics Server may take a while to restart, so please check back shortly."
|
||||||
|
/>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
33
app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
Normal file
33
app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
import { PageHeader } from '@/react/components/PageHeader';
|
||||||
|
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
||||||
|
|
||||||
|
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||||
|
|
||||||
|
export function ClusterView() {
|
||||||
|
const { data: environment } = useCurrentEnvironment();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Cluster"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Environments', link: 'portainer.endpoints' },
|
||||||
|
{
|
||||||
|
label: environment?.Name || '',
|
||||||
|
link: 'portainer.endpoints.endpoint',
|
||||||
|
linkParams: { id: environment?.Id },
|
||||||
|
},
|
||||||
|
'Cluster information',
|
||||||
|
]}
|
||||||
|
reload
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClusterResourceReservation />
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<NodesDatatable />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
1
app/react/kubernetes/cluster/ClusterView/index.ts
Normal file
1
app/react/kubernetes/cluster/ClusterView/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export { ClusterView } from './ClusterView';
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './useClusterResourceLimitsQuery';
|
||||||
|
export * from './useClusterResourceReservationQuery';
|
||||||
|
export * from './useClusterResourceUsageQuery';
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { round, reduce } from 'lodash';
|
||||||
|
import filesizeParser from 'filesize-parser';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||||
|
import { parseCpu } from '@/react/kubernetes/utils';
|
||||||
|
import { getNodes } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||||
|
|
||||||
|
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'clusterResourceLimits'],
|
||||||
|
async () => getNodes(environmentId),
|
||||||
|
{
|
||||||
|
...withGlobalError('Unable to retrieve resource limit data', 'Failure'),
|
||||||
|
enabled: !!environmentId,
|
||||||
|
select: aggregateResourceLimits,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes node data to calculate total CPU and memory limits for the cluster
|
||||||
|
* and sets the state for memory limit in MB and CPU limit rounded to 3 decimal places.
|
||||||
|
*/
|
||||||
|
function aggregateResourceLimits(nodes: Node[]) {
|
||||||
|
const processedNodes = nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
memory: filesizeParser(node.status?.allocatable?.memory ?? ''),
|
||||||
|
cpu: parseCpu(node.status?.allocatable?.cpu ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: processedNodes,
|
||||||
|
memoryLimit: reduce(
|
||||||
|
processedNodes,
|
||||||
|
(acc, node) =>
|
||||||
|
KubernetesResourceReservationHelper.megaBytesValue(node.memory || 0) +
|
||||||
|
acc,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
cpuLimit: round(
|
||||||
|
reduce(processedNodes, (acc, node) => (node.cpu || 0) + acc, 0),
|
||||||
|
3
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics';
|
||||||
|
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||||
|
|
||||||
|
export function useClusterResourceReservationQuery(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
nodes: Node[]
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'clusterResourceReservation'],
|
||||||
|
() => getTotalResourcesForAllApplications(environmentId),
|
||||||
|
{
|
||||||
|
enabled: !!environmentId && nodes.length > 0,
|
||||||
|
select: (data) => ({
|
||||||
|
cpu: data.CpuRequest / 1000,
|
||||||
|
memory: KubernetesResourceReservationHelper.megaBytesValue(
|
||||||
|
data.MemoryRequest
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { getMetricsForAllNodes } from '@/react/kubernetes/metrics/metrics';
|
||||||
|
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import { NodeMetrics } from '@/react/kubernetes/metrics/types';
|
||||||
|
|
||||||
|
export function useClusterResourceUsageQuery(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
serverMetricsEnabled: boolean,
|
||||||
|
authorized: boolean,
|
||||||
|
nodes: Node[]
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'clusterResourceUsage'],
|
||||||
|
() => getMetricsForAllNodes(environmentId),
|
||||||
|
{
|
||||||
|
enabled:
|
||||||
|
authorized &&
|
||||||
|
serverMetricsEnabled &&
|
||||||
|
!!environmentId &&
|
||||||
|
nodes.length > 0,
|
||||||
|
select: aggregateResourceUsage,
|
||||||
|
...withGlobalError('Unable to retrieve resource usage data.', 'Failure'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateResourceUsage(data: NodeMetrics) {
|
||||||
|
return data.items.reduce(
|
||||||
|
(total, item) => ({
|
||||||
|
cpu:
|
||||||
|
total.cpu +
|
||||||
|
KubernetesResourceReservationHelper.parseCPU(item.usage.cpu),
|
||||||
|
memory:
|
||||||
|
total.memory +
|
||||||
|
KubernetesResourceReservationHelper.megaBytesValue(item.usage.memory),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cpu: 0,
|
||||||
|
memory: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useClusterResourceLimitsQuery,
|
||||||
|
useClusterResourceReservationQuery,
|
||||||
|
useClusterResourceUsageQuery,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export function useClusterResourceReservationData() {
|
||||||
|
const { data: environment } = useCurrentEnvironment();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
// Check if server metrics is enabled
|
||||||
|
const serverMetricsEnabled =
|
||||||
|
environment?.Kubernetes?.Configuration?.UseServerMetrics || false;
|
||||||
|
|
||||||
|
// User needs to have K8sClusterNodeR authorization to view resource usage data
|
||||||
|
const { authorized: hasK8sClusterNodeR } = useAuthorizations(
|
||||||
|
['K8sClusterNodeR'],
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get resource limits for the cluster
|
||||||
|
const { data: resourceLimits, isLoading: isResourceLimitLoading } =
|
||||||
|
useClusterResourceLimitsQuery(environmentId);
|
||||||
|
|
||||||
|
// Get resource reservation info for the cluster
|
||||||
|
const {
|
||||||
|
data: resourceReservation,
|
||||||
|
isFetching: isResourceReservationLoading,
|
||||||
|
} = useClusterResourceReservationQuery(
|
||||||
|
environmentId,
|
||||||
|
resourceLimits?.nodes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get resource usage info for the cluster
|
||||||
|
const {
|
||||||
|
data: resourceUsage,
|
||||||
|
isFetching: isResourceUsageLoading,
|
||||||
|
isError: isResourceUsageError,
|
||||||
|
} = useClusterResourceUsageQuery(
|
||||||
|
environmentId,
|
||||||
|
serverMetricsEnabled,
|
||||||
|
hasK8sClusterNodeR,
|
||||||
|
resourceLimits?.nodes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
memoryLimit: getSafeValue(resourceLimits?.memoryLimit || 0),
|
||||||
|
cpuLimit: getSafeValue(resourceLimits?.cpuLimit || 0),
|
||||||
|
displayResourceUsage: hasK8sClusterNodeR && serverMetricsEnabled,
|
||||||
|
resourceUsage: {
|
||||||
|
cpu: getSafeValue(resourceUsage?.cpu || 0),
|
||||||
|
memory: getSafeValue(resourceUsage?.memory || 0),
|
||||||
|
},
|
||||||
|
resourceReservation: {
|
||||||
|
cpu: getSafeValue(resourceReservation?.cpu || 0),
|
||||||
|
memory: getSafeValue(resourceReservation?.memory || 0),
|
||||||
|
},
|
||||||
|
isLoading:
|
||||||
|
isResourceLimitLoading ||
|
||||||
|
isResourceReservationLoading ||
|
||||||
|
isResourceUsageLoading,
|
||||||
|
// Display warning if server metrics isn't responding but should be
|
||||||
|
displayWarning:
|
||||||
|
hasK8sClusterNodeR && serverMetricsEnabled && isResourceUsageError,
|
||||||
|
};
|
||||||
|
}
|
|
@ -45,7 +45,7 @@ export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// getNodes is used to get a list of nodes using the kubernetes API
|
// getNodes is used to get a list of nodes using the kubernetes API
|
||||||
async function getNodes(environmentId: EnvironmentId) {
|
export async function getNodes(environmentId: EnvironmentId) {
|
||||||
try {
|
try {
|
||||||
const { data: nodeList } = await axios.get<NodeList>(
|
const { data: nodeList } = await axios.get<NodeList>(
|
||||||
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
|
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
|
||||||
|
|
129
app/react/kubernetes/components/ResourceReservation.tsx
Normal file
129
app/react/kubernetes/components/ResourceReservation.tsx
Normal file
|
@ -0,0 +1,129 @@
|
||||||
|
import { round } from 'lodash';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormSectionTitle } from '@/react/components/form-components/FormSectionTitle';
|
||||||
|
import { TextTip } from '@/react/components/Tip/TextTip';
|
||||||
|
import { ResourceUsageItem } from '@/react/kubernetes/components/ResourceUsageItem';
|
||||||
|
import { getPercentageString, getSafeValue } from '@/react/kubernetes/utils';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
interface ResourceMetrics {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
displayResourceUsage: boolean;
|
||||||
|
resourceReservation: ResourceMetrics;
|
||||||
|
resourceUsage: ResourceMetrics;
|
||||||
|
cpuLimit: number;
|
||||||
|
memoryLimit: number;
|
||||||
|
description: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
title?: string;
|
||||||
|
displayWarning?: boolean;
|
||||||
|
warningMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceReservation({
|
||||||
|
displayResourceUsage,
|
||||||
|
resourceReservation,
|
||||||
|
resourceUsage,
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
description,
|
||||||
|
title = 'Resource reservation',
|
||||||
|
isLoading = false,
|
||||||
|
displayWarning = false,
|
||||||
|
warningMessage = '',
|
||||||
|
}: Props) {
|
||||||
|
const memoryReservationAnnotation = `${getSafeValue(
|
||||||
|
resourceReservation.memory
|
||||||
|
)} / ${memoryLimit} MB ${getPercentageString(
|
||||||
|
resourceReservation.memory,
|
||||||
|
memoryLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const memoryUsageAnnotation = `${getSafeValue(
|
||||||
|
resourceUsage.memory
|
||||||
|
)} / ${memoryLimit} MB ${getPercentageString(
|
||||||
|
resourceUsage.memory,
|
||||||
|
memoryLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const cpuReservationAnnotation = `${round(
|
||||||
|
getSafeValue(resourceReservation.cpu),
|
||||||
|
2
|
||||||
|
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
|
||||||
|
resourceReservation.cpu,
|
||||||
|
cpuLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const cpuUsageAnnotation = `${round(
|
||||||
|
getSafeValue(resourceUsage.cpu),
|
||||||
|
2
|
||||||
|
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
|
||||||
|
resourceUsage.cpu,
|
||||||
|
cpuLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSectionTitle>{title}</FormSectionTitle>
|
||||||
|
<TextTip color="blue" className="mb-2">
|
||||||
|
{description}
|
||||||
|
</TextTip>
|
||||||
|
<div className="form-horizontal">
|
||||||
|
{memoryLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceReservation.memory}
|
||||||
|
total={memoryLimit}
|
||||||
|
label="Memory reservation"
|
||||||
|
annotation={memoryReservationAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="memory-reservation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayResourceUsage && memoryLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceUsage.memory}
|
||||||
|
total={memoryLimit}
|
||||||
|
label="Memory usage"
|
||||||
|
annotation={memoryUsageAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="memory-usage"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cpuLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceReservation.cpu}
|
||||||
|
total={cpuLimit}
|
||||||
|
label="CPU reservation"
|
||||||
|
annotation={cpuReservationAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="cpu-reservation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayResourceUsage && cpuLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceUsage.cpu}
|
||||||
|
total={cpuLimit}
|
||||||
|
label="CPU usage"
|
||||||
|
annotation={cpuUsageAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="cpu-usage"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayWarning && (
|
||||||
|
<div className="form-group">
|
||||||
|
<span className="col-sm-12 text-warning small vertical-center">
|
||||||
|
<Icon icon={AlertTriangle} mode="warning" />
|
||||||
|
{warningMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,11 +1,13 @@
|
||||||
import { ProgressBar } from '@@/ProgressBar';
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { ProgressBar } from '@@/ProgressBar';
|
||||||
|
|
||||||
interface ResourceUsageItemProps {
|
interface ResourceUsageItemProps {
|
||||||
value: number;
|
value: number;
|
||||||
total: number;
|
total: number;
|
||||||
annotation?: React.ReactNode;
|
annotation?: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
dataCy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceUsageItem({
|
export function ResourceUsageItem({
|
||||||
|
@ -13,9 +15,16 @@ export function ResourceUsageItem({
|
||||||
total,
|
total,
|
||||||
annotation,
|
annotation,
|
||||||
label,
|
label,
|
||||||
|
isLoading = false,
|
||||||
|
dataCy,
|
||||||
}: ResourceUsageItemProps) {
|
}: ResourceUsageItemProps) {
|
||||||
return (
|
return (
|
||||||
<FormControl label={label}>
|
<FormControl
|
||||||
|
label={label}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className={isLoading ? 'mb-1.5' : ''}
|
||||||
|
dataCy={dataCy}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
steps={[
|
steps={[
|
|
@ -37,8 +37,8 @@ export type Usage = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ApplicationResource = {
|
export type ApplicationResource = {
|
||||||
cpuRequest: number;
|
CpuRequest: number;
|
||||||
cpuLimit: number;
|
CpuLimit: number;
|
||||||
memoryRequest: number;
|
MemoryRequest: number;
|
||||||
memoryLimit: number;
|
MemoryLimit: number;
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||||
|
|
||||||
|
import { ResourceQuotaFormValues } from './types';
|
||||||
|
import { useNamespaceResourceReservationData } from './useNamespaceResourceReservationData';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
namespaceName: string;
|
||||||
|
environmentId: number;
|
||||||
|
resourceQuotaValues: ResourceQuotaFormValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NamespaceResourceReservation({
|
||||||
|
environmentId,
|
||||||
|
namespaceName,
|
||||||
|
resourceQuotaValues,
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
displayResourceUsage,
|
||||||
|
resourceUsage,
|
||||||
|
resourceReservation,
|
||||||
|
isLoading,
|
||||||
|
} = useNamespaceResourceReservationData(
|
||||||
|
environmentId,
|
||||||
|
namespaceName,
|
||||||
|
resourceQuotaValues
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resourceQuotaValues.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceReservation
|
||||||
|
displayResourceUsage={displayResourceUsage}
|
||||||
|
resourceReservation={resourceReservation}
|
||||||
|
resourceUsage={resourceUsage}
|
||||||
|
cpuLimit={cpuLimit}
|
||||||
|
memoryLimit={memoryLimit}
|
||||||
|
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -13,8 +13,8 @@ import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||||
|
|
||||||
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
||||||
|
|
||||||
import { ResourceReservationUsage } from './ResourceReservationUsage';
|
|
||||||
import { ResourceQuotaFormValues } from './types';
|
import { ResourceQuotaFormValues } from './types';
|
||||||
|
import { NamespaceResourceReservation } from './NamespaceResourceReservation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
values: ResourceQuotaFormValues;
|
values: ResourceQuotaFormValues;
|
||||||
|
@ -128,7 +128,7 @@ export function ResourceQuotaFormSection({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{namespaceName && isEdit && (
|
{namespaceName && isEdit && (
|
||||||
<ResourceReservationUsage
|
<NamespaceResourceReservation
|
||||||
namespaceName={namespaceName}
|
namespaceName={namespaceName}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
resourceQuotaValues={values}
|
resourceQuotaValues={values}
|
||||||
|
|
|
@ -1,150 +0,0 @@
|
||||||
import { round } from 'lodash';
|
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
|
||||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
|
||||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
|
||||||
|
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
|
||||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
|
||||||
|
|
||||||
import { megaBytesValue, parseCPU } from '../../../resourceQuotaUtils';
|
|
||||||
import { ResourceUsageItem } from '../../ResourceUsageItem';
|
|
||||||
|
|
||||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
|
||||||
import { ResourceQuotaFormValues } from './types';
|
|
||||||
|
|
||||||
export function ResourceReservationUsage({
|
|
||||||
namespaceName,
|
|
||||||
environmentId,
|
|
||||||
resourceQuotaValues,
|
|
||||||
}: {
|
|
||||||
namespaceName: string;
|
|
||||||
environmentId: EnvironmentId;
|
|
||||||
resourceQuotaValues: ResourceQuotaFormValues;
|
|
||||||
}) {
|
|
||||||
const namespaceMetricsQuery = useMetricsForNamespace(
|
|
||||||
environmentId,
|
|
||||||
namespaceName,
|
|
||||||
{
|
|
||||||
select: aggregatePodUsage,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const usedResourceQuotaQuery = useResourceQuotaUsed(
|
|
||||||
environmentId,
|
|
||||||
namespaceName
|
|
||||||
);
|
|
||||||
const { data: namespaceMetrics } = namespaceMetricsQuery;
|
|
||||||
const { data: usedResourceQuota } = usedResourceQuotaQuery;
|
|
||||||
|
|
||||||
const memoryQuota = Number(resourceQuotaValues.memory) ?? 0;
|
|
||||||
const cpuQuota = Number(resourceQuotaValues.cpu) ?? 0;
|
|
||||||
|
|
||||||
if (!resourceQuotaValues.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormSectionTitle>Resource reservation</FormSectionTitle>
|
|
||||||
<TextTip color="blue" className="mb-2">
|
|
||||||
Resource reservation represents the total amount of resource assigned to
|
|
||||||
all the applications deployed inside this namespace.
|
|
||||||
</TextTip>
|
|
||||||
{!!usedResourceQuota && memoryQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={usedResourceQuota.memory}
|
|
||||||
total={getSafeValue(memoryQuota)}
|
|
||||||
label="Memory reservation"
|
|
||||||
annotation={`${usedResourceQuota.memory} / ${getSafeValue(
|
|
||||||
memoryQuota
|
|
||||||
)} MB ${getPercentageString(usedResourceQuota.memory, memoryQuota)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!namespaceMetrics && memoryQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={namespaceMetrics.memory}
|
|
||||||
total={getSafeValue(memoryQuota)}
|
|
||||||
label="Memory used"
|
|
||||||
annotation={`${namespaceMetrics.memory} / ${getSafeValue(
|
|
||||||
memoryQuota
|
|
||||||
)} MB ${getPercentageString(namespaceMetrics.memory, memoryQuota)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!usedResourceQuota && cpuQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={usedResourceQuota.cpu}
|
|
||||||
total={cpuQuota}
|
|
||||||
label="CPU reservation"
|
|
||||||
annotation={`${
|
|
||||||
usedResourceQuota.cpu
|
|
||||||
} / ${cpuQuota} ${getPercentageString(
|
|
||||||
usedResourceQuota.cpu,
|
|
||||||
cpuQuota
|
|
||||||
)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!namespaceMetrics && cpuQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={namespaceMetrics.cpu}
|
|
||||||
total={cpuQuota}
|
|
||||||
label="CPU used"
|
|
||||||
annotation={`${
|
|
||||||
namespaceMetrics.cpu
|
|
||||||
} / ${cpuQuota} ${getPercentageString(
|
|
||||||
namespaceMetrics.cpu,
|
|
||||||
cpuQuota
|
|
||||||
)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSafeValue(value: number | string) {
|
|
||||||
const valueNumber = Number(value);
|
|
||||||
if (Number.isNaN(valueNumber)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return valueNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the percentage of the value over the total.
|
|
||||||
* @param value - The value to calculate the percentage for.
|
|
||||||
* @param total - The total value to compare the percentage to.
|
|
||||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
|
||||||
*/
|
|
||||||
function getPercentageString(value: number, total?: number | string) {
|
|
||||||
const totalNumber = Number(total);
|
|
||||||
if (
|
|
||||||
totalNumber === 0 ||
|
|
||||||
total === undefined ||
|
|
||||||
total === '' ||
|
|
||||||
Number.isNaN(totalNumber)
|
|
||||||
) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (value > totalNumber) {
|
|
||||||
return '- Exceeded';
|
|
||||||
}
|
|
||||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates the resource usage of all the containers in the namespace.
|
|
||||||
* @param podMetricsList - List of pod metrics
|
|
||||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
|
||||||
*/
|
|
||||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
|
||||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
|
||||||
i.containers.map((c) => c.usage)
|
|
||||||
);
|
|
||||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
|
||||||
(total, usage) => ({
|
|
||||||
cpu: total.cpu + parseCPU(usage.cpu),
|
|
||||||
memory: total.memory + megaBytesValue(usage.memory),
|
|
||||||
}),
|
|
||||||
{ cpu: 0, memory: 0 }
|
|
||||||
);
|
|
||||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
|
||||||
return namespaceResourceUsage;
|
|
||||||
}
|
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { round } from 'lodash';
|
||||||
|
|
||||||
|
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||||
|
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||||
|
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||||
|
import {
|
||||||
|
megaBytesValue,
|
||||||
|
parseCPU,
|
||||||
|
} from '@/react/kubernetes/namespaces/resourceQuotaUtils';
|
||||||
|
|
||||||
|
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||||
|
import { ResourceQuotaFormValues } from './types';
|
||||||
|
|
||||||
|
export function useNamespaceResourceReservationData(
|
||||||
|
environmentId: number,
|
||||||
|
namespaceName: string,
|
||||||
|
resourceQuotaValues: ResourceQuotaFormValues
|
||||||
|
) {
|
||||||
|
const { data: quota, isLoading: isQuotaLoading } = useResourceQuotaUsed(
|
||||||
|
environmentId,
|
||||||
|
namespaceName
|
||||||
|
);
|
||||||
|
const { data: metrics, isLoading: isMetricsLoading } = useMetricsForNamespace(
|
||||||
|
environmentId,
|
||||||
|
namespaceName,
|
||||||
|
{
|
||||||
|
select: aggregatePodUsage,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuLimit: Number(resourceQuotaValues.cpu) || 0,
|
||||||
|
memoryLimit: Number(resourceQuotaValues.memory) || 0,
|
||||||
|
displayResourceUsage: !!metrics,
|
||||||
|
resourceReservation: {
|
||||||
|
cpu: getSafeValue(quota?.cpu || 0),
|
||||||
|
memory: getSafeValue(quota?.memory || 0),
|
||||||
|
},
|
||||||
|
resourceUsage: {
|
||||||
|
cpu: getSafeValue(metrics?.cpu || 0),
|
||||||
|
memory: getSafeValue(metrics?.memory || 0),
|
||||||
|
},
|
||||||
|
isLoading: isQuotaLoading || isMetricsLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates the resource usage of all the containers in the namespace.
|
||||||
|
* @param podMetricsList - List of pod metrics
|
||||||
|
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||||
|
*/
|
||||||
|
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||||
|
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||||
|
i.containers.map((c) => c.usage)
|
||||||
|
);
|
||||||
|
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||||
|
(total, usage) => ({
|
||||||
|
cpu: total.cpu + parseCPU(usage.cpu),
|
||||||
|
memory: total.memory + megaBytesValue(usage.memory),
|
||||||
|
}),
|
||||||
|
{ cpu: 0, memory: 0 }
|
||||||
|
);
|
||||||
|
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||||
|
return namespaceResourceUsage;
|
||||||
|
}
|
|
@ -20,3 +20,38 @@ export function prepareAnnotations(annotations?: Annotation[]) {
|
||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the safe value of the given number or string.
|
||||||
|
* @param value - The value to get the safe value for.
|
||||||
|
* @returns The safe value of the given number or string.
|
||||||
|
*/
|
||||||
|
export function getSafeValue(value: number | string) {
|
||||||
|
const valueNumber = Number(value);
|
||||||
|
if (Number.isNaN(valueNumber)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return valueNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the percentage of the value over the total.
|
||||||
|
* @param value - The value to calculate the percentage for.
|
||||||
|
* @param total - The total value to compare the percentage to.
|
||||||
|
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||||
|
*/
|
||||||
|
export function getPercentageString(value: number, total?: number | string) {
|
||||||
|
const totalNumber = Number(total);
|
||||||
|
if (
|
||||||
|
totalNumber === 0 ||
|
||||||
|
total === undefined ||
|
||||||
|
total === '' ||
|
||||||
|
Number.isNaN(totalNumber)
|
||||||
|
) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (value > totalNumber) {
|
||||||
|
return '- Exceeded';
|
||||||
|
}
|
||||||
|
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue