mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 07:49:41 +02:00
feat(k8sconfigure): migrate configure to react [EE-5524] (#10218)
This commit is contained in:
parent
0f1e77a6d5
commit
515b02813b
59 changed files with 1819 additions and 833 deletions
|
@ -0,0 +1,412 @@
|
|||
import { Formik, Form, FormikProps, FormikHelpers } from 'formik';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import _ from 'lodash';
|
||||
import { useTransitionHook } from '@uirouter/react';
|
||||
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { useAnalytics } from '@/react/hooks/useAnalytics';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
|
||||
import {
|
||||
IngressControllerClassMap,
|
||||
IngressControllerClassMapRowData,
|
||||
} from '../../ingressClass/types';
|
||||
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
|
||||
|
||||
import { useStorageClassesFormValues } from './useStorageClassesFormValues';
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
import { configureValidationSchema } from './validation';
|
||||
import { RBACAlert } from './RBACAlert';
|
||||
import { EnableMetricsInput } from './EnableMetricsInput';
|
||||
import { StorageClassDatatable } from './StorageClassDatatable';
|
||||
import { useConfigureClusterMutation } from './useConfigureClusterMutation';
|
||||
import { handleSubmitConfigureCluster } from './handleSubmitConfigureCluster';
|
||||
|
||||
export function ConfigureForm() {
|
||||
const { trackEvent } = useAnalytics();
|
||||
const configureClusterMutation = useConfigureClusterMutation();
|
||||
// get the initial values
|
||||
const { data: environment } = useCurrentEnvironment();
|
||||
const { data: storageClassFormValues } =
|
||||
useStorageClassesFormValues(environment);
|
||||
const { data: ingressClasses, ...ingressClassesQuery } =
|
||||
useIngressControllerClassMapQuery({
|
||||
environmentId: environment?.Id,
|
||||
});
|
||||
const initialValues = useInitialValues(
|
||||
environment,
|
||||
storageClassFormValues,
|
||||
ingressClasses
|
||||
);
|
||||
|
||||
if (!initialValues || !environment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Formik<ConfigureFormValues>
|
||||
initialValues={initialValues}
|
||||
onSubmit={(
|
||||
values: ConfigureFormValues,
|
||||
formikHelpers: FormikHelpers<ConfigureFormValues>
|
||||
) => {
|
||||
handleSubmitConfigureCluster(
|
||||
values,
|
||||
initialValues,
|
||||
configureClusterMutation,
|
||||
formikHelpers,
|
||||
trackEvent,
|
||||
environment
|
||||
);
|
||||
}}
|
||||
validationSchema={configureValidationSchema}
|
||||
validateOnMount
|
||||
enableReinitialize // enableReinitialize is needed to update the form values when the ingress classes data is fetched
|
||||
>
|
||||
{(formikProps) => (
|
||||
<InnerForm
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...formikProps}
|
||||
isIngressClassesLoading={ingressClassesQuery.isLoading}
|
||||
environmentId={environment.Id}
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerForm({
|
||||
initialValues,
|
||||
setFieldValue,
|
||||
isValid,
|
||||
isSubmitting,
|
||||
values,
|
||||
errors,
|
||||
isIngressClassesLoading,
|
||||
environmentId,
|
||||
}: FormikProps<ConfigureFormValues> & {
|
||||
isIngressClassesLoading: boolean;
|
||||
environmentId: EnvironmentId;
|
||||
}) {
|
||||
const { data: isRBACEnabled, ...isRBACEnabledQuery } =
|
||||
useIsRBACEnabledQuery(environmentId);
|
||||
|
||||
const onChangeControllers = useCallback(
|
||||
(controllerClassMap: IngressControllerClassMap[]) =>
|
||||
setFieldValue('ingressClasses', controllerClassMap),
|
||||
[setFieldValue]
|
||||
);
|
||||
|
||||
// when navigating away from the page with unsaved changes, show a portainer prompt to confirm
|
||||
useTransitionHook('onBefore', {}, async () => {
|
||||
if (!isFormChanged(values, initialValues)) {
|
||||
return true;
|
||||
}
|
||||
const confirmed = await confirm({
|
||||
modalType: ModalType.Warn,
|
||||
title: 'Are you sure?',
|
||||
message:
|
||||
'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
|
||||
confirmButton: buildConfirmButton('Yes', 'danger'),
|
||||
});
|
||||
return confirmed;
|
||||
});
|
||||
|
||||
// when reloading or exiting the page with unsaved changes, show a browser prompt to confirm
|
||||
useEffect(() => {
|
||||
// the handler for showing the prompt
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload
|
||||
function handler(event: BeforeUnloadEvent) {
|
||||
event.preventDefault();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
event.returnValue = '';
|
||||
}
|
||||
|
||||
// if the form is changed, then set the onbeforeunload
|
||||
if (isFormChanged(values, initialValues)) {
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handler);
|
||||
};
|
||||
}
|
||||
return () => {};
|
||||
}, [values, initialValues]);
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<div className="flex flex-col">
|
||||
<FormSection title="Networking - Services">
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Enabling the load balancer feature will allow users to expose
|
||||
applications they deploy over an external IP address assigned by the
|
||||
cloud provider.
|
||||
</TextTip>
|
||||
<TextTip color="orange" className="mb-4">
|
||||
If you want to use this feature, ensure your cloud provider allows
|
||||
you to create load balancers. This may incur costs.
|
||||
</TextTip>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="useLoadBalancer"
|
||||
data-cy="kubeSetup-loadBalancerToggle"
|
||||
label="Allow users to use external load balancers"
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.useLoadBalancer}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('useLoadBalancer', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="Networking - Ingresses">
|
||||
<IngressClassDatatable
|
||||
onChangeControllers={onChangeControllers}
|
||||
description="Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here."
|
||||
ingressControllers={
|
||||
values.ingressClasses as IngressControllerClassMapRowData[]
|
||||
}
|
||||
initialIngressControllers={
|
||||
initialValues.ingressClasses as IngressControllerClassMapRowData[]
|
||||
}
|
||||
allowNoneIngressClass={values.allowNoneIngressClass}
|
||||
isLoading={isIngressClassesLoading}
|
||||
noIngressControllerLabel="No supported ingress controllers found."
|
||||
view="cluster"
|
||||
/>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="allowNoneIngressClass"
|
||||
data-cy="kubeSetup-allowNoneIngressClass"
|
||||
label='Allow ingress class to be set to "none"'
|
||||
tooltip='This allows users setting up ingresses to select "none" as the ingress class.'
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.allowNoneIngressClass}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('allowNoneIngressClass', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="ingressAvailabilityPerNamespace"
|
||||
data-cy="kubeSetup-ingressAvailabilityPerNamespace"
|
||||
label="Configure ingress controller availability per namespace"
|
||||
tooltip="This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications."
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.ingressAvailabilityPerNamespace}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('ingressAvailabilityPerNamespace', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="restrictStandardUserIngressW"
|
||||
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
|
||||
label="Only allow admins to deploy ingresses"
|
||||
featureId={FeatureId.K8S_ADM_ONLY_USR_INGRESS_DEPLY}
|
||||
tooltip="Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so)."
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.restrictStandardUserIngressW}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('restrictStandardUserIngressW', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TextTip color="blue" className="mb-5">
|
||||
You may set up ingress defaults (hostnames and annotations) via
|
||||
Create/Edit ingress. Users may then select them via the hostname
|
||||
dropdown in Create/Edit application.
|
||||
</TextTip>
|
||||
</FormSection>
|
||||
<FormSection title="Change Window Settings">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="changeWindow.Enabled"
|
||||
data-cy="kubeSetup-changeWindowEnabledToggle"
|
||||
label="Enable Change Window"
|
||||
tooltip="GitOps updates to stacks or applications outside the defined change window will not occur.'"
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={false}
|
||||
featureId={FeatureId.HIDE_AUTO_UPDATE_WINDOW}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="Security">
|
||||
{!isRBACEnabled && isRBACEnabledQuery.isSuccess && <RBACAlert />}
|
||||
<TextTip color="blue">
|
||||
<p>
|
||||
By default, all the users have access to the default namespace.
|
||||
Enable this option to set accesses on the default namespace.
|
||||
</p>
|
||||
</TextTip>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
name="restrictDefaultNamespace"
|
||||
data-cy="kubeSetup-restrictDefaultNsToggle"
|
||||
label="Restrict access to the default namespace"
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.restrictDefaultNamespace}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('restrictDefaultNamespace', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="Resources and Metrics">
|
||||
<TextTip color="orange">
|
||||
<p>
|
||||
By ENABLING resource over-commit, you are able to assign more
|
||||
resources to namespaces than is physically available in the
|
||||
cluster. This may lead to unexpected deployment failures if there
|
||||
is insufficient resource to service demand.
|
||||
</p>
|
||||
</TextTip>
|
||||
<TextTip color="blue">
|
||||
<p>
|
||||
By DISABLING resource over-commit (highly recommended), you are
|
||||
only able to assign resources to namespaces that are less (in
|
||||
aggregate) than the cluster total minus any system resource
|
||||
reservation.
|
||||
</p>
|
||||
</TextTip>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
label="Allow resource over-commit"
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
name="resourceOverCommitPercentage"
|
||||
checked={values.enableResourceOverCommit}
|
||||
featureId={FeatureId.K8S_SETUP_DEFAULT}
|
||||
onChange={(checked: boolean) => {
|
||||
setFieldValue('enableResourceOverCommit', checked);
|
||||
// set 20% as the default resourceOverCommitPercentage value
|
||||
if (!checked) {
|
||||
setFieldValue('resourceOverCommitPercentage', 20);
|
||||
}
|
||||
}}
|
||||
data-cy="kubeSetup-resourceOverCommitToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<EnableMetricsInput
|
||||
environmentId={environmentId}
|
||||
error={errors.useServerMetrics}
|
||||
value={values.useServerMetrics}
|
||||
/>
|
||||
</FormSection>
|
||||
<FormSection title="Available storage options">
|
||||
{initialValues.storageClasses.length === 0 && (
|
||||
<TextTip color="orange" className="mb-4">
|
||||
Unable to detect any storage class available to persist data.
|
||||
Users won't be able to persist application data inside this
|
||||
cluster.
|
||||
</TextTip>
|
||||
)}
|
||||
{initialValues.storageClasses.length > 0 && (
|
||||
<>
|
||||
<TextTip color="blue">
|
||||
<p>
|
||||
Select which storage options will be available for use when
|
||||
deploying applications. Have a look at your storage driver
|
||||
documentation to figure out which access policy to configure
|
||||
and if the volume expansion capability is supported.
|
||||
</p>
|
||||
<p>
|
||||
You can find more information about access modes{' '}
|
||||
<a
|
||||
href="https://kubernetes.io/docs/concepts/storage/persistent-volumes/#access-modes"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
in the official Kubernetes documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</TextTip>
|
||||
<StorageClassDatatable
|
||||
storageClassValues={values.storageClasses}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormSection>
|
||||
<FormActions
|
||||
submitLabel="Save configuration"
|
||||
loadingText="Saving configuration"
|
||||
isLoading={isSubmitting}
|
||||
isValid={
|
||||
isValid &&
|
||||
!isIngressClassesLoading &&
|
||||
isFormChanged(values, initialValues)
|
||||
}
|
||||
data-cy="kubeSetup-saveConfigurationButton"
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function useInitialValues(
|
||||
environment?: Environment | null,
|
||||
storageClassFormValues?: StorageClassFormValues[],
|
||||
ingressClasses?: IngressControllerClassMapRowData[]
|
||||
): ConfigureFormValues | undefined {
|
||||
return useMemo(() => {
|
||||
if (!environment) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
storageClasses: storageClassFormValues || [],
|
||||
useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer,
|
||||
useServerMetrics: !!environment.Kubernetes.Configuration.UseServerMetrics,
|
||||
enableResourceOverCommit:
|
||||
!!environment.Kubernetes.Configuration.EnableResourceOverCommit,
|
||||
resourceOverCommitPercentage:
|
||||
environment.Kubernetes.Configuration.ResourceOverCommitPercentage || 20,
|
||||
restrictDefaultNamespace:
|
||||
!!environment.Kubernetes.Configuration.RestrictDefaultNamespace,
|
||||
restrictStandardUserIngressW:
|
||||
!!environment.Kubernetes.Configuration.RestrictStandardUserIngressW,
|
||||
ingressAvailabilityPerNamespace:
|
||||
!!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
allowNoneIngressClass:
|
||||
!!environment.Kubernetes.Configuration.AllowNoneIngressClass,
|
||||
ingressClasses: ingressClasses || [],
|
||||
};
|
||||
}, [environment, ingressClasses, storageClassFormValues]);
|
||||
}
|
||||
|
||||
function isFormChanged(
|
||||
values: ConfigureFormValues,
|
||||
initialValues: ConfigureFormValues
|
||||
) {
|
||||
// check if the form values are different from the initial values
|
||||
return !_.isEqual(values, initialValues);
|
||||
}
|
|
@ -0,0 +1,101 @@
|
|||
import { Field, useFormikContext } from 'formik';
|
||||
import { useState } from 'react';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
import { useGetMetricsMutation } from '@/react/kubernetes/queries/useGetMetricsMutation';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Switch } from '@@/form-components/SwitchField/Switch';
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
|
||||
import { ConfigureFormValues } from './types';
|
||||
|
||||
type Props = {
|
||||
environmentId: number;
|
||||
value: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function EnableMetricsInput({ value, error, environmentId }: Props) {
|
||||
const { setFieldValue } = useFormikContext<ConfigureFormValues>();
|
||||
const [metricsFound, setMetricsFound] = useState<boolean>();
|
||||
const getMetricsMutation = useGetMetricsMutation();
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<TextTip color="blue">
|
||||
<p>
|
||||
Enabling this feature will allow users to use specific features like
|
||||
autoscaling and to see container and node resource usage.
|
||||
</p>
|
||||
<p>
|
||||
Ensure that
|
||||
<a
|
||||
href="https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/#metrics-server"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
metrics server
|
||||
</a>
|
||||
or
|
||||
<a
|
||||
href="https://github.com/kubernetes-sigs/prometheus-adapter"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
prometheus
|
||||
</a>
|
||||
is running inside your cluster.
|
||||
</p>
|
||||
</TextTip>
|
||||
<FormControl
|
||||
label="Enable features using the metrics API"
|
||||
className="mb-0"
|
||||
size="large"
|
||||
errors={error}
|
||||
>
|
||||
<Field
|
||||
name="useServerMetrics"
|
||||
as={Switch}
|
||||
checked={value}
|
||||
onChange={(checked: boolean) => {
|
||||
// if turning off, just set the value
|
||||
if (!checked) {
|
||||
setFieldValue('useServerMetrics', checked);
|
||||
return;
|
||||
}
|
||||
// if turning on, see if the metrics server is available, then set the value to on if it is
|
||||
getMetricsMutation.mutate(environmentId, {
|
||||
onSuccess: () => {
|
||||
setMetricsFound(true);
|
||||
setFieldValue('useServerMetrics', checked);
|
||||
},
|
||||
onError: () => {
|
||||
setMetricsFound(false);
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-cy="kubeSetup-metricsToggle"
|
||||
/>
|
||||
</FormControl>
|
||||
{getMetricsMutation.isLoading && (
|
||||
<InlineLoader size="sm">Checking metrics API...</InlineLoader>
|
||||
)}
|
||||
{!getMetricsMutation.isLoading && (
|
||||
<>
|
||||
{metricsFound === false && (
|
||||
<TextTip color="red" icon={XCircle}>
|
||||
Unable to reach metrics API, make sure metrics server is properly
|
||||
deployed inside that cluster.
|
||||
</TextTip>
|
||||
)}
|
||||
{metricsFound === true && (
|
||||
<TextTip color="green" icon={CheckCircle}>
|
||||
Successfully reached metrics API
|
||||
</TextTip>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { Alert } from '@@/Alert';
|
||||
|
||||
export function RBACAlert() {
|
||||
return (
|
||||
<Alert color="warn" className="mb-4">
|
||||
<div className="flex-flex-col">
|
||||
<p>
|
||||
Your cluster does not have Kubernetes role-based access control (RBAC)
|
||||
enabled.
|
||||
</p>
|
||||
<p>This means you can't use Portainer RBAC functionality to</p>
|
||||
<p className="mb-0">
|
||||
To enable RBAC, start the
|
||||
<a
|
||||
className="th-highcontrast:text-blue-4 th-dark:text-blue-7"
|
||||
href="https://kubernetes.io/docs/concepts/overview/components/#kube-apiserver"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
API server
|
||||
</a>
|
||||
with the
|
||||
<code className="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">
|
||||
--authorization-mode
|
||||
</code>
|
||||
flag set to a comma-separated list that includes
|
||||
<code className="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">
|
||||
RBAC
|
||||
</code>
|
||||
, for example:
|
||||
<code className="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">
|
||||
kube-apiserver --authorization-mode=Example1,RBAC,Example2
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { components, MultiValueGenericProps } from 'react-select';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
interface Option {
|
||||
Name: string;
|
||||
Description: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: Option[];
|
||||
onChange(value: readonly Option[]): void;
|
||||
options: Option[];
|
||||
inputId?: string;
|
||||
storageClassName: string;
|
||||
}
|
||||
|
||||
export function StorageAccessModeSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
inputId,
|
||||
storageClassName,
|
||||
}: Props) {
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
getOptionLabel={(option) => option.Description}
|
||||
getOptionValue={(option) => option.Name}
|
||||
components={{ MultiValueLabel }}
|
||||
options={options}
|
||||
value={value}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={(value) => onChange(value)}
|
||||
inputId={inputId}
|
||||
placeholder="Not configured"
|
||||
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MultiValueLabel({
|
||||
data,
|
||||
innerProps,
|
||||
selectProps,
|
||||
}: MultiValueGenericProps<Option>) {
|
||||
if (!data || !data.Name) {
|
||||
throw new Error('missing option name');
|
||||
}
|
||||
|
||||
return (
|
||||
<components.MultiValueLabel
|
||||
data={data}
|
||||
innerProps={innerProps}
|
||||
selectProps={selectProps}
|
||||
>
|
||||
{data.Name}
|
||||
</components.MultiValueLabel>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
import { useFormikContext } from 'formik';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { Switch } from '@@/form-components/SwitchField/Switch';
|
||||
|
||||
import { StorageAccessModeSelector } from './StorageAccessModeSelector';
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
import { availableStorageClassPolicies } from './useStorageClassesFormValues';
|
||||
|
||||
type Props = {
|
||||
storageClassValues: StorageClassFormValues[];
|
||||
};
|
||||
|
||||
export function StorageClassDatatable({ storageClassValues }: Props) {
|
||||
const { setFieldValue } = useFormikContext<ConfigureFormValues>();
|
||||
return (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 mt-2.5">
|
||||
<table className="table table-fixed">
|
||||
<tbody>
|
||||
<tr className="text-muted">
|
||||
<td>Storage</td>
|
||||
<td>Shared access policy</td>
|
||||
<td>Volume expansion</td>
|
||||
</tr>
|
||||
{storageClassValues.map((storageClassValue, index) => (
|
||||
<tr
|
||||
key={`${storageClassValue.Name}${storageClassValue.Provisioner}`}
|
||||
>
|
||||
<td>
|
||||
<div className="flex h-full flex-row items-center">
|
||||
<Switch
|
||||
checked={storageClassValue.selected}
|
||||
onChange={(checked) =>
|
||||
setFieldValue(
|
||||
`storageClasses.${index}.selected`,
|
||||
checked
|
||||
)
|
||||
}
|
||||
className="mr-2 mb-0"
|
||||
id={`kubeSetup-storageToggle${storageClassValue.Name}`}
|
||||
name={`kubeSetup-storageToggle${storageClassValue.Name}`}
|
||||
dataCy={`kubeSetup-storageToggle${storageClassValue.Name}`}
|
||||
/>
|
||||
<span>{storageClassValue.Name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<StorageAccessModeSelector
|
||||
options={availableStorageClassPolicies}
|
||||
value={storageClassValue.AccessModes}
|
||||
onChange={(accessModes) => {
|
||||
setFieldValue(
|
||||
`storageClasses.${index}.AccessModes`,
|
||||
accessModes
|
||||
);
|
||||
}}
|
||||
storageClassName={storageClassValue.Name}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex h-full flex-row items-center">
|
||||
<Switch
|
||||
checked={storageClassValue.AllowVolumeExpansion}
|
||||
onChange={(checked) =>
|
||||
setFieldValue(
|
||||
`storageClasses.${index}.AllowVolumeExpansion`,
|
||||
checked
|
||||
)
|
||||
}
|
||||
className="mr-2 mb-0"
|
||||
dataCy={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
|
||||
id={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
|
||||
name={`kubeSetup-storageExpansionToggle${storageClassValue.Name}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!hasValidStorageConfiguration(storageClassValues) && (
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="orange">
|
||||
Shared access policy configuration required.
|
||||
</TextTip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasValidStorageConfiguration(
|
||||
storageClassValues: StorageClassFormValues[]
|
||||
) {
|
||||
return storageClassValues.every(
|
||||
(storageClassValue) =>
|
||||
// if the storage class is not selected, it's valid
|
||||
!storageClassValue.selected ||
|
||||
// if the storage class is selected, it must have at least one access mode
|
||||
storageClassValue.AccessModes.length > 0
|
||||
);
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
import { FormikHelpers } from 'formik';
|
||||
import { StorageClass } from 'kubernetes-types/storage/v1';
|
||||
import { compare } from 'fast-json-patch';
|
||||
import { UseMutationResult } from 'react-query';
|
||||
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { TrackEventProps } from '@/angulartics.matomo/analytics-services';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
|
||||
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
import { ConfigureClusterPayloads } from './useConfigureClusterMutation';
|
||||
|
||||
// handle the form submission
|
||||
export async function handleSubmitConfigureCluster(
|
||||
values: ConfigureFormValues,
|
||||
initialValues: ConfigureFormValues | undefined,
|
||||
configureClusterMutation: UseMutationResult<
|
||||
void,
|
||||
unknown,
|
||||
ConfigureClusterPayloads,
|
||||
unknown
|
||||
>,
|
||||
{ resetForm }: FormikHelpers<ConfigureFormValues>,
|
||||
trackEvent: (action: string, properties: TrackEventProps) => void,
|
||||
environment?: Environment
|
||||
) {
|
||||
if (!environment) {
|
||||
notifyError('Unable to save configuration: environment not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// send metrics if needed
|
||||
if (
|
||||
values.restrictDefaultNamespace &&
|
||||
!initialValues?.restrictDefaultNamespace
|
||||
) {
|
||||
trackEvent('kubernetes-configure', {
|
||||
category: 'kubernetes',
|
||||
metadata: {
|
||||
restrictAccessToDefaultNamespace: values.restrictDefaultNamespace,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// transform the form values into the environment object
|
||||
const selectedStorageClasses = values.storageClasses.filter(
|
||||
(storageClass) => storageClass.selected
|
||||
);
|
||||
const updatedEnvironment = assignFormValuesToEnvironment(
|
||||
environment,
|
||||
values,
|
||||
selectedStorageClasses
|
||||
);
|
||||
const storageClassPatches = createStorageClassPatches(
|
||||
selectedStorageClasses,
|
||||
initialValues?.storageClasses
|
||||
);
|
||||
|
||||
// update the environment using a react query mutation
|
||||
await configureClusterMutation.mutateAsync(
|
||||
{
|
||||
id: environment.Id,
|
||||
updateEnvironmentPayload: updatedEnvironment,
|
||||
ingressControllers:
|
||||
values.ingressClasses as IngressControllerClassMapRowData[],
|
||||
storageClassPatches,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Configuration successfully applied');
|
||||
resetForm();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createStorageClassPatches(
|
||||
storageClasses: StorageClassFormValues[],
|
||||
oldStorageClasses?: StorageClassFormValues[]
|
||||
) {
|
||||
const storageClassPatches = storageClasses.flatMap((storageClass) => {
|
||||
const oldStorageClass = oldStorageClasses?.find(
|
||||
(sc) => sc.Name === storageClass.Name
|
||||
);
|
||||
if (!oldStorageClass) {
|
||||
return [];
|
||||
}
|
||||
const newPayload = createStorageClassPayload(storageClass);
|
||||
const oldPayload = createStorageClassPayload(oldStorageClass);
|
||||
const patch = compare(oldPayload, newPayload);
|
||||
return [{ name: storageClass.Name, patch }];
|
||||
});
|
||||
return storageClassPatches;
|
||||
}
|
||||
|
||||
function createStorageClassPayload(storageClass: StorageClassFormValues) {
|
||||
const payload: StorageClass = {
|
||||
provisioner: storageClass.Provisioner,
|
||||
allowVolumeExpansion: storageClass.AllowVolumeExpansion,
|
||||
metadata: {
|
||||
uid: '',
|
||||
name: storageClass.Name,
|
||||
namespace: '',
|
||||
labels: {},
|
||||
annotations: {},
|
||||
},
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
function assignFormValuesToEnvironment(
|
||||
environment: Environment,
|
||||
values: ConfigureFormValues,
|
||||
selectedStorageClasses: StorageClassFormValues[]
|
||||
) {
|
||||
// note that the ingress datatable form values are omitted and included in another call
|
||||
const updatedEnvironment: Partial<UpdateEnvironmentPayload> = {
|
||||
Kubernetes: {
|
||||
...environment.Kubernetes,
|
||||
Configuration: {
|
||||
...environment.Kubernetes.Configuration,
|
||||
UseLoadBalancer: values.useLoadBalancer,
|
||||
UseServerMetrics: values.useServerMetrics,
|
||||
EnableResourceOverCommit: values.enableResourceOverCommit,
|
||||
ResourceOverCommitPercentage: values.resourceOverCommitPercentage,
|
||||
RestrictDefaultNamespace: values.restrictDefaultNamespace,
|
||||
RestrictStandardUserIngressW: values.restrictStandardUserIngressW,
|
||||
IngressAvailabilityPerNamespace: values.ingressAvailabilityPerNamespace,
|
||||
AllowNoneIngressClass: values.allowNoneIngressClass,
|
||||
StorageClasses: selectedStorageClasses.map((storageClass) => ({
|
||||
Name: storageClass.Name,
|
||||
AccessModes: storageClass.AccessModes.map(
|
||||
(accessMode) => accessMode.Name
|
||||
),
|
||||
AllowVolumeExpansion: storageClass.AllowVolumeExpansion,
|
||||
Provisioner: storageClass.Provisioner,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
return updatedEnvironment;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ConfigureForm } from './ConfigureForm';
|
|
@ -0,0 +1,28 @@
|
|||
import { IngressControllerClassMap } from '../../ingressClass/types';
|
||||
|
||||
export type AccessMode = {
|
||||
Description: string;
|
||||
Name: string;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type StorageClassFormValues = {
|
||||
Name: string;
|
||||
AccessModes: AccessMode[];
|
||||
Provisioner: string;
|
||||
AllowVolumeExpansion: boolean;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type ConfigureFormValues = {
|
||||
useLoadBalancer: boolean;
|
||||
useServerMetrics: boolean;
|
||||
enableResourceOverCommit: boolean;
|
||||
resourceOverCommitPercentage: number;
|
||||
restrictDefaultNamespace: boolean;
|
||||
restrictStandardUserIngressW: boolean;
|
||||
ingressAvailabilityPerNamespace: boolean;
|
||||
allowNoneIngressClass: boolean;
|
||||
storageClasses: StorageClassFormValues[];
|
||||
ingressClasses: IngressControllerClassMap[];
|
||||
};
|
|
@ -0,0 +1,72 @@
|
|||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { Operation } from 'fast-json-patch';
|
||||
|
||||
import { withError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
|
||||
import {
|
||||
UpdateEnvironmentPayload,
|
||||
updateEnvironment,
|
||||
} from '@/react/portainer/environments/queries/useUpdateEnvironmentMutation';
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError';
|
||||
|
||||
import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap';
|
||||
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
|
||||
|
||||
export type ConfigureClusterPayloads = {
|
||||
id: number;
|
||||
updateEnvironmentPayload: Partial<UpdateEnvironmentPayload>;
|
||||
ingressControllers: IngressControllerClassMapRowData[];
|
||||
storageClassPatches: {
|
||||
name: string;
|
||||
patch: Operation[];
|
||||
}[];
|
||||
};
|
||||
|
||||
// useConfigureClusterMutation updates the environment, the ingress classes and the storage classes
|
||||
export function useConfigureClusterMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
async ({
|
||||
id,
|
||||
updateEnvironmentPayload,
|
||||
ingressControllers,
|
||||
storageClassPatches,
|
||||
}: ConfigureClusterPayloads) => {
|
||||
await updateEnvironment({ id, payload: updateEnvironmentPayload });
|
||||
await Promise.all(
|
||||
storageClassPatches.map(({ name, patch }) =>
|
||||
patchStorageClass(id, name, patch)
|
||||
)
|
||||
);
|
||||
await updateIngressControllerClassMap(id, ingressControllers);
|
||||
},
|
||||
{
|
||||
...withInvalidate(queryClient, [environmentQueryKeys.base()]),
|
||||
...withError('Unable to apply configuration', 'Failure'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function patchStorageClass(
|
||||
environmentId: number,
|
||||
name: string,
|
||||
storageClassPatch: Operation[]
|
||||
) {
|
||||
try {
|
||||
await axios.patch(
|
||||
`/endpoints/${environmentId}/kubernetes/apis/storage.k8s.io/v1/storageclasses/${name}`,
|
||||
storageClassPatch,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json-patch+json',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
`Unable to patch StorageClass ${name}`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { useQuery } from 'react-query';
|
||||
import { StorageClass, StorageClassList } from 'kubernetes-types/storage/v1';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../../../axiosError';
|
||||
|
||||
import { AccessMode, StorageClassFormValues } from './types';
|
||||
|
||||
export const availableStorageClassPolicies = [
|
||||
{
|
||||
Name: 'RWO',
|
||||
Description: 'Allow read-write from a single pod only (RWO)',
|
||||
selected: true,
|
||||
},
|
||||
{
|
||||
Name: 'RWX',
|
||||
Description:
|
||||
'Allow read-write access from one or more pods concurrently (RWX)',
|
||||
selected: false,
|
||||
},
|
||||
];
|
||||
|
||||
export function useStorageClassesFormValues(
|
||||
environment: Environment | null | undefined
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
'environments',
|
||||
environment?.Id,
|
||||
'kubernetes',
|
||||
'storageclasses',
|
||||
// include the storage classes in the cache key to force a refresh when the storage classes change in the environment object
|
||||
JSON.stringify(environment?.Kubernetes.Configuration.StorageClasses),
|
||||
],
|
||||
async () => {
|
||||
if (!environment) {
|
||||
return [];
|
||||
}
|
||||
const storageClasses = await getStorageClasses(environment.Id);
|
||||
const storageClassFormValues = transformStorageClassesToFormValues(
|
||||
storageClasses,
|
||||
environment
|
||||
);
|
||||
return storageClassFormValues;
|
||||
},
|
||||
{
|
||||
...withError('Failure', `Unable to get Storage Classes`),
|
||||
enabled: !!environment,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getStorageClasses(
|
||||
environmentId: EnvironmentId
|
||||
): Promise<StorageClass[]> {
|
||||
try {
|
||||
const { data: storageClassList } = await axios.get<StorageClassList>(
|
||||
`/endpoints/${environmentId}/kubernetes/apis/storage.k8s.io/v1/storageclasses`
|
||||
);
|
||||
return storageClassList.items;
|
||||
} catch (e) {
|
||||
throw parseKubernetesAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve Storage Classes'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function transformStorageClassesToFormValues(
|
||||
storageClasses: StorageClass[],
|
||||
environment: Environment
|
||||
) {
|
||||
const storageClassFormValues: StorageClassFormValues[] = storageClasses.map(
|
||||
(storageClass) => {
|
||||
const enabledStorage =
|
||||
environment.Kubernetes.Configuration.StorageClasses?.find(
|
||||
(sc) => sc.Name === storageClass.metadata?.name
|
||||
);
|
||||
let selected = false;
|
||||
let AccessModes: AccessMode[] = [];
|
||||
if (enabledStorage) {
|
||||
selected = true;
|
||||
AccessModes =
|
||||
enabledStorage.AccessModes.flatMap(
|
||||
(name) =>
|
||||
availableStorageClassPolicies.find(
|
||||
(accessMode) => accessMode.Name === name
|
||||
) || []
|
||||
) || [];
|
||||
} else {
|
||||
// set a default access mode if the storage class is not enabled and there are available access modes
|
||||
AccessModes = [availableStorageClassPolicies[0]];
|
||||
}
|
||||
|
||||
return {
|
||||
Name: storageClass.metadata?.name || '',
|
||||
Provisioner: storageClass.provisioner,
|
||||
AllowVolumeExpansion: !!storageClass.allowVolumeExpansion,
|
||||
selected,
|
||||
AccessModes,
|
||||
};
|
||||
}
|
||||
);
|
||||
return storageClassFormValues;
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { object, string, boolean, array, number, SchemaOf } from 'yup';
|
||||
|
||||
import { IngressControllerClassMap } from '../../ingressClass/types';
|
||||
|
||||
import { ConfigureFormValues } from './types';
|
||||
|
||||
// Define Yup schema for AccessMode
|
||||
const accessModeSchema = object().shape({
|
||||
Description: string().required(),
|
||||
Name: string().required(),
|
||||
selected: boolean().required(),
|
||||
});
|
||||
|
||||
// Define Yup schema for StorageClassFormValues
|
||||
const storageClassFormValuesSchema = array()
|
||||
.of(
|
||||
object().shape({
|
||||
Name: string().required(),
|
||||
AccessModes: array().of(accessModeSchema).required(),
|
||||
Provisioner: string().required(),
|
||||
AllowVolumeExpansion: boolean().required(),
|
||||
selected: boolean().required(),
|
||||
})
|
||||
)
|
||||
.test(
|
||||
// invalid if any storage class is not selected or if it's selected and at least one access mode is selected
|
||||
'accessModes',
|
||||
'Shared access policy configuration required.',
|
||||
(storageClasses) => {
|
||||
const isValid = storageClasses?.every(
|
||||
(value) =>
|
||||
!value.selected ||
|
||||
value.AccessModes?.some((accessMode) => accessMode.selected)
|
||||
);
|
||||
return isValid || false;
|
||||
}
|
||||
);
|
||||
|
||||
// Define Yup schema for IngressControllerClassMap
|
||||
const ingressControllerClassMapSchema: SchemaOf<IngressControllerClassMap> =
|
||||
object().shape({
|
||||
Name: string().required(),
|
||||
ClassName: string().required(),
|
||||
Type: string().required(),
|
||||
Availability: boolean().required(),
|
||||
New: boolean().required(),
|
||||
Used: boolean().required(),
|
||||
});
|
||||
|
||||
// Define Yup schema for ConfigureFormValues
|
||||
export const configureValidationSchema: SchemaOf<ConfigureFormValues> = object({
|
||||
useLoadBalancer: boolean().required(),
|
||||
useServerMetrics: boolean().required(),
|
||||
enableResourceOverCommit: boolean().required(),
|
||||
resourceOverCommitPercentage: number().required(),
|
||||
restrictDefaultNamespace: boolean().required(),
|
||||
restrictStandardUserIngressW: boolean().required(),
|
||||
ingressAvailabilityPerNamespace: boolean().required(),
|
||||
allowNoneIngressClass: boolean().required(),
|
||||
storageClasses: storageClassFormValuesSchema.required(),
|
||||
ingressClasses: array().of(ingressControllerClassMapSchema).required(),
|
||||
});
|
Loading…
Add table
Add a link
Reference in a new issue