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

refactor(namespace): migrate namespace edit to react [r8s-125] (#38)

This commit is contained in:
Ali 2024-12-11 10:15:46 +13:00 committed by GitHub
parent 40c7742e46
commit ce7e0d8d60
108 changed files with 3183 additions and 2194 deletions

View file

@ -11,7 +11,7 @@ interface Props {
annotations: Annotation[];
handleAnnotationChange: (
index: number,
key: 'Key' | 'Value',
key: 'key' | 'value',
val: string
) => void;
removeAnnotation: (index: number) => void;
@ -33,7 +33,7 @@ export function AnnotationsForm({
return (
<>
{annotations.map((annotation, i) => (
<div className="row" key={annotation.ID}>
<div className="row" key={annotation.id}>
<div className="form-group col-sm-4 !m-0 !pl-0">
<div className="input-group input-group-sm">
<span className="input-group-addon required">Key</span>
@ -42,16 +42,16 @@ export function AnnotationsForm({
type="text"
className="form-control form-control-sm"
placeholder={placeholder[0]}
defaultValue={annotation.Key}
defaultValue={annotation.key}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleAnnotationChange(i, 'Key', e.target.value)
handleAnnotationChange(i, 'key', e.target.value)
}
data-cy={`annotation-key-${i}`}
/>
</div>
{annotationErrors?.[i]?.Key && (
{annotationErrors?.[i]?.key && (
<FormError className="!mb-0 mt-1">
{annotationErrors[i]?.Key}
{annotationErrors[i]?.key}
</FormError>
)}
</div>
@ -63,16 +63,16 @@ export function AnnotationsForm({
type="text"
className="form-control form-control-sm"
placeholder={placeholder[1]}
defaultValue={annotation.Value}
defaultValue={annotation.value}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
handleAnnotationChange(i, 'Value', e.target.value)
handleAnnotationChange(i, 'value', e.target.value)
}
data-cy={`annotation-value-${i}`}
/>
</div>
{annotationErrors?.[i]?.Value && (
{annotationErrors?.[i]?.value && (
<FormError className="!mb-0 mt-1">
{annotationErrors[i]?.Value}
{annotationErrors[i]?.value}
</FormError>
)}
</div>

View file

@ -1,9 +1,9 @@
import { FormikErrors } from 'formik';
export interface Annotation {
Key: string;
Value: string;
ID: string;
key: string;
value: string;
id: string;
}
export type AnnotationsPayload = Record<string, string>;

View file

@ -11,12 +11,12 @@ export const annotationsSchema: SchemaOf<Annotation[]> = array(
).test(
'unique',
'Duplicate keys are not allowed.',
buildUniquenessTest(() => 'Duplicate keys are not allowed.', 'Key')
buildUniquenessTest(() => 'Duplicate keys are not allowed.', 'key')
);
function getAnnotationValidation(): SchemaOf<Annotation> {
return object({
Key: string()
key: string()
.required('Key is required.')
.test('is-valid', (value, { createError }) => {
if (!value) {
@ -62,7 +62,7 @@ function getAnnotationValidation(): SchemaOf<Annotation> {
}
return true;
}),
Value: string().required('Value is required.'),
ID: string().required('ID is required.'),
value: string().required('Value is required.'),
id: string().required('ID is required.'),
});
}

View file

@ -70,6 +70,9 @@ export async function updateIngressControllerClassMap(
ingressControllerClassMap: IngressControllerClassMap[],
namespace?: string
) {
if (ingressControllerClassMap.length === 0) {
return [];
}
try {
const { data: controllerMaps } = await axios.put<
IngressControllerClassMap[]

View file

@ -10,6 +10,7 @@ type Props = {
resourceId?: string;
/** if undefined, events are fetched for the cluster */
namespace?: string;
noWidget?: boolean;
};
/** ResourceEventsDatatable returns the EventsDatatable for all events that relate to a specific resource id */
@ -17,6 +18,7 @@ export function ResourceEventsDatatable({
storageKey,
resourceId,
namespace,
noWidget = true,
}: Props) {
const tableState = useKubeStore(storageKey, {
id: 'Date',
@ -47,7 +49,7 @@ export function ResourceEventsDatatable({
tableState={tableState}
isLoading={resourceEventsQuery.isLoading}
data-cy="k8sNodeDetail-eventsTable"
noWidget
noWidget={noWidget}
/>
);
}

View file

@ -5,7 +5,6 @@ import { debounce } from 'lodash';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets';
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser';
import { Annotation } from '@/react/kubernetes/annotations/types';
@ -24,6 +23,7 @@ import {
useUpdateIngress,
useIngressControllers,
} from '../queries';
import { useNamespaceServices } from '../../services/useNamespaceServices';
import {
Rule,
@ -410,13 +410,13 @@ export function CreateIngressView() {
const duplicatedAnnotations: string[] = [];
const re = /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/;
rule.Annotations?.forEach((a, i) => {
if (!a.Key) {
if (!a.key) {
errors[`annotations.key[${i}]`] = 'Key is required.';
} else if (duplicatedAnnotations.includes(a.Key)) {
} else if (duplicatedAnnotations.includes(a.key)) {
errors[`annotations.key[${i}]`] =
'Key is a duplicate of an existing one.';
} else {
const key = a.Key.split('/');
const key = a.key.split('/');
if (key.length > 2) {
errors[`annotations.key[${i}]`] =
'Two segments are allowed, separated by a slash (/): a prefix (optional) and a name.';
@ -441,10 +441,10 @@ export function CreateIngressView() {
}
}
}
if (!a.Value) {
if (!a.value) {
errors[`annotations.value[${i}]`] = 'Value is required.';
}
duplicatedAnnotations.push(a.Key);
duplicatedAnnotations.push(a.key);
});
const duplicatedHosts: string[] = [];
@ -677,7 +677,7 @@ export function CreateIngressView() {
function handleAnnotationChange(
index: number,
key: 'Key' | 'Value',
key: 'key' | 'value',
val: string
) {
setIngressRule((prevRules) => {
@ -685,8 +685,8 @@ export function CreateIngressView() {
rules.Annotations = rules.Annotations || [];
rules.Annotations[index] = rules.Annotations[index] || {
Key: '',
Value: '',
key: '',
value: '',
};
rules.Annotations[index][key] = val;
@ -760,22 +760,22 @@ export function CreateIngressView() {
const rule = { ...ingressRule };
const annotation: Annotation = {
Key: '',
Value: '',
ID: uuidv4(),
key: '',
value: '',
id: uuidv4(),
};
switch (type) {
case 'rewrite':
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
annotation.Value = '/$1';
annotation.key = 'nginx.ingress.kubernetes.io/rewrite-target';
annotation.value = '/$1';
break;
case 'regex':
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
annotation.Value = 'true';
annotation.key = 'nginx.ingress.kubernetes.io/use-regex';
annotation.value = 'true';
break;
case 'ingressClass':
annotation.Key = 'kubernetes.io/ingress.class';
annotation.Value = '';
annotation.key = 'kubernetes.io/ingress.class';
annotation.value = '';
break;
default:
break;

View file

@ -73,7 +73,7 @@ interface Props {
) => void;
handleAnnotationChange: (
index: number,
key: 'Key' | 'Value',
key: 'key' | 'value',
val: string
) => void;
handlePathChange: (

View file

@ -83,9 +83,9 @@ export function getAnnotationsForEdit(
Object.keys(annotations).forEach((k) => {
if (ignoreAnnotationsForEdit.indexOf(k) === -1) {
result.push({
Key: k,
Value: annotations[k],
ID: uuidv4(),
key: k,
value: annotations[k],
id: uuidv4(),
});
}
});

View file

@ -32,20 +32,6 @@ export async function getMetricsForNode(
}
}
export async function getMetricsForAllPods(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: pods } = await axios.get(
`kubernetes/${environmentId}/metrics/pods/namespace/${namespace}`
);
return pods;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve metrics for all pods');
}
}
export async function getMetricsForPod(
environmentId: EnvironmentId,
namespace: string,

View file

@ -0,0 +1,9 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys as namespaceQueryKeys } from '@/react/kubernetes/namespaces/queries/queryKeys';
export const queryKeys = {
namespaceMetrics: (environmentId: EnvironmentId, namespaceName: string) => [
...namespaceQueryKeys.namespace(environmentId, namespaceName),
'metrics',
],
};

View file

@ -0,0 +1,34 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { PodMetrics } from '../types';
import { queryKeys } from './query-keys';
export function useMetricsForNamespace<T = PodMetrics>(
environmentId: EnvironmentId,
namespaceName: string,
queryOptions?: UseQueryOptions<PodMetrics, unknown, T>
) {
return useQuery({
queryKey: queryKeys.namespaceMetrics(environmentId, namespaceName),
queryFn: () => getMetricsForNamespace(environmentId, namespaceName),
...queryOptions,
});
}
export async function getMetricsForNamespace(
environmentId: EnvironmentId,
namespaceName: string
) {
try {
const { data: pods } = await axios.get<PodMetrics>(
`kubernetes/${environmentId}/metrics/pods/namespace/${namespaceName}`
);
return pods;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve metrics for all pods');
}
}

View file

@ -1,3 +1,20 @@
export type PodMetrics = {
items: PodMetric[];
};
export type PodMetric = {
containers: ContainerMetric[];
};
type ContainerMetric = {
usage: ResourceUsage;
};
type ResourceUsage = {
cpu: string;
memory: string;
};
export type NodeMetrics = {
items: NodeMetric[];
};

View file

@ -10,18 +10,17 @@ import { useCurrentUser } from '@/react/hooks/useUser';
import { Widget, WidgetBody } from '@@/Widget';
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
import { NamespaceInnerForm } from '../components/NamespaceInnerForm';
import { NamespaceInnerForm } from '../components/NamespaceForm/NamespaceInnerForm';
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
import { useClusterResourceLimitsQuery } from '../queries/useResourceLimitsQuery';
import { useCreateNamespaceMutation } from '../queries/useCreateNamespaceMutation';
import { getNamespaceValidationSchema } from '../components/NamespaceForm/NamespaceForm.validation';
import { transformFormValuesToNamespacePayload } from '../components/NamespaceForm/utils';
import {
CreateNamespaceFormValues,
CreateNamespacePayload,
NamespaceFormValues,
NamespacePayload,
UpdateRegistryPayload,
} from './types';
import { useClusterResourceLimitsQuery } from './queries/useResourceLimitsQuery';
import { getNamespaceValidationSchema } from './CreateNamespaceForm.validation';
import { transformFormValuesToNamespacePayload } from './utils';
import { useCreateNamespaceMutation } from './queries/useCreateNamespaceMutation';
} from '../types';
export function CreateNamespaceForm() {
const router = useRouter();
@ -49,8 +48,8 @@ export function CreateNamespaceForm() {
}
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
const initialValues: CreateNamespaceFormValues = {
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
const initialValues: NamespaceFormValues = {
name: '',
ingressClasses: ingressClasses ?? [],
resourceQuota: {
@ -71,6 +70,7 @@ export function CreateNamespaceForm() {
validateOnMount
validationSchema={getNamespaceValidationSchema(
memoryLimit,
cpuLimit,
namespaceNames
)}
>
@ -80,8 +80,8 @@ export function CreateNamespaceForm() {
</Widget>
);
function handleSubmit(values: CreateNamespaceFormValues, userName: string) {
const createNamespacePayload: CreateNamespacePayload =
function handleSubmit(values: NamespaceFormValues, userName: string) {
const createNamespacePayload: NamespacePayload =
transformFormValuesToNamespacePayload(values, userName);
const updateRegistriesPayload: UpdateRegistryPayload[] =
values.registries.flatMap((registryFormValues) => {
@ -93,7 +93,7 @@ export function CreateNamespaceForm() {
return [];
}
const envNamespacesWithAccess =
selectedRegistry.RegistryAccesses[`${environmentId}`]?.Namespaces ||
selectedRegistry.RegistryAccesses?.[`${environmentId}`]?.Namespaces ||
[];
return {
Id: selectedRegistry.Id,

View file

@ -4,7 +4,7 @@ import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
import {
ResourceQuotaFormValues,
ResourceQuotaPayload,
} from '../components/ResourceQuotaFormSection/types';
} from '../components/NamespaceForm/ResourceQuotaFormSection/types';
export type CreateNamespaceFormValues = {
name: string;

View file

@ -2,14 +2,16 @@ import { ModalType } from '@@/modals';
import { confirm } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils';
export function confirmUpdateNamespace(
quotaWarning: boolean,
ingressWarning: boolean,
registriesWarning: boolean
) {
type Warnings = {
quota: boolean;
ingress: boolean;
registries: boolean;
};
export function confirmUpdateNamespace(warnings: Warnings) {
const message = (
<>
{quotaWarning && (
{warnings.quota && (
<p>
Reducing the quota assigned to an &quot;in-use&quot; namespace may
have unintended consequences, including preventing running
@ -17,13 +19,13 @@ export function confirmUpdateNamespace(
them from running at all.
</p>
)}
{ingressWarning && (
{warnings.ingress && (
<p>
Deactivating ingresses may cause applications to be unaccessible. All
ingress configurations from affected applications will be removed.
</p>
)}
{registriesWarning && (
{warnings.registries && (
<p>
Some registries you removed might be used by one or more applications
inside this environment. Removing the registries access could lead to

View file

@ -1,7 +1,8 @@
import { Code } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Datatable, TableSettingsMenu } from '@@/datatables';
import { useRepeater } from '@@/datatables/useRepeater';
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
import { useTableStateWithStorage } from '@@/datatables/useTableState';
import {
@ -10,20 +11,14 @@ import {
RefreshableTableSettings,
} from '@@/datatables/types';
import { NamespaceApp } from './types';
import { useApplications } from '../../applications/queries/useApplications';
import { useColumns } from './columns';
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
export function NamespaceAppsDatatable({
dataset,
onRefresh,
isLoading,
}: {
dataset: Array<NamespaceApp>;
onRefresh: () => void;
isLoading: boolean;
}) {
export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
const environmentId = useEnvironmentId();
const tableState = useTableStateWithStorage<TableSettings>(
'kube-namespace-apps',
'Name',
@ -31,18 +26,25 @@ export function NamespaceAppsDatatable({
...refreshableSettings(set),
})
);
useRepeater(tableState.autoRefreshRate, onRefresh);
const applicationsQuery = useApplications(environmentId, {
refetchInterval: tableState.autoRefreshRate * 1000,
namespace,
withDependencies: true,
});
const applications = applicationsQuery.data ?? [];
const columns = useColumns();
return (
<Datatable
dataset={dataset}
dataset={applications}
settingsManager={tableState}
columns={columns}
disableSelect
title="Applications running in this namespace"
titleIcon={Code}
isLoading={isLoading}
isLoading={applicationsQuery.isLoading}
renderTableSettings={() => (
<TableSettingsMenu>
<TableSettingsMenuAutoRefresh

View file

@ -0,0 +1,82 @@
import { useCurrentStateAndParams } from '@uirouter/react';
import { AlertTriangle, Code, Layers, History } from 'lucide-react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { PageHeader } from '@@/PageHeader';
import { findSelectedTabIndex, Tab, WidgetTabs } from '@@/Widget/WidgetTabs';
import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon';
import { useEventWarningsCount } from '../../queries/useEvents';
import { NamespaceYAMLEditor } from '../components/NamespaceYamlEditor';
import { ResourceEventsDatatable } from '../../components/EventsDatatable/ResourceEventsDatatable';
import { UpdateNamespaceForm } from './UpdateNamespaceForm';
import { NamespaceAppsDatatable } from './NamespaceAppsDatatable';
export function NamespaceView() {
const stateAndParams = useCurrentStateAndParams();
const {
params: { id: namespace },
} = stateAndParams;
const environmentId = useEnvironmentId();
const eventWarningCount = useEventWarningsCount(environmentId, namespace);
const tabs: Tab[] = [
{
name: 'Namespace',
icon: Layers,
widget: <UpdateNamespaceForm />,
selectedTabParam: 'namespace',
},
{
name: (
<div className="flex items-center gap-x-2">
Events
{eventWarningCount >= 1 && (
<Badge type="warnSecondary">
<Icon icon={AlertTriangle} className="!mr-1" />
{eventWarningCount}
</Badge>
)}
</div>
),
icon: History,
widget: (
<ResourceEventsDatatable
namespace={namespace}
storageKey="kubernetes.namespace.events"
noWidget={false}
/>
),
selectedTabParam: 'events',
},
{
name: 'YAML',
icon: Code,
widget: <NamespaceYAMLEditor />,
selectedTabParam: 'YAML',
},
];
const currentTabIndex = findSelectedTabIndex(stateAndParams, tabs);
return (
<>
<PageHeader
title="Namespace details"
breadcrumbs={[
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
namespace,
]}
reload
/>
<>
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
{tabs[currentTabIndex].widget}
<NamespaceAppsDatatable namespace={namespace} />
</>
</>
);
}

View file

@ -0,0 +1,256 @@
import { Formik } from 'formik';
import { useCurrentStateAndParams, 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 { useCurrentUser } from '@/react/hooks/useUser';
import { Registry } from '@/react/portainer/registries/types/registry';
import { Loading, Widget, WidgetBody } from '@@/Widget';
import { Alert } from '@@/Alert';
import { NamespaceInnerForm } from '../components/NamespaceForm/NamespaceInnerForm';
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
import { useClusterResourceLimitsQuery } from '../queries/useResourceLimitsQuery';
import { NamespaceFormValues, NamespacePayload } from '../types';
import { getNamespaceValidationSchema } from '../components/NamespaceForm/NamespaceForm.validation';
import { transformFormValuesToNamespacePayload } from '../components/NamespaceForm/utils';
import { useNamespaceQuery } from '../queries/useNamespaceQuery';
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
import { ResourceQuotaFormValues } from '../components/NamespaceForm/ResourceQuotaFormSection/types';
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
import { useUpdateNamespaceMutation } from '../queries/useUpdateNamespaceMutation';
import { useNamespaceFormValues } from './useNamespaceFormValues';
import { confirmUpdateNamespace } from './ConfirmUpdateNamespace';
import { createUpdateRegistriesPayload } from './createUpdateRegistriesPayload';
export function UpdateNamespaceForm() {
const {
params: { id: namespaceName },
} = useCurrentStateAndParams();
const router = useRouter();
// for initial values
const { user } = useCurrentUser();
const environmentId = useEnvironmentId();
const environmentQuery = useCurrentEnvironment();
const namespacesQuery = useNamespacesQuery(environmentId);
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
const namespaceQuery = useNamespaceQuery(environmentId, namespaceName, {
params: { withResourceQuota: 'true' },
});
const registriesQuery = useEnvironmentRegistries(environmentId, {
hideDefault: true,
});
const ingressClassesQuery = useIngressControllerClassMapQuery({
environmentId,
namespace: namespaceName,
allowedOnly: true,
});
const storageClasses =
environmentQuery.data?.Kubernetes.Configuration.StorageClasses;
const { data: namespaces } = namespacesQuery;
const { data: resourceLimits } = resourceLimitsQuery;
const { data: namespace } = namespaceQuery;
const { data: registries } = registriesQuery;
const { data: ingressClasses } = ingressClassesQuery;
const updateNamespaceMutation = useUpdateNamespaceMutation(environmentId);
const namespaceNames = Object.keys(namespaces || {});
const memoryLimit = resourceLimits?.Memory ?? 0;
const cpuLimit = resourceLimits?.CPU ?? 0;
const initialValues = useNamespaceFormValues({
namespaceName,
environmentId,
storageClasses,
namespace,
registries,
ingressClasses,
});
const isQueryLoading =
environmentQuery.isLoading ||
resourceLimitsQuery.isLoading ||
namespacesQuery.isLoading ||
namespaceQuery.isLoading ||
registriesQuery.isLoading ||
ingressClassesQuery.isLoading;
const isQueryError =
environmentQuery.isError ||
resourceLimitsQuery.isError ||
namespacesQuery.isError ||
namespaceQuery.isError ||
registriesQuery.isError ||
ingressClassesQuery.isError;
if (isQueryLoading) {
return <Loading />;
}
if (isQueryError) {
return (
<Alert color="error" title="Error">
Error loading namespace
</Alert>
);
}
if (!initialValues) {
return (
<Alert color="warn" title="Warning">
No data found for namespace
</Alert>
);
}
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values, user.Username)}
validateOnMount
validationSchema={getNamespaceValidationSchema(
memoryLimit,
cpuLimit,
namespaceNames
)}
>
{(formikProps) => (
<NamespaceInnerForm
// eslint-disable-next-line react/jsx-props-no-spreading
{...formikProps}
isEdit
/>
)}
</Formik>
</WidgetBody>
</Widget>
</div>
</div>
);
async function handleSubmit(values: NamespaceFormValues, userName: string) {
const createNamespacePayload: NamespacePayload =
transformFormValuesToNamespacePayload(values, userName);
const updateRegistriesPayload = createUpdateRegistriesPayload({
registries,
namespaceName,
newRegistriesValues: values.registries,
initialRegistriesValues: initialValues?.registries || [],
environmentId,
});
// give update warnings if needed
const isNamespaceAccessRemoved = hasNamespaceAccessBeenRemoved(
values.registries,
initialValues?.registries || [],
environmentId,
values.name
);
const isIngressClassesRemoved = hasIngressClassesBeenRemoved(
values.ingressClasses,
initialValues?.ingressClasses || []
);
const warnings = {
quota: hasResourceQuotaBeenReduced(
values.resourceQuota,
initialValues?.resourceQuota
),
ingress: isIngressClassesRemoved,
registries: isNamespaceAccessRemoved,
};
if (Object.values(warnings).some(Boolean)) {
const confirmed = await confirmUpdateNamespace(warnings);
if (!confirmed) {
return;
}
}
// update the namespace
updateNamespaceMutation.mutate(
{
createNamespacePayload,
updateRegistriesPayload,
namespaceIngressControllerPayload: values.ingressClasses,
},
{
onSuccess: () => {
notifySuccess(
'Success',
`Namespace '${values.name}' updated successfully`
);
router.stateService.reload();
},
}
);
}
}
function hasResourceQuotaBeenReduced(
newResourceQuota: ResourceQuotaFormValues,
initialResourceQuota?: ResourceQuotaFormValues
) {
if (!initialResourceQuota) {
return false;
}
// if the new value is an empty string or '0', it's counted as 'unlimited'
const unlimitedValue = String(Number.MAX_SAFE_INTEGER);
return (
(Number(initialResourceQuota.cpu) || unlimitedValue) >
(Number(newResourceQuota.cpu) || unlimitedValue) ||
(Number(initialResourceQuota.memory) || unlimitedValue) >
(Number(newResourceQuota.memory) || unlimitedValue)
);
}
function hasNamespaceAccessBeenRemoved(
newRegistries: Registry[],
initialRegistries: Registry[],
environmentId: number,
namespaceName: string
) {
return initialRegistries.some((oldRegistry) => {
// Check if the namespace was in the old registry's accesses
const isNamespaceInOldAccesses =
oldRegistry.RegistryAccesses?.[`${environmentId}`]?.Namespaces.includes(
namespaceName
);
if (!isNamespaceInOldAccesses) {
return false;
}
// Find the corresponding new registry
const newRegistry = newRegistries.find((r) => r.Id === oldRegistry.Id);
if (!newRegistry) {
return true;
}
// If the registry no longer exists or the namespace is not in its accesses, access has been removed
const isNamespaceInNewAccesses =
newRegistry.RegistryAccesses?.[`${environmentId}`]?.Namespaces.includes(
namespaceName
);
return !isNamespaceInNewAccesses;
});
}
function hasIngressClassesBeenRemoved(
newIngressClasses: IngressControllerClassMap[],
initialIngressClasses: IngressControllerClassMap[]
) {
// go through all old classes and check if their availability has changed
return initialIngressClasses.some((oldClass) => {
const newClass = newIngressClasses.find((c) => c.Name === oldClass.Name);
return newClass?.Availability !== oldClass.Availability;
});
}

View file

@ -2,18 +2,17 @@ import { createColumnHelper } from '@tanstack/react-table';
import _ from 'lodash';
import { useMemo } from 'react';
import { humanize, truncate } from '@/portainer/filters/filters';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { humanize } from '@/portainer/filters/filters';
import { Link } from '@@/Link';
import { ExternalBadge } from '@@/Badge/ExternalBadge';
import { isExternalApplication } from '../../applications/utils';
import { cpuHumanValue } from '../../applications/utils/cpuHumanValue';
import { Application } from '../../applications/ListView/ApplicationsDatatable/types';
import { NamespaceApp } from './types';
const columnHelper = createColumnHelper<NamespaceApp>();
const columnHelper = createColumnHelper<Application>();
export function useColumns() {
const hideStacksQuery = usePublicSettings<boolean>({
@ -27,7 +26,7 @@ export function useColumns() {
columnHelper.accessor('Name', {
header: 'Name',
cell: ({ row: { original: item } }) => (
<>
<div className="flex flex-0">
<Link
to="kubernetes.applications.application"
params={{ name: item.Name, namespace: item.ResourcePool }}
@ -40,7 +39,7 @@ export function useColumns() {
<ExternalBadge />
</div>
)}
</>
</div>
),
}),
!hideStacksQuery.data &&
@ -50,23 +49,34 @@ export function useColumns() {
}),
columnHelper.accessor('Image', {
header: 'Image',
cell: ({ row: { original: item } }) => (
<>
{truncate(item.Image, 64)}
{item.Containers?.length > 1 && (
<>+ {item.Containers.length - 1}</>
)}
</>
cell: ({ getValue }) => (
<div className="max-w-md truncate">{getValue()}</div>
),
}),
columnHelper.accessor('CPU', {
header: 'CPU',
cell: ({ getValue }) => cpuHumanValue(getValue()),
}),
columnHelper.accessor('Memory', {
header: 'Memory',
cell: ({ getValue }) => humanize(getValue()),
}),
columnHelper.accessor(
(row) =>
row.Resource?.CpuRequest
? cpuHumanValue(row.Resource?.CpuRequest)
: '-',
{
header: 'CPU',
cell: ({ getValue }) => getValue(),
}
),
columnHelper.accessor(
(row) =>
row.Resource?.MemoryRequest ? row.Resource?.MemoryRequest : '-',
{
header: 'Memory',
cell: ({ getValue }) => {
const value = getValue();
if (value === '-') {
return value;
}
return humanize(value);
},
}
),
]),
[hideStacksQuery.data]
);

View file

@ -0,0 +1,518 @@
import { createUpdateRegistriesPayload } from './createUpdateRegistriesPayload';
const tests: {
testName: string;
params: Parameters<typeof createUpdateRegistriesPayload>[0];
expected: ReturnType<typeof createUpdateRegistriesPayload>;
}[] = [
{
testName: 'Add new registry',
params: {
registries: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: [],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
namespaceName: 'newns',
newRegistriesValues: [
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: [],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
initialRegistriesValues: [
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
environmentId: 7,
},
expected: [
{
Id: 2,
Namespaces: ['newns'],
},
{
Id: 1,
Namespaces: ['newns'],
},
],
},
{
testName: 'Remove a registry',
params: {
registries: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
namespaceName: 'newns',
newRegistriesValues: [
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
initialRegistriesValues: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
environmentId: 7,
},
expected: [
{
Id: 1,
Namespaces: [],
},
{
Id: 2,
Namespaces: ['newns'],
},
],
},
{
testName: 'Remove all registries',
params: {
registries: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
namespaceName: 'newns',
newRegistriesValues: [],
initialRegistriesValues: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'portainer',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
{
Id: 2,
Type: 3,
Name: 'portainertest',
URL: 'test123.com',
BaseURL: '',
Authentication: false,
Username: '',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'7': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
environmentId: 7,
},
expected: [
{
Id: 1,
Namespaces: [],
},
{
Id: 2,
Namespaces: [],
},
],
},
];
describe('createUpdateRegistriesPayload', () => {
tests.forEach(({ testName, params, expected }) => {
it(`Should return the correct payload: ${testName}`, () => {
expect(createUpdateRegistriesPayload(params)).toEqual(expected);
});
});
});

View file

@ -0,0 +1,50 @@
import { uniqBy } from 'lodash';
import { Registry } from '@/react/portainer/registries/types/registry';
import { UpdateRegistryPayload } from '../types';
export function createUpdateRegistriesPayload({
registries,
namespaceName,
newRegistriesValues,
initialRegistriesValues,
environmentId,
}: {
registries: Registry[] | undefined;
namespaceName: string;
newRegistriesValues: Registry[];
initialRegistriesValues: Registry[];
environmentId: number;
}): UpdateRegistryPayload[] {
if (!registries) {
return [];
}
// Get all unique registries from both initial and new values
const uniqueRegistries = uniqBy(
[...initialRegistriesValues, ...newRegistriesValues],
'Id'
);
const payload = uniqueRegistries.map((registry) => {
const currentNamespaces =
registry.RegistryAccesses?.[`${environmentId}`]?.Namespaces || [];
const existsInNewValues = newRegistriesValues.some(
(r) => r.Id === registry.Id
);
// If registry is in new values, add namespace; if not, remove it
const updatedNamespaces = existsInNewValues
? [...new Set([...currentNamespaces, namespaceName])]
: currentNamespaces.filter((ns) => ns !== namespaceName);
return {
Id: registry.Id,
Namespaces: updatedNamespaces,
};
});
return payload;
}

View file

@ -0,0 +1,247 @@
import { computeInitialValues } from './useNamespaceFormValues';
type NamespaceTestData = {
testName: string;
namespaceData: Parameters<typeof computeInitialValues>[0];
expectedFormValues: ReturnType<typeof computeInitialValues>;
};
// various namespace data from simple to complex
const tests: NamespaceTestData[] = [
{
testName:
'No resource quotas, registries, storage requests or ingress controllers',
namespaceData: {
namespaceName: 'test',
environmentId: 4,
storageClasses: [
{
Name: 'local-path',
AccessModes: ['RWO'],
Provisioner: 'rancher.io/local-path',
AllowVolumeExpansion: false,
},
],
namespace: {
Id: '6110390e-f7cb-4f23-b219-197e4a1d0291',
Name: 'test',
Status: {
phase: 'Active',
},
Annotations: null,
CreationDate: '2024-10-17T17:50:08+13:00',
NamespaceOwner: 'admin',
IsSystem: false,
IsDefault: false,
},
registries: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'aliharriss',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Ecr: {
Region: '',
},
Quay: {
OrganisationName: '',
},
RegistryAccesses: {
'4': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
ingressClasses: [
{
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
},
],
},
expectedFormValues: {
name: 'test',
ingressClasses: [
{
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
},
],
resourceQuota: {
enabled: false,
memory: '0',
cpu: '0',
},
registries: [],
},
},
{
testName:
'With annotations, registry, storage request, resource quota and disabled ingress controller',
namespaceData: {
namespaceName: 'newns',
environmentId: 4,
storageClasses: [
{
Name: 'local-path',
AccessModes: ['RWO'],
Provisioner: 'rancher.io/local-path',
AllowVolumeExpansion: false,
},
],
namespace: {
Id: 'd5c3cb69-bf9b-4625-b754-d7ba6ce2c688',
Name: 'newns',
Status: {
phase: 'Active',
},
Annotations: {
asdf: 'asdf',
},
CreationDate: '2024-10-01T10:20:46+13:00',
NamespaceOwner: 'admin',
IsSystem: false,
IsDefault: false,
ResourceQuota: {
metadata: {},
spec: {
hard: {
'limits.cpu': '800m',
'limits.memory': '768M',
'local-path.storageclass.storage.k8s.io/requests.storage': '1G',
'requests.cpu': '800m',
'requests.memory': '768M',
'services.loadbalancers': '1',
},
},
},
},
registries: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'aliharriss',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'4': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
ingressClasses: [
{
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
},
],
},
expectedFormValues: {
name: 'newns',
ingressClasses: [
{
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
},
],
resourceQuota: {
enabled: true,
memory: '768',
cpu: '0.8',
},
registries: [
{
Id: 1,
Type: 6,
Name: 'dockerhub',
URL: 'docker.io',
BaseURL: '',
Authentication: true,
Username: 'aliharriss',
Gitlab: {
ProjectId: 0,
InstanceURL: '',
ProjectPath: '',
},
Quay: {
OrganisationName: '',
},
Ecr: {
Region: '',
},
RegistryAccesses: {
'4': {
UserAccessPolicies: null,
TeamAccessPolicies: null,
Namespaces: ['newns'],
},
},
Github: {
UseOrganisation: false,
OrganisationName: '',
},
},
],
},
},
];
describe('useNamespaceFormValues', () => {
tests.forEach((test) => {
it(`should return the correct form values: ${test.testName}`, () => {
const formValues = computeInitialValues(test.namespaceData);
expect(formValues).toEqual(test.expectedFormValues);
});
});
});

View file

@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { StorageClass } from '@/react/portainer/environments/types';
import { Registry } from '@/react/portainer/registries/types/registry';
import { NamespaceFormValues, PortainerNamespace } from '../types';
import { megaBytesValue, parseCPU } from '../resourceQuotaUtils';
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
interface ComputeInitialValuesParams {
namespaceName: string;
environmentId: number;
storageClasses?: StorageClass[];
namespace?: PortainerNamespace;
registries?: Registry[];
ingressClasses?: IngressControllerClassMap[];
}
export function computeInitialValues({
namespaceName,
environmentId,
namespace,
registries,
ingressClasses,
}: ComputeInitialValuesParams): NamespaceFormValues | null {
if (!namespace) {
return null;
}
const memory = namespace.ResourceQuota?.spec?.hard?.['requests.memory'] ?? '';
const cpu = namespace.ResourceQuota?.spec?.hard?.['requests.cpu'] ?? '';
const registriesUsed = registries?.filter(
(registry) =>
registry.RegistryAccesses?.[`${environmentId}`]?.Namespaces.includes(
namespaceName
)
);
return {
name: namespaceName,
ingressClasses: ingressClasses ?? [],
resourceQuota: {
enabled: !!memory || !!cpu,
memory: `${megaBytesValue(memory)}`,
cpu: `${parseCPU(cpu)}`,
},
registries: registriesUsed ?? [],
};
}
export function useNamespaceFormValues({
namespaceName,
environmentId,
storageClasses,
namespace,
registries,
ingressClasses,
}: ComputeInitialValuesParams): NamespaceFormValues | null {
return useMemo(
() =>
computeInitialValues({
namespaceName,
environmentId,
storageClasses,
namespace,
registries,
ingressClasses,
}),
[
storageClasses,
namespace,
registries,
namespaceName,
ingressClasses,
environmentId,
]
);
}

View file

@ -91,7 +91,7 @@ export function NamespacesDatatable() {
function TableActions({
selectedItems,
namespaces: namespacesQueryData,
namespaces,
}: {
selectedItems: PortainerNamespace[];
namespaces?: PortainerNamespace[];
@ -168,18 +168,21 @@ function TableActions({
// Plain invalidation / refetching is confusing because namespaces hang in a terminating state
// instead, optimistically update the cache manually to hide the deleting (terminating) namespaces
const remainingNamespaces = deletedNamespaces.reduce(
(acc, ns) => {
const index = acc.findIndex((n) => n.Name === ns);
if (index !== -1) {
acc.splice(index, 1);
}
return acc;
},
[...(namespaces ?? [])]
);
queryClient.setQueryData(
queryKeys.list(environmentId, {
withResourceQuota: true,
}),
() =>
deletedNamespaces.reduce(
(acc, ns) => {
delete acc[ns as keyof typeof acc];
return acc;
},
{ ...namespacesQueryData }
)
() => remainingNamespaces
);
},
}

View file

@ -1,14 +1,15 @@
import { string, object, array, SchemaOf } from 'yup';
import { registriesValidationSchema } from '../components/RegistriesFormSection/registriesValidationSchema';
import { getResourceQuotaValidationSchema } from '../components/ResourceQuotaFormSection/getResourceQuotaValidationSchema';
import { NamespaceFormValues } from '../../types';
import { CreateNamespaceFormValues } from './types';
import { registriesValidationSchema } from './RegistriesFormSection/registriesValidationSchema';
import { getResourceQuotaValidationSchema } from './ResourceQuotaFormSection/getResourceQuotaValidationSchema';
export function getNamespaceValidationSchema(
memoryLimit: number,
cpuLimit: number,
namespaceNames: string[]
): SchemaOf<CreateNamespaceFormValues> {
): SchemaOf<NamespaceFormValues> {
return object({
name: string()
.matches(
@ -19,7 +20,7 @@ export function getNamespaceValidationSchema(
// must not have the same name as an existing namespace
.notOneOf(namespaceNames, 'Name must be unique.')
.required('Name is required.'),
resourceQuota: getResourceQuotaValidationSchema(memoryLimit),
resourceQuota: getResourceQuotaValidationSchema(memoryLimit, cpuLimit),
// ingress classes table is constrained already, and doesn't need validation
ingressClasses: array(),
registries: registriesValidationSchema,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,9 @@
import { CreateNamespaceFormValues, CreateNamespacePayload } from './types';
import { NamespaceFormValues, NamespacePayload } from '../../types';
export function transformFormValuesToNamespacePayload(
createNamespaceFormValues: CreateNamespaceFormValues,
createNamespaceFormValues: NamespaceFormValues,
owner: string
): CreateNamespacePayload {
): NamespacePayload {
const memoryInBytes =
Number(createNamespaceFormValues.resourceQuota.memory) * 10 ** 6;
return {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,12 @@
import { compact } from 'lodash';
import { EnvironmentId } from '@/react/portainer/environments/types';
export const queryKeys = {
list: (environmentId: number, options?: { withResourceQuota?: boolean }) =>
list: (
environmentId: EnvironmentId,
options?: { withResourceQuota?: boolean }
) =>
compact([
'environments',
environmentId,
@ -9,7 +14,7 @@ export const queryKeys = {
'namespaces',
options?.withResourceQuota,
]),
namespace: (environmentId: number, namespace: string) =>
namespace: (environmentId: EnvironmentId, namespace: string) =>
[
'environments',
environmentId,
@ -17,4 +22,13 @@ export const queryKeys = {
'namespaces',
namespace,
] as const,
namespaceYAML: (environmentId: EnvironmentId, namespace: string) =>
[
'environments',
environmentId,
'kubernetes',
'namespaces',
namespace,
'yaml',
] as const,
};

View file

@ -1,23 +1,25 @@
import { useMutation } from '@tanstack/react-query';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { withGlobalError, withInvalidate } 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';
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
import { updateIngressControllerClassMap } from '../../cluster/ingressClass/useIngressControllerClassMap';
import { Namespaces, NamespacePayload, UpdateRegistryPayload } from '../types';
import { queryKeys } from './queryKeys';
export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation(
async ({
createNamespacePayload,
updateRegistriesPayload,
namespaceIngressControllerPayload,
}: {
createNamespacePayload: CreateNamespacePayload;
createNamespacePayload: NamespacePayload;
updateRegistriesPayload: UpdateRegistryPayload[];
namespaceIngressControllerPayload: IngressControllerClassMap[];
}) => {
@ -51,7 +53,8 @@ export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
]);
},
{
...withError('Unable to create namespace'),
...withGlobalError('Unable to create namespace'),
...withInvalidate(queryClient, [queryKeys.list(environmentId)]),
}
);
}
@ -59,7 +62,7 @@ export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
// createNamespace is used to create a namespace using the Portainer backend
async function createNamespace(
environmentId: EnvironmentId,
payload: CreateNamespacePayload
payload: NamespacePayload
) {
try {
const { data: ns } = await axios.post<Namespaces>(

View file

@ -4,10 +4,11 @@ import { PortainerNamespace } from '../types';
import { useNamespaceQuery } from './useNamespaceQuery';
export function useIsSystemNamespace(namespace: string) {
export function useIsSystemNamespace(namespace: string, enabled = true) {
const envId = useEnvironmentId();
const query = useNamespaceQuery(envId, namespace, {
select: (namespace) => namespace.IsSystem,
enabled,
});
return !!query.data;

View file

@ -8,19 +8,26 @@ import { PortainerNamespace } from '../types';
import { queryKeys } from './queryKeys';
type QueryParams = 'withResourceQuota';
export function useNamespaceQuery<T = PortainerNamespace>(
environmentId: EnvironmentId,
namespace: string,
{
select,
enabled,
params,
}: {
select?(namespace: PortainerNamespace): T;
params?: Record<QueryParams, string>;
enabled?: boolean;
} = {}
) {
return useQuery(
queryKeys.namespace(environmentId, namespace),
() => getNamespace(environmentId, namespace),
() => getNamespace(environmentId, namespace, params),
{
enabled: !!environmentId && !!namespace && enabled,
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get namespace.');
},
@ -32,11 +39,15 @@ export function useNamespaceQuery<T = PortainerNamespace>(
// getNamespace is used to retrieve a namespace using the Portainer backend
export async function getNamespace(
environmentId: EnvironmentId,
namespace: string
namespace: string,
params?: Record<QueryParams, string>
) {
try {
const { data: ns } = await axios.get<PortainerNamespace>(
`kubernetes/${environmentId}/namespaces/${namespace}`
`kubernetes/${environmentId}/namespaces/${namespace}`,
{
params,
}
);
return ns;
} catch (e) {

View file

@ -0,0 +1,71 @@
import { useQuery } from '@tanstack/react-query';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { isFulfilled } from '@/portainer/helpers/promise-utils';
import { parseKubernetesAxiosError } from '../../axiosError';
import { generateResourceQuotaName } from '../resourceQuotaUtils';
import { queryKeys } from './queryKeys';
/**
* Gets the YAML for a namespace and its resource quota directly from the K8s proxy API.
*/
export function useNamespaceYAML(
environmentId: EnvironmentId,
namespaceName: string
) {
return useQuery({
queryKey: queryKeys.namespaceYAML(environmentId, namespaceName),
queryFn: () => composeNamespaceYAML(environmentId, namespaceName),
});
}
async function composeNamespaceYAML(
environmentId: EnvironmentId,
namespace: string
) {
const settledPromises = await Promise.allSettled([
getNamespaceYAML(environmentId, namespace),
getResourceQuotaYAML(environmentId, namespace),
]);
const resolvedPromises = settledPromises.filter(isFulfilled);
return resolvedPromises.map((p) => p.value).join('\n---\n');
}
async function getNamespaceYAML(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: yaml } = await axios.get<string>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}`,
{
headers: {
Accept: 'application/yaml',
},
}
);
return yaml;
} catch (error) {
throw parseKubernetesAxiosError(error, 'Unable to retrieve namespace YAML');
}
}
async function getResourceQuotaYAML(
environmentId: EnvironmentId,
namespace: string
) {
const resourceQuotaName = generateResourceQuotaName(namespace);
try {
const { data: yaml } = await axios.get<string>(
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/resourcequotas/${resourceQuotaName}`,
{ headers: { Accept: 'application/yaml' } }
);
return yaml;
} catch (e) {
// silently ignore if resource quota does not exist
return null;
}
}

View file

@ -0,0 +1,34 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { queryKeys } from './queryKeys';
export function useToggleSystemNamespaceMutation(
environmentId: EnvironmentId,
namespaceName: string
) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (isSystem: boolean) =>
toggleSystemNamespace(environmentId, namespaceName, isSystem),
...withInvalidate(queryClient, [
queryKeys.namespace(environmentId, namespaceName),
]),
...withGlobalError('Failed to update namespace'),
});
}
async function toggleSystemNamespace(
environmentId: EnvironmentId,
namespaceName: string,
system: boolean
) {
const response = await axios.put(
`/kubernetes/${environmentId}/namespaces/${namespaceName}/system`,
{ system }
);
return response.data;
}

View file

@ -0,0 +1,83 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifyError } from '@/portainer/services/notifications';
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
import { updateIngressControllerClassMap } from '../../cluster/ingressClass/useIngressControllerClassMap';
import { Namespaces, NamespacePayload, UpdateRegistryPayload } from '../types';
import { queryKeys } from './queryKeys';
export function useUpdateNamespaceMutation(environmentId: EnvironmentId) {
const queryClient = useQueryClient();
return useMutation(
async ({
createNamespacePayload,
updateRegistriesPayload,
namespaceIngressControllerPayload,
}: {
createNamespacePayload: NamespacePayload;
updateRegistriesPayload: UpdateRegistryPayload[];
namespaceIngressControllerPayload: IngressControllerClassMap[];
}) => {
const { Name: namespaceName } = createNamespacePayload;
const updatedNamespace = await updateNamespace(
environmentId,
namespaceName,
createNamespacePayload
);
// collect promises
const updateRegistriesPromises = updateRegistriesPayload.map(
({ Id, Namespaces }) =>
updateEnvironmentRegistryAccess(environmentId, Id, {
Namespaces,
})
);
const updateIngressControllerPromise = updateIngressControllerClassMap(
environmentId,
namespaceIngressControllerPayload,
createNamespacePayload.Name
);
const results = await Promise.allSettled([
updateIngressControllerPromise,
...updateRegistriesPromises,
]);
// Check for any failures in the additional updates
const failures = results.filter((result) => result.status === 'rejected');
failures.forEach((failure) => {
notifyError(
'Unable to update namespace',
undefined,
failure.reason as string
);
});
return updatedNamespace;
},
{
...withGlobalError('Unable to update namespace'),
...withInvalidate(queryClient, [queryKeys.list(environmentId)]),
}
);
}
// updateNamespace is used to update a namespace using the Portainer backend
async function updateNamespace(
environmentId: EnvironmentId,
namespace: string,
payload: NamespacePayload
) {
try {
const { data: ns } = await axios.put<Namespaces>(
`kubernetes/${environmentId}/namespaces/${namespace}`,
payload
);
return ns;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create namespace');
}
}

View file

@ -0,0 +1,17 @@
import { parseCPU } from './resourceQuotaUtils';
// test parseCPU with '', '2', '100m', '100u'
describe('parseCPU', () => {
it('should return 0 for empty string', () => {
expect(parseCPU('')).toBe(0);
});
it('should return 2 for 2', () => {
expect(parseCPU('2')).toBe(2);
});
it('should return 0.1 for 100m', () => {
expect(parseCPU('100m')).toBe(0.1);
});
it('should return 0.0001 for 100u', () => {
expect(parseCPU('100u')).toBe(0.0001);
});
});

View file

@ -0,0 +1,64 @@
import { endsWith } from 'lodash';
import filesizeParser from 'filesize-parser';
export const KubernetesPortainerResourceQuotaPrefix = 'portainer-rq-';
export function generateResourceQuotaName(name: string) {
return `${KubernetesPortainerResourceQuotaPrefix}${name}`;
}
/**
* parseCPU converts a CPU string to a number in cores.
* It supports m (milli), u (micro), n (nano), p (pico) suffixes.
*
* If given an empty string, it returns 0.
*/
export function parseCPU(cpu: string) {
let res = parseInt(cpu, 10);
if (Number.isNaN(res)) {
return 0;
}
if (endsWith(cpu, 'm')) {
// milli
res /= 1000;
} else if (endsWith(cpu, 'u')) {
// micro
res /= 1000000;
} else if (endsWith(cpu, 'n')) {
// nano
res /= 1000000000;
} else if (endsWith(cpu, 'p')) {
// pico
res /= 1000000000000;
}
return res;
}
export function terabytesValue(value: string | number) {
return gigabytesValue(value) / 1000;
}
export function gigabytesValue(value: string | number) {
return megaBytesValue(value) / 1000;
}
export function megaBytesValue(value: string | number) {
return Math.floor(safeFilesizeParser(value, 10) / 1000 / 1000);
}
export function bytesValue(mem: string | number) {
return safeFilesizeParser(mem, 10) * 1000 * 1000;
}
/**
* The default base is 2, you can use base 10 if you want
* https://github.com/patrickkettner/filesize-parser#readme
*/
function safeFilesizeParser(value: string | number, base: 2 | 10 = 2) {
if (!value || Number.isNaN(value)) {
return 0;
}
return filesizeParser(value, { base });
}

View file

@ -1,10 +1,17 @@
import { NamespaceStatus, ResourceQuota } from 'kubernetes-types/core/v1';
import { Registry } from '@/react/portainer/registries/types/registry';
import { IngressControllerClassMap } from '../cluster/ingressClass/types';
import { ResourceQuotaFormValues } from './components/NamespaceForm/ResourceQuotaFormSection/types';
export interface PortainerNamespace {
Id: string;
Name: string;
Status: NamespaceStatus;
CreationDate: number;
Annotations: Record<string, string> | null;
CreationDate: string;
NamespaceOwner: string;
IsSystem: boolean;
IsDefault: boolean;
@ -14,3 +21,21 @@ export interface PortainerNamespace {
// type returned via the internal portainer namespaces api, with simplified fields
// it is a record currently (legacy reasons), but it should be an array
export type Namespaces = Record<string, PortainerNamespace>;
export type NamespaceFormValues = {
name: string;
resourceQuota: ResourceQuotaFormValues;
ingressClasses: IngressControllerClassMap[];
registries: Registry[];
};
export type NamespacePayload = {
Name: string;
Owner: string;
ResourceQuota: ResourceQuotaFormValues;
};
export type UpdateRegistryPayload = {
Id: number;
Namespaces: string[];
};

View file

@ -1,30 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { getServices } from './service';
import { Service } from './types';
export function useNamespaceServices(
environmentId: EnvironmentId,
namespace: string
) {
return useQuery(
[
'environments',
environmentId,
'kubernetes',
'namespaces',
namespace,
'services',
],
() =>
namespace ? getServices(environmentId, namespace) : ([] as Service[]),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get services');
},
}
);
}

View file

@ -1,23 +0,0 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Service } from './types';
export async function getServices(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: services } = await axios.get<Service[]>(
buildUrl(environmentId, namespace)
);
return services;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve services');
}
}
function buildUrl(environmentId: EnvironmentId, namespace: string) {
const url = `kubernetes/${environmentId}/namespaces/${namespace}/services`;
return url;
}

View file

@ -1,33 +0,0 @@
export interface Port {
Name: string;
Protocol: string;
Port: number;
TargetPort: number;
NodePort?: number;
}
export interface IngressIP {
IP: string;
}
export interface LoadBalancer {
Ingress: IngressIP[];
}
export interface Status {
LoadBalancer: LoadBalancer;
}
export interface Service {
Annotations?: Document;
CreationTimestamp?: string;
Labels?: Document;
Name: string;
Namespace: string;
UID: string;
AllocateLoadBalancerNodePorts?: boolean;
Ports?: Port[];
Selector?: Document;
Type: string;
Status?: Status;
}

View file

@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import axios from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { withGlobalError } from '@/react-tools/react-query';
import { parseKubernetesAxiosError } from '../axiosError';
@ -71,7 +71,7 @@ export function useEvents(
queryKeys.base(environmentId, { params, namespace }),
() => getEvents(environmentId, { params, namespace }),
{
...withError('Unable to retrieve events'),
...withGlobalError('Unable to retrieve events'),
refetchInterval() {
return queryOptions?.autoRefreshRate ?? false;
},
@ -79,6 +79,17 @@ export function useEvents(
);
}
export function useEventWarningsCount(
environmentId: EnvironmentId,
namespace?: string
) {
const resourceEventsQuery = useEvents(environmentId, {
namespace,
});
const events = resourceEventsQuery.data || [];
return events.filter((e) => e.type === 'Warning').length;
}
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
return namespace
? `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/events`

View file

@ -26,6 +26,7 @@ import { Service } from '../../types';
import { columns } from './columns';
import { createStore } from './datatable-store';
import { ServiceRowData } from './types';
const storageKey = 'k8sServicesDatatable';
const settingsStore = createStore(storageKey);
@ -104,7 +105,7 @@ export function ServicesDatatable() {
function useServicesRowData(
services: Service[],
namespaces?: Namespaces
): Service[] {
): ServiceRowData[] {
return useMemo(
() =>
services.map((service) => ({
@ -119,9 +120,12 @@ function useServicesRowData(
// needed to apply custom styling to the row cells and not globally.
// required in the AC's for this ticket.
function servicesRenderRow(row: Row<Service>, highlightedItemId?: string) {
function servicesRenderRow(
row: Row<ServiceRowData>,
highlightedItemId?: string
) {
return (
<Table.Row<Service>
<Table.Row<ServiceRowData>
cells={row.getVisibleCells()}
className={clsx('[&>td]:!py-4 [&>td]:!align-top', {
active: highlightedItemId === row.id,
@ -136,7 +140,7 @@ interface SelectedService {
}
type TableActionsProps = {
selectedItems: Service[];
selectedItems: ServiceRowData[];
};
function TableActions({ selectedItems }: TableActionsProps) {

View file

@ -4,7 +4,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Link } from '@@/Link';
import { Service } from '../../../types';
import { ServiceRowData } from '../types';
import { columnHelper } from './helper';
@ -17,7 +17,7 @@ export const application = columnHelper.accessor(
}
);
function Cell({ row, getValue }: CellContext<Service, string>) {
function Cell({ row, getValue }: CellContext<ServiceRowData, string>) {
const appName = getValue();
const environmentId = useEnvironmentId();

View file

@ -1,6 +1,6 @@
import { CellContext } from '@tanstack/react-table';
import { Service } from '../../../types';
import { ServiceRowData } from '../types';
import { ExternalIPLink } from './ExternalIPLink';
import { columnHelper } from './helper';
@ -46,7 +46,7 @@ export const externalIP = columnHelper.accessor(
}
);
function Cell({ row }: CellContext<Service, string>) {
function Cell({ row }: CellContext<ServiceRowData, string>) {
if (row.original.Type === 'ExternalName') {
if (row.original.ExternalName) {
const linkTo = `http://${row.original.ExternalName}`;
@ -106,7 +106,7 @@ function Cell({ row }: CellContext<Service, string>) {
// calculate the scheme based on the ports of the service
// favour https over http.
function getSchemeAndPort(svc: Service): [string, number] {
function getSchemeAndPort(svc: ServiceRowData): [string, number] {
let scheme = '';
let servicePort = 0;

View file

@ -1,5 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Service } from '../../../types';
import { ServiceRowData } from '../types';
export const columnHelper = createColumnHelper<Service>();
export const columnHelper = createColumnHelper<ServiceRowData>();

View file

@ -4,7 +4,7 @@ import { filterHOC } from '@/react/components/datatables/Filter';
import { Link } from '@@/Link';
import { Service } from '../../../types';
import { ServiceRowData } from '../types';
import { columnHelper } from './helper';
@ -31,6 +31,9 @@ export const namespace = columnHelper.accessor('Namespace', {
filter: filterHOC('Filter by namespace'),
},
enableColumnFilter: true,
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
filterValue.length === 0 || filterValue.includes(row.original.Namespace),
filterFn: (
row: Row<ServiceRowData>,
columnId: string,
filterValue: string[]
) => filterValue.length === 0 || filterValue.includes(row.original.Namespace),
});

View file

@ -2,7 +2,7 @@ import { Row } from '@tanstack/react-table';
import { filterHOC } from '@@/datatables/Filter';
import { Service } from '../../../types';
import { ServiceRowData } from '../types';
import { columnHelper } from './helper';
@ -13,6 +13,9 @@ export const type = columnHelper.accessor('Type', {
filter: filterHOC('Filter by type'),
},
enableColumnFilter: true,
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
filterValue.length === 0 || filterValue.includes(row.original.Type),
filterFn: (
row: Row<ServiceRowData>,
columnId: string,
filterValue: string[]
) => filterValue.length === 0 || filterValue.includes(row.original.Type),
});

View file

@ -0,0 +1,5 @@
import { Service } from '../../types';
export type ServiceRowData = Service & {
IsSystem: boolean;
};

View file

@ -26,18 +26,17 @@ export type ServiceType =
export type Service = {
Name: string;
UID: string;
Type: ServiceType;
Namespace: string;
Annotations?: Record<string, string>;
CreationDate: string;
Labels?: Record<string, string>;
Type: ServiceType;
AllocateLoadBalancerNodePorts?: boolean;
Ports?: Array<ServicePort>;
Selector?: Record<string, string>;
ClusterIPs?: Array<string>;
IngressStatus?: Array<IngressStatus>;
Applications?: Application[];
ClusterIPs?: Array<string>;
ExternalName?: string;
ExternalIPs?: Array<string>;
CreationDate: string;
Applications?: Application[];
IsSystem: boolean;
};

View file

@ -0,0 +1,43 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { Service } from './types';
export function useNamespaceServices<T = Service[]>(
environmentId: EnvironmentId,
namespace: string,
queryOptions?: UseQueryOptions<Service[], unknown, T>
) {
return useQuery({
queryKey: [
'environments',
environmentId,
'kubernetes',
'namespaces',
namespace,
'services',
],
queryFn: () => getServices(environmentId, namespace),
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get services');
},
...queryOptions,
});
}
export async function getServices(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: services } = await axios.get<Service[]>(
`kubernetes/${environmentId}/namespaces/${namespace}/services`
);
return services;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve services');
}
}

View file

@ -13,7 +13,7 @@ export function parseCpu(cpu: string) {
export function prepareAnnotations(annotations?: Annotation[]) {
const result = annotations?.reduce(
(acc, a) => {
acc[a.Key] = a.Value;
acc[a.key] = a.value;
return acc;
},
{} as Record<string, string>

View file

@ -10,7 +10,7 @@ export interface VolumeViewModel {
storageClass: {
Name: string;
};
Storage?: unknown;
Storage?: string | number;
CreationDate?: string;
ApplicationOwner?: string;
IsExternal?: boolean;

View file

@ -0,0 +1,51 @@
import { useQuery } from '@tanstack/react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { withGlobalError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { K8sVolumeInfo } from '../types';
import { queryKeys } from './query-keys';
import { convertToVolumeViewModels } from './useVolumesQuery';
// useQuery to get a list of all volumes in a cluster
export function useNamespaceVolumes(
environmentId: EnvironmentId,
namespace: string,
queryOptions?: {
refetchInterval?: number;
withApplications?: boolean;
}
) {
return useQuery(
queryKeys.volumes(environmentId),
() =>
getNamespaceVolumes(environmentId, namespace, {
withApplications: queryOptions?.withApplications ?? false,
}),
{
enabled: !!namespace,
refetchInterval: queryOptions?.refetchInterval,
select: convertToVolumeViewModels,
...withGlobalError('Unable to retrieve volumes'),
}
);
}
// get all volumes in a cluster
async function getNamespaceVolumes(
environmentId: EnvironmentId,
namespace: string,
params?: { withApplications: boolean }
) {
try {
const { data } = await axios.get<K8sVolumeInfo[]>(
`/kubernetes/${environmentId}/namespaces/${namespace}/volumes`,
{ params }
);
return data;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve volumes');
}
}

View file

@ -49,7 +49,7 @@ export function useAllStoragesQuery(
);
}
// get all volumes from a namespace
// get all volumes in a cluster
export async function getAllVolumes(
environmentId: EnvironmentId,
params?: { withApplications: boolean }
@ -65,7 +65,7 @@ export async function getAllVolumes(
}
}
function convertToVolumeViewModels(
export function convertToVolumeViewModels(
volumes: K8sVolumeInfo[]
): VolumeViewModel[] {
return volumes.map((volume) => {