1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

feat(system): path to upgrade standalone to BE [EE-4071] (#8095)

This commit is contained in:
Chaim Lev-Ari 2022-12-11 08:58:22 +02:00 committed by GitHub
parent 756ac034ec
commit 5cbf52377d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 1374 additions and 421 deletions

View file

@ -12,15 +12,31 @@ export default {
interface TextFieldProps {
label: string;
tooltip?: string;
vertical?: boolean;
required?: boolean;
error?: string;
}
export { TextField, SelectField };
function TextField({ label, tooltip = '' }: TextFieldProps) {
function TextField({
label,
tooltip = '',
required,
error,
vertical,
}: TextFieldProps) {
const [value, setValue] = useState('');
const inputId = 'input';
return (
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
<FormControl
inputId={inputId}
label={label}
tooltip={tooltip}
required={required}
errors={error}
size={vertical ? 'vertical' : undefined}
>
<Input
id={inputId}
type="text"
@ -34,9 +50,18 @@ function TextField({ label, tooltip = '' }: TextFieldProps) {
TextField.args = {
label: 'label',
tooltip: '',
vertical: false,
required: false,
error: '',
};
function SelectField({ label, tooltip = '' }: TextFieldProps) {
function SelectField({
label,
tooltip = '',
vertical,
required,
error,
}: TextFieldProps) {
const options = [
{ value: 1, label: 'one' },
{ value: 2, label: 'two' },
@ -44,7 +69,14 @@ function SelectField({ label, tooltip = '' }: TextFieldProps) {
const [value, setValue] = useState(0);
const inputId = 'input';
return (
<FormControl inputId={inputId} label={label} tooltip={tooltip}>
<FormControl
inputId={inputId}
label={label}
tooltip={tooltip}
size={vertical ? 'vertical' : undefined}
required={required}
errors={error}
>
<Select
className="form-control"
value={value}
@ -58,4 +90,7 @@ function SelectField({ label, tooltip = '' }: TextFieldProps) {
SelectField.args = {
label: 'select',
tooltip: '',
vertical: false,
required: false,
error: '',
};

View file

@ -5,9 +5,7 @@ import { Tooltip } from '@@/Tip/Tooltip';
import { FormError } from '../FormError';
import styles from './FormControl.module.css';
export type Size = 'xsmall' | 'small' | 'medium' | 'large';
export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
export interface Props {
inputId?: string;
@ -29,7 +27,12 @@ export function FormControl({
required,
}: PropsWithChildren<Props>) {
return (
<div className={clsx('form-group', styles.container)}>
<div
className={clsx(
'form-group',
'after:content-[""] after:clear-both after:table' // to fix issues with float
)}
>
<label
htmlFor={inputId}
className={clsx(sizeClassLabel(size), 'control-label', 'text-left')}
@ -62,6 +65,8 @@ function sizeClassLabel(size?: Size) {
return 'col-sm-4 col-lg-3';
case 'xsmall':
return 'col-sm-2';
case 'vertical':
return '';
default:
return 'col-sm-3 col-lg-2';
}
@ -75,6 +80,8 @@ function sizeClassChildren(size?: Size) {
return 'col-sm-8 col-lg-9';
case 'xsmall':
return 'col-sm-8';
case 'vertical':
return '';
default:
return 'col-sm-9 col-lg-10';
}

View file

@ -0,0 +1,22 @@
.close {
color: var(--button-close-color);
opacity: var(--button-opacity);
padding: 0;
cursor: pointer;
background: transparent;
border: 0;
appearance: none;
font-size: 21px;
font-weight: bold;
line-height: 1;
text-shadow: 0 1px 0 #fff;
filter: alpha(opacity=20);
}
.close:hover,
.close:focus {
color: var(--button-close-color);
opacity: var(--button-opacity-hover);
}

View file

@ -0,0 +1,21 @@
import clsx from 'clsx';
import styles from './CloseButton.module.css';
export function CloseButton({
onClose,
className,
}: {
onClose: () => void;
className?: string;
}) {
return (
<button
type="button"
className={clsx(styles.close, className, 'absolute top-2 right-2')}
onClick={() => onClose()}
>
×
</button>
);
}

View file

@ -0,0 +1,20 @@
.modal-dialog {
width: 450px;
display: inline-block;
text-align: left;
vertical-align: middle;
}
.modal-content {
background-color: var(--bg-modal-content-color);
padding: 20px;
position: relative;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
outline: 0;
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
}

View file

@ -0,0 +1,53 @@
import { DialogContent, DialogOverlay } from '@reach/dialog';
import clsx from 'clsx';
import { createContext, PropsWithChildren, useContext } from 'react';
import { CloseButton } from './CloseButton';
import styles from './Modal.module.css';
const Context = createContext<boolean | null>(null);
Context.displayName = 'ModalContext';
export function useModalContext() {
const context = useContext(Context);
if (!context) {
throw new Error('should be nested under Modal');
}
return context;
}
interface Props {
onDismiss?(): void;
'aria-label'?: string;
'aria-labelledby'?: string;
}
export function Modal({
children,
onDismiss,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
}: PropsWithChildren<Props>) {
return (
<Context.Provider value>
<DialogOverlay
isOpen
className="flex items-center justify-center z-50"
onDismiss={onDismiss}
role="dialog"
>
<DialogContent
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={clsx(styles.modalDialog, 'p-0 bg-transparent')}
>
<div className={clsx(styles.modalContent, 'relative')}>
{children}
{onDismiss && <CloseButton onClose={onDismiss} />}
</div>
</DialogContent>
</DialogOverlay>
</Context.Provider>
);
}

View file

@ -0,0 +1,4 @@
.modal-body {
padding: 10px 0px;
border-bottom: none;
}

View file

@ -0,0 +1,9 @@
import { PropsWithChildren } from 'react';
import { useModalContext } from './Modal';
import styles from './ModalBody.module.css';
export function ModalBody({ children }: PropsWithChildren<unknown>) {
useModalContext();
return <div className={styles.modalBody}>{children}</div>;
}

View file

@ -0,0 +1,5 @@
.modal-footer {
padding: 10px 0px;
border-top: none;
display: flex;
}

View file

@ -0,0 +1,15 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { useModalContext } from './Modal';
import styles from './ModalFooter.module.css';
export function ModalFooter({ children }: PropsWithChildren<unknown>) {
useModalContext();
return (
<div className={clsx(styles.modalFooter, 'flex justify-end')}>
{children}
</div>
);
}

View file

@ -0,0 +1,19 @@
.modal-header {
margin-bottom: 10px;
padding: 0px;
border-bottom: none;
}
.background-error {
padding-top: 55px;
background-image: url(~assets/images/icon-error.svg);
background-repeat: no-repeat;
background-position: top left;
}
.background-warning {
padding-top: 55px;
background-image: url(~assets/images/icon-warning.svg);
background-repeat: no-repeat;
background-position: top left;
}

View file

@ -0,0 +1,33 @@
import clsx from 'clsx';
import { ReactNode } from 'react';
import { ModalType } from './types';
import { useModalContext } from './Modal';
import styles from './ModalHeader.module.css';
interface Props {
title: ReactNode;
modalType?: ModalType;
}
export function ModalHeader({ title, modalType }: Props) {
useModalContext();
return (
<div className={styles.modalHeader}>
{modalType && (
<div
className={clsx({
[styles.backgroundError]: modalType === ModalType.Destructive,
[styles.backgroundWarning]: modalType === ModalType.Warn,
})}
/>
)}
{typeof title === 'string' ? (
<h5 className="font-bold">{title}</h5>
) : (
title
)}
</div>
);
}

View file

@ -0,0 +1,18 @@
import { Modal as MainComponent } from './Modal';
import { ModalHeader } from './ModalHeader';
import { ModalBody } from './ModalBody';
import { ModalFooter } from './ModalFooter';
interface WithSubComponents {
Header: typeof ModalHeader;
Body: typeof ModalBody;
Footer: typeof ModalFooter;
}
const Modal = MainComponent as typeof MainComponent & WithSubComponents;
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
export { Modal };

View file

@ -0,0 +1,6 @@
export type OnSubmit<TResult> = (result?: TResult) => void;
export enum ModalType {
Warn = 'warning',
Destructive = 'error',
}

View file

@ -0,0 +1,24 @@
import { ComponentType } from 'react';
/**
* Hides the wrapped component if portainer is running as a docker extension.
*/
export function withHideOnExtension<T>(
WrappedComponent: ComponentType<T>
): ComponentType<T> {
// Try to create a nice displayName for React Dev Tools.
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
function WrapperComponent(props: T) {
if (window.ddExtension) {
return null;
}
return <WrappedComponent {...props} />;
}
WrapperComponent.displayName = `withHideOnExtension(${displayName})`;
return WrapperComponent;
}

View file

@ -11,7 +11,7 @@ test('when user is using more nodes then allowed he should see message', async (
rest.get('/api/licenses/info', (req, res, ctx) =>
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
),
rest.get('/api/status/nodes', (req, res, ctx) =>
rest.get('/api/system/nodes', (req, res, ctx) =>
res(ctx.json({ nodes: used }))
)
);
@ -32,7 +32,7 @@ test("when user is using less nodes then allowed he shouldn't see message", asyn
rest.get('/api/licenses/info', (req, res, ctx) =>
res(ctx.json({ nodes: allowed, type: LicenseType.Subscription }))
),
rest.get('/api/status/nodes', (req, res, ctx) =>
rest.get('/api/system/nodes', (req, res, ctx) =>
res(ctx.json({ nodes: used }))
)
);

View file

@ -1,13 +1,11 @@
import { useQuery } from 'react-query';
import { error as notifyError } from '@/portainer/services/notifications';
import { LicenseType } from '@/portainer/license-management/types';
import { useLicenseInfo } from '@/portainer/license-management/use-license.service';
import { getNodesCount } from '@/portainer/services/api/status.service';
import { TextTip } from '@@/Tip/TextTip';
import { InformationPanel } from '@@/InformationPanel';
import { useNodesCount } from '../system/useNodesCount';
export function LicenseNodePanel() {
const nodesValid = useNodesValid();
@ -26,7 +24,7 @@ export function LicenseNodePanel() {
}
function useNodesValid() {
const { isLoading: isLoadingNodes, nodesCount } = useNodesCounts();
const { isLoading: isLoadingNodes, data: nodesCount = 0 } = useNodesCount();
const { isLoading: isLoadingLicense, info } = useLicenseInfo();
if (
@ -40,17 +38,3 @@ function useNodesValid() {
return nodesCount <= info.nodes;
}
function useNodesCounts() {
const { isLoading, data } = useQuery(
['status', 'nodes'],
() => getNodesCount(),
{
onError(error) {
notifyError('Failure', error as Error, 'Failed to get nodes count');
},
}
);
return { nodesCount: data || 0, isLoading };
}

View file

@ -1,10 +1,10 @@
import { useStatus } from '@/portainer/services/api/status.service';
import { useSettings } from '@/react/portainer/settings/queries';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
export function useAgentDetails() {
const settingsQuery = useSettings();
const versionQuery = useStatus((status) => status.Version);
const versionQuery = useSystemStatus({ select: (status) => status.Version });
if (!versionQuery.isSuccess || !settingsQuery.isSuccess) {
return null;

View file

@ -2,9 +2,7 @@ import { useRouter } from '@uirouter/react';
import { usePublicSettings } from '@/react/portainer/settings/queries';
export enum FeatureFlag {
BEUpgrade = 'beUpgrade',
}
export enum FeatureFlag {}
export function useFeatureFlag(
flag: FeatureFlag,

View file

@ -0,0 +1,22 @@
import { ComponentType } from 'react';
export function withEdition<T>(
WrappedComponent: ComponentType<T>,
edition: 'BE' | 'CE'
): ComponentType<T> {
// Try to create a nice displayName for React Dev Tools.
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
function WrapperComponent(props: T) {
if (process.env.PORTAINER_EDITION !== edition) {
return null;
}
return <WrappedComponent {...props} />;
}
WrapperComponent.displayName = `with${edition}Edition(${displayName})`;
return WrapperComponent;
}

View file

@ -0,0 +1,26 @@
import { ComponentType } from 'react';
import { FeatureFlag, useFeatureFlag } from './useRedirectFeatureFlag';
export function withFeatureFlag<T>(
WrappedComponent: ComponentType<T>,
flag: FeatureFlag
): ComponentType<T> {
// Try to create a nice displayName for React Dev Tools.
const displayName =
WrappedComponent.displayName || WrappedComponent.name || 'Component';
function WrapperComponent(props: T) {
const featureFlagQuery = useFeatureFlag(flag);
if (!featureFlagQuery.data) {
return null;
}
return <WrappedComponent {...props} />;
}
WrapperComponent.displayName = `with${flag}FeatureFlag(${displayName})`;
return WrapperComponent;
}

View file

@ -1,5 +1,5 @@
export function buildUrl(action?: string) {
let url = '/status';
let url = '/system';
if (action) {
url += `/${action}`;

View file

@ -0,0 +1,3 @@
export const queryKeys = {
base: () => ['system'] as const,
};

View file

@ -4,6 +4,9 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'nodes'] as const;
export interface NodesCountResponse {
nodes: number;
@ -19,7 +22,7 @@ async function getNodesCount() {
}
export function useNodesCount() {
return useQuery(['status', 'nodes'], getNodesCount, {
return useQuery(queryKey, getNodesCount, {
...withError('Unable to retrieve nodes count'),
});
}

View file

@ -4,9 +4,19 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'info'] as const;
export type ContainerPlatform =
| 'Docker Standalone'
| 'Docker Swarm'
| 'Kubernetes'
| 'Podman'
| 'Nomad';
export interface SystemInfoResponse {
platform: string;
platform: ContainerPlatform;
agents: number;
edgeAgents: number;
edgeDevices: number;
@ -14,7 +24,7 @@ export interface SystemInfoResponse {
async function getSystemInfo() {
try {
const { data } = await axios.get<SystemInfoResponse>(buildUrl('system'));
const { data } = await axios.get<SystemInfoResponse>(buildUrl('info'));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
@ -22,7 +32,7 @@ async function getSystemInfo() {
}
export function useSystemInfo() {
return useQuery(['status', 'system'], getSystemInfo, {
return useQuery(queryKey, getSystemInfo, {
...withError('Unable to retrieve system info'),
});
}

View file

@ -0,0 +1,46 @@
import { useQuery } from 'react-query';
import { RetryValue } from 'react-query/types/core/retryer';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'status'] as const;
export interface StatusResponse {
Edition: string;
Version: string;
InstanceID: string;
}
export async function getSystemStatus() {
try {
const { data } = await axios.get<StatusResponse>(buildUrl('status'));
data.Edition = 'Community Edition';
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export function useSystemStatus<T = StatusResponse>({
select,
enabled,
retry,
onSuccess,
}: {
select?: (status: StatusResponse) => T;
enabled?: boolean;
retry?: RetryValue<unknown>;
onSuccess?: (data: T) => void;
} = {}) {
return useQuery(queryKey, () => getSystemStatus(), {
select,
enabled,
retry,
onSuccess,
});
}

View file

@ -0,0 +1,38 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl } from './build-url';
import { queryKeys } from './query-keys';
export const queryKey = [...queryKeys.base(), 'version'] as const;
export interface VersionResponse {
// Whether portainer has an update available
UpdateAvailable: boolean;
// The latest version available
LatestVersion: string;
ServerVersion: string;
DatabaseVersion: string;
Build: {
BuildNumber: string;
ImageTag: string;
NodejsVersion: string;
YarnVersion: string;
WebpackVersion: string;
GoVersion: string;
};
}
export async function getSystemVersion() {
try {
const { data } = await axios.get<VersionResponse>(buildUrl('version'));
return data;
} catch (error) {
throw parseAxiosError(error as Error);
}
}
export function useSystemVersion() {
return useQuery(queryKey, () => getSystemVersion());
}

View file

@ -0,0 +1,20 @@
import { useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { buildUrl } from './build-url';
export function useUpgradeEditionMutation() {
return useMutation(upgradeEdition, {
...withError('Unable to upgrade edition'),
});
}
async function upgradeEdition({ license }: { license: string }) {
try {
await axios.post(buildUrl('upgrade'), { license });
} catch (error) {
throw parseAxiosError(error as Error);
}
}

View file

@ -2,10 +2,8 @@ import { useState } from 'react';
import { Database, Hash, Server, Tag, Wrench } from 'lucide-react';
import { DialogOverlay } from '@reach/dialog';
import {
useStatus,
useVersionStatus,
} from '@/portainer/services/api/status.service';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import { Button } from '@@/buttons';
@ -13,7 +11,7 @@ import styles from './Footer.module.css';
export function BuildInfoModalButton() {
const [isBuildInfoVisible, setIsBuildInfoVisible] = useState(false);
const statusQuery = useStatus();
const statusQuery = useSystemStatus();
if (!statusQuery.data) {
return null;
@ -39,8 +37,8 @@ export function BuildInfoModalButton() {
}
function BuildInfoModal({ closeModal }: { closeModal: () => void }) {
const versionQuery = useVersionStatus();
const statusQuery = useStatus();
const versionQuery = useSystemVersion();
const statusQuery = useSystemStatus();
if (!statusQuery.data || !versionQuery.data) {
return null;

View file

@ -23,15 +23,6 @@ function CEFooter() {
<span>Community Edition</span>
<BuildInfoModalButton />
<a
href="https://www.portainer.io/install-BE-now"
className="text-blue-6 font-medium"
target="_blank"
rel="noreferrer"
>
Upgrade
</a>
</FooterContent>
</div>
);

View file

@ -1,9 +1,8 @@
import { useQuery } from 'react-query';
import clsx from 'clsx';
import { DownloadCloud } from 'lucide-react';
import { getVersionStatus } from '@/portainer/services/api/status.service';
import { useUIState } from '@/react/hooks/useUIState';
import { useSystemVersion } from '@/react/portainer/system/useSystemVersion';
import { Icon } from '@@/Icon';
@ -11,7 +10,7 @@ import styles from './UpdateNotifications.module.css';
export function UpdateNotification() {
const uiStateStore = useUIState();
const query = useUpdateNotification();
const query = useSystemVersion();
if (!query.data || !query.data.UpdateAvailable) {
return null;
@ -67,7 +66,3 @@ export function UpdateNotification() {
uiStateStore.dismissUpdateVersion(version);
}
}
function useUpdateNotification() {
return useQuery(['status', 'version'], () => getVersionStatus());
}

View file

@ -13,7 +13,7 @@ import { SidebarItem } from './SidebarItem';
import { Footer } from './Footer';
import { Header } from './Header';
import { SidebarProvider } from './useSidebarState';
import { UpgradeBEBanner } from './UpgradeBEBanner';
import { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
export function Sidebar() {
const { isAdmin, user } = useUser();
@ -31,7 +31,7 @@ export function Sidebar() {
/* in the future (when we remove r2a) this should wrap the whole app - to change root styles */
<SidebarProvider>
<div className={clsx(styles.root, 'sidebar flex flex-col')}>
<UpgradeBEBanner />
<UpgradeBEBannerWrapper />
<nav
className={clsx(
styles.nav,

View file

@ -1,63 +0,0 @@
import { ArrowRight } from 'lucide-react';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import {
useFeatureFlag,
FeatureFlag,
} from '@/react/portainer/feature-flags/useRedirectFeatureFlag';
import { useNodesCount } from '@/react/portainer/status/useNodesCount';
import { useSystemInfo } from '@/react/portainer/status/useSystemInfo';
import { useSidebarState } from './useSidebarState';
export function UpgradeBEBanner() {
const { data } = useFeatureFlag(FeatureFlag.BEUpgrade, { enabled: !isBE });
if (isBE || !data) {
return null;
}
return <Inner />;
}
function Inner() {
const { trackEvent } = useAnalytics();
const { isOpen } = useSidebarState();
const nodesCountQuery = useNodesCount();
const systemInfoQuery = useSystemInfo();
if (!nodesCountQuery.data || !systemInfoQuery.data) {
return null;
}
const nodesCount = nodesCountQuery.data;
const systemInfo = systemInfoQuery.data;
const metadata = {
upgrade: false,
nodeCount: nodesCount,
platform: systemInfo.platform,
edgeAgents: systemInfo.edgeAgents,
edgeDevices: systemInfo.edgeDevices,
agents: systemInfo.agents,
};
return (
<button
type="button"
className="border-0 bg-warning-5 text-warning-9 w-full min-h-[48px] h-12 font-semibold flex justify-center items-center gap-3"
onClick={handleClick}
>
{isOpen && <>Upgrade to Business Edition</>}
<ArrowRight className="text-lg lucide" />
</button>
);
function handleClick() {
trackEvent('portainer-upgrade-admin', {
category: 'portainer',
metadata,
});
}
}

View file

@ -0,0 +1,52 @@
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useSystemStatus } from '@/react/portainer/system/useSystemStatus';
import { Modal } from '@@/modals/Modal';
import { Icon } from '@@/Icon';
export function LoadingDialog() {
useWaitForServerStatus();
return (
<Modal aria-label="Upgrade Portainer to Business Edition">
<Modal.Body>
<div className="flex flex-col items-center justify-center w-full">
<Icon
icon={Loader2}
className="animate-spin-slow !text-8xl !text-blue-8"
aria-label="loading"
/>
<h1 className="!text-2xl">Upgrading Portainer...</h1>
<p className="text-center text-gray-6 text-xl">
Please wait while we upgrade your Portainer to Business Edition.
</p>
</div>
</Modal.Body>
</Modal>
);
}
function useWaitForServerStatus() {
const [enabled, setEnabled] = useState(false);
useSystemStatus({
enabled,
retry: true,
onSuccess() {
window.location.reload();
},
});
useEffect(() => {
const timeoutId = setTimeout(() => {
setEnabled(true);
}, 3000);
return () => {
clearTimeout(timeoutId);
};
});
}

View file

@ -0,0 +1,52 @@
import { ExternalLink } from 'lucide-react';
import { Button } from '@@/buttons';
import { Modal } from '@@/modals/Modal';
import { ModalType } from '@@/modals/Modal/types';
export function NonAdminUpgradeDialog({
onDismiss,
}: {
onDismiss: () => void;
}) {
return (
<Modal aria-label="Upgrade Portainer to Business Edition">
<Modal.Header
title="Contact your administrator"
modalType={ModalType.Warn}
/>
<Modal.Body>
You need to be logged in as an admin to upgrade Portainer to Business
Edition.
</Modal.Body>
<Modal.Footer>
<div className="flex gap-2 w-full">
<Button
color="default"
size="medium"
className="w-1/3"
onClick={() => onDismiss()}
>
Cancel
</Button>
<a
href="https://www.portainer.io/take-5"
target="_blank"
rel="noreferrer"
className="no-link w-2/3"
>
<Button
color="primary"
size="medium"
className="w-full"
icon={ExternalLink}
>
Learn about Business Edition
</Button>
</a>
</div>
</Modal.Footer>
</Modal>
);
}

View file

@ -0,0 +1,78 @@
import { ArrowRight } from 'lucide-react';
import { useState } from 'react';
import { useAnalytics } from '@/angulartics.matomo/analytics-services';
import { useNodesCount } from '@/react/portainer/system/useNodesCount';
import {
ContainerPlatform,
useSystemInfo,
} from '@/react/portainer/system/useSystemInfo';
import { useUser } from '@/react/hooks/useUser';
import { withEdition } from '@/react/portainer/feature-flags/withEdition';
import { withHideOnExtension } from '@/react/hooks/withHideOnExtension';
import { useSidebarState } from '../useSidebarState';
import { UpgradeDialog } from './UpgradeDialog';
export const UpgradeBEBannerWrapper = withHideOnExtension(
withEdition(UpgradeBEBanner, 'CE')
);
const enabledPlatforms: Array<ContainerPlatform> = ['Docker Standalone'];
function UpgradeBEBanner() {
const { isAdmin } = useUser();
const { trackEvent } = useAnalytics();
const { isOpen: isSidebarOpen } = useSidebarState();
const nodesCountQuery = useNodesCount();
const systemInfoQuery = useSystemInfo();
const [isOpen, setIsOpen] = useState(false);
if (!nodesCountQuery.isSuccess || !systemInfoQuery.data) {
return null;
}
const nodesCount = nodesCountQuery.data;
const systemInfo = systemInfoQuery.data;
const metadata = {
upgrade: false,
nodeCount: nodesCount,
platform: systemInfo.platform,
edgeAgents: systemInfo.edgeAgents,
edgeDevices: systemInfo.edgeDevices,
agents: systemInfo.agents,
};
if (!enabledPlatforms.includes(systemInfo.platform)) {
return null;
}
return (
<>
<button
type="button"
className="border-0 bg-warning-5 text-warning-9 w-full min-h-[48px] h-12 font-semibold flex justify-center items-center gap-3"
onClick={handleClick}
>
{isSidebarOpen && <>Upgrade to Business Edition</>}
<ArrowRight className="text-lg lucide" />
</button>
{isOpen && <UpgradeDialog onDismiss={() => setIsOpen(false)} />}
</>
);
function handleClick() {
trackEvent(
isAdmin ? 'portainer-upgrade-admin' : 'portainer-upgrade-non-admin',
{
category: 'portainer',
metadata,
}
);
setIsOpen(true);
}
}

View file

@ -0,0 +1,41 @@
import { useState } from 'react';
import { useUser } from '@/react/hooks/useUser';
import { UploadLicenseDialog } from './UploadLicenseDialog';
import { LoadingDialog } from './LoadingDialog';
import { NonAdminUpgradeDialog } from './NonAdminUpgradeDialog';
type Step = 'uploadLicense' | 'loading' | 'getLicense';
export function UpgradeDialog({ onDismiss }: { onDismiss: () => void }) {
const { isAdmin } = useUser();
const [currentStep, setCurrentStep] = useState<Step>('uploadLicense');
const component = getDialog();
return component;
function getDialog() {
if (!isAdmin) {
return <NonAdminUpgradeDialog onDismiss={onDismiss} />;
}
switch (currentStep) {
case 'getLicense':
throw new Error('Not implemented');
// return <GetLicense setCurrentStep={setCurrentStep} />;
case 'uploadLicense':
return (
<UploadLicenseDialog
goToLoading={() => setCurrentStep('loading')}
onDismiss={onDismiss}
/>
);
case 'loading':
return <LoadingDialog />;
default:
throw new Error('step type not found');
}
}
}

View file

@ -0,0 +1,107 @@
import { Field, Form, Formik } from 'formik';
import { object, SchemaOf, string } from 'yup';
import { ExternalLink } from 'lucide-react';
import { useUpgradeEditionMutation } from '@/react/portainer/system/useUpgradeEditionMutation';
import { notifySuccess } from '@/portainer/services/notifications';
import { Button, LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { Modal } from '@@/modals/Modal';
interface FormValues {
license: string;
}
const initialValues: FormValues = {
license: '',
};
export function UploadLicenseDialog({
onDismiss,
goToLoading,
}: {
onDismiss: () => void;
goToLoading: () => void;
}) {
const upgradeMutation = useUpgradeEditionMutation();
return (
<Modal
onDismiss={onDismiss}
aria-label="Upgrade Portainer to Business Edition"
>
<Modal.Header
title={<h4 className="font-medium text-xl">Upgrade Portainer</h4>}
/>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
>
{({ errors }) => (
<Form noValidate>
<Modal.Body>
<p className="font-semibold text-gray-7">
Please enter your Portainer License Below
</p>
<FormControl
label="License"
errors={errors.license}
required
size="vertical"
>
<Field name="license" as={Input} required />
</FormControl>
</Modal.Body>
<Modal.Footer>
<div className="flex gap-2 [&>*]:w-1/2 w-full">
<a
href="https://www.portainer.io/take-5"
target="_blank"
rel="noreferrer"
className="no-link"
>
<Button
color="default"
size="medium"
className="w-full"
icon={ExternalLink}
>
Get a license
</Button>
</a>
<LoadingButton
color="primary"
size="medium"
loadingText="Validating License"
isLoading={upgradeMutation.isLoading}
>
Start upgrade
</LoadingButton>
</div>
</Modal.Footer>
</Form>
)}
</Formik>
</Modal>
);
function handleSubmit(values: FormValues) {
upgradeMutation.mutate(values, {
onSuccess() {
notifySuccess('Starting upgrade', 'License validated successfully');
goToLoading();
},
});
}
}
function validation(): SchemaOf<FormValues> {
return object().shape({
license: string()
.required('License is required')
.matches(/^\d-.+/, 'License is invalid'),
});
}

View file

@ -0,0 +1 @@
export { UpgradeBEBannerWrapper } from './UpgradeBEBanner';