mirror of
https://github.com/portainer/portainer.git
synced 2025-08-02 12:25:22 +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,119 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { NamespaceInnerForm } from '../components/NamespaceInnerForm';
|
||||
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
|
||||
|
||||
import {
|
||||
CreateNamespaceFormValues,
|
||||
CreateNamespacePayload,
|
||||
UpdateRegistryPayload,
|
||||
} from './types';
|
||||
import { useClusterResourceLimitsQuery } from './queries/useResourceLimitsQuery';
|
||||
import { getNamespaceValidationSchema } from './CreateNamespaceForm.validation';
|
||||
import { transformFormValuesToNamespacePayload } from './utils';
|
||||
import { useCreateNamespaceMutation } from './queries/useCreateNamespaceMutation';
|
||||
|
||||
export function CreateNamespaceForm() {
|
||||
const router = useRouter();
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: environment, ...environmentQuery } = useCurrentEnvironment();
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const { data: registries } = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
// for namespace create, show ingress classes that are allowed in the current environment.
|
||||
// the ingressClasses show the none option, so we don't need to add it here.
|
||||
const { data: ingressClasses } = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
allowedOnly: true,
|
||||
});
|
||||
|
||||
const { data: namespaces } = useNamespacesQuery(environmentId);
|
||||
const namespaceNames = Object.keys(namespaces || {});
|
||||
|
||||
const createNamespaceMutation = useCreateNamespaceMutation(environmentId);
|
||||
|
||||
if (resourceLimitsQuery.isLoading || environmentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
const initialValues: CreateNamespaceFormValues = {
|
||||
name: '',
|
||||
ingressClasses: ingressClasses ?? [],
|
||||
resourceQuota: {
|
||||
enabled: false,
|
||||
memory: '0',
|
||||
cpu: '0',
|
||||
},
|
||||
registries: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validateOnMount
|
||||
validationSchema={getNamespaceValidationSchema(
|
||||
memoryLimit,
|
||||
namespaceNames
|
||||
)}
|
||||
>
|
||||
{NamespaceInnerForm}
|
||||
</Formik>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
function handleSubmit(values: CreateNamespaceFormValues) {
|
||||
const createNamespacePayload: CreateNamespacePayload =
|
||||
transformFormValuesToNamespacePayload(values);
|
||||
const updateRegistriesPayload: UpdateRegistryPayload[] =
|
||||
values.registries.flatMap((registryFormValues) => {
|
||||
// find the matching registry from the cluster registries
|
||||
const selectedRegistry = registries?.find(
|
||||
(registry) => registryFormValues.Id === registry.Id
|
||||
);
|
||||
if (!selectedRegistry) {
|
||||
return [];
|
||||
}
|
||||
const envNamespacesWithAccess =
|
||||
selectedRegistry.RegistryAccesses[`${environmentId}`]?.Namespaces ||
|
||||
[];
|
||||
return {
|
||||
Id: selectedRegistry.Id,
|
||||
Namespaces: [...envNamespacesWithAccess, values.name],
|
||||
};
|
||||
});
|
||||
|
||||
createNamespaceMutation.mutate(
|
||||
{
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload: values.ingressClasses,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
`Namespace '${values.name}' created successfully`
|
||||
);
|
||||
router.stateService.go('kubernetes.resourcePools');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { string, object, array, SchemaOf } from 'yup';
|
||||
|
||||
import { registriesValidationSchema } from '../components/RegistriesFormSection/registriesValidationSchema';
|
||||
import { getResourceQuotaValidationSchema } from '../components/ResourceQuotaFormSection/getResourceQuotaValidationSchema';
|
||||
|
||||
import { CreateNamespaceFormValues } from './types';
|
||||
|
||||
export function getNamespaceValidationSchema(
|
||||
memoryLimit: number,
|
||||
namespaceNames: string[]
|
||||
): SchemaOf<CreateNamespaceFormValues> {
|
||||
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),
|
||||
// ingress classes table is constrained already, and doesn't need validation
|
||||
ingressClasses: array(),
|
||||
registries: registriesValidationSchema,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { CreateNamespaceForm } from './CreateNamespaceForm';
|
||||
|
||||
export function CreateNamespaceView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations: 'K8sResourcePoolsW',
|
||||
forceEnvironmentId: environmentId,
|
||||
adminOnlyCE: !isBE,
|
||||
},
|
||||
{
|
||||
to: 'kubernetes.resourcePools',
|
||||
params: {
|
||||
id: environmentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<PageHeader
|
||||
title="Create a namespace"
|
||||
breadcrumbs="Create a namespace"
|
||||
reload
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<CreateNamespaceForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
1
app/react/kubernetes/namespaces/CreateView/index.tsx
Normal file
1
app/react/kubernetes/namespaces/CreateView/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { CreateNamespaceView } from './CreateNamespaceView';
|
|
@ -0,0 +1,83 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { IngressControllerClassMap } from '../../../cluster/ingressClass/types';
|
||||
import { updateIngressControllerClassMap } from '../../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { Namespaces } from '../../types';
|
||||
import { CreateNamespacePayload, UpdateRegistryPayload } from '../types';
|
||||
|
||||
export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
|
||||
return useMutation(
|
||||
async ({
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload,
|
||||
}: {
|
||||
createNamespacePayload: CreateNamespacePayload;
|
||||
updateRegistriesPayload: UpdateRegistryPayload[];
|
||||
namespaceIngressControllerPayload: IngressControllerClassMap[];
|
||||
}) => {
|
||||
try {
|
||||
// create the namespace first, so that it exists before referencing it in the registry access request
|
||||
await createNamespace(environmentId, createNamespacePayload);
|
||||
} catch (e) {
|
||||
throw new Error(e as string);
|
||||
}
|
||||
|
||||
// collect promises
|
||||
const updateRegistriesPromises = updateRegistriesPayload.map(
|
||||
({ Id, Namespaces }) =>
|
||||
updateEnvironmentRegistryAccess(environmentId, Id, {
|
||||
Namespaces,
|
||||
})
|
||||
);
|
||||
const updateIngressControllerPromise =
|
||||
namespaceIngressControllerPayload.length > 0
|
||||
? updateIngressControllerClassMap(
|
||||
environmentId,
|
||||
namespaceIngressControllerPayload,
|
||||
createNamespacePayload.Name
|
||||
)
|
||||
: Promise.resolve();
|
||||
|
||||
// return combined promises
|
||||
return Promise.allSettled([
|
||||
updateIngressControllerPromise,
|
||||
...updateRegistriesPromises,
|
||||
]);
|
||||
},
|
||||
{
|
||||
...withError('Unable to create namespace'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// createNamespace is used to create a namespace using the Portainer backend
|
||||
async function createNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
payload: CreateNamespacePayload
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.post<Namespaces>(
|
||||
buildUrl(environmentId),
|
||||
payload
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create namespace');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||
let url = `kubernetes/${environmentId}/namespaces`;
|
||||
|
||||
if (namespace) {
|
||||
url += `/${namespace}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
type K8sNodeLimits = {
|
||||
CPU: number;
|
||||
Memory: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* useClusterResourceLimitsQuery is used to retrieve the total resource limits for a cluster, minus the allocated resources taken by existing namespaces
|
||||
* @returns the available resource limits for the cluster
|
||||
* */
|
||||
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'max_resource_limits'],
|
||||
() => getResourceLimits(environmentId),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get resource limits');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getResourceLimits(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: limits } = await axios.get<K8sNodeLimits>(
|
||||
`/kubernetes/${environmentId}/max_resource_limits`
|
||||
);
|
||||
return limits;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to retrieve resource limits');
|
||||
}
|
||||
}
|
24
app/react/kubernetes/namespaces/CreateView/types.ts
Normal file
24
app/react/kubernetes/namespaces/CreateView/types.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
|
||||
import {
|
||||
ResourceQuotaFormValues,
|
||||
ResourceQuotaPayload,
|
||||
} from '../components/ResourceQuotaFormSection/types';
|
||||
|
||||
export type CreateNamespaceFormValues = {
|
||||
name: string;
|
||||
resourceQuota: ResourceQuotaFormValues;
|
||||
ingressClasses: IngressControllerClassMap[];
|
||||
registries: Registry[];
|
||||
};
|
||||
|
||||
export type CreateNamespacePayload = {
|
||||
Name: string;
|
||||
ResourceQuota: ResourceQuotaPayload;
|
||||
};
|
||||
|
||||
export type UpdateRegistryPayload = {
|
||||
Id: number;
|
||||
Namespaces: string[];
|
||||
};
|
16
app/react/kubernetes/namespaces/CreateView/utils.ts
Normal file
16
app/react/kubernetes/namespaces/CreateView/utils.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { CreateNamespaceFormValues, CreateNamespacePayload } from './types';
|
||||
|
||||
export function transformFormValuesToNamespacePayload(
|
||||
createNamespaceFormValues: CreateNamespaceFormValues
|
||||
): CreateNamespacePayload {
|
||||
const memoryInBytes =
|
||||
Number(createNamespaceFormValues.resourceQuota.memory) * 10 ** 6;
|
||||
return {
|
||||
Name: createNamespaceFormValues.name,
|
||||
ResourceQuota: {
|
||||
enabled: createNamespaceFormValues.resourceQuota.enabled,
|
||||
cpu: createNamespaceFormValues.resourceQuota.cpu,
|
||||
memory: `${memoryInBytes}`,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
interface Props {
|
||||
value: Registry[];
|
||||
onChange(value: readonly Registry[]): void;
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options: Registry[];
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function CreateNamespaceRegistriesSelector({
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
|
@ -26,7 +28,7 @@ export function CreateNamespaceRegistriesSelector({
|
|||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registry"
|
||||
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';
|
|
@ -0,0 +1,51 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
interface SelfSubjectAccessReviewResponse {
|
||||
status: {
|
||||
allowed: boolean;
|
||||
};
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
namespace: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* getSelfSubjectAccessReview is used to retrieve the self subject access review for a given namespace.
|
||||
* It's great to use this to determine if a user has access to a namespace.
|
||||
* @returns the self subject access review for the given namespace
|
||||
* */
|
||||
export async function getSelfSubjectAccessReview(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
verb = 'list',
|
||||
resource = 'deployments',
|
||||
group = 'apps'
|
||||
) {
|
||||
try {
|
||||
const { data: accessReview } =
|
||||
await axios.post<SelfSubjectAccessReviewResponse>(
|
||||
`endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`,
|
||||
{
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
group,
|
||||
resource,
|
||||
verb,
|
||||
namespace: namespaceName,
|
||||
},
|
||||
},
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
}
|
||||
);
|
||||
return accessReview;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve self subject access review'
|
||||
);
|
||||
}
|
||||
}
|
37
app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
Normal file
37
app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Namespaces } from '../types';
|
||||
|
||||
export function useNamespaceQuery(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'namespaces', namespace],
|
||||
() => getNamespace(environmentId, namespace),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get namespace.');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// getNamespace is used to retrieve a namespace using the Portainer backend
|
||||
export async function getNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.get<Namespaces>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}`
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespace');
|
||||
}
|
||||
}
|
|
@ -1,17 +1,13 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import {
|
||||
getNamespaces,
|
||||
getNamespace,
|
||||
getSelfSubjectAccessReview,
|
||||
} from './service';
|
||||
import { Namespaces } from './types';
|
||||
import { Namespaces } from '../types';
|
||||
import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview';
|
||||
|
||||
export function useNamespaces(
|
||||
export function useNamespacesQuery(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
|
@ -46,14 +42,14 @@ export function useNamespaces(
|
|||
);
|
||||
}
|
||||
|
||||
export function useNamespace(environmentId: EnvironmentId, namespace: string) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'namespaces', namespace],
|
||||
() => getNamespace(environmentId, namespace),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get namespace.');
|
||||
},
|
||||
}
|
||||
);
|
||||
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching
|
||||
async function getNamespaces(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: namespaces } = await axios.get<Namespaces>(
|
||||
`kubernetes/${environmentId}/namespaces`
|
||||
);
|
||||
return namespaces;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
|
||||
}
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Namespaces, SelfSubjectAccessReviewResponse } from './types';
|
||||
|
||||
// getNamespace is used to retrieve a namespace using the Portainer backend
|
||||
export async function getNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.get<Namespaces>(
|
||||
buildUrl(environmentId, namespace)
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespace');
|
||||
}
|
||||
}
|
||||
|
||||
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching
|
||||
export async function getNamespaces(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: namespaces } = await axios.get<Namespaces>(
|
||||
buildUrl(environmentId)
|
||||
);
|
||||
return namespaces;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSelfSubjectAccessReview(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
verb = 'list',
|
||||
resource = 'deployments',
|
||||
group = 'apps'
|
||||
) {
|
||||
try {
|
||||
const { data: accessReview } =
|
||||
await axios.post<SelfSubjectAccessReviewResponse>(
|
||||
`endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`,
|
||||
{
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
group,
|
||||
resource,
|
||||
verb,
|
||||
namespace: namespaceName,
|
||||
},
|
||||
},
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
}
|
||||
);
|
||||
return accessReview;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve self subject access review'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||
let url = `kubernetes/${environmentId}/namespaces`;
|
||||
|
||||
if (namespace) {
|
||||
url += `/${namespace}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -4,14 +4,3 @@ export interface Namespaces {
|
|||
IsSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfSubjectAccessReviewResponse {
|
||||
status: {
|
||||
allowed: boolean;
|
||||
};
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
namespace: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue