feat(license): remove untrusted devices from node count [EE-5357] (#8817)

pull/8906/head
Chaim Lev-Ari 2023-05-05 09:02:31 +07:00 committed by GitHub
parent 5f6ddc2fad
commit cfed481d6e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 102 additions and 35 deletions

View File

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

View File

@ -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>;
} }

View File

@ -69,6 +69,7 @@ export function TooltipWithChildren({
arrow arrow
allowHTML allowHTML
interactive interactive
disabled={!message}
> >
{children} {children}
</Tippy> </Tippy>

View File

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

View File

@ -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 />
</> </>
); );

View File

@ -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;
} }

View File

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