mirror of
https://github.com/portainer/portainer.git
synced 2025-07-24 15:59:41 +02:00
feat(namespace): migrate create ns to react [EE-2226] (#10377)
This commit is contained in:
parent
31bcba96c6
commit
7218eb0892
83 changed files with 1869 additions and 358 deletions
|
@ -0,0 +1,26 @@
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
export function LoadBalancerFormSection() {
|
||||
return (
|
||||
<FormSection title="Load balancers">
|
||||
<TextTip color="blue">
|
||||
You can set a quota on the number of external load balancers that can be
|
||||
created inside this namespace. Set this quota to 0 to effectively
|
||||
disable the use of load balancers in this namespace.
|
||||
</TextTip>
|
||||
<SwitchField
|
||||
dataCy="k8sNamespaceCreate-loadBalancerQuotaToggle"
|
||||
label="Load balancer quota"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={false}
|
||||
featureId={FeatureId.K8S_RESOURCE_POOL_LB_QUOTA}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
|
@ -0,0 +1,119 @@
|
|||
import { Field, Form, FormikProps } from 'formik';
|
||||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
|
||||
import { IngressClassDatatable } from '../../cluster/ingressClass/IngressClassDatatable';
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { CreateNamespaceFormValues } from '../CreateView/types';
|
||||
import { AnnotationsBeTeaser } from '../../annotations/AnnotationsBeTeaser';
|
||||
|
||||
import { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
||||
import { NamespaceSummary } from './NamespaceSummary';
|
||||
import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection';
|
||||
import { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
||||
import { RegistriesFormSection } from './RegistriesFormSection';
|
||||
import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types';
|
||||
|
||||
export function NamespaceInnerForm({
|
||||
errors,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
values,
|
||||
isSubmitting,
|
||||
initialValues,
|
||||
}: FormikProps<CreateNamespaceFormValues>) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const ingressClassesQuery = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
allowedOnly: true,
|
||||
});
|
||||
|
||||
if (environmentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useLoadBalancer =
|
||||
environmentQuery.data?.Kubernetes.Configuration.UseLoadBalancer;
|
||||
const enableResourceOverCommit =
|
||||
environmentQuery.data?.Kubernetes.Configuration.EnableResourceOverCommit;
|
||||
const enableIngressControllersPerNamespace =
|
||||
environmentQuery.data?.Kubernetes.Configuration
|
||||
.IngressAvailabilityPerNamespace;
|
||||
const storageClasses =
|
||||
environmentQuery.data?.Kubernetes.Configuration.StorageClasses ?? [];
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormControl
|
||||
inputId="namespace"
|
||||
label="Name"
|
||||
required
|
||||
errors={errors.name}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="namespace"
|
||||
name="name"
|
||||
placeholder="e.g. my-namespace"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
/>
|
||||
</FormControl>
|
||||
<AnnotationsBeTeaser />
|
||||
<ResourceQuotaFormSection
|
||||
enableResourceOverCommit={enableResourceOverCommit}
|
||||
values={values.resourceQuota}
|
||||
onChange={(resourceQuota: ResourceQuotaFormValues) =>
|
||||
setFieldValue('resourceQuota', resourceQuota)
|
||||
}
|
||||
errors={errors.resourceQuota}
|
||||
/>
|
||||
{useLoadBalancer && <LoadBalancerFormSection />}
|
||||
{enableIngressControllersPerNamespace && (
|
||||
<FormSection title="Networking">
|
||||
<IngressClassDatatable
|
||||
onChange={(classes) => setFieldValue('ingressClasses', classes)}
|
||||
values={values.ingressClasses}
|
||||
description="Enable the ingress controllers that users can select when publishing applications in this namespace."
|
||||
noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster."
|
||||
view="namespace"
|
||||
isLoading={ingressClassesQuery.isLoading}
|
||||
initialValues={initialValues.ingressClasses}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
<RegistriesFormSection
|
||||
values={values.registries}
|
||||
onChange={(registries: MultiValue<Registry>) =>
|
||||
setFieldValue('registries', registries)
|
||||
}
|
||||
errors={errors.registries}
|
||||
/>
|
||||
{storageClasses.length > 0 && (
|
||||
<StorageQuotaFormSection storageClasses={storageClasses} />
|
||||
)}
|
||||
<NamespaceSummary
|
||||
initialValues={initialValues}
|
||||
values={values}
|
||||
isValid={isValid}
|
||||
/>
|
||||
<FormSection title="Actions">
|
||||
<FormActions
|
||||
submitLabel="Create namespace"
|
||||
loadingText="Creating namespace"
|
||||
isLoading={isSubmitting}
|
||||
isValid={isValid}
|
||||
data-cy="k8sNamespaceCreate-submitButton"
|
||||
/>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { CreateNamespaceFormValues } from '../CreateView/types';
|
||||
|
||||
interface Props {
|
||||
initialValues: CreateNamespaceFormValues;
|
||||
values: CreateNamespaceFormValues;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export function NamespaceSummary({ initialValues, values, isValid }: Props) {
|
||||
const hasChanges = !_.isEqual(values, initialValues);
|
||||
|
||||
if (!hasChanges || !isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Summary" isFoldable defaultFolded={false}>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
Portainer will execute the following Kubernetes actions.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-12 small text-muted pt-1">
|
||||
<ul>
|
||||
<li>
|
||||
Create a <span className="bold">Namespace</span> named{' '}
|
||||
<code>{values.name}</code>
|
||||
</li>
|
||||
{values.resourceQuota.enabled && (
|
||||
<li>
|
||||
Create a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
import { RegistriesSelector } from './RegistriesSelector';
|
||||
|
||||
type Props = {
|
||||
values: MultiValue<Registry>;
|
||||
onChange: (value: MultiValue<Registry>) => void;
|
||||
errors?: string | string[] | FormikErrors<Registry>[];
|
||||
};
|
||||
|
||||
export function RegistriesFormSection({ values, onChange, errors }: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
return (
|
||||
<FormSection title="Registries">
|
||||
<FormControl
|
||||
inputId="registries"
|
||||
label="Select registries"
|
||||
required
|
||||
errors={errors}
|
||||
>
|
||||
{registriesQuery.isLoading && (
|
||||
<InlineLoader>Loading registries...</InlineLoader>
|
||||
)}
|
||||
{registriesQuery.data && (
|
||||
<RegistriesSelector
|
||||
value={values}
|
||||
onChange={(registries) => onChange(registries)}
|
||||
options={registriesQuery.data}
|
||||
inputId="registries"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
interface Props {
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options: Registry[];
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
inputId,
|
||||
}: Props) {
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
getOptionLabel={(option) => option.Name}
|
||||
getOptionValue={(option) => String(option.Id)}
|
||||
options={options}
|
||||
value={value}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registries"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { RegistriesFormSection } from './RegistriesFormSection';
|
|
@ -0,0 +1,10 @@
|
|||
import { SchemaOf, array, object, number, string } from 'yup';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
export const registriesValidationSchema: SchemaOf<Registry[]> = array(
|
||||
object({
|
||||
Id: number().required('Registry ID is required.'),
|
||||
Name: string().required('Registry name is required.'),
|
||||
})
|
||||
);
|
|
@ -0,0 +1,121 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../../CreateView/queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
onChange: (value: ResourceQuotaFormValues) => void;
|
||||
enableResourceOverCommit?: boolean;
|
||||
errors?: FormikErrors<ResourceQuotaFormValues>;
|
||||
}
|
||||
|
||||
export function ResourceQuotaFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
enableResourceOverCommit,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
return (
|
||||
<FormSection title="Resource Quota">
|
||||
{values.enabled ? (
|
||||
<TextTip color="blue">
|
||||
A namespace is a logical abstraction of a Kubernetes cluster, to
|
||||
provide for more flexible management of resources. Best practice is to
|
||||
set a quota assignment as this ensures greatest security/stability;
|
||||
alternatively, you can disable assigning a quota for unrestricted
|
||||
access (not recommended).
|
||||
</TextTip>
|
||||
) : (
|
||||
<TextTip color="blue">
|
||||
A namespace is a logical abstraction of a Kubernetes cluster, to
|
||||
provide for more flexible management of resources. Resource
|
||||
over-commit is disabled, please assign a capped limit of resources to
|
||||
this namespace.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceCreate-resourceAssignmentToggle"
|
||||
disabled={enableResourceOverCommit}
|
||||
label="Resource assignment"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={values.enabled || !!enableResourceOverCommit}
|
||||
onChange={(enabled) => onChange({ ...values, enabled })}
|
||||
/>
|
||||
|
||||
{(values.enabled || !!enableResourceOverCommit) && (
|
||||
<div className="pt-5">
|
||||
<div className="flex flex-row">
|
||||
<FormSectionTitle>Resource Limits</FormSectionTitle>
|
||||
</div>
|
||||
{/* keep the FormError component present, but invisible to avoid layout shift */}
|
||||
<FormError
|
||||
className={typeof errors === 'string' ? 'visible' : 'invisible'}
|
||||
>
|
||||
{/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */}
|
||||
{errors || 'error'}
|
||||
</FormError>
|
||||
<FormControl
|
||||
className="flex flex-row"
|
||||
label="Memory limit (MB)"
|
||||
inputId="memory-limit"
|
||||
>
|
||||
<div className="col-xs-8">
|
||||
<SliderWithInput
|
||||
value={Number(values.memory) ?? 0}
|
||||
onChange={(value) =>
|
||||
onChange({ ...values, memory: `${value}` })
|
||||
}
|
||||
max={memoryLimit}
|
||||
step={128}
|
||||
dataCy="k8sNamespaceCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
/>
|
||||
{errors?.memory && (
|
||||
<FormError className="pt-1">{errors.memory}</FormError>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormControl className="flex flex-row" label="CPU limit">
|
||||
<div className="col-xs-8">
|
||||
<Slider
|
||||
min={0}
|
||||
max={cpuLimit / 1000}
|
||||
step={0.1}
|
||||
value={Number(values.cpu) ?? 0}
|
||||
onChange={(cpu) => {
|
||||
if (Array.isArray(cpu)) {
|
||||
return;
|
||||
}
|
||||
onChange({ ...values, cpu: cpu.toString() });
|
||||
}}
|
||||
dataCy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
visibleTooltip
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { boolean, string, object, SchemaOf, TestContext } from 'yup';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function getResourceQuotaValidationSchema(
|
||||
memoryLimit: number
|
||||
): SchemaOf<ResourceQuotaFormValues> {
|
||||
return object({
|
||||
enabled: boolean().required('Resource quota enabled status is required.'),
|
||||
memory: string().test(
|
||||
'memory-validation',
|
||||
`Value must be between 0 and ${memoryLimit}.`,
|
||||
memoryValidation
|
||||
),
|
||||
cpu: string().test(
|
||||
'cpu-validation',
|
||||
'CPU limit value is required.',
|
||||
cpuValidation
|
||||
),
|
||||
}).test(
|
||||
'resource-quota-validation',
|
||||
'At least a single limit must be set.',
|
||||
oneLimitSet
|
||||
);
|
||||
|
||||
function oneLimitSet({
|
||||
enabled,
|
||||
memory,
|
||||
cpu,
|
||||
}: Partial<ResourceQuotaFormValues>) {
|
||||
return !enabled || (Number(memory) ?? 0) > 0 || (Number(cpu) ?? 0) > 0;
|
||||
}
|
||||
|
||||
function memoryValidation(this: TestContext, memoryValue?: string) {
|
||||
const memory = Number(memoryValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || (memory >= 0 && memory <= memoryLimit);
|
||||
}
|
||||
|
||||
function cpuValidation(this: TestContext, cpuValue?: string) {
|
||||
const cpu = Number(cpuValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || cpu >= 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* @property enabled - Whether resource quota is enabled
|
||||
* @property memory - Memory limit in bytes
|
||||
* @property cpu - CPU limit in cores
|
||||
* @property loadBalancer - Load balancer limit in number of load balancers
|
||||
*/
|
||||
export type ResourceQuotaFormValues = {
|
||||
enabled: boolean;
|
||||
memory?: string;
|
||||
cpu?: string;
|
||||
};
|
||||
|
||||
export type ResourceQuotaPayload = {
|
||||
enabled: boolean;
|
||||
memory?: string;
|
||||
cpu?: string;
|
||||
loadBalancerLimit?: string;
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { StorageQuotaItem } from './StorageQuotaItem';
|
||||
|
||||
interface Props {
|
||||
storageClasses: StorageClass[];
|
||||
}
|
||||
|
||||
export function StorageQuotaFormSection({ storageClasses }: Props) {
|
||||
return (
|
||||
<FormSection title="Storage">
|
||||
<TextTip color="blue">
|
||||
Quotas can be set on each storage option to prevent users from exceeding
|
||||
a specific threshold when deploying applications. You can set a quota to
|
||||
0 to effectively prevent the usage of a specific storage option inside
|
||||
this namespace.
|
||||
</TextTip>
|
||||
|
||||
{storageClasses.map((storageClass) => (
|
||||
<StorageQuotaItem key={storageClass.Name} storageClass={storageClass} />
|
||||
))}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Database } from 'lucide-react';
|
||||
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
type Props = {
|
||||
storageClass: StorageClass;
|
||||
};
|
||||
|
||||
export function StorageQuotaItem({ storageClass }: Props) {
|
||||
return (
|
||||
<div key={storageClass.Name}>
|
||||
<FormSectionTitle>
|
||||
<div className="vertical-center text-muted inline-flex gap-1 align-top">
|
||||
<Icon icon={Database} className="!mt-0.5 flex-none" />
|
||||
<span>{storageClass.Name}</span>
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
<hr className="mt-2 mb-0 w-full" />
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceEdit-storageClassQuota"
|
||||
disabled={false}
|
||||
label="Enable quota"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StorageQuotaFormSection } from './StorageQuotaFormSection';
|
Loading…
Add table
Add a link
Reference in a new issue