mirror of
https://github.com/portainer/portainer.git
synced 2025-07-23 15:29:42 +02:00
refactor(namespace): migrate namespace edit to react [r8s-125] (#38)
This commit is contained in:
parent
40c7742e46
commit
ce7e0d8d60
108 changed files with 3183 additions and 2194 deletions
|
@ -0,0 +1,28 @@
|
|||
import { string, object, array, SchemaOf } from 'yup';
|
||||
|
||||
import { NamespaceFormValues } from '../../types';
|
||||
|
||||
import { registriesValidationSchema } from './RegistriesFormSection/registriesValidationSchema';
|
||||
import { getResourceQuotaValidationSchema } from './ResourceQuotaFormSection/getResourceQuotaValidationSchema';
|
||||
|
||||
export function getNamespaceValidationSchema(
|
||||
memoryLimit: number,
|
||||
cpuLimit: number,
|
||||
namespaceNames: string[]
|
||||
): SchemaOf<NamespaceFormValues> {
|
||||
return object({
|
||||
name: string()
|
||||
.matches(
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,251}[a-z0-9])?$/,
|
||||
"This field must consist of lower case alphanumeric characters or '-', and contain at most 63 characters, and must start and end with an alphanumeric character."
|
||||
)
|
||||
.max(63, 'Name must be at most 63 characters.')
|
||||
// must not have the same name as an existing namespace
|
||||
.notOneOf(namespaceNames, 'Name must be unique.')
|
||||
.required('Name is required.'),
|
||||
resourceQuota: getResourceQuotaValidationSchema(memoryLimit, cpuLimit),
|
||||
// ingress classes table is constrained already, and doesn't need validation
|
||||
ingressClasses: array(),
|
||||
registries: registriesValidationSchema,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
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/registry';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
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 { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
|
||||
import { IngressClassDatatable } from '../../../cluster/ingressClass/IngressClassDatatable';
|
||||
import { useIngressControllerClassMapQuery } from '../../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { CreateNamespaceFormValues } from '../../CreateView/types';
|
||||
import { AnnotationsBeTeaser } from '../../../annotations/AnnotationsBeTeaser';
|
||||
import { isDefaultNamespace } from '../../isDefaultNamespace';
|
||||
import { useIsSystemNamespace } from '../../queries/useIsSystemNamespace';
|
||||
|
||||
import { NamespaceSummary } from './NamespaceSummary';
|
||||
import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection';
|
||||
import { RegistriesFormSection } from './RegistriesFormSection';
|
||||
import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types';
|
||||
import { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
||||
import { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
||||
import { ToggleSystemNamespaceButton } from './ToggleSystemNamespaceButton';
|
||||
|
||||
const namespaceWriteAuth = 'K8sResourcePoolDetailsW';
|
||||
|
||||
export function NamespaceInnerForm({
|
||||
errors,
|
||||
isValid,
|
||||
dirty,
|
||||
setFieldValue,
|
||||
values,
|
||||
isSubmitting,
|
||||
initialValues,
|
||||
isEdit,
|
||||
}: FormikProps<CreateNamespaceFormValues> & { isEdit?: boolean }) {
|
||||
const { authorized: hasNamespaceWriteAuth } = useAuthorizations(
|
||||
namespaceWriteAuth,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
const isSystemNamespace = useIsSystemNamespace(values.name, isEdit === true);
|
||||
const isEditingDisabled =
|
||||
!hasNamespaceWriteAuth ||
|
||||
isDefaultNamespace(values.name) ||
|
||||
isSystemNamespace;
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const ingressClassesQuery = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
namespace: values.name,
|
||||
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 className="form-horizontal">
|
||||
<FormControl
|
||||
inputId="namespace"
|
||||
label="Name"
|
||||
required={!isEdit}
|
||||
errors={errors.name}
|
||||
>
|
||||
{isEdit ? (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{values.name}
|
||||
{isSystemNamespace && <SystemBadge />}
|
||||
</div>
|
||||
) : (
|
||||
<Field
|
||||
as={Input}
|
||||
id="namespace"
|
||||
name="name"
|
||||
disabled={isEdit}
|
||||
placeholder="e.g. my-namespace"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<AnnotationsBeTeaser />
|
||||
{(values.resourceQuota.enabled || !isEditingDisabled) && (
|
||||
<ResourceQuotaFormSection
|
||||
isEdit={isEdit}
|
||||
enableResourceOverCommit={enableResourceOverCommit}
|
||||
values={values.resourceQuota}
|
||||
onChange={(resourceQuota: ResourceQuotaFormValues) =>
|
||||
setFieldValue('resourceQuota', resourceQuota)
|
||||
}
|
||||
errors={errors.resourceQuota}
|
||||
namespaceName={values.name}
|
||||
isEditingDisabled={isEditingDisabled}
|
||||
/>
|
||||
)}
|
||||
{useLoadBalancer && <LoadBalancerFormSection />}
|
||||
{enableIngressControllersPerNamespace && (
|
||||
<Authorized authorizations={[namespaceWriteAuth]}>
|
||||
<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>
|
||||
</Authorized>
|
||||
)}
|
||||
<RegistriesFormSection
|
||||
values={values.registries}
|
||||
onChange={(registries: MultiValue<Registry>) =>
|
||||
setFieldValue('registries', registries)
|
||||
}
|
||||
errors={errors.registries}
|
||||
isEditingDisabled={isEditingDisabled}
|
||||
/>
|
||||
{storageClasses.length > 0 && (
|
||||
<StorageQuotaFormSection storageClasses={storageClasses} />
|
||||
)}
|
||||
<Authorized authorizations={[namespaceWriteAuth]}>
|
||||
<NamespaceSummary
|
||||
initialValues={initialValues}
|
||||
values={values}
|
||||
isValid={isValid}
|
||||
/>
|
||||
<FormActions
|
||||
submitLabel={isEdit ? 'Update namespace' : 'Create namespace'}
|
||||
loadingText={isEdit ? 'Updating namespace' : 'Creating namespace'}
|
||||
isLoading={isSubmitting}
|
||||
isValid={isValid && dirty}
|
||||
data-cy="k8sNamespaceCreate-submitButton"
|
||||
>
|
||||
{isEdit && (
|
||||
<ToggleSystemNamespaceButton
|
||||
isSystemNamespace={isSystemNamespace}
|
||||
isEdit={isEdit}
|
||||
environmentId={environmentId}
|
||||
namespaceName={values.name}
|
||||
/>
|
||||
)}
|
||||
</FormActions>
|
||||
</Authorized>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { NamespaceFormValues } from '../../types';
|
||||
|
||||
interface Props {
|
||||
initialValues: NamespaceFormValues;
|
||||
values: NamespaceFormValues;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export function NamespaceSummary({ initialValues, values, isValid }: Props) {
|
||||
// only compare the values from k8s related resources
|
||||
const { registries: newRegistryValues, ...newK8sValues } = values;
|
||||
const { registries: oldRegistryValues, ...oldK8sValues } = initialValues;
|
||||
const hasChanges = !isEqual(newK8sValues, oldK8sValues);
|
||||
if (!hasChanges || !isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCreatingNamespace = !oldK8sValues.name && newK8sValues.name;
|
||||
|
||||
const enabledQuotaInitialValues = initialValues.resourceQuota.enabled;
|
||||
const enabledQuotaNewValues = values.resourceQuota.enabled;
|
||||
|
||||
const isCreatingResourceQuota =
|
||||
!enabledQuotaInitialValues && enabledQuotaNewValues;
|
||||
const isUpdatingResourceQuota =
|
||||
enabledQuotaInitialValues && enabledQuotaNewValues;
|
||||
const isDeletingResourceQuota =
|
||||
enabledQuotaInitialValues && !enabledQuotaNewValues;
|
||||
|
||||
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>
|
||||
{isCreatingNamespace && (
|
||||
<li>
|
||||
Create a <span className="bold">Namespace</span> named{' '}
|
||||
<code>{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
{isCreatingResourceQuota && (
|
||||
<li>
|
||||
Create a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
{isUpdatingResourceQuota && (
|
||||
<li>
|
||||
Update a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
{isDeletingResourceQuota && (
|
||||
<li>
|
||||
Delete a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -16,22 +16,30 @@ type Props = {
|
|||
values: MultiValue<Registry>;
|
||||
onChange: (value: MultiValue<Registry>) => void;
|
||||
errors?: string | string[] | FormikErrors<Registry>[];
|
||||
isEditingDisabled: boolean;
|
||||
};
|
||||
|
||||
export function RegistriesFormSection({ values, onChange, errors }: Props) {
|
||||
export function RegistriesFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
isEditingDisabled,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
return (
|
||||
<FormSection title="Registries">
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Define which registries can be used by users who have access to this
|
||||
namespace.
|
||||
</TextTip>
|
||||
{!isEditingDisabled && (
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Define which registries can be used by users who have access to this
|
||||
namespace.
|
||||
</TextTip>
|
||||
)}
|
||||
<FormControl
|
||||
inputId="registries"
|
||||
label="Select registries"
|
||||
label={isEditingDisabled ? 'Selected registries' : 'Select registries'}
|
||||
errors={errors}
|
||||
>
|
||||
{registriesQuery.isLoading && (
|
||||
|
@ -43,6 +51,7 @@ export function RegistriesFormSection({ values, onChange, errors }: Props) {
|
|||
onChange={(registries) => onChange(registries)}
|
||||
options={registriesQuery.data}
|
||||
inputId="registries"
|
||||
isEditingDisabled={isEditingDisabled}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
|
@ -0,0 +1,74 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options?: Registry[];
|
||||
inputId?: string;
|
||||
isEditingDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
inputId,
|
||||
isEditingDisabled,
|
||||
}: Props) {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<p className="text-muted mb-1 mt-2 text-xs">
|
||||
{isPureAdmin ? (
|
||||
<span>
|
||||
No registries available. Head over to the{' '}
|
||||
<Link
|
||||
to="portainer.registries"
|
||||
target="_blank"
|
||||
data-cy="namespace-permissions-registries-selector"
|
||||
>
|
||||
registry view
|
||||
</Link>{' '}
|
||||
to define a container registry.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
No registries available. Contact your administrator to create a
|
||||
container registry.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditingDisabled) {
|
||||
return (
|
||||
<p className="text-muted mb-1 mt-2 text-xs">
|
||||
{value.length === 0 ? 'None' : value.map((v) => v.Name).join(', ')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
isDisabled={isEditingDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
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 '../../../queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceReservationUsage } from './ResourceReservationUsage';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
onChange: (value: ResourceQuotaFormValues) => void;
|
||||
enableResourceOverCommit?: boolean;
|
||||
errors?: FormikErrors<ResourceQuotaFormValues>;
|
||||
namespaceName?: string;
|
||||
isEdit?: boolean;
|
||||
isEditingDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ResourceQuotaFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
isEdit,
|
||||
enableResourceOverCommit,
|
||||
namespaceName,
|
||||
isEditingDisabled,
|
||||
}: 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">
|
||||
{!isEditingDisabled && (
|
||||
<>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
A resource quota sets boundaries on the compute resources a
|
||||
namespace can use. It's good practice to set a quota for a
|
||||
namespace to manage resources effectively. Alternatively, you can
|
||||
disable assigning a quota for unrestricted access (not recommended).
|
||||
</TextTip>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceCreate-resourceAssignmentToggle"
|
||||
disabled={!enableResourceOverCommit}
|
||||
label="Resource assignment"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
checked={values.enabled || !enableResourceOverCommit}
|
||||
onChange={(enabled) => onChange({ ...values, enabled })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(values.enabled || !enableResourceOverCommit) && !isEditingDisabled && (
|
||||
<div>
|
||||
<FormSectionTitle>Resource Limits</FormSectionTitle>
|
||||
{(!cpuLimit || !memoryLimit) && (
|
||||
<FormError>
|
||||
Not enough resources available in the cluster to apply a resource
|
||||
reservation.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
label="Memory limit (MB)"
|
||||
inputId="memory-limit"
|
||||
className="[&>label]:mt-8"
|
||||
errors={errors?.memory}
|
||||
>
|
||||
{memoryLimit >= 0 && (
|
||||
<SliderWithInput
|
||||
value={Number(values.memory) ?? 0}
|
||||
onChange={(value) =>
|
||||
onChange({ ...values, memory: `${value}` })
|
||||
}
|
||||
max={memoryLimit}
|
||||
min={0}
|
||||
step={128}
|
||||
dataCy="k8sNamespaceCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
inputId="memory-limit"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="CPU limit"
|
||||
inputId="cpu-limit"
|
||||
className="[&>label]:mt-8"
|
||||
errors={errors?.cpu}
|
||||
>
|
||||
{cpuLimit >= 0 && (
|
||||
<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
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{cpuLimit && memoryLimit && typeof errors === 'string' ? (
|
||||
<FormError>{errors}</FormError>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{namespaceName && isEdit && (
|
||||
<ResourceReservationUsage
|
||||
namespaceName={namespaceName}
|
||||
environmentId={environmentId}
|
||||
resourceQuotaValues={values}
|
||||
/>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
import { round } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
|
||||
import { megaBytesValue, parseCPU } from '../../../resourceQuotaUtils';
|
||||
import { ResourceUsageItem } from '../../ResourceUsageItem';
|
||||
|
||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function ResourceReservationUsage({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
resourceQuotaValues,
|
||||
}: {
|
||||
namespaceName: string;
|
||||
environmentId: EnvironmentId;
|
||||
resourceQuotaValues: ResourceQuotaFormValues;
|
||||
}) {
|
||||
const namespaceMetricsQuery = useMetricsForNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
{
|
||||
select: aggregatePodUsage,
|
||||
}
|
||||
);
|
||||
const usedResourceQuotaQuery = useResourceQuotaUsed(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
const { data: namespaceMetrics } = namespaceMetricsQuery;
|
||||
const { data: usedResourceQuota } = usedResourceQuotaQuery;
|
||||
|
||||
const memoryQuota = Number(resourceQuotaValues.memory) ?? 0;
|
||||
const cpuQuota = Number(resourceQuotaValues.cpu) ?? 0;
|
||||
|
||||
if (!resourceQuotaValues.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>Resource reservation</FormSectionTitle>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Resource reservation represents the total amount of resource assigned to
|
||||
all the applications deployed inside this namespace.
|
||||
</TextTip>
|
||||
{!!usedResourceQuota && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory reservation"
|
||||
annotation={`${usedResourceQuota.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(usedResourceQuota.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory used"
|
||||
annotation={`${namespaceMetrics.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(namespaceMetrics.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!usedResourceQuota && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU reservation"
|
||||
annotation={`${
|
||||
usedResourceQuota.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
usedResourceQuota.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU used"
|
||||
annotation={`${
|
||||
namespaceMetrics.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
namespaceMetrics.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeValue(value: number | string) {
|
||||
const valueNumber = Number(value);
|
||||
if (Number.isNaN(valueNumber)) {
|
||||
return 0;
|
||||
}
|
||||
return valueNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of the value over the total.
|
||||
* @param value - The value to calculate the percentage for.
|
||||
* @param total - The total value to compare the percentage to.
|
||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||
*/
|
||||
function getPercentageString(value: number, total?: number | string) {
|
||||
const totalNumber = Number(total);
|
||||
if (
|
||||
totalNumber === 0 ||
|
||||
total === undefined ||
|
||||
total === '' ||
|
||||
Number.isNaN(totalNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (value > totalNumber) {
|
||||
return '- Exceeded';
|
||||
}
|
||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the resource usage of all the containers in the namespace.
|
||||
* @param podMetricsList - List of pod metrics
|
||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||
i.containers.map((c) => c.usage)
|
||||
);
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||
(total, usage) => ({
|
||||
cpu: total.cpu + parseCPU(usage.cpu),
|
||||
memory: total.memory + megaBytesValue(usage.memory),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||
return namespaceResourceUsage;
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { boolean, string, object, SchemaOf, TestContext } from 'yup';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function getResourceQuotaValidationSchema(
|
||||
memoryLimit: number,
|
||||
cpuLimit: number
|
||||
): SchemaOf<ResourceQuotaFormValues> {
|
||||
return object({
|
||||
enabled: boolean().required('Resource quota enabled status is required.'),
|
||||
memory: string()
|
||||
.test(
|
||||
'non-negative-memory-validation',
|
||||
'Existing namespaces already have memory limits exceeding what is available in the cluster. Before you can set values here, you must reduce amounts in namespaces (and you may have to turn on over-commit temporarily to do so).',
|
||||
() => nonNegativeLimit(memoryLimit)
|
||||
)
|
||||
.test(
|
||||
'memory-validation',
|
||||
`Value must be between 0 and ${memoryLimit}.`,
|
||||
memoryValidation
|
||||
),
|
||||
cpu: string()
|
||||
.test(
|
||||
'non-negative-memory-validation',
|
||||
'Existing namespaces already have CPU limits exceeding what is available in the cluster. Before you can set values here, you must reduce amounts in namespaces (and you may have to turn on over-commit temporarily to do so).',
|
||||
() => nonNegativeLimit(cpuLimit)
|
||||
)
|
||||
.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 nonNegativeLimit(limit: number) {
|
||||
return limit >= 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;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
* @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;
|
|
@ -0,0 +1,38 @@
|
|||
import { round } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { useNamespaceQuery } from '../../../queries/useNamespaceQuery';
|
||||
import { parseCPU, megaBytesValue } from '../../../resourceQuotaUtils';
|
||||
import { PortainerNamespace } from '../../../types';
|
||||
|
||||
/**
|
||||
* Returns the resource quota used for a namespace.
|
||||
* @param environmentId - The environment ID.
|
||||
* @param namespaceName - The namespace name.
|
||||
* @param enabled - Whether the resource quota is enabled.
|
||||
* @returns The resource quota used for the namespace. CPU (cores) is rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
export function useResourceQuotaUsed(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
enabled = true
|
||||
) {
|
||||
return useNamespaceQuery(environmentId, namespaceName, {
|
||||
select: parseResourceQuotaUsed,
|
||||
enabled,
|
||||
params: { withResourceQuota: 'true' },
|
||||
});
|
||||
}
|
||||
|
||||
function parseResourceQuotaUsed(namespace: PortainerNamespace) {
|
||||
return {
|
||||
cpu: round(
|
||||
parseCPU(namespace.ResourceQuota?.status?.used?.['requests.cpu'] ?? ''),
|
||||
3
|
||||
),
|
||||
memory: megaBytesValue(
|
||||
namespace.ResourceQuota?.status?.used?.['requests.memory'] ?? ''
|
||||
),
|
||||
};
|
||||
}
|
|
@ -1,9 +1,15 @@
|
|||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { StorageQuotaItem } from './StorageQuotaItem';
|
||||
|
||||
export function StorageQuotaFormSection() {
|
||||
interface Props {
|
||||
storageClasses: StorageClass[];
|
||||
}
|
||||
|
||||
export function StorageQuotaFormSection({ storageClasses }: Props) {
|
||||
return (
|
||||
<FormSection title="Storage">
|
||||
<TextTip color="blue">
|
||||
|
@ -13,7 +19,9 @@ export function StorageQuotaFormSection() {
|
|||
this namespace.
|
||||
</TextTip>
|
||||
|
||||
<StorageQuotaItem />
|
||||
{storageClasses.map((storageClass) => (
|
||||
<StorageQuotaItem key={storageClass.Name} storageClass={storageClass} />
|
||||
))}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { Database } from 'lucide-react';
|
||||
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
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="mb-0 mt-2 w-full" />
|
||||
<Authorized authorizations={['K8sResourcePoolDetailsW']}>
|
||||
<div className="form-group mb-4">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceEdit-storageClassQuota"
|
||||
disabled
|
||||
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>
|
||||
</Authorized>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { confirmUpdate } from '@@/modals/confirm';
|
||||
|
||||
import { useToggleSystemNamespaceMutation } from '../../queries/useToggleSystemNamespace';
|
||||
|
||||
export function ToggleSystemNamespaceButton({
|
||||
isSystemNamespace,
|
||||
isEdit,
|
||||
environmentId,
|
||||
namespaceName,
|
||||
}: {
|
||||
isSystemNamespace: boolean;
|
||||
isEdit: boolean;
|
||||
environmentId: EnvironmentId;
|
||||
namespaceName: string;
|
||||
}) {
|
||||
const toggleSystemNamespaceMutation = useToggleSystemNamespaceMutation(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
if (!isEdit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingButton
|
||||
onClick={markUnmarkAsSystem}
|
||||
className="!ml-0"
|
||||
data-cy="mark-as-system-button"
|
||||
color="default"
|
||||
type="button"
|
||||
loadingText={
|
||||
isSystemNamespace ? 'Unmarking as system' : 'Marking as system'
|
||||
}
|
||||
isLoading={toggleSystemNamespaceMutation.isLoading}
|
||||
>
|
||||
{isSystemNamespace ? 'Unmark as system' : 'Mark as system'}
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
async function markUnmarkAsSystem() {
|
||||
const confirmed = await confirmMarkUnmarkAsSystem(isSystemNamespace);
|
||||
if (confirmed) {
|
||||
toggleSystemNamespaceMutation.mutate(!isSystemNamespace, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Namespace updated');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMarkUnmarkAsSystem(isSystemNamespace: boolean) {
|
||||
const message = isSystemNamespace
|
||||
? 'Unmarking this namespace as system will allow non administrator users to manage it and the resources in contains depending on the access control settings. Are you sure?'
|
||||
: 'Marking this namespace as a system namespace will prevent non administrator users from managing it and the resources it contains. Are you sure?';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
confirmUpdate(message, resolve);
|
||||
});
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import { NamespaceFormValues, NamespacePayload } from '../../types';
|
||||
|
||||
export function transformFormValuesToNamespacePayload(
|
||||
createNamespaceFormValues: NamespaceFormValues,
|
||||
owner: string
|
||||
): NamespacePayload {
|
||||
const memoryInBytes =
|
||||
Number(createNamespaceFormValues.resourceQuota.memory) * 10 ** 6;
|
||||
return {
|
||||
Name: createNamespaceFormValues.name,
|
||||
Owner: owner,
|
||||
ResourceQuota: {
|
||||
enabled: createNamespaceFormValues.resourceQuota.enabled,
|
||||
cpu: createNamespaceFormValues.resourceQuota.cpu,
|
||||
memory: `${memoryInBytes}`,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,115 +0,0 @@
|
|||
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/registry';
|
||||
|
||||
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 />}
|
||||
<NamespaceSummary
|
||||
initialValues={initialValues}
|
||||
values={values}
|
||||
isValid={isValid}
|
||||
/>
|
||||
<FormActions
|
||||
submitLabel="Create namespace"
|
||||
loadingText="Creating namespace"
|
||||
isLoading={isSubmitting}
|
||||
isValid={isValid}
|
||||
data-cy="k8sNamespaceCreate-submitButton"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
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 { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Widget } from '@@/Widget/Widget';
|
||||
import { WidgetBody } from '@@/Widget';
|
||||
|
||||
import { YAMLInspector } from '../../components/YAMLInspector';
|
||||
import { useNamespaceYAML } from '../queries/useNamespaceYAML';
|
||||
|
||||
export function NamespaceYAMLEditor() {
|
||||
const {
|
||||
params: { id: namespace, endpointId: environmentId },
|
||||
} = useCurrentStateAndParams();
|
||||
const { data: fullNamespaceYaml, isLoading: isNamespaceYAMLLoading } =
|
||||
useNamespaceYAML(environmentId, namespace);
|
||||
|
||||
if (isNamespaceYAMLLoading) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<InlineLoader>Loading namespace YAML...</InlineLoader>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<YAMLInspector
|
||||
identifier="namespace-yaml"
|
||||
data={fullNamespaceYaml || ''}
|
||||
hideMessage
|
||||
data-cy="namespace-yaml"
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options?: Registry[];
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
inputId,
|
||||
}: Props) {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.length === 0 && (
|
||||
<p className="text-muted mb-1 mt-2 text-xs">
|
||||
{isPureAdmin ? (
|
||||
<span>
|
||||
No registries available. Head over to the{' '}
|
||||
<Link
|
||||
to="portainer.registries"
|
||||
target="_blank"
|
||||
data-cy="namespace-permissions-registries-selector"
|
||||
>
|
||||
registry view
|
||||
</Link>{' '}
|
||||
to define a container registry.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
No registries available. Contact your administrator to create a
|
||||
container registry.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
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">
|
||||
<TextTip color="blue">
|
||||
A resource quota sets boundaries on the compute resources a namespace
|
||||
can use. It's good practice to set a quota for a namespace to
|
||||
manage resources effectively. Alternatively, you can disable assigning a
|
||||
quota for unrestricted access (not recommended).
|
||||
</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>
|
||||
|
||||
{(!cpuLimit || !memoryLimit) && (
|
||||
<FormError>
|
||||
Not enough resources available in the cluster to apply a resource
|
||||
reservation.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
{/* keep the FormError component present, but invisible to avoid layout shift */}
|
||||
{cpuLimit && memoryLimit ? (
|
||||
<FormError
|
||||
className={typeof errors === 'string' ? 'visible' : 'invisible'}
|
||||
>
|
||||
{/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */}
|
||||
{typeof errors === 'string' ? errors : 'error'}
|
||||
</FormError>
|
||||
) : null}
|
||||
|
||||
<FormControl
|
||||
className="flex flex-row"
|
||||
label="Memory limit (MB)"
|
||||
inputId="memory-limit"
|
||||
>
|
||||
<div className="col-xs-8">
|
||||
{memoryLimit >= 0 && (
|
||||
<SliderWithInput
|
||||
value={Number(values.memory) ?? 0}
|
||||
onChange={(value) =>
|
||||
onChange({ ...values, memory: `${value}` })
|
||||
}
|
||||
max={memoryLimit}
|
||||
step={128}
|
||||
dataCy="k8sNamespaceCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
inputId="memory-limit"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
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,32 @@
|
|||
import { ProgressBar } from '@@/ProgressBar';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
||||
interface ResourceUsageItemProps {
|
||||
value: number;
|
||||
total: number;
|
||||
annotation?: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ResourceUsageItem({
|
||||
value,
|
||||
total,
|
||||
annotation,
|
||||
label,
|
||||
}: ResourceUsageItemProps) {
|
||||
return (
|
||||
<FormControl label={label}>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<ProgressBar
|
||||
steps={[
|
||||
{
|
||||
value,
|
||||
},
|
||||
]}
|
||||
total={total}
|
||||
/>
|
||||
<div className="text-xs flex shrink-0">{annotation}</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import { Database } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
export function StorageQuotaItem() {
|
||||
return (
|
||||
<div>
|
||||
<FormSectionTitle>
|
||||
<div className="vertical-center text-muted inline-flex gap-1 align-top">
|
||||
<Icon icon={Database} className="!mt-0.5 flex-none" />
|
||||
<span>standard</span>
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
<hr className="mb-0 mt-2 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>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue