(
+ `kubernetes/${environmentId}/metrics/pods/namespace/${namespaceName}`
+ );
+ return pods;
+ } catch (e) {
+ throw parseAxiosError(e, 'Unable to retrieve metrics for all pods');
+ }
+}
diff --git a/app/react/kubernetes/metrics/types.ts b/app/react/kubernetes/metrics/types.ts
index 78223dda0..fa7118b8c 100644
--- a/app/react/kubernetes/metrics/types.ts
+++ b/app/react/kubernetes/metrics/types.ts
@@ -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[];
};
diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx
index 134e3b6be..d09f5a761 100644
--- a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx
+++ b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx
@@ -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() {
);
- 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,
diff --git a/app/react/kubernetes/namespaces/CreateView/types.ts b/app/react/kubernetes/namespaces/CreateView/types.ts
index 9aa04b20c..7b4102833 100644
--- a/app/react/kubernetes/namespaces/CreateView/types.ts
+++ b/app/react/kubernetes/namespaces/CreateView/types.ts
@@ -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;
diff --git a/app/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace.tsx b/app/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace.tsx
index b80fc2454..00fa8107e 100644
--- a/app/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace.tsx
+++ b/app/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace.tsx
@@ -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 && (
Reducing the quota assigned to an "in-use" namespace may
have unintended consequences, including preventing running
@@ -17,13 +19,13 @@ export function confirmUpdateNamespace(
them from running at all.
)}
- {ingressWarning && (
+ {warnings.ingress && (
Deactivating ingresses may cause applications to be unaccessible. All
ingress configurations from affected applications will be removed.
)}
- {registriesWarning && (
+ {warnings.registries && (
Some registries you removed might be used by one or more applications
inside this environment. Removing the registries access could lead to
diff --git a/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx b/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx
index e0805c1de..1ab17c5d7 100644
--- a/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx
+++ b/app/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable.tsx
@@ -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;
- onRefresh: () => void;
- isLoading: boolean;
-}) {
+export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
+ const environmentId = useEnvironmentId();
const tableState = useTableStateWithStorage(
'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 (
(
,
+ selectedTabParam: 'namespace',
+ },
+ {
+ name: (
+
+ Events
+ {eventWarningCount >= 1 && (
+
+
+ {eventWarningCount}
+
+ )}
+
+ ),
+ icon: History,
+ widget: (
+
+ ),
+ selectedTabParam: 'events',
+ },
+ {
+ name: 'YAML',
+ icon: Code,
+ widget: ,
+ selectedTabParam: 'YAML',
+ },
+ ];
+ const currentTabIndex = findSelectedTabIndex(stateAndParams, tabs);
+
+ return (
+ <>
+
+ <>
+
+ {tabs[currentTabIndex].widget}
+
+ >
+ >
+ );
+}
diff --git a/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.tsx b/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.tsx
new file mode 100644
index 000000000..632fba952
--- /dev/null
+++ b/app/react/kubernetes/namespaces/ItemView/UpdateNamespaceForm.tsx
@@ -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 ;
+ }
+
+ if (isQueryError) {
+ return (
+
+ Error loading namespace
+
+ );
+ }
+
+ if (!initialValues) {
+ return (
+
+ No data found for namespace
+
+ );
+ }
+
+ return (
+
+
+
+
+ handleSubmit(values, user.Username)}
+ validateOnMount
+ validationSchema={getNamespaceValidationSchema(
+ memoryLimit,
+ cpuLimit,
+ namespaceNames
+ )}
+ >
+ {(formikProps) => (
+
+ )}
+
+
+
+
+
+ );
+
+ 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;
+ });
+}
diff --git a/app/react/kubernetes/namespaces/ItemView/columns.tsx b/app/react/kubernetes/namespaces/ItemView/columns.tsx
index f29c084bf..18a7e30f1 100644
--- a/app/react/kubernetes/namespaces/ItemView/columns.tsx
+++ b/app/react/kubernetes/namespaces/ItemView/columns.tsx
@@ -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();
+const columnHelper = createColumnHelper();
export function useColumns() {
const hideStacksQuery = usePublicSettings({
@@ -27,7 +26,7 @@ export function useColumns() {
columnHelper.accessor('Name', {
header: 'Name',
cell: ({ row: { original: item } }) => (
- <>
+
)}
- >
+
),
}),
!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 }) => (
+ {getValue()}
),
}),
- 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]
);
diff --git a/app/react/kubernetes/namespaces/ItemView/createUpdateRegistriesPayload.test.ts b/app/react/kubernetes/namespaces/ItemView/createUpdateRegistriesPayload.test.ts
new file mode 100644
index 000000000..f4c574840
--- /dev/null
+++ b/app/react/kubernetes/namespaces/ItemView/createUpdateRegistriesPayload.test.ts
@@ -0,0 +1,518 @@
+import { createUpdateRegistriesPayload } from './createUpdateRegistriesPayload';
+
+const tests: {
+ testName: string;
+ params: Parameters[0];
+ expected: ReturnType;
+}[] = [
+ {
+ 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);
+ });
+ });
+});
diff --git a/app/react/kubernetes/namespaces/ItemView/createUpdateRegistriesPayload.ts b/app/react/kubernetes/namespaces/ItemView/createUpdateRegistriesPayload.ts
new file mode 100644
index 000000000..523af1ba8
--- /dev/null
+++ b/app/react/kubernetes/namespaces/ItemView/createUpdateRegistriesPayload.ts
@@ -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;
+}
diff --git a/app/react/kubernetes/namespaces/ItemView/useNamespaceFormValues.test.ts b/app/react/kubernetes/namespaces/ItemView/useNamespaceFormValues.test.ts
new file mode 100644
index 000000000..623a4993c
--- /dev/null
+++ b/app/react/kubernetes/namespaces/ItemView/useNamespaceFormValues.test.ts
@@ -0,0 +1,247 @@
+import { computeInitialValues } from './useNamespaceFormValues';
+
+type NamespaceTestData = {
+ testName: string;
+ namespaceData: Parameters[0];
+ expectedFormValues: ReturnType;
+};
+
+// 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);
+ });
+ });
+});
diff --git a/app/react/kubernetes/namespaces/ItemView/useNamespaceFormValues.ts b/app/react/kubernetes/namespaces/ItemView/useNamespaceFormValues.ts
new file mode 100644
index 000000000..b3f0202d7
--- /dev/null
+++ b/app/react/kubernetes/namespaces/ItemView/useNamespaceFormValues.ts
@@ -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,
+ ]
+ );
+}
diff --git a/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx b/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx
index 17092a653..7307f830e 100644
--- a/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx
+++ b/app/react/kubernetes/namespaces/ListView/NamespacesDatatable.tsx
@@ -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
);
},
}
diff --git a/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/LoadBalancerFormSection.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/LoadBalancerFormSection/LoadBalancerFormSection.tsx
similarity index 100%
rename from app/react/kubernetes/namespaces/components/LoadBalancerFormSection/LoadBalancerFormSection.tsx
rename to app/react/kubernetes/namespaces/components/NamespaceForm/LoadBalancerFormSection/LoadBalancerFormSection.tsx
diff --git a/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/index.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/LoadBalancerFormSection/index.ts
similarity index 100%
rename from app/react/kubernetes/namespaces/components/LoadBalancerFormSection/index.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/LoadBalancerFormSection/index.ts
diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.validation.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceForm.validation.tsx
similarity index 67%
rename from app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.validation.tsx
rename to app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceForm.validation.tsx
index 1eb8572f6..9c6f5acfb 100644
--- a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.validation.tsx
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceForm.validation.tsx
@@ -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 {
+): SchemaOf {
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,
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceInnerForm.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceInnerForm.tsx
new file mode 100644
index 000000000..99ea225d8
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceInnerForm.tsx
@@ -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 & { 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 (
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceSummary.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceSummary.tsx
new file mode 100644
index 000000000..3c1a36aea
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/NamespaceSummary.tsx
@@ -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 (
+
+
+
+
+ Portainer will execute the following Kubernetes actions.
+
+
+
+
+
+ {isCreatingNamespace && (
+
+ Create a Namespace named{' '}
+ {values.name}
+
+ )}
+ {isCreatingResourceQuota && (
+
+ Create a ResourceQuota named{' '}
+ portainer-rq-{values.name}
+
+ )}
+ {isUpdatingResourceQuota && (
+
+ Update a ResourceQuota named{' '}
+ portainer-rq-{values.name}
+
+ )}
+ {isDeletingResourceQuota && (
+
+ Delete a ResourceQuota named{' '}
+ portainer-rq-{values.name}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesFormSection.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/RegistriesFormSection.tsx
similarity index 75%
rename from app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesFormSection.tsx
rename to app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/RegistriesFormSection.tsx
index 597a4b86d..e491fb101 100644
--- a/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesFormSection.tsx
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/RegistriesFormSection.tsx
@@ -16,22 +16,30 @@ type Props = {
values: MultiValue;
onChange: (value: MultiValue) => void;
errors?: string | string[] | FormikErrors[];
+ 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 (
-
- Define which registries can be used by users who have access to this
- namespace.
-
+ {!isEditingDisabled && (
+
+ Define which registries can be used by users who have access to this
+ namespace.
+
+ )}
{registriesQuery.isLoading && (
@@ -43,6 +51,7 @@ export function RegistriesFormSection({ values, onChange, errors }: Props) {
onChange={(registries) => onChange(registries)}
options={registriesQuery.data}
inputId="registries"
+ isEditingDisabled={isEditingDisabled}
/>
)}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/RegistriesSelector.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/RegistriesSelector.tsx
new file mode 100644
index 000000000..4cf9d5f8f
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/RegistriesSelector.tsx
@@ -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;
+ onChange(value: MultiValue): void;
+ options?: Registry[];
+ inputId?: string;
+ isEditingDisabled?: boolean;
+}
+
+export function RegistriesSelector({
+ value,
+ onChange,
+ options = [],
+ inputId,
+ isEditingDisabled,
+}: Props) {
+ const { isPureAdmin } = useCurrentUser();
+
+ if (options.length === 0) {
+ return (
+
+ {isPureAdmin ? (
+
+ No registries available. Head over to the{' '}
+
+ registry view
+ {' '}
+ to define a container registry.
+
+ ) : (
+
+ No registries available. Contact your administrator to create a
+ container registry.
+
+ )}
+
+ );
+ }
+
+ if (isEditingDisabled) {
+ return (
+
+ {value.length === 0 ? 'None' : value.map((v) => v.Name).join(', ')}
+
+ );
+ }
+
+ return (
+ 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}
+ />
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/RegistriesFormSection/index.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/index.ts
similarity index 100%
rename from app/react/kubernetes/namespaces/components/RegistriesFormSection/index.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/index.ts
diff --git a/app/react/kubernetes/namespaces/components/RegistriesFormSection/registriesValidationSchema.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/registriesValidationSchema.ts
similarity index 100%
rename from app/react/kubernetes/namespaces/components/RegistriesFormSection/registriesValidationSchema.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/RegistriesFormSection/registriesValidationSchema.ts
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
new file mode 100644
index 000000000..f3412aaba
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
@@ -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;
+ 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 (
+
+ {!isEditingDisabled && (
+ <>
+
+ A resource quota sets boundaries on the compute resources a
+ namespace can use. It's good practice to set a quota for a
+ namespace to manage resources effectively. Alternatively, you can
+ disable assigning a quota for unrestricted access (not recommended).
+
+
+
+
+ onChange({ ...values, enabled })}
+ />
+
+
+ >
+ )}
+
+ {(values.enabled || !enableResourceOverCommit) && !isEditingDisabled && (
+
+ Resource Limits
+ {(!cpuLimit || !memoryLimit) && (
+
+ Not enough resources available in the cluster to apply a resource
+ reservation.
+
+ )}
+
+
+ {memoryLimit >= 0 && (
+
+ onChange({ ...values, memory: `${value}` })
+ }
+ max={memoryLimit}
+ min={0}
+ step={128}
+ dataCy="k8sNamespaceCreate-memoryLimit"
+ visibleTooltip
+ inputId="memory-limit"
+ />
+ )}
+
+
+
+ {cpuLimit >= 0 && (
+ {
+ if (Array.isArray(cpu)) {
+ return;
+ }
+ onChange({ ...values, cpu: cpu.toString() });
+ }}
+ dataCy="k8sNamespaceCreate-cpuLimitSlider"
+ visibleTooltip
+ />
+ )}
+
+
+ {cpuLimit && memoryLimit && typeof errors === 'string' ? (
+ {errors}
+ ) : null}
+
+ )}
+ {namespaceName && isEdit && (
+
+ )}
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceReservationUsage.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceReservationUsage.tsx
new file mode 100644
index 000000000..adbe920d6
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/ResourceReservationUsage.tsx
@@ -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 (
+ <>
+ Resource reservation
+
+ Resource reservation represents the total amount of resource assigned to
+ all the applications deployed inside this namespace.
+
+ {!!usedResourceQuota && memoryQuota > 0 && (
+
+ )}
+ {!!namespaceMetrics && memoryQuota > 0 && (
+
+ )}
+ {!!usedResourceQuota && cpuQuota > 0 && (
+
+ )}
+ {!!namespaceMetrics && cpuQuota > 0 && (
+
+ )}
+ >
+ );
+}
+
+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;
+}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts
new file mode 100644
index 000000000..827759a18
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts
@@ -0,0 +1,58 @@
+import { boolean, string, object, SchemaOf, TestContext } from 'yup';
+
+import { ResourceQuotaFormValues } from './types';
+
+export function getResourceQuotaValidationSchema(
+ memoryLimit: number,
+ cpuLimit: number
+): SchemaOf {
+ 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) {
+ 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;
+ }
+}
diff --git a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/index.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/index.ts
similarity index 100%
rename from app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/index.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/index.ts
diff --git a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/types.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/types.ts
similarity index 82%
rename from app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/types.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/types.ts
index 765bc3d04..4a0fda91b 100644
--- a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/types.ts
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/types.ts
@@ -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;
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useResourceQuotaUsed.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useResourceQuotaUsed.ts
new file mode 100644
index 000000000..7b936d654
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ResourceQuotaFormSection/useResourceQuotaUsed.ts
@@ -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'] ?? ''
+ ),
+ };
+}
diff --git a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaFormSection.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/StorageQuotaFormSection.tsx
similarity index 62%
rename from app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaFormSection.tsx
rename to app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/StorageQuotaFormSection.tsx
index eb97ff7c6..454f226ec 100644
--- a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaFormSection.tsx
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/StorageQuotaFormSection.tsx
@@ -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 (
@@ -13,7 +19,9 @@ export function StorageQuotaFormSection() {
this namespace.
-
+ {storageClasses.map((storageClass) => (
+
+ ))}
);
}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/StorageQuotaItem.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/StorageQuotaItem.tsx
new file mode 100644
index 000000000..f0ee7ed5f
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/StorageQuotaItem.tsx
@@ -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 (
+
+
+
+
+ {storageClass.Name}
+
+
+
+
+
+
+ {}}
+ featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
+ />
+
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/index.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/index.ts
similarity index 100%
rename from app/react/kubernetes/namespaces/components/StorageQuotaFormSection/index.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/StorageQuotaFormSection/index.ts
diff --git a/app/react/kubernetes/namespaces/components/NamespaceForm/ToggleSystemNamespaceButton.tsx b/app/react/kubernetes/namespaces/components/NamespaceForm/ToggleSystemNamespaceButton.tsx
new file mode 100644
index 000000000..d220444e3
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/ToggleSystemNamespaceButton.tsx
@@ -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 (
+
+ {isSystemNamespace ? 'Unmark as system' : 'Mark as system'}
+
+ );
+
+ 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);
+ });
+}
diff --git a/app/react/kubernetes/namespaces/CreateView/utils.ts b/app/react/kubernetes/namespaces/components/NamespaceForm/utils.ts
similarity index 72%
rename from app/react/kubernetes/namespaces/CreateView/utils.ts
rename to app/react/kubernetes/namespaces/components/NamespaceForm/utils.ts
index 9e3c9379d..fe9b90647 100644
--- a/app/react/kubernetes/namespaces/CreateView/utils.ts
+++ b/app/react/kubernetes/namespaces/components/NamespaceForm/utils.ts
@@ -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 {
diff --git a/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx b/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx
deleted file mode 100644
index 878117403..000000000
--- a/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx
+++ /dev/null
@@ -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) {
- 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 (
-
- );
-}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceSummary.tsx b/app/react/kubernetes/namespaces/components/NamespaceSummary.tsx
deleted file mode 100644
index 3ec92b25b..000000000
--- a/app/react/kubernetes/namespaces/components/NamespaceSummary.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- Portainer will execute the following Kubernetes actions.
-
-
-
-
-
-
- Create a Namespace named{' '}
- {values.name}
-
- {values.resourceQuota.enabled && (
-
- Create a ResourceQuota named{' '}
- portainer-rq-{values.name}
-
- )}
-
-
-
- );
-}
diff --git a/app/react/kubernetes/namespaces/components/NamespaceYamlEditor.tsx b/app/react/kubernetes/namespaces/components/NamespaceYamlEditor.tsx
new file mode 100644
index 000000000..6442cd570
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/NamespaceYamlEditor.tsx
@@ -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 (
+
+
+
+
+ Loading namespace YAML...
+
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector.tsx b/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector.tsx
deleted file mode 100644
index 81cbb6e85..000000000
--- a/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector.tsx
+++ /dev/null
@@ -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;
- onChange(value: MultiValue): void;
- options?: Registry[];
- inputId?: string;
-}
-
-export function RegistriesSelector({
- value,
- onChange,
- options = [],
- inputId,
-}: Props) {
- const { isPureAdmin } = useCurrentUser();
-
- return (
- <>
- {options.length === 0 && (
-
- {isPureAdmin ? (
-
- No registries available. Head over to the{' '}
-
- registry view
- {' '}
- to define a container registry.
-
- ) : (
-
- No registries available. Contact your administrator to create a
- container registry.
-
- )}
-
- )}
- 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"
- />
- >
- );
-}
diff --git a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
deleted file mode 100644
index a7f3be7d0..000000000
--- a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx
+++ /dev/null
@@ -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;
-}
-
-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 (
-
-
- A resource quota sets boundaries on the compute resources a namespace
- can use. It's good practice to set a quota for a namespace to
- manage resources effectively. Alternatively, you can disable assigning a
- quota for unrestricted access (not recommended).
-
-
- onChange({ ...values, enabled })}
- />
-
- {(values.enabled || !enableResourceOverCommit) && (
-
-
- Resource Limits
-
-
- {(!cpuLimit || !memoryLimit) && (
-
- Not enough resources available in the cluster to apply a resource
- reservation.
-
- )}
-
- {/* keep the FormError component present, but invisible to avoid layout shift */}
- {cpuLimit && memoryLimit ? (
-
- {/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */}
- {typeof errors === 'string' ? errors : 'error'}
-
- ) : null}
-
-
-
- {memoryLimit >= 0 && (
-
- onChange({ ...values, memory: `${value}` })
- }
- max={memoryLimit}
- step={128}
- dataCy="k8sNamespaceCreate-memoryLimit"
- visibleTooltip
- inputId="memory-limit"
- />
- )}
- {errors?.memory && (
- {errors.memory}
- )}
-
-
-
-
-
- {
- if (Array.isArray(cpu)) {
- return;
- }
- onChange({ ...values, cpu: cpu.toString() });
- }}
- dataCy="k8sNamespaceCreate-cpuLimitSlider"
- visibleTooltip
- />
-
-
-
- )}
-
- );
-}
diff --git a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts
deleted file mode 100644
index d7565aa53..000000000
--- a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { boolean, string, object, SchemaOf, TestContext } from 'yup';
-
-import { ResourceQuotaFormValues } from './types';
-
-export function getResourceQuotaValidationSchema(
- memoryLimit: number
-): SchemaOf {
- 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) {
- 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;
- }
-}
diff --git a/app/react/kubernetes/namespaces/components/ResourceUsageItem.tsx b/app/react/kubernetes/namespaces/components/ResourceUsageItem.tsx
new file mode 100644
index 000000000..647464ba9
--- /dev/null
+++ b/app/react/kubernetes/namespaces/components/ResourceUsageItem.tsx
@@ -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 (
+
+
+
+ );
+}
diff --git a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaItem.tsx b/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaItem.tsx
deleted file mode 100644
index 4de76917d..000000000
--- a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaItem.tsx
+++ /dev/null
@@ -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 (
-
-
-
-
- standard
-
-
-
-
-
- {}}
- featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
- />
-
-
-
- );
-}
diff --git a/app/react/kubernetes/namespaces/queries/queryKeys.ts b/app/react/kubernetes/namespaces/queries/queryKeys.ts
index 67f2a4566..ecfe4ea58 100644
--- a/app/react/kubernetes/namespaces/queries/queryKeys.ts
+++ b/app/react/kubernetes/namespaces/queries/queryKeys.ts
@@ -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,
};
diff --git a/app/react/kubernetes/namespaces/CreateView/queries/useCreateNamespaceMutation.ts b/app/react/kubernetes/namespaces/queries/useCreateNamespaceMutation.ts
similarity index 75%
rename from app/react/kubernetes/namespaces/CreateView/queries/useCreateNamespaceMutation.ts
rename to app/react/kubernetes/namespaces/queries/useCreateNamespaceMutation.ts
index 717d0979a..638d2977c 100644
--- a/app/react/kubernetes/namespaces/CreateView/queries/useCreateNamespaceMutation.ts
+++ b/app/react/kubernetes/namespaces/queries/useCreateNamespaceMutation.ts
@@ -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(
diff --git a/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts b/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts
index f9e806524..2b59ac9cb 100644
--- a/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts
+++ b/app/react/kubernetes/namespaces/queries/useIsSystemNamespace.ts
@@ -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;
diff --git a/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
index b14c3d625..194016774 100644
--- a/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
+++ b/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
@@ -8,19 +8,26 @@ import { PortainerNamespace } from '../types';
import { queryKeys } from './queryKeys';
+type QueryParams = 'withResourceQuota';
+
export function useNamespaceQuery(
environmentId: EnvironmentId,
namespace: string,
{
select,
+ enabled,
+ params,
}: {
select?(namespace: PortainerNamespace): T;
+ params?: Record;
+ 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(
// getNamespace is used to retrieve a namespace using the Portainer backend
export async function getNamespace(
environmentId: EnvironmentId,
- namespace: string
+ namespace: string,
+ params?: Record
) {
try {
const { data: ns } = await axios.get(
- `kubernetes/${environmentId}/namespaces/${namespace}`
+ `kubernetes/${environmentId}/namespaces/${namespace}`,
+ {
+ params,
+ }
);
return ns;
} catch (e) {
diff --git a/app/react/kubernetes/namespaces/queries/useNamespaceYAML.ts b/app/react/kubernetes/namespaces/queries/useNamespaceYAML.ts
new file mode 100644
index 000000000..436b200be
--- /dev/null
+++ b/app/react/kubernetes/namespaces/queries/useNamespaceYAML.ts
@@ -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(
+ `/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(
+ `/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;
+ }
+}
diff --git a/app/react/kubernetes/namespaces/CreateView/queries/useResourceLimitsQuery.ts b/app/react/kubernetes/namespaces/queries/useResourceLimitsQuery.ts
similarity index 100%
rename from app/react/kubernetes/namespaces/CreateView/queries/useResourceLimitsQuery.ts
rename to app/react/kubernetes/namespaces/queries/useResourceLimitsQuery.ts
diff --git a/app/react/kubernetes/namespaces/queries/useToggleSystemNamespace.ts b/app/react/kubernetes/namespaces/queries/useToggleSystemNamespace.ts
new file mode 100644
index 000000000..eab570ef4
--- /dev/null
+++ b/app/react/kubernetes/namespaces/queries/useToggleSystemNamespace.ts
@@ -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;
+}
diff --git a/app/react/kubernetes/namespaces/queries/useUpdateNamespaceMutation.ts b/app/react/kubernetes/namespaces/queries/useUpdateNamespaceMutation.ts
new file mode 100644
index 000000000..c281a4b60
--- /dev/null
+++ b/app/react/kubernetes/namespaces/queries/useUpdateNamespaceMutation.ts
@@ -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(
+ `kubernetes/${environmentId}/namespaces/${namespace}`,
+ payload
+ );
+ return ns;
+ } catch (e) {
+ throw parseAxiosError(e as Error, 'Unable to create namespace');
+ }
+}
diff --git a/app/react/kubernetes/namespaces/resourceQuotaUtils.test.ts b/app/react/kubernetes/namespaces/resourceQuotaUtils.test.ts
new file mode 100644
index 000000000..4376e996a
--- /dev/null
+++ b/app/react/kubernetes/namespaces/resourceQuotaUtils.test.ts
@@ -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);
+ });
+});
diff --git a/app/react/kubernetes/namespaces/resourceQuotaUtils.ts b/app/react/kubernetes/namespaces/resourceQuotaUtils.ts
new file mode 100644
index 000000000..b19879cf5
--- /dev/null
+++ b/app/react/kubernetes/namespaces/resourceQuotaUtils.ts
@@ -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 });
+}
diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts
index 922e74a15..ba4abb744 100644
--- a/app/react/kubernetes/namespaces/types.ts
+++ b/app/react/kubernetes/namespaces/types.ts
@@ -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 | 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;
+
+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[];
+};
diff --git a/app/react/kubernetes/networks/services/queries.ts b/app/react/kubernetes/networks/services/queries.ts
deleted file mode 100644
index 00b798e9a..000000000
--- a/app/react/kubernetes/networks/services/queries.ts
+++ /dev/null
@@ -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');
- },
- }
- );
-}
diff --git a/app/react/kubernetes/networks/services/service.ts b/app/react/kubernetes/networks/services/service.ts
deleted file mode 100644
index 5ea7c27e9..000000000
--- a/app/react/kubernetes/networks/services/service.ts
+++ /dev/null
@@ -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(
- 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;
-}
diff --git a/app/react/kubernetes/networks/services/types.ts b/app/react/kubernetes/networks/services/types.ts
deleted file mode 100644
index 1ed154eba..000000000
--- a/app/react/kubernetes/networks/services/types.ts
+++ /dev/null
@@ -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;
-}
diff --git a/app/react/kubernetes/queries/useEvents.ts b/app/react/kubernetes/queries/useEvents.ts
index 870a468ca..87d38eb94 100644
--- a/app/react/kubernetes/queries/useEvents.ts
+++ b/app/react/kubernetes/queries/useEvents.ts
@@ -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`
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx
index 86e717128..4786eaf2f 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx
@@ -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, highlightedItemId?: string) {
+function servicesRenderRow(
+ row: Row,
+ highlightedItemId?: string
+) {
return (
-
+
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) {
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx
index ba643739e..05b01d9cd 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/application.tsx
@@ -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) {
+function Cell({ row, getValue }: CellContext) {
const appName = getValue();
const environmentId = useEnvironmentId();
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx
index a392d5fff..6a9a10be6 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/externalIP.tsx
@@ -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) {
+function Cell({ row }: CellContext) {
if (row.original.Type === 'ExternalName') {
if (row.original.ExternalName) {
const linkTo = `http://${row.original.ExternalName}`;
@@ -106,7 +106,7 @@ function Cell({ row }: CellContext) {
// 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;
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts
index 1debacd16..b6b4547cf 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/helper.ts
@@ -1,5 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
-import { Service } from '../../../types';
+import { ServiceRowData } from '../types';
-export const columnHelper = createColumnHelper();
+export const columnHelper = createColumnHelper();
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx
index 9c37c96c7..050e24b00 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/namespace.tsx
@@ -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, columnId: string, filterValue: string[]) =>
- filterValue.length === 0 || filterValue.includes(row.original.Namespace),
+ filterFn: (
+ row: Row,
+ columnId: string,
+ filterValue: string[]
+ ) => filterValue.length === 0 || filterValue.includes(row.original.Namespace),
});
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx
index f443865e1..2dde20598 100644
--- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/columns/type.tsx
@@ -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, columnId: string, filterValue: string[]) =>
- filterValue.length === 0 || filterValue.includes(row.original.Type),
+ filterFn: (
+ row: Row,
+ columnId: string,
+ filterValue: string[]
+ ) => filterValue.length === 0 || filterValue.includes(row.original.Type),
});
diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/types.ts b/app/react/kubernetes/services/ServicesView/ServicesDatatable/types.ts
new file mode 100644
index 000000000..00bc0393c
--- /dev/null
+++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/types.ts
@@ -0,0 +1,5 @@
+import { Service } from '../../types';
+
+export type ServiceRowData = Service & {
+ IsSystem: boolean;
+};
diff --git a/app/react/kubernetes/services/types.ts b/app/react/kubernetes/services/types.ts
index c03bc3e69..805397c54 100644
--- a/app/react/kubernetes/services/types.ts
+++ b/app/react/kubernetes/services/types.ts
@@ -26,18 +26,17 @@ export type ServiceType =
export type Service = {
Name: string;
UID: string;
+ Type: ServiceType;
Namespace: string;
Annotations?: Record;
+ CreationDate: string;
Labels?: Record;
- Type: ServiceType;
+ AllocateLoadBalancerNodePorts?: boolean;
Ports?: Array;
Selector?: Record;
- ClusterIPs?: Array;
IngressStatus?: Array;
+ Applications?: Application[];
+ ClusterIPs?: Array;
ExternalName?: string;
ExternalIPs?: Array;
- CreationDate: string;
- Applications?: Application[];
-
- IsSystem: boolean;
};
diff --git a/app/react/kubernetes/services/useNamespaceServices.ts b/app/react/kubernetes/services/useNamespaceServices.ts
new file mode 100644
index 000000000..5da0dcdd1
--- /dev/null
+++ b/app/react/kubernetes/services/useNamespaceServices.ts
@@ -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(
+ environmentId: EnvironmentId,
+ namespace: string,
+ queryOptions?: UseQueryOptions
+) {
+ 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(
+ `kubernetes/${environmentId}/namespaces/${namespace}/services`
+ );
+ return services;
+ } catch (e) {
+ throw parseAxiosError(e as Error, 'Unable to retrieve services');
+ }
+}
diff --git a/app/react/kubernetes/utils.ts b/app/react/kubernetes/utils.ts
index 97805cf79..644debad0 100644
--- a/app/react/kubernetes/utils.ts
+++ b/app/react/kubernetes/utils.ts
@@ -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
diff --git a/app/react/kubernetes/volumes/ListView/types.ts b/app/react/kubernetes/volumes/ListView/types.ts
index dd8b3efcf..64cde553b 100644
--- a/app/react/kubernetes/volumes/ListView/types.ts
+++ b/app/react/kubernetes/volumes/ListView/types.ts
@@ -10,7 +10,7 @@ export interface VolumeViewModel {
storageClass: {
Name: string;
};
- Storage?: unknown;
+ Storage?: string | number;
CreationDate?: string;
ApplicationOwner?: string;
IsExternal?: boolean;
diff --git a/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts b/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts
new file mode 100644
index 000000000..cdf6b6ee0
--- /dev/null
+++ b/app/react/kubernetes/volumes/queries/useNamespaceVolumes.ts
@@ -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(
+ `/kubernetes/${environmentId}/namespaces/${namespace}/volumes`,
+ { params }
+ );
+ return data;
+ } catch (e) {
+ throw parseAxiosError(e, 'Unable to retrieve volumes');
+ }
+}
diff --git a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts
index b70e2dc67..94b30345b 100644
--- a/app/react/kubernetes/volumes/queries/useVolumesQuery.ts
+++ b/app/react/kubernetes/volumes/queries/useVolumesQuery.ts
@@ -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) => {
diff --git a/app/react/portainer/environments/environment.service/registries.ts b/app/react/portainer/environments/environment.service/registries.ts
index 024fc1877..b176ec955 100644
--- a/app/react/portainer/environments/environment.service/registries.ts
+++ b/app/react/portainer/environments/environment.service/registries.ts
@@ -32,7 +32,7 @@ export async function updateEnvironmentRegistryAccess(
try {
await axios.put(buildRegistryUrl(environmentId, registryId), access);
} catch (e) {
- throw parseAxiosError(e as Error);
+ throw parseAxiosError(e);
}
}
@@ -46,7 +46,7 @@ export async function getEnvironmentRegistries(
});
return data;
} catch (e) {
- throw parseAxiosError(e as Error);
+ throw parseAxiosError(e);
}
}
@@ -60,7 +60,7 @@ export async function getEnvironmentRegistry(
);
return data;
} catch (e) {
- throw parseAxiosError(e as Error);
+ throw parseAxiosError(e);
}
}
diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts
index 14dfd7624..e9dc5aa25 100644
--- a/app/react/portainer/environments/types.ts
+++ b/app/react/portainer/environments/types.ts
@@ -56,6 +56,8 @@ export interface KubernetesSnapshot {
export type IngressClass = {
Name: string;
Type: string;
+ Blocked?: boolean;
+ BlockedNamespaces?: string[] | null;
};
export interface StorageClass {
@@ -82,6 +84,11 @@ export interface KubernetesConfiguration {
export interface KubernetesSettings {
Snapshots?: KubernetesSnapshot[] | null;
Configuration: KubernetesConfiguration;
+ Flags: {
+ IsServerMetricsDetected: boolean;
+ IsServerIngressClassDetected: boolean;
+ IsServerStorageDetected: boolean;
+ };
}
export type EnvironmentEdge = {
@@ -153,11 +160,21 @@ export type Environment = {
Snapshots: DockerSnapshot[];
Kubernetes: KubernetesSettings;
PublicURL?: string;
- UserTrusted: boolean;
+ UserTrusted?: boolean;
AMTDeviceGUID?: string;
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
Gpus?: { name: string; value: string }[];
+ TLSConfig?: {
+ TLS: boolean;
+ TLSSkipVerify: boolean;
+ };
+ AzureCredentials?: {
+ ApplicationID: string;
+ TenantID: string;
+ AuthenticationKey: string;
+ };
+ ComposeSyntaxMaxVersion: string;
EnableImageNotification: boolean;
LocalTimeZone?: string;
diff --git a/app/react/portainer/registries/types/registry.ts b/app/react/portainer/registries/types/registry.ts
index 37b0a175a..34c6d2e09 100644
--- a/app/react/portainer/registries/types/registry.ts
+++ b/app/react/portainer/registries/types/registry.ts
@@ -21,8 +21,8 @@ export enum RegistryTypes {
}
export interface RegistryAccess {
- UserAccessPolicies: UserAccessPolicies;
- TeamAccessPolicies: TeamAccessPolicies;
+ UserAccessPolicies: UserAccessPolicies | null;
+ TeamAccessPolicies: TeamAccessPolicies | null;
Namespaces: string[];
}
@@ -37,7 +37,7 @@ export interface Gitlab {
}
export interface Quay {
- UseOrganisation: boolean;
+ UseOrganisation?: boolean;
OrganisationName: string;
}
@@ -71,7 +71,7 @@ export interface Registry {
Authentication: boolean;
Username: string;
Password?: string;
- RegistryAccesses: RegistryAccesses;
+ RegistryAccesses: RegistryAccesses | null;
Gitlab: Gitlab;
Quay: Quay;
Github: Github;