mirror of https://github.com/portainer/portainer
feat(license): remove untrusted devices from node count [EE-5357] (#8817)
parent
5f6ddc2fad
commit
cfed481d6e
|
@ -2,13 +2,16 @@ package status
|
||||||
|
|
||||||
import (
|
import (
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NodesCount returns the total node number of all environments
|
// NodesCount returns the total node number of all environments
|
||||||
func NodesCount(endpoints []portainer.Endpoint) int {
|
func NodesCount(endpoints []portainer.Endpoint) int {
|
||||||
nodes := 0
|
nodes := 0
|
||||||
for _, env := range endpoints {
|
for _, env := range endpoints {
|
||||||
nodes += countNodes(&env)
|
if !endpointutils.IsEdgeEndpoint(&env) || env.UserTrusted {
|
||||||
|
nodes += countNodes(&env)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes
|
return nodes
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { AlertCircle, CheckCircle, XCircle } from 'lucide-react';
|
import { AlertCircle, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
||||||
import { PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@@/Icon';
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
type AlertType = 'success' | 'error' | 'info';
|
type AlertType = 'success' | 'error' | 'info' | 'warn';
|
||||||
|
|
||||||
const alertSettings: Record<
|
const alertSettings: Record<
|
||||||
AlertType,
|
AlertType,
|
||||||
|
@ -31,22 +31,37 @@ const alertSettings: Record<
|
||||||
body: 'text-blue-7',
|
body: 'text-blue-7',
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
},
|
},
|
||||||
|
warn: {
|
||||||
|
container:
|
||||||
|
'border-warning-4 bg-warning-2 th-dark:bg-warning-3 th-dark:border-warning-5',
|
||||||
|
header: 'text-warning-8',
|
||||||
|
body: 'text-warning-7',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Alert({
|
export function Alert({
|
||||||
color,
|
color,
|
||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<{ color: AlertType; title: string }>) {
|
}: PropsWithChildren<{ color: AlertType; title?: string }>) {
|
||||||
const { container, header, body, icon } = alertSettings[color];
|
const { container, header, body, icon } = alertSettings[color];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AlertContainer className={container}>
|
<AlertContainer className={container}>
|
||||||
<AlertHeader className={header}>
|
{title ? (
|
||||||
<Icon icon={icon} />
|
<>
|
||||||
{title}
|
<AlertHeader className={header}>
|
||||||
</AlertHeader>
|
<Icon icon={icon} />
|
||||||
<AlertBody className={body}>{children}</AlertBody>
|
{title}
|
||||||
|
</AlertHeader>
|
||||||
|
<AlertBody className={body}>{children}</AlertBody>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<AlertBody className={clsx(body, 'flex items-center gap-2')}>
|
||||||
|
<Icon icon={icon} /> {children}
|
||||||
|
</AlertBody>
|
||||||
|
)}
|
||||||
</AlertContainer>
|
</AlertContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -68,7 +83,11 @@ function AlertHeader({
|
||||||
}: PropsWithChildren<{ className?: string }>) {
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
return (
|
return (
|
||||||
<h4
|
<h4
|
||||||
className={clsx('text-base', '!m-0 flex items-center gap-2', className)}
|
className={clsx(
|
||||||
|
'text-base',
|
||||||
|
'!m-0 mb-2 flex items-center gap-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</h4>
|
</h4>
|
||||||
|
@ -79,5 +98,5 @@ function AlertBody({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<{ className?: string }>) {
|
}: PropsWithChildren<{ className?: string }>) {
|
||||||
return <div className={clsx('ml-6 mt-2 text-sm', className)}>{children}</div>;
|
return <div className={clsx('ml-6 text-sm', className)}>{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -69,6 +69,7 @@ export function TooltipWithChildren({
|
||||||
arrow
|
arrow
|
||||||
allowHTML
|
allowHTML
|
||||||
interactive
|
interactive
|
||||||
|
disabled={!message}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Tippy>
|
</Tippy>
|
||||||
|
|
|
@ -6,12 +6,12 @@ import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/qu
|
||||||
|
|
||||||
import { Datatable as GenericDatatable } from '@@/datatables';
|
import { Datatable as GenericDatatable } from '@@/datatables';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
|
||||||
import { createPersistedStore } from '@@/datatables/types';
|
import { createPersistedStore } from '@@/datatables/types';
|
||||||
import { useTableState } from '@@/datatables/useTableState';
|
import { useTableState } from '@@/datatables/useTableState';
|
||||||
import { confirm } from '@@/modals/confirm';
|
import { confirm } from '@@/modals/confirm';
|
||||||
import { buildConfirmButton } from '@@/modals/utils';
|
import { buildConfirmButton } from '@@/modals/utils';
|
||||||
import { ModalType } from '@@/modals';
|
import { ModalType } from '@@/modals';
|
||||||
|
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||||
|
|
||||||
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
|
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
|
||||||
|
|
||||||
|
@ -26,7 +26,7 @@ const settingsStore = createPersistedStore(storageKey, 'Name');
|
||||||
export function Datatable() {
|
export function Datatable() {
|
||||||
const associateMutation = useAssociateDeviceMutation();
|
const associateMutation = useAssociateDeviceMutation();
|
||||||
const removeMutation = useDeleteEnvironmentsMutation();
|
const removeMutation = useDeleteEnvironmentsMutation();
|
||||||
const licenseOverused = useLicenseOverused();
|
const { willExceed } = useLicenseOverused();
|
||||||
const tableState = useTableState(settingsStore, storageKey);
|
const tableState = useTableState(settingsStore, storageKey);
|
||||||
const { data: environments, totalCount, isLoading } = useEnvironments();
|
const { data: environments, totalCount, isLoading } = useEnvironments();
|
||||||
|
|
||||||
|
@ -41,28 +41,34 @@ export function Datatable() {
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRemoveDevice(selectedRows)}
|
onClick={() => handleRemoveDevice(selectedRows)}
|
||||||
disabled={selectedRows.length === 0 || licenseOverused}
|
disabled={selectedRows.length === 0}
|
||||||
color="dangerlight"
|
color="dangerlight"
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
>
|
>
|
||||||
Remove Device
|
Remove Device
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<TooltipWithChildren
|
||||||
onClick={() => handleAssociateDevice(selectedRows)}
|
message={
|
||||||
disabled={selectedRows.length === 0 || licenseOverused}
|
willExceed(selectedRows.length) && (
|
||||||
|
<>
|
||||||
|
Associating devices is disabled as your node count exceeds
|
||||||
|
your license limit
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Associate Device
|
<span>
|
||||||
</Button>
|
<Button
|
||||||
|
onClick={() => handleAssociateDevice(selectedRows)}
|
||||||
{licenseOverused ? (
|
disabled={
|
||||||
<div className="ml-2 mt-2">
|
selectedRows.length === 0 || willExceed(selectedRows.length)
|
||||||
<TextTip color="orange">
|
}
|
||||||
Associating devices is disabled as your node count exceeds your
|
>
|
||||||
license limit
|
Associate Device
|
||||||
</TextTip>
|
</Button>
|
||||||
</div>
|
</span>
|
||||||
) : null}
|
</TooltipWithChildren>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
|
|
|
@ -3,12 +3,17 @@ import { withLimitToBE } from '@/react/hooks/useLimitToBE';
|
||||||
import { InformationPanel } from '@@/InformationPanel';
|
import { InformationPanel } from '@@/InformationPanel';
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
import { PageHeader } from '@@/PageHeader';
|
import { PageHeader } from '@@/PageHeader';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
import { Datatable } from './Datatable';
|
import { Datatable } from './Datatable';
|
||||||
|
import { useLicenseOverused, useUntrustedCount } from './queries';
|
||||||
|
|
||||||
export default withLimitToBE(WaitingRoomView);
|
export default withLimitToBE(WaitingRoomView);
|
||||||
|
|
||||||
function WaitingRoomView() {
|
function WaitingRoomView() {
|
||||||
|
const untrustedCount = useUntrustedCount();
|
||||||
|
const { willExceed } = useLicenseOverused();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
@ -27,6 +32,19 @@ function WaitingRoomView() {
|
||||||
</TextTip>
|
</TextTip>
|
||||||
</InformationPanel>
|
</InformationPanel>
|
||||||
|
|
||||||
|
{willExceed(untrustedCount) && (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Alert color="warn">
|
||||||
|
Associating all nodes in waiting room will exceed the node limit
|
||||||
|
of your current license. Go to{' '}
|
||||||
|
<Link to="portainer.licenses">Licenses</Link> page to view the
|
||||||
|
current usage.
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Datatable />
|
<Datatable />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { useMutation, useQueryClient } from 'react-query';
|
import { useMutation, useQueryClient } from 'react-query';
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
import { promiseSequence } from '@/portainer/helpers/promise-utils';
|
||||||
import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service';
|
import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service';
|
||||||
|
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||||
|
|
||||||
export function useAssociateDeviceMutation() {
|
export function useAssociateDeviceMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
@ -35,8 +36,23 @@ async function associateDevice(environmentId: EnvironmentId) {
|
||||||
|
|
||||||
export function useLicenseOverused() {
|
export function useLicenseOverused() {
|
||||||
const integratedInfo = useIntegratedLicenseInfo();
|
const integratedInfo = useIntegratedLicenseInfo();
|
||||||
if (integratedInfo && integratedInfo.licenseInfo.enforcedAt > 0) {
|
return {
|
||||||
return true;
|
willExceed,
|
||||||
|
isOverused: willExceed(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
function willExceed(moreNodes: number) {
|
||||||
|
return (
|
||||||
|
!!integratedInfo &&
|
||||||
|
integratedInfo.usedNodes + moreNodes >= integratedInfo.licenseInfo.nodes
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return false;
|
}
|
||||||
|
|
||||||
|
export function useUntrustedCount() {
|
||||||
|
const query = useEnvironmentList({
|
||||||
|
edgeDeviceUntrusted: true,
|
||||||
|
types: EdgeTypes,
|
||||||
|
});
|
||||||
|
return query.totalCount;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
// matches https://github.com/portainer/liblicense/blob/master/liblicense.go#L66-L74
|
// matches https://github.com/portainer/liblicense/blob/develop/liblicense.go#L66-L74
|
||||||
export enum Edition {
|
export enum Edition {
|
||||||
CE = 1,
|
CE = 1,
|
||||||
BE,
|
BE,
|
||||||
EE,
|
EE,
|
||||||
}
|
}
|
||||||
|
|
||||||
// matches https://github.com/portainer/liblicense/blob/master/liblicense.go#L60-L64
|
// matches https://github.com/portainer/liblicense/blob/develop/liblicense.go#L64-L69
|
||||||
|
|
||||||
export enum LicenseType {
|
export enum LicenseType {
|
||||||
Trial = 1,
|
Trial = 1,
|
||||||
Subscription,
|
Subscription,
|
||||||
|
/**
|
||||||
|
* Essentials is the free 5-node license type
|
||||||
|
*/
|
||||||
|
Essentials,
|
||||||
}
|
}
|
||||||
|
|
||||||
// matches https://github.com/portainer/liblicense/blob/master/liblicense.go#L35-L50
|
// matches https://github.com/portainer/liblicense/blob/develop/liblicense.go#L35-L50
|
||||||
export interface License {
|
export interface License {
|
||||||
id: string;
|
id: string;
|
||||||
company: string;
|
company: string;
|
||||||
|
|
Loading…
Reference in New Issue