1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49: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

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