diff --git a/api/internal/nodes/nodes.go b/api/internal/nodes/nodes.go index 1e061cd7d..a95895f98 100644 --- a/api/internal/nodes/nodes.go +++ b/api/internal/nodes/nodes.go @@ -2,13 +2,16 @@ package status import ( portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/endpointutils" ) // NodesCount returns the total node number of all environments func NodesCount(endpoints []portainer.Endpoint) int { nodes := 0 for _, env := range endpoints { - nodes += countNodes(&env) + if !endpointutils.IsEdgeEndpoint(&env) || env.UserTrusted { + nodes += countNodes(&env) + } } return nodes diff --git a/app/react/components/Alert/Alert.tsx b/app/react/components/Alert/Alert.tsx index 46ea3b63c..6cbd101a6 100644 --- a/app/react/components/Alert/Alert.tsx +++ b/app/react/components/Alert/Alert.tsx @@ -1,10 +1,10 @@ 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 { Icon } from '@@/Icon'; -type AlertType = 'success' | 'error' | 'info'; +type AlertType = 'success' | 'error' | 'info' | 'warn'; const alertSettings: Record< AlertType, @@ -31,22 +31,37 @@ const alertSettings: Record< body: 'text-blue-7', 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({ color, title, children, -}: PropsWithChildren<{ color: AlertType; title: string }>) { +}: PropsWithChildren<{ color: AlertType; title?: string }>) { const { container, header, body, icon } = alertSettings[color]; return ( - - - {title} - - {children} + {title ? ( + <> + + + {title} + + {children} + + ) : ( + + {children} + + )} ); } @@ -68,7 +83,11 @@ function AlertHeader({ }: PropsWithChildren<{ className?: string }>) { return (

{children}

@@ -79,5 +98,5 @@ function AlertBody({ className, children, }: PropsWithChildren<{ className?: string }>) { - return
{children}
; + return
{children}
; } diff --git a/app/react/components/Tip/TooltipWithChildren/TooltipWithChildren.tsx b/app/react/components/Tip/TooltipWithChildren/TooltipWithChildren.tsx index a39ee4fa0..738d22a84 100644 --- a/app/react/components/Tip/TooltipWithChildren/TooltipWithChildren.tsx +++ b/app/react/components/Tip/TooltipWithChildren/TooltipWithChildren.tsx @@ -69,6 +69,7 @@ export function TooltipWithChildren({ arrow allowHTML interactive + disabled={!message} > {children} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx index 05e59fe83..04b96f9d8 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx @@ -6,12 +6,12 @@ import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/qu import { Datatable as GenericDatatable } from '@@/datatables'; import { Button } from '@@/buttons'; -import { TextTip } from '@@/Tip/TextTip'; import { createPersistedStore } from '@@/datatables/types'; import { useTableState } from '@@/datatables/useTableState'; import { confirm } from '@@/modals/confirm'; import { buildConfirmButton } from '@@/modals/utils'; import { ModalType } from '@@/modals'; +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { useAssociateDeviceMutation, useLicenseOverused } from '../queries'; @@ -26,7 +26,7 @@ const settingsStore = createPersistedStore(storageKey, 'Name'); export function Datatable() { const associateMutation = useAssociateDeviceMutation(); const removeMutation = useDeleteEnvironmentsMutation(); - const licenseOverused = useLicenseOverused(); + const { willExceed } = useLicenseOverused(); const tableState = useTableState(settingsStore, storageKey); const { data: environments, totalCount, isLoading } = useEnvironments(); @@ -41,28 +41,34 @@ export function Datatable() { <> - - - {licenseOverused ? ( -
- - Associating devices is disabled as your node count exceeds your - license limit - -
- ) : null} + + + + )} isLoading={isLoading} diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx index 396fb0a3a..aa2f0faca 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx @@ -3,12 +3,17 @@ import { withLimitToBE } from '@/react/hooks/useLimitToBE'; import { InformationPanel } from '@@/InformationPanel'; import { TextTip } from '@@/Tip/TextTip'; import { PageHeader } from '@@/PageHeader'; +import { Link } from '@@/Link'; +import { Alert } from '@@/Alert'; import { Datatable } from './Datatable'; +import { useLicenseOverused, useUntrustedCount } from './queries'; export default withLimitToBE(WaitingRoomView); function WaitingRoomView() { + const untrustedCount = useUntrustedCount(); + const { willExceed } = useLicenseOverused(); return ( <> + {willExceed(untrustedCount) && ( +
+
+ + Associating all nodes in waiting room will exceed the node limit + of your current license. Go to{' '} + Licenses page to view the + current usage. + +
+
+ )} + ); diff --git a/app/react/edge/edge-devices/WaitingRoomView/queries.ts b/app/react/edge/edge-devices/WaitingRoomView/queries.ts index f20d1d9c9..2ac4311de 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/queries.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/queries.ts @@ -1,9 +1,10 @@ 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 { promiseSequence } from '@/portainer/helpers/promise-utils'; import { useIntegratedLicenseInfo } from '@/react/portainer/licenses/use-license.service'; +import { useEnvironmentList } from '@/react/portainer/environments/queries'; export function useAssociateDeviceMutation() { const queryClient = useQueryClient(); @@ -35,8 +36,23 @@ async function associateDevice(environmentId: EnvironmentId) { export function useLicenseOverused() { const integratedInfo = useIntegratedLicenseInfo(); - if (integratedInfo && integratedInfo.licenseInfo.enforcedAt > 0) { - return true; + return { + 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; } diff --git a/app/react/portainer/licenses/types.ts b/app/react/portainer/licenses/types.ts index fac1d326c..f1b35a8ba 100644 --- a/app/react/portainer/licenses/types.ts +++ b/app/react/portainer/licenses/types.ts @@ -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 { CE = 1, BE, 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 { Trial = 1, 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 { id: string; company: string;