1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(k8sconfigure): migrate configure to react [EE-5524] (#10218)

This commit is contained in:
Ali 2023-09-05 18:06:36 +02:00 committed by GitHub
parent 0f1e77a6d5
commit 515b02813b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
59 changed files with 1819 additions and 833 deletions

View file

@ -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&apos;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);
}

View file

@ -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&nbsp;
<a
href="https://kubernetes.io/docs/tasks/debug-application-cluster/resource-metrics-pipeline/#metrics-server"
target="_blank"
rel="noreferrer"
>
metrics server
</a>
&nbsp;or&nbsp;
<a
href="https://github.com/kubernetes-sigs/prometheus-adapter"
target="_blank"
rel="noreferrer"
>
prometheus
</a>
&nbsp;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>
);
}

View file

@ -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&apos;t use Portainer RBAC functionality to</p>
<p className="mb-0">
To enable RBAC, start the&nbsp;
<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>
&nbsp;with the&nbsp;
<code className="bg-gray-4 box-decoration-clone th-highcontrast:bg-black th-dark:bg-black">
--authorization-mode
</code>
&nbsp;flag set to a comma-separated list that includes&nbsp;
<code className="bg-gray-4 th-highcontrast:bg-black th-dark:bg-black">
RBAC
</code>
, for example:&nbsp;
<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>
);
}

View file

@ -9,7 +9,7 @@ interface Option {
interface Props {
value: Option[];
onChange(storageClassName: string, value: readonly Option[]): void;
onChange(value: readonly Option[]): void;
options: Option[];
inputId?: string;
storageClassName: string;
@ -31,7 +31,7 @@ export function StorageAccessModeSelector({
options={options}
value={value}
closeMenuOnSelect={false}
onChange={(value) => onChange(storageClassName, value)}
onChange={(value) => onChange(value)}
inputId={inputId}
placeholder="Not configured"
data-cy={`kubeSetup-storageAccessSelect${storageClassName}`}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(),
});

View file

@ -0,0 +1,39 @@
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody } from '@@/Widget';
import { ConfigureForm } from './ConfigureForm';
export function ConfigureView() {
const { data: environment } = useCurrentEnvironment();
// get the initial values
return (
<>
<PageHeader
title="Kubernetes features configuration"
reload
breadcrumbs={[
{ label: 'Environments', link: 'portainer.endpoints' },
{
label: environment?.Name || '',
link: 'portainer.endpoints.endpoint',
linkParams: { id: environment?.Id },
},
'Kubernetes configuration',
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
<ConfigureForm />
</WidgetBody>
</Widget>
</div>
</div>
</>
);
}

View file

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