1
0
Fork 0
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:
Ali 2023-10-11 20:32:02 +01:00 committed by GitHub
parent 31bcba96c6
commit 7218eb0892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1869 additions and 358 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View 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}`,
},
};
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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');
}
}

View file

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

View file

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

View file

@ -4,14 +4,3 @@ export interface Namespaces {
IsSystem: boolean;
};
}
export interface SelfSubjectAccessReviewResponse {
status: {
allowed: boolean;
};
spec: {
resourceAttributes: {
namespace: string;
};
};
}