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:
parent
756ac034ec
commit
5cbf52377d
73 changed files with 1374 additions and 421 deletions
|
@ -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: '',
|
||||
};
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
|
22
app/react/components/modals/Modal/CloseButton.module.css
Normal file
22
app/react/components/modals/Modal/CloseButton.module.css
Normal 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);
|
||||
}
|
21
app/react/components/modals/Modal/CloseButton.tsx
Normal file
21
app/react/components/modals/Modal/CloseButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
20
app/react/components/modals/Modal/Modal.module.css
Normal file
20
app/react/components/modals/Modal/Modal.module.css
Normal 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%);
|
||||
}
|
53
app/react/components/modals/Modal/Modal.tsx
Normal file
53
app/react/components/modals/Modal/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
4
app/react/components/modals/Modal/ModalBody.module.css
Normal file
4
app/react/components/modals/Modal/ModalBody.module.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.modal-body {
|
||||
padding: 10px 0px;
|
||||
border-bottom: none;
|
||||
}
|
9
app/react/components/modals/Modal/ModalBody.tsx
Normal file
9
app/react/components/modals/Modal/ModalBody.tsx
Normal 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>;
|
||||
}
|
5
app/react/components/modals/Modal/ModalFooter.module.css
Normal file
5
app/react/components/modals/Modal/ModalFooter.module.css
Normal file
|
@ -0,0 +1,5 @@
|
|||
.modal-footer {
|
||||
padding: 10px 0px;
|
||||
border-top: none;
|
||||
display: flex;
|
||||
}
|
15
app/react/components/modals/Modal/ModalFooter.tsx
Normal file
15
app/react/components/modals/Modal/ModalFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
19
app/react/components/modals/Modal/ModalHeader.module.css
Normal file
19
app/react/components/modals/Modal/ModalHeader.module.css
Normal 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;
|
||||
}
|
33
app/react/components/modals/Modal/ModalHeader.tsx
Normal file
33
app/react/components/modals/Modal/ModalHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
18
app/react/components/modals/Modal/index.tsx
Normal file
18
app/react/components/modals/Modal/index.tsx
Normal 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 };
|
6
app/react/components/modals/Modal/types.ts
Normal file
6
app/react/components/modals/Modal/types.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export type OnSubmit<TResult> = (result?: TResult) => void;
|
||||
|
||||
export enum ModalType {
|
||||
Warn = 'warning',
|
||||
Destructive = 'error',
|
||||
}
|
24
app/react/hooks/withHideOnExtension.tsx
Normal file
24
app/react/hooks/withHideOnExtension.tsx
Normal 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;
|
||||
}
|
|
@ -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 }))
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
22
app/react/portainer/feature-flags/withEdition.tsx
Normal file
22
app/react/portainer/feature-flags/withEdition.tsx
Normal 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;
|
||||
}
|
26
app/react/portainer/feature-flags/withFeatureFlag.tsx
Normal file
26
app/react/portainer/feature-flags/withFeatureFlag.tsx
Normal 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;
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
export function buildUrl(action?: string) {
|
||||
let url = '/status';
|
||||
let url = '/system';
|
||||
|
||||
if (action) {
|
||||
url += `/${action}`;
|
3
app/react/portainer/system/query-keys.ts
Normal file
3
app/react/portainer/system/query-keys.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const queryKeys = {
|
||||
base: () => ['system'] as const,
|
||||
};
|
|
@ -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'),
|
||||
});
|
||||
}
|
|
@ -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'),
|
||||
});
|
||||
}
|
46
app/react/portainer/system/useSystemStatus.ts
Normal file
46
app/react/portainer/system/useSystemStatus.ts
Normal 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,
|
||||
});
|
||||
}
|
38
app/react/portainer/system/useSystemVersion.ts
Normal file
38
app/react/portainer/system/useSystemVersion.ts
Normal 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());
|
||||
}
|
20
app/react/portainer/system/useUpgradeEditionMutation.ts
Normal file
20
app/react/portainer/system/useUpgradeEditionMutation.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
52
app/react/sidebar/UpgradeBEBanner/LoadingDialog.tsx
Normal file
52
app/react/sidebar/UpgradeBEBanner/LoadingDialog.tsx
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
52
app/react/sidebar/UpgradeBEBanner/NonAdminUpgradeDialog.tsx
Normal file
52
app/react/sidebar/UpgradeBEBanner/NonAdminUpgradeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
78
app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx
Normal file
78
app/react/sidebar/UpgradeBEBanner/UpgradeBEBanner.tsx
Normal 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);
|
||||
}
|
||||
}
|
41
app/react/sidebar/UpgradeBEBanner/UpgradeDialog.tsx
Normal file
41
app/react/sidebar/UpgradeBEBanner/UpgradeDialog.tsx
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
107
app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx
Normal file
107
app/react/sidebar/UpgradeBEBanner/UploadLicenseDialog.tsx
Normal 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'),
|
||||
});
|
||||
}
|
1
app/react/sidebar/UpgradeBEBanner/index.ts
Normal file
1
app/react/sidebar/UpgradeBEBanner/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { UpgradeBEBannerWrapper } from './UpgradeBEBanner';
|
Loading…
Add table
Add a link
Reference in a new issue