diff --git a/api/http/handler/kubernetes/handler.go b/api/http/handler/kubernetes/handler.go index 375f27f25..7efa0bb6b 100644 --- a/api/http/handler/kubernetes/handler.go +++ b/api/http/handler/kubernetes/handler.go @@ -53,6 +53,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza endpointRouter.Use(h.kubeClient) endpointRouter.PathPrefix("/nodes_limits").Handler(httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet) + endpointRouter.PathPrefix("/max_resource_limits").Handler(httperror.LoggerHandler(h.getKubernetesMaxResourceLimits)).Methods(http.MethodGet) endpointRouter.Path("/metrics/nodes").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllNodes)).Methods(http.MethodGet) endpointRouter.Path("/metrics/nodes/{name}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForNode)).Methods(http.MethodGet) endpointRouter.Path("/metrics/pods/namespace/{namespace}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllPods)).Methods(http.MethodGet) diff --git a/api/http/handler/kubernetes/namespaces.go b/api/http/handler/kubernetes/namespaces.go index 13084d179..8f7ae1850 100644 --- a/api/http/handler/kubernetes/namespaces.go +++ b/api/http/handler/kubernetes/namespaces.go @@ -121,7 +121,7 @@ func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Re // @success 200 {string} string "Success" // @failure 400 "Invalid request" // @failure 500 "Server error" -// @router /kubernetes/{id}/namespaces/{namespace} [post] +// @router /kubernetes/{id}/namespaces [post] func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { @@ -157,6 +157,7 @@ func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http err, ) } + return nil } diff --git a/api/http/handler/kubernetes/nodes_limits.go b/api/http/handler/kubernetes/nodes_limits.go index ba1c2fe08..3a5b6a639 100644 --- a/api/http/handler/kubernetes/nodes_limits.go +++ b/api/http/handler/kubernetes/nodes_limits.go @@ -51,3 +51,38 @@ func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http. return response.JSON(w, nodesLimits) } + +func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return httperror.BadRequest( + "Invalid environment identifier route variable", + err, + ) + } + endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID)) + if handler.DataStore.IsErrObjectNotFound(err) { + return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err) + } else if err != nil { + return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err) + } + + cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint) + if err != nil { + return httperror.InternalServerError( + "Failed to lookup KubeClient", + err, + ) + } + + overCommit := endpoint.Kubernetes.Configuration.EnableResourceOverCommit + overCommitPercent := endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage + + // name is set to "" so all namespaces resources are considered when calculating max resource limits + resourceLimit, err := cli.GetMaxResourceLimits("", overCommit, overCommitPercent) + if err != nil { + return httperror.InternalServerError("Unable to retrieve max resource limit", err) + } + + return response.JSON(w, resourceLimit) +} diff --git a/api/http/models/kubernetes/namespaces.go b/api/http/models/kubernetes/namespaces.go index ca824ae8a..3679e684d 100644 --- a/api/http/models/kubernetes/namespaces.go +++ b/api/http/models/kubernetes/namespaces.go @@ -1,12 +1,36 @@ package kubernetes -import "net/http" +import ( + "fmt" + "net/http" + + "k8s.io/apimachinery/pkg/api/resource" +) type K8sNamespaceDetails struct { - Name string `json:"Name"` - Annotations map[string]string `json:"Annotations"` + Name string `json:"Name"` + Annotations map[string]string `json:"Annotations"` + ResourceQuota *K8sResourceQuota `json:"ResourceQuota"` +} + +type K8sResourceQuota struct { + Enabled bool `json:"enabled"` + Memory string `json:"memory"` + CPU string `json:"cpu"` } func (r *K8sNamespaceDetails) Validate(request *http.Request) error { + if r.ResourceQuota != nil && r.ResourceQuota.Enabled { + _, err := resource.ParseQuantity(r.ResourceQuota.Memory) + if err != nil { + return fmt.Errorf("error parsing memory quota value: %w", err) + } + + _, err = resource.ParseQuantity(r.ResourceQuota.CPU) + if err != nil { + return fmt.Errorf("error parsing cpu quota value: %w", err) + } + } + return nil } diff --git a/api/kubernetes/cli/namespace.go b/api/kubernetes/cli/namespace.go index 64067a743..b3dd643cb 100644 --- a/api/kubernetes/cli/namespace.go +++ b/api/kubernetes/cli/namespace.go @@ -8,7 +8,9 @@ import ( "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" models "github.com/portainer/portainer/api/http/models/kubernetes" + "github.com/rs/zerolog/log" v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -61,14 +63,59 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er // CreateNamespace creates a new ingress in a given namespace in a k8s endpoint. func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error { - client := kcl.cli.CoreV1().Namespaces() var ns v1.Namespace ns.Name = info.Name ns.Annotations = info.Annotations - _, err := client.Create(context.Background(), &ns, metav1.CreateOptions{}) - return err + resourceQuota := &v1.ResourceQuota{ + ObjectMeta: metav1.ObjectMeta{ + Name: "portainer-rq-" + info.Name, + Namespace: info.Name, + }, + Spec: v1.ResourceQuotaSpec{ + Hard: v1.ResourceList{}, + }, + } + + _, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{}) + if err != nil { + log.Error(). + Err(err). + Str("Namespace", info.Name). + Interface("ResourceQuota", resourceQuota). + Msg("Failed to create the namespace due to a resource quota issue.") + return err + } + + if info.ResourceQuota != nil { + log.Info().Msgf("Creating resource quota for namespace %s", info.Name) + log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota) + + if info.ResourceQuota.Enabled { + memory := resource.MustParse(info.ResourceQuota.Memory) + cpu := resource.MustParse(info.ResourceQuota.CPU) + if memory.Value() > 0 { + memQuota := memory + resourceQuota.Spec.Hard[v1.ResourceLimitsMemory] = memQuota + resourceQuota.Spec.Hard[v1.ResourceRequestsMemory] = memQuota + } + + if cpu.Value() > 0 { + cpuQuota := cpu + resourceQuota.Spec.Hard[v1.ResourceLimitsCPU] = cpuQuota + resourceQuota.Spec.Hard[v1.ResourceRequestsCPU] = cpuQuota + } + } + + _, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{}) + if err != nil { + log.Error().Msgf("Failed to create resource quota for namespace %s: %s", info.Name, err) + return err + } + } + + return nil } func isSystemNamespace(namespace v1.Namespace) bool { diff --git a/api/kubernetes/cli/nodes_limits.go b/api/kubernetes/cli/nodes_limits.go index 7512499af..ad7e04e28 100644 --- a/api/kubernetes/cli/nodes_limits.go +++ b/api/kubernetes/cli/nodes_limits.go @@ -4,6 +4,7 @@ import ( "context" portainer "github.com/portainer/portainer/api" + "github.com/rs/zerolog/log" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -42,3 +43,62 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) { return nodesLimits, nil } + +// GetMaxResourceLimits gets the maximum CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection, minus the accumulated resourcequotas for all namespaces except the one we're editing (skipNamespace) +// if skipNamespace is set to "" then all namespaces are considered +func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) { + limits := portainer.K8sNodeLimits{} + nodes, err := client.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return limits, err + } + + // accumulated node limits + memory := int64(0) + for _, node := range nodes.Items { + limits.CPU += node.Status.Allocatable.Cpu().MilliValue() + memory += node.Status.Allocatable.Memory().Value() + } + limits.Memory = memory / 1000000 // B to MB + + if !overCommitEnabled { + namespaces, err := client.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return limits, err + } + + reservedPercent := float64(resourceOverCommitPercent) / 100.0 + + reserved := portainer.K8sNodeLimits{} + for _, namespace := range namespaces.Items { + // skip the namespace we're editing + if namespace.Name == skipNamespace { + continue + } + + // minus accumulated resourcequotas for all namespaces except the one we're editing + resourceQuota, err := client.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + log.Debug().Msgf("error getting resourcequota for namespace %s: %s", namespace.Name, err) + continue // skip it + } + + for _, rq := range resourceQuota.Items { + hardLimits := rq.Status.Hard + for resourceType, limit := range hardLimits { + switch resourceType { + case "limits.cpu": + reserved.CPU += limit.MilliValue() + case "limits.memory": + reserved.Memory += limit.ScaledValue(6) // MB + } + } + } + } + + limits.CPU = limits.CPU - int64(float64(limits.CPU)*reservedPercent) - reserved.CPU + limits.Memory = limits.Memory - int64(float64(limits.Memory)*reservedPercent) - reserved.Memory + } + + return limits, nil +} diff --git a/api/portainer.go b/api/portainer.go index 89ae3009e..873bde1c3 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1495,6 +1495,7 @@ type ( GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) DeleteServices(reqs models.K8sServiceDeleteRequests) error GetNodesLimits() (K8sNodesLimits, error) + GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error DeleteRegistrySecret(registry *Registry, namespace string) error diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index c779cb9e4..576fb422b 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -1,5 +1,5 @@ import { EnvironmentStatus } from '@/react/portainer/environments/types'; -import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/service'; +import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview'; import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; @@ -375,12 +375,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo }, }; - const resourcePoolCreation = { + const namespaceCreation = { name: 'kubernetes.resourcePools.new', url: '/new', views: { 'content@': { - component: 'kubernetesCreateResourcePoolView', + component: 'kubernetesCreateNamespaceView', }, }, }; @@ -504,7 +504,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo $stateRegistryProvider.register(node); $stateRegistryProvider.register(nodeStats); $stateRegistryProvider.register(resourcePools); - $stateRegistryProvider.register(resourcePoolCreation); + $stateRegistryProvider.register(namespaceCreation); $stateRegistryProvider.register(resourcePool); $stateRegistryProvider.register(resourcePoolAccess); $stateRegistryProvider.register(volumes); diff --git a/app/kubernetes/react/components/index.ts b/app/kubernetes/react/components/index.ts index 140a254b8..d5609c2e6 100644 --- a/app/kubernetes/react/components/index.ts +++ b/app/kubernetes/react/components/index.ts @@ -1,11 +1,11 @@ import angular from 'angular'; import { r2a } from '@/react-tools/react2angular'; -import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable'; +import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular'; import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector'; import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector'; import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; -import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector'; +import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector'; import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector'; import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; @@ -27,7 +27,7 @@ export const ngModule = angular .module('portainer.kubernetes.react.components', []) .component( 'ingressClassDatatable', - r2a(IngressClassDatatable, [ + r2a(IngressClassDatatableAngular, [ 'onChangeControllers', 'description', 'ingressControllers', @@ -74,12 +74,7 @@ export const ngModule = angular ) .component( 'createNamespaceRegistriesSelector', - r2a(CreateNamespaceRegistriesSelector, [ - 'inputId', - 'onChange', - 'options', - 'value', - ]) + r2a(RegistriesSelector, ['inputId', 'onChange', 'options', 'value']) ) .component( 'kubeApplicationAccessPolicySelector', diff --git a/app/kubernetes/react/views/index.ts b/app/kubernetes/react/views/index.ts index 3d6633286..62285d246 100644 --- a/app/kubernetes/react/views/index.ts +++ b/app/kubernetes/react/views/index.ts @@ -10,11 +10,16 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView'; import { ServicesView } from '@/react/kubernetes/services/ServicesView'; import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView'; +import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView'; import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView'; export const viewsModule = angular .module('portainer.kubernetes.react.views', []) + .component( + 'kubernetesCreateNamespaceView', + r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), []) + ) .component( 'kubernetesServicesView', r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), []) diff --git a/app/kubernetes/views/resource-pools/create/createResourcePool.js b/app/kubernetes/views/resource-pools/create/createResourcePool.js deleted file mode 100644 index a192da090..000000000 --- a/app/kubernetes/views/resource-pools/create/createResourcePool.js +++ /dev/null @@ -1,10 +0,0 @@ -import angular from 'angular'; -import KubernetesCreateResourcePoolController from './createResourcePoolController'; - -angular.module('portainer.kubernetes').component('kubernetesCreateResourcePoolView', { - templateUrl: './createResourcePool.html', - controller: KubernetesCreateResourcePoolController, - bindings: { - endpoint: '<', - }, -}); diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html index f8675ecd1..af0ff4582 100644 --- a/app/kubernetes/views/resource-pools/edit/resourcePool.html +++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html @@ -339,7 +339,7 @@ refresh-callback="ctrl.getIngresses" loading="ctrl.state.ingressesLoading" title-text="Ingress routes and applications" - title-icon="svg-route" + title-icon="database" > diff --git a/app/react-tools/react-query.ts b/app/react-tools/react-query.ts index 441b7c495..1e9ae125b 100644 --- a/app/react-tools/react-query.ts +++ b/app/react-tools/react-query.ts @@ -29,13 +29,19 @@ type OptionalReadonly = T | Readonly; export function withInvalidate( queryClient: QueryClient, - queryKeysToInvalidate: Array>> + queryKeysToInvalidate: Array>>, + // skipRefresh will set the mutation state to success without waiting for the invalidated queries to refresh + // see the following for info: https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises + { skipRefresh }: { skipRefresh?: boolean } = {} ) { return { onSuccess() { - return Promise.all( + const promise = Promise.all( queryKeysToInvalidate.map((keys) => queryClient.invalidateQueries(keys)) ); + return skipRefresh + ? undefined // don't wait for queries to refresh before setting state to success + : promise; // stay loading until all queries are refreshed }, }; } diff --git a/app/react/components/form-components/Slider/Slider.tsx b/app/react/components/form-components/Slider/Slider.tsx index 723ae99db..4ec7c0ba0 100644 --- a/app/react/components/form-components/Slider/Slider.tsx +++ b/app/react/components/form-components/Slider/Slider.tsx @@ -36,7 +36,7 @@ export function Slider({ return (
void; max: number; + dataCy: string; + step?: number; + visibleTooltip?: boolean; }) { return (
@@ -22,7 +28,9 @@ export function SliderWithInput({ value={value} min={0} max={max} - step={256} + step={step} + dataCy={`${dataCy}Slider`} + visibleTooltip={visibleTooltip} />
)} @@ -33,6 +41,7 @@ export function SliderWithInput({ value={value} onChange={(e) => onChange(e.target.valueAsNumber)} className="w-32" + data-cy={`${dataCy}Input`} />
); diff --git a/app/react/components/form-components/formikUtils.ts b/app/react/components/form-components/formikUtils.ts new file mode 100644 index 000000000..459b0259c --- /dev/null +++ b/app/react/components/form-components/formikUtils.ts @@ -0,0 +1,18 @@ +import { FormikErrors } from 'formik'; + +export function isErrorType( + error: string | FormikErrors | undefined +): error is FormikErrors { + return error !== undefined && typeof error !== 'string'; +} + +export function isArrayErrorType( + error: + | string[] + | FormikErrors[] + | string + | undefined + | (FormikErrors | undefined)[] +): error is FormikErrors[] { + return error !== undefined && typeof error !== 'string'; +} diff --git a/app/react/components/form-components/validate-unique.ts b/app/react/components/form-components/validate-unique.ts new file mode 100644 index 000000000..1547b0d6a --- /dev/null +++ b/app/react/components/form-components/validate-unique.ts @@ -0,0 +1,56 @@ +import _ from 'lodash'; +import { AnySchema, TestContext, TypeOf, ValidationError } from 'yup'; +import Lazy from 'yup/lib/Lazy'; +import { AnyObject } from 'yup/lib/types'; + +/** + * Builds a uniqueness test for yup. + * @param errorMessage The error message to display for duplicates. + * @param path The path to the value to test for uniqueness (if the list item is an object). + * @returns A function that can be passed to yup's `test` method. + */ +export function buildUniquenessTest< + T extends AnySchema | Lazy, +>(errorMessage: (errorIndex: number) => string, path = '') { + return ( + list: Array> | undefined, + testContext: TestContext + ) => { + if (!list) { + return true; + } + + const values = list.map(mapper); + + // check for duplicates, adding the index of each duplicate to an array + const seen = new Set>(); + const duplicates: number[] = []; + values.forEach((value, i) => { + if (seen.has(value)) { + duplicates.push(i); + } else { + seen.add(value); + } + }); + + // create an array of yup validation errors for each duplicate + const errors = duplicates.map((i) => { + const error = new ValidationError( + errorMessage(i), + list[i], + `${testContext.path}[${i}]${path}` + ); + return error; + }); + + if (errors.length > 0) { + throw new ValidationError(errors); + } + + return true; + }; + + function mapper(a: TypeOf) { + return path ? _.get(a, path) : a; + } +} diff --git a/app/react/docker/containers/CreateView/ResourcesTab/ResourcesFieldset.tsx b/app/react/docker/containers/CreateView/ResourcesTab/ResourcesFieldset.tsx index ac97aa6c7..10cd27f8b 100644 --- a/app/react/docker/containers/CreateView/ResourcesTab/ResourcesFieldset.tsx +++ b/app/react/docker/containers/CreateView/ResourcesTab/ResourcesFieldset.tsx @@ -38,6 +38,8 @@ export function ResourceFieldset({ value={values.reservation} onChange={(value) => onChange({ ...values, reservation: value })} max={maxMemory} + step={256} + dataCy="k8sNamespaceCreate-resourceReservationMemory" /> @@ -46,6 +48,8 @@ export function ResourceFieldset({ value={values.limit} onChange={(value) => onChange({ ...values, limit: value })} max={maxMemory} + step={256} + dataCy="k8sNamespaceCreate-resourceLimitMemory" /> diff --git a/app/react/hooks/useUnauthorizedRedirect.ts b/app/react/hooks/useUnauthorizedRedirect.ts new file mode 100644 index 000000000..abfed76cf --- /dev/null +++ b/app/react/hooks/useUnauthorizedRedirect.ts @@ -0,0 +1,48 @@ +import { useRouter } from '@uirouter/react'; +import { useEffect } from 'react'; + +import { EnvironmentId } from '../portainer/environments/types'; + +import { useAuthorizations } from './useUser'; + +type AuthorizationOptions = { + authorizations: string | string[]; + forceEnvironmentId?: EnvironmentId; + adminOnlyCE?: boolean; +}; + +type RedirectOptions = { + to: string; + params: Record; +}; + +/** + * Redirects to the given route if the user is not authorized. + * @param authorizations The authorizations to check. + * @param forceEnvironmentId The environment id to use for the check. + * @param adminOnlyCE Whether to check only for admin authorizations in CE. + * @param to The route to redirect to. + * @param params The params to pass to the route. + */ +export function useUnauthorizedRedirect( + { + authorizations, + forceEnvironmentId, + adminOnlyCE = false, + }: AuthorizationOptions, + { to, params }: RedirectOptions +) { + const router = useRouter(); + + const isAuthorized = useAuthorizations( + authorizations, + forceEnvironmentId, + adminOnlyCE + ); + + useEffect(() => { + if (!isAuthorized) { + router.stateService.go(to, params); + } + }, [isAuthorized, params, to, router.stateService]); +} diff --git a/app/react/kubernetes/ingresses/CreateIngressView/Annotations/index.tsx b/app/react/kubernetes/annotations/AnnotationsForm.tsx similarity index 79% rename from app/react/kubernetes/ingresses/CreateIngressView/Annotations/index.tsx rename to app/react/kubernetes/annotations/AnnotationsForm.tsx index fb60a99ba..834e9a995 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/Annotations/index.tsx +++ b/app/react/kubernetes/annotations/AnnotationsForm.tsx @@ -1,10 +1,11 @@ -import { ChangeEvent, ReactNode } from 'react'; +import { ChangeEvent } from 'react'; import { Trash2 } from 'lucide-react'; import { FormError } from '@@/form-components/FormError'; import { Button } from '@@/buttons'; +import { isArrayErrorType } from '@@/form-components/formikUtils'; -import { Annotation } from './types'; +import { Annotation, AnnotationErrors } from './types'; interface Props { annotations: Annotation[]; @@ -14,17 +15,21 @@ interface Props { val: string ) => void; removeAnnotation: (index: number) => void; - errors: Record; + errors: AnnotationErrors; placeholder: string[]; } -export function Annotations({ +export function AnnotationsForm({ annotations, handleAnnotationChange, removeAnnotation, errors, placeholder, }: Props) { + const annotationErrors = isArrayErrorType(errors) + ? errors + : undefined; + return ( <> {annotations.map((annotation, i) => ( @@ -43,9 +48,9 @@ export function Annotations({ } /> - {errors[`annotations.key[${i}]`] && ( - - {errors[`annotations.key[${i}]`]} + {annotationErrors?.[i]?.Key && ( + + {annotationErrors[i]?.Key} )} @@ -63,9 +68,9 @@ export function Annotations({ } /> - {errors[`annotations.value[${i}]`] && ( - - {errors[`annotations.value[${i}]`]} + {annotationErrors?.[i]?.Value && ( + + {annotationErrors[i]?.Value} )} diff --git a/app/react/kubernetes/annotations/types.ts b/app/react/kubernetes/annotations/types.ts new file mode 100644 index 000000000..d364c8aed --- /dev/null +++ b/app/react/kubernetes/annotations/types.ts @@ -0,0 +1,15 @@ +import { FormikErrors } from 'formik'; + +export interface Annotation { + Key: string; + Value: string; + ID: string; +} + +export type AnnotationsPayload = Record; + +export type AnnotationErrors = + | string + | string[] + | FormikErrors[] + | undefined; diff --git a/app/react/kubernetes/annotations/validation.ts b/app/react/kubernetes/annotations/validation.ts new file mode 100644 index 000000000..3f5ced0a0 --- /dev/null +++ b/app/react/kubernetes/annotations/validation.ts @@ -0,0 +1,68 @@ +import { SchemaOf, array, object, string } from 'yup'; + +import { buildUniquenessTest } from '@@/form-components/validate-unique'; + +import { Annotation } from './types'; + +const re = /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/; + +export const annotationsSchema: SchemaOf = array( + getAnnotationValidation() +).test( + 'unique', + 'Duplicate keys are not allowed.', + buildUniquenessTest(() => 'Duplicate keys are not allowed.', 'Key') +); + +function getAnnotationValidation(): SchemaOf { + return object({ + Key: string() + .required('Key is required.') + .test('is-valid', (value, { createError }) => { + if (!value) { + return true; + } + const keySegments = value.split('/'); + if (keySegments.length > 2) { + return createError({ + message: + 'Two segments are allowed, separated by a slash (/): a prefix (optional) and a name.', + }); + } + if (keySegments.length === 2) { + if (keySegments[0].length > 253) { + return createError({ + message: "Prefix (before the slash) can't exceed 253 characters.", + }); + } + if (keySegments[1].length > 63) { + return createError({ + message: "Name (after the slash) can't exceed 63 characters.", + }); + } + if (!re.test(keySegments[1])) { + return createError({ + message: + 'Start and end with alphanumeric characters only, limiting characters in between to dashes, underscores, and alphanumerics.', + }); + } + } else if (keySegments.length === 1) { + if (keySegments[0].length > 63) { + return createError({ + message: + "Name (the segment after a slash (/), or only segment if no slash) can't exceed 63 characters.", + }); + } + if (!re.test(keySegments[0])) { + return createError({ + message: + 'Start and end with alphanumeric characters only, limiting characters in between to dashes, underscores, and alphanumerics.', + }); + } + } + return true; + }), + Value: string().required('Value is required.'), + ID: string().required('ID is required.'), + }); +} diff --git a/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx index 45f57c7e0..3d21911c9 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/cluster-ip/ClusterIpServiceForm.tsx @@ -7,8 +7,9 @@ import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector import { Button } from '@@/buttons'; import { Card } from '@@/Card'; import { Widget } from '@@/Widget'; +import { isErrorType } from '@@/form-components/formikUtils'; -import { isErrorType, newPort } from '../utils'; +import { newPort } from '../utils'; import { ServiceFormValues, ServicePort, diff --git a/app/react/kubernetes/applications/CreateView/application-services/load-balancer/LoadBalancerServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/load-balancer/LoadBalancerServiceForm.tsx index 12b8174b7..1027597af 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/load-balancer/LoadBalancerServiceForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/load-balancer/LoadBalancerServiceForm.tsx @@ -8,8 +8,9 @@ import { Button } from '@@/buttons'; import { Widget } from '@@/Widget'; import { Card } from '@@/Card'; import { InputGroup } from '@@/form-components/InputGroup'; +import { isErrorType } from '@@/form-components/formikUtils'; -import { isErrorType, newPort } from '../utils'; +import { newPort } from '../utils'; import { ContainerPortInput } from '../components/ContainerPortInput'; import { ServiceFormValues, diff --git a/app/react/kubernetes/applications/CreateView/application-services/node-port/NodePortServiceForm.tsx b/app/react/kubernetes/applications/CreateView/application-services/node-port/NodePortServiceForm.tsx index 933b7ed9f..749d07727 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/node-port/NodePortServiceForm.tsx +++ b/app/react/kubernetes/applications/CreateView/application-services/node-port/NodePortServiceForm.tsx @@ -8,8 +8,9 @@ import { Button } from '@@/buttons'; import { Widget } from '@@/Widget'; import { Card } from '@@/Card'; import { InputGroup } from '@@/form-components/InputGroup'; +import { isErrorType } from '@@/form-components/formikUtils'; -import { isErrorType, newPort } from '../utils'; +import { newPort } from '../utils'; import { ContainerPortInput } from '../components/ContainerPortInput'; import { ServiceFormValues, diff --git a/app/react/kubernetes/applications/CreateView/application-services/utils.ts b/app/react/kubernetes/applications/CreateView/application-services/utils.ts index 7e8747088..0cea21cce 100644 --- a/app/react/kubernetes/applications/CreateView/application-services/utils.ts +++ b/app/react/kubernetes/applications/CreateView/application-services/utils.ts @@ -1,15 +1,7 @@ -import { FormikErrors } from 'formik'; - import { Ingress } from '@/react/kubernetes/ingresses/types'; import { ServiceFormValues, ServicePort } from './types'; -export function isErrorType( - error: string | FormikErrors | undefined -): error is FormikErrors { - return error !== undefined && typeof error !== 'string'; -} - export function newPort(serviceName?: string) { return { port: undefined, diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx index 5d1f73147..d3152f699 100644 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/ConfigureForm.tsx @@ -21,11 +21,9 @@ import { ModalType } from '@@/modals'; import { buildConfirmButton } from '@@/modals/utils'; import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap'; -import { - IngressControllerClassMap, - IngressControllerClassMapRowData, -} from '../../ingressClass/types'; +import { IngressControllerClassMap } from '../../ingressClass/types'; import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled'; +import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils'; import { useStorageClassesFormValues } from './useStorageClassesFormValues'; import { ConfigureFormValues, StorageClassFormValues } from './types'; @@ -176,15 +174,10 @@ function InnerForm({ - setFieldValue('allowNoneIngressClass', checked) - } + onChange={(checked) => { + setFieldValue('allowNoneIngressClass', checked); + // add or remove the none ingress class from the ingress classes list + if (checked) { + setFieldValue( + 'ingressClasses', + getIngressClassesFormValues( + checked, + initialValues.ingressClasses + ) + ); + } + }} /> @@ -376,12 +379,15 @@ function InnerForm({ function useInitialValues( environment?: Environment | null, storageClassFormValues?: StorageClassFormValues[], - ingressClasses?: IngressControllerClassMapRowData[] + ingressClasses?: IngressControllerClassMap[] ): ConfigureFormValues | undefined { return useMemo(() => { if (!environment) { return undefined; } + const allowNoneIngressClass = + !!environment.Kubernetes.Configuration.AllowNoneIngressClass; + return { storageClasses: storageClassFormValues || [], useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer, @@ -396,9 +402,10 @@ function useInitialValues( !!environment.Kubernetes.Configuration.RestrictStandardUserIngressW, ingressAvailabilityPerNamespace: !!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace, - allowNoneIngressClass: - !!environment.Kubernetes.Configuration.AllowNoneIngressClass, - ingressClasses: ingressClasses || [], + allowNoneIngressClass, + ingressClasses: + getIngressClassesFormValues(allowNoneIngressClass, ingressClasses) || + [], }; }, [environment, ingressClasses, storageClassFormValues]); } diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts index a5a385a03..1c608da70 100644 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/handleSubmitConfigureCluster.ts @@ -8,8 +8,6 @@ import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries import { Environment } from '@/react/portainer/environments/types'; import { TrackEventProps } from '@/angulartics.matomo/analytics-services'; -import { IngressControllerClassMapRowData } from '../../ingressClass/types'; - import { ConfigureFormValues, StorageClassFormValues } from './types'; import { ConfigureClusterPayloads } from './useConfigureClusterMutation'; @@ -64,10 +62,8 @@ export async function handleSubmitConfigureCluster( { id: environment.Id, updateEnvironmentPayload: updatedEnvironment, - initialIngressControllers: - initialValues?.ingressClasses as IngressControllerClassMapRowData[], - ingressControllers: - values.ingressClasses as IngressControllerClassMapRowData[], + initialIngressControllers: initialValues?.ingressClasses ?? [], + ingressControllers: values.ingressClasses, storageClassPatches, }, { diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts index 9f957bc3b..9ef252a34 100644 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureForm/useConfigureClusterMutation.ts @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query'; import { Operation } from 'fast-json-patch'; import _ from 'lodash'; -import { withError } from '@/react-tools/react-query'; +import { withError, withInvalidate } from '@/react-tools/react-query'; import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys'; import { UpdateEnvironmentPayload, @@ -12,13 +12,13 @@ import axios from '@/portainer/services/axios'; import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError'; import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap'; -import { IngressControllerClassMapRowData } from '../../ingressClass/types'; +import { IngressControllerClassMap } from '../../ingressClass/types'; export type ConfigureClusterPayloads = { id: number; updateEnvironmentPayload: Partial; - initialIngressControllers: IngressControllerClassMapRowData[]; - ingressControllers: IngressControllerClassMapRowData[]; + initialIngressControllers: IngressControllerClassMap[]; + ingressControllers: IngressControllerClassMap[]; storageClassPatches: { name: string; patch: Operation[]; @@ -48,10 +48,9 @@ export function useConfigureClusterMutation() { } }, { - onSuccess: () => { - // not returning the promise here because we don't want to wait for the invalidateQueries to complete (longer than the mutation itself) - queryClient.invalidateQueries(environmentQueryKeys.base()); - }, + ...withInvalidate(queryClient, [environmentQueryKeys.base()], { + skipRefresh: true, + }), ...withError('Unable to apply configuration', 'Failure'), } ); diff --git a/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx b/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx index 804d25858..8aef84f71 100644 --- a/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx +++ b/app/react/kubernetes/cluster/ConfigureView/ConfigureView.tsx @@ -1,4 +1,5 @@ import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; import { PageHeader } from '@@/PageHeader'; import { Widget, WidgetBody } from '@@/Widget'; @@ -8,7 +9,19 @@ import { ConfigureForm } from './ConfigureForm'; export function ConfigureView() { const { data: environment } = useCurrentEnvironment(); - // get the initial values + useUnauthorizedRedirect( + { + authorizations: 'K8sClusterW', + forceEnvironmentId: environment?.Id, + adminOnlyCE: false, + }, + { + params: { + id: environment?.Id, + }, + to: 'kubernetes.dashboard', + } + ); return ( <> diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx index 206ba1185..48d15a1c1 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatable.tsx @@ -1,5 +1,3 @@ -import { useEffect, useState } from 'react'; - import Route from '@/assets/ico/route.svg?c'; import { confirm } from '@@/modals/confirm'; @@ -11,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils'; import { useTableState } from '@@/datatables/useTableState'; import { TextTip } from '@@/Tip/TextTip'; -import { IngressControllerClassMapRowData } from '../types'; +import { IngressControllerClassMap } from '../types'; import { columns } from './columns'; @@ -19,82 +17,31 @@ const storageKey = 'ingressClasses'; const settingsStore = createPersistedStore(storageKey, 'name'); interface Props { - onChangeControllers: ( - controllerClassMap: IngressControllerClassMapRowData[] - ) => void; // angular function to save the ingress class list + onChange: (controllerClassMap: IngressControllerClassMap[]) => void; // angular function to save the ingress class list description: string; - ingressControllers: IngressControllerClassMapRowData[] | undefined; - initialIngressControllers: IngressControllerClassMapRowData[] | undefined; - allowNoneIngressClass: boolean; + values: IngressControllerClassMap[] | undefined; + initialValues: IngressControllerClassMap[] | undefined; isLoading: boolean; noIngressControllerLabel: string; view: string; } export function IngressClassDatatable({ - onChangeControllers, + onChange, description, - initialIngressControllers, - ingressControllers, - allowNoneIngressClass, + initialValues, + values, isLoading, noIngressControllerLabel, view, }: Props) { const tableState = useTableState(settingsStore, storageKey); - const [ingControllerFormValues, setIngControllerFormValues] = useState( - ingressControllers || [] - ); - - // set the ingress controller form values when the ingress controller list changes - // and the ingress controller form values are not set - useEffect(() => { - if ( - ingressControllers && - ingControllerFormValues.length !== ingressControllers.length - ) { - setIngControllerFormValues(ingressControllers); - } - }, [ingressControllers, ingControllerFormValues]); - - useEffect(() => { - if (allowNoneIngressClass === undefined || isLoading) { - return; - } - - let newIngFormValues: IngressControllerClassMapRowData[]; - const isCustomTypeExist = ingControllerFormValues.some( - (ic) => ic.Type === 'custom' - ); - if (allowNoneIngressClass) { - newIngFormValues = [...ingControllerFormValues]; - // add the ingress controller type 'custom' with a 'none' ingress class name - if (!isCustomTypeExist) { - newIngFormValues.push({ - Name: 'none', - ClassName: 'none', - Type: 'custom', - Availability: true, - New: false, - Used: false, - }); - } - } else { - newIngFormValues = ingControllerFormValues.filter( - (ingController) => ingController.ClassName !== 'none' - ); - } - setIngControllerFormValues(newIngFormValues); - onChangeControllers(newIngFormValues); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [allowNoneIngressClass, onChangeControllers]); - return (
); - function renderTableActions( - selectedRows: IngressControllerClassMapRowData[] - ) { + function renderTableActions(selectedRows: IngressControllerClassMap[]) { return (
@@ -121,11 +66,7 @@ export function IngressClassDatatable({ color="dangerlight" size="small" onClick={() => - updateIngressControllers( - selectedRows, - ingControllerFormValues || [], - false - ) + updateIngressControllers(selectedRows, values || [], false) } > Disallow selected @@ -138,11 +79,7 @@ export function IngressClassDatatable({ color="default" size="small" onClick={() => - updateIngressControllers( - selectedRows, - ingControllerFormValues || [], - true - ) + updateIngressControllers(selectedRows, values || [], true) } > Allow selected @@ -156,38 +93,34 @@ export function IngressClassDatatable({ return (
{description}
- {initialIngressControllers && - ingControllerFormValues && - isUnsavedChanges( - initialIngressControllers, - ingControllerFormValues - ) && Unsaved changes.} + {initialValues && values && isUnsavedChanges(initialValues, values) && ( + Unsaved changes. + )}
); } async function updateIngressControllers( - selectedRows: IngressControllerClassMapRowData[], - ingControllerFormValues: IngressControllerClassMapRowData[], + selectedRows: IngressControllerClassMap[], + values: IngressControllerClassMap[], availability: boolean ) { const updatedIngressControllers = getUpdatedIngressControllers( selectedRows, - ingControllerFormValues || [], + values || [], availability ); - if (ingressControllers && ingressControllers.length) { + if (values && values.length) { const newAllowed = updatedIngressControllers.map( (ingController) => ingController.Availability ); if (view === 'namespace') { - setIngControllerFormValues(updatedIngressControllers); - onChangeControllers(updatedIngressControllers); + onChange(updatedIngressControllers); return; } - const usedControllersToDisallow = ingressControllers.filter( + const usedControllersToDisallow = values.filter( (ingController, index) => { // if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list if ( @@ -229,15 +162,14 @@ export function IngressClassDatatable({ return; } } - setIngControllerFormValues(updatedIngressControllers); - onChangeControllers(updatedIngressControllers); + onChange(updatedIngressControllers); } } } function isUnsavedChanges( - oldIngressControllers: IngressControllerClassMapRowData[], - newIngressControllers: IngressControllerClassMapRowData[] + oldIngressControllers: IngressControllerClassMap[], + newIngressControllers: IngressControllerClassMap[] ) { if (oldIngressControllers.length !== newIngressControllers.length) { return true; @@ -254,8 +186,8 @@ function isUnsavedChanges( } function getUpdatedIngressControllers( - selectedRows: IngressControllerClassMapRowData[], - allRows: IngressControllerClassMapRowData[], + selectedRows: IngressControllerClassMap[], + allRows: IngressControllerClassMap[], allow: boolean ) { const selectedRowClassNames = selectedRows.map((row) => row.ClassName); diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular.tsx new file mode 100644 index 000000000..a7f647432 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular.tsx @@ -0,0 +1,269 @@ +import { useEffect, useState } from 'react'; + +import Route from '@/assets/ico/route.svg?c'; + +import { confirm } from '@@/modals/confirm'; +import { ModalType } from '@@/modals'; +import { Datatable } from '@@/datatables'; +import { Button, ButtonGroup } from '@@/buttons'; +import { createPersistedStore } from '@@/datatables/types'; +import { buildConfirmButton } from '@@/modals/utils'; +import { useTableState } from '@@/datatables/useTableState'; +import { TextTip } from '@@/Tip/TextTip'; + +import { IngressControllerClassMap } from '../types'; + +import { columns } from './columns'; + +const storageKey = 'ingressClasses'; +const settingsStore = createPersistedStore(storageKey, 'name'); + +interface Props { + onChangeControllers: ( + controllerClassMap: IngressControllerClassMap[] + ) => void; // angular function to save the ingress class list + description: string; + ingressControllers: IngressControllerClassMap[] | undefined; + initialIngressControllers: IngressControllerClassMap[] | undefined; + allowNoneIngressClass: boolean; + isLoading: boolean; + noIngressControllerLabel: string; + view: string; +} + +// This is a legacy component that has more state logic than the new one, for angular views +// Delete this component when the namespace edit view is migrated to react +export function IngressClassDatatableAngular({ + onChangeControllers, + description, + initialIngressControllers, + ingressControllers, + allowNoneIngressClass, + isLoading, + noIngressControllerLabel, + view, +}: Props) { + const tableState = useTableState(settingsStore, storageKey); + + const [ingControllerFormValues, setIngControllerFormValues] = useState( + ingressControllers || [] + ); + + // set the ingress controller form values when the ingress controller list changes + // and the ingress controller form values are not set + useEffect(() => { + if ( + ingressControllers && + ingControllerFormValues.length !== ingressControllers.length + ) { + setIngControllerFormValues(ingressControllers); + } + }, [ingressControllers, ingControllerFormValues]); + + useEffect(() => { + if (allowNoneIngressClass === undefined || isLoading) { + return; + } + + let newIngFormValues: IngressControllerClassMap[]; + const isCustomTypeExist = ingControllerFormValues.some( + (ic) => ic.Type === 'custom' + ); + if (allowNoneIngressClass) { + newIngFormValues = [...ingControllerFormValues]; + // add the ingress controller type 'custom' with a 'none' ingress class name + if (!isCustomTypeExist) { + newIngFormValues.push({ + Name: 'none', + ClassName: 'none', + Type: 'custom', + Availability: true, + New: false, + Used: false, + }); + } + } else { + newIngFormValues = ingControllerFormValues.filter( + (ingController) => ingController.ClassName !== 'none' + ); + } + setIngControllerFormValues(newIngFormValues); + onChangeControllers(newIngFormValues); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allowNoneIngressClass, onChangeControllers]); + + return ( +
+ `${row.Name}-${row.ClassName}-${row.Type}`} + renderTableActions={(selectedRows) => renderTableActions(selectedRows)} + description={renderIngressClassDescription()} + /> +
+ ); + + function renderTableActions(selectedRows: IngressControllerClassMap[]) { + return ( +
+ + + + +
+ ); + } + + function renderIngressClassDescription() { + return ( +
+
{description}
+ {initialIngressControllers && + ingControllerFormValues && + isUnsavedChanges( + initialIngressControllers, + ingControllerFormValues + ) && Unsaved changes.} +
+ ); + } + + async function updateIngressControllers( + selectedRows: IngressControllerClassMap[], + ingControllerFormValues: IngressControllerClassMap[], + availability: boolean + ) { + const updatedIngressControllers = getUpdatedIngressControllers( + selectedRows, + ingControllerFormValues || [], + availability + ); + + if (ingressControllers && ingressControllers.length) { + const newAllowed = updatedIngressControllers.map( + (ingController) => ingController.Availability + ); + if (view === 'namespace') { + setIngControllerFormValues(updatedIngressControllers); + onChangeControllers(updatedIngressControllers); + return; + } + + const usedControllersToDisallow = ingressControllers.filter( + (ingController, index) => { + // if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list + if ( + ingController.Availability && + ingController.Used && + !newAllowed[index] + ) { + return true; + } + return false; + } + ); + + if (usedControllersToDisallow.length > 0) { + const confirmed = await confirm({ + title: 'Disallow in-use ingress controllers?', + modalType: ModalType.Warn, + message: ( +
+

+ There are ingress controllers you want to disallow that are in + use: +

+
    + {usedControllersToDisallow.map((controller) => ( +
  • {controller.ClassName}
  • + ))} +
+

+ No new ingress rules can be created for the disallowed + controllers. +

+
+ ), + confirmButton: buildConfirmButton('Disallow', 'warning'), + }); + + if (!confirmed) { + return; + } + } + setIngControllerFormValues(updatedIngressControllers); + onChangeControllers(updatedIngressControllers); + } + } +} + +function isUnsavedChanges( + oldIngressControllers: IngressControllerClassMap[], + newIngressControllers: IngressControllerClassMap[] +) { + if (oldIngressControllers.length !== newIngressControllers.length) { + return true; + } + for (let i = 0; i < newIngressControllers.length; i += 1) { + if ( + oldIngressControllers[i]?.Availability !== + newIngressControllers[i]?.Availability + ) { + return true; + } + } + return false; +} + +function getUpdatedIngressControllers( + selectedRows: IngressControllerClassMap[], + allRows: IngressControllerClassMap[], + allow: boolean +) { + const selectedRowClassNames = selectedRows.map((row) => row.ClassName); + const updatedIngressControllers = allRows?.map((row) => { + if (selectedRowClassNames.includes(row.ClassName)) { + return { ...row, Availability: allow }; + } + return row; + }); + return updatedIngressControllers; +} diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx index 485fb8ffc..0c78b901a 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/availability.tsx @@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react'; import { Badge } from '@@/Badge'; import { Icon } from '@@/Icon'; -import type { IngressControllerClassMapRowData } from '../../types'; +import type { IngressControllerClassMap } from '../../types'; import { columnHelper } from './helper'; @@ -16,9 +16,7 @@ export const availability = columnHelper.accessor('Availability', { sortingFn: 'basic', }); -function Cell({ - getValue, -}: CellContext) { +function Cell({ getValue }: CellContext) { const availability = getValue(); return ( diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts index 3cb62e247..9014d0eaa 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/helper.ts @@ -1,6 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { IngressControllerClassMapRowData } from '../../types'; +import { IngressControllerClassMap } from '../../types'; -export const columnHelper = - createColumnHelper(); +export const columnHelper = createColumnHelper(); diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx index 66fc1ab9f..be22a5362 100644 --- a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/columns/name.tsx @@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table'; import { Badge } from '@@/Badge'; -import type { IngressControllerClassMapRowData } from '../../types'; +import type { IngressControllerClassMap } from '../../types'; import { columnHelper } from './helper'; @@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', { function NameCell({ row, getValue, -}: CellContext) { +}: CellContext) { const className = getValue(); return ( diff --git a/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/utils.ts b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/utils.ts new file mode 100644 index 000000000..79332eda7 --- /dev/null +++ b/app/react/kubernetes/cluster/ingressClass/IngressClassDatatable/utils.ts @@ -0,0 +1,37 @@ +import { IngressControllerClassMap } from '../types'; + +export function getIngressClassesFormValues( + allowNoneIngressClass: boolean, + ingressClasses?: IngressControllerClassMap[] +) { + const ingressClassesFormValues = ingressClasses ? [...ingressClasses] : []; + const noneIngressClassIndex = ingressClassesFormValues.findIndex( + (ingressClass) => + ingressClass.Name === 'none' && + ingressClass.ClassName === 'none' && + ingressClass.Type === 'custom' + ); + // add the none ingress class if it doesn't exist + if (allowNoneIngressClass && noneIngressClassIndex === -1) { + return [ + ...ingressClassesFormValues, + { + Name: 'none', + ClassName: 'none', + Type: 'custom', + Availability: true, + New: false, + Used: false, + }, + ]; + } + // remove the none ingress class if it exists + if (!allowNoneIngressClass && noneIngressClassIndex > -1) { + return [ + ...ingressClassesFormValues.slice(0, noneIngressClassIndex), + ...ingressClassesFormValues.slice(noneIngressClassIndex + 1), + ]; + } + // otherwise return the ingress classes as is + return ingressClassesFormValues; +} diff --git a/app/react/kubernetes/cluster/ingressClass/types.ts b/app/react/kubernetes/cluster/ingressClass/types.ts index 44238c776..f0d5df461 100644 --- a/app/react/kubernetes/cluster/ingressClass/types.ts +++ b/app/react/kubernetes/cluster/ingressClass/types.ts @@ -4,7 +4,6 @@ export type SupportedIngControllerTypes = | 'other' | 'custom'; -// Not having 'extends Record' fixes validation type errors from yup export interface IngressControllerClassMap { Name: string; ClassName: string; @@ -13,8 +12,3 @@ export interface IngressControllerClassMap { New: boolean; Used: boolean; // if the controller is used by any ingress in the cluster } - -// Record fixes type errors when using the type with a react datatable -export interface IngressControllerClassMapRowData - extends Record, - IngressControllerClassMap {} diff --git a/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts b/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts index 40a347843..64fe37394 100644 --- a/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts +++ b/app/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap.ts @@ -5,7 +5,7 @@ import PortainerError from '@/portainer/error'; import axios from '@/portainer/services/axios'; import { withError } from '@/react-tools/react-query'; -import { IngressControllerClassMapRowData } from './types'; +import { IngressControllerClassMap } from './types'; export function useIngressControllerClassMapQuery({ environmentId, @@ -54,7 +54,7 @@ export async function getIngressControllerClassMap({ }) { try { const { data: controllerMaps } = await axios.get< - IngressControllerClassMapRowData[] + IngressControllerClassMap[] >( buildUrl(environmentId, namespace), allowedOnly ? { params: { allowedOnly: true } } : undefined @@ -68,12 +68,12 @@ export async function getIngressControllerClassMap({ // get all supported ingress classes and controllers for the cluster export async function updateIngressControllerClassMap( environmentId: EnvironmentId, - ingressControllerClassMap: IngressControllerClassMapRowData[], + ingressControllerClassMap: IngressControllerClassMap[], namespace?: string ) { try { const { data: controllerMaps } = await axios.put< - IngressControllerClassMapRowData[] + IngressControllerClassMap[] >(buildUrl(environmentId, namespace), ingressControllerClassMap); return controllerMaps; } catch (e) { diff --git a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx index 1ac840d6f..b518d60f8 100644 --- a/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx +++ b/app/react/kubernetes/configs/ListView/ConfigMapsDatatable/ConfigMapsDatatable.tsx @@ -4,7 +4,6 @@ import { ConfigMap } from 'kubernetes-types/core/v1'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; -import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils'; @@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries'; import { Application } from '@/react/kubernetes/applications/types'; import { pluralize } from '@/portainer/helpers/strings'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { confirmDelete } from '@@/modals/confirm'; @@ -40,7 +40,7 @@ export function ConfigMapsDatatable() { ); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = useNamespaces( + const { data: namespaces, ...namespacesQuery } = useNamespacesQuery( environmentId, { autoRefreshRate: tableState.autoRefreshRate * 1000, diff --git a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx index 2a7d8cc3c..ac7dc04f6 100644 --- a/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx +++ b/app/react/kubernetes/configs/ListView/SecretsDatatable/SecretsDatatable.tsx @@ -4,7 +4,6 @@ import { Secret } from 'kubernetes-types/core/v1'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; -import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils'; @@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries'; import { Application } from '@/react/kubernetes/applications/types'; import { pluralize } from '@/portainer/helpers/strings'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { Datatable, TableSettingsMenu } from '@@/datatables'; import { confirmDelete } from '@@/modals/confirm'; @@ -40,7 +40,7 @@ export function SecretsDatatable() { ); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = useNamespaces( + const { data: namespaces, ...namespacesQuery } = useNamespacesQuery( environmentId, { autoRefreshRate: tableState.autoRefreshRate * 1000, diff --git a/app/react/kubernetes/dashboard/DashboardView.tsx b/app/react/kubernetes/dashboard/DashboardView.tsx index 8a99a1056..a64154067 100644 --- a/app/react/kubernetes/dashboard/DashboardView.tsx +++ b/app/react/kubernetes/dashboard/DashboardView.tsx @@ -8,20 +8,21 @@ import { DashboardGrid } from '@@/DashboardItem/DashboardGrid'; import { DashboardItem } from '@@/DashboardItem/DashboardItem'; import { PageHeader } from '@@/PageHeader'; -import { useNamespaces } from '../namespaces/queries'; import { useApplicationsForCluster } from '../applications/application.queries'; import { usePVCsForCluster } from '../volumes/queries'; import { useServicesForCluster } from '../services/service'; import { useIngresses } from '../ingresses/queries'; import { useConfigMapsForCluster } from '../configs/configmap.service'; import { useSecretsForCluster } from '../configs/secret.service'; +import { useNamespacesQuery } from '../namespaces/queries/useNamespacesQuery'; import { EnvironmentInfo } from './EnvironmentInfo'; export function DashboardView() { const queryClient = useQueryClient(); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); + const { data: namespaces, ...namespacesQuery } = + useNamespacesQuery(environmentId); const namespaceNames = namespaces && Object.keys(namespaces); const { data: applications, ...applicationsQuery } = useApplicationsForCluster(environmentId, namespaceNames); diff --git a/app/react/kubernetes/ingresses/CreateIngressView/Annotations/types.ts b/app/react/kubernetes/ingresses/CreateIngressView/Annotations/types.ts deleted file mode 100644 index 6464a9e31..000000000 --- a/app/react/kubernetes/ingresses/CreateIngressView/Annotations/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface Annotation { - Key: string; - Value: string; - ID: string; -} diff --git a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx index fbeffe164..dbb34884d 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/CreateIngressView.tsx @@ -1,11 +1,10 @@ -import { useState, useEffect, useMemo, ReactNode, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, ReactNode } from 'react'; import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; import { v4 as uuidv4 } from 'uuid'; import { debounce } from 'lodash'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useConfigurations } from '@/react/kubernetes/configs/queries'; -import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries'; import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { useAuthorizations } from '@/react/hooks/useUser'; @@ -15,6 +14,7 @@ import { PageHeader } from '@@/PageHeader'; import { Option } from '@@/form-components/Input/Select'; import { Button } from '@@/buttons'; +import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery'; import { Ingress, IngressController } from '../types'; import { useCreateIngress, @@ -22,8 +22,15 @@ import { useUpdateIngress, useIngressControllers, } from '../queries'; +import { Annotation } from '../../annotations/types'; -import { Rule, Path, Host, GroupedServiceOptions } from './types'; +import { + Rule, + Path, + Host, + GroupedServiceOptions, + IngressErrors, +} from './types'; import { IngressForm } from './IngressForm'; import { prepareTLS, @@ -32,7 +39,6 @@ import { prepareRuleFromIngress, checkIfPathExistsWithHost, } from './utils'; -import { Annotation } from './Annotations/types'; export function CreateIngressView() { const environmentId = useEnvironmentId(); @@ -56,11 +62,10 @@ export function CreateIngressView() { // isEditClassNameSet is used to prevent premature validation of the classname in the edit view const [isEditClassNameSet, setIsEditClassNameSet] = useState(false); - const [errors, setErrors] = useState>( - {} as Record - ); + const [errors, setErrors] = useState({}); - const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); + const { data: namespaces, ...namespacesQuery } = + useNamespacesQuery(environmentId); const { data: allServices } = useNamespaceServices(environmentId, namespace); const configResults = useConfigurations(environmentId, namespace); diff --git a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx index b6355036d..a54c05adc 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx +++ b/app/react/kubernetes/ingresses/CreateIngressView/IngressForm.tsx @@ -1,4 +1,4 @@ -import { ChangeEvent, ReactNode, useEffect } from 'react'; +import { ChangeEvent, useEffect } from 'react'; import { Plus, RefreshCw, Trash2 } from 'lucide-react'; import Route from '@/assets/ico/route.svg?c'; @@ -16,8 +16,14 @@ import { InputGroup } from '@@/form-components/InputGroup'; import { InlineLoader } from '@@/InlineLoader'; import { Select } from '@@/form-components/ReactSelect'; -import { Annotations } from './Annotations'; -import { GroupedServiceOptions, Rule, ServicePorts } from './types'; +import { AnnotationsForm } from '../../annotations/AnnotationsForm'; + +import { + GroupedServiceOptions, + IngressErrors, + Rule, + ServicePorts, +} from './types'; import '../style.css'; @@ -36,7 +42,7 @@ interface Props { environmentID: number; rule: Rule; - errors: Record; + errors: IngressErrors; isEdit: boolean; namespace: string; @@ -298,12 +304,12 @@ export function IngressForm({
{rule?.Annotations && ( - )} diff --git a/app/react/kubernetes/ingresses/CreateIngressView/types.ts b/app/react/kubernetes/ingresses/CreateIngressView/types.ts index 0380eeac8..79f29f964 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/types.ts +++ b/app/react/kubernetes/ingresses/CreateIngressView/types.ts @@ -1,6 +1,8 @@ +import { ReactNode } from 'react'; + import { Option } from '@@/form-components/Input/Select'; -import { Annotation } from './Annotations/types'; +import { Annotation, AnnotationErrors } from '../../annotations/types'; export interface Path { Key: string; @@ -40,3 +42,7 @@ export type GroupedServiceOptions = { label: string; options: ServiceOption[]; }[]; + +export type IngressErrors = Record & { + annotations?: AnnotationErrors; +}; diff --git a/app/react/kubernetes/ingresses/CreateIngressView/utils.ts b/app/react/kubernetes/ingresses/CreateIngressView/utils.ts index b6dab20da..2381b8f86 100644 --- a/app/react/kubernetes/ingresses/CreateIngressView/utils.ts +++ b/app/react/kubernetes/ingresses/CreateIngressView/utils.ts @@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid'; import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types'; import { TLS, Ingress } from '../types'; +import { Annotation } from '../../annotations/types'; -import { Annotation } from './Annotations/types'; import { Host, Rule } from './types'; const ignoreAnnotationsForEdit = [ diff --git a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx index 2a6f44269..bfdad6935 100644 --- a/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx +++ b/app/react/kubernetes/ingresses/IngressDatatable/IngressDatatable.tsx @@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react'; import { useMemo } from 'react'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; -import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { useAuthorizations, Authorized } from '@/react/hooks/useUser'; import Route from '@/assets/ico/route.svg?c'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; @@ -19,6 +18,7 @@ import { useTableState } from '@@/datatables/useTableState'; import { DeleteIngressesRequest, Ingress } from '../types'; import { useDeleteIngresses, useIngresses } from '../queries'; +import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery'; import { columns } from './columns'; @@ -39,7 +39,8 @@ export function IngressDatatable() { const canAccessSystemResources = useAuthorizations( 'K8sAccessSystemNamespaces' ); - const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); + const { data: namespaces, ...namespacesQuery } = + useNamespacesQuery(environmentId); const { data: ingresses, ...ingressesQuery } = useIngresses( environmentId, Object.keys(namespaces || {}), diff --git a/app/react/kubernetes/ingresses/queries.ts b/app/react/kubernetes/ingresses/queries.ts index 86ea4a380..ae3a7fe08 100644 --- a/app/react/kubernetes/ingresses/queries.ts +++ b/app/react/kubernetes/ingresses/queries.ts @@ -181,7 +181,8 @@ export function useDeleteIngresses() { */ export function useIngressControllers( environmentId: EnvironmentId, - namespace?: string + namespace?: string, + allowedOnly?: boolean ) { return useQuery( [ @@ -193,7 +194,9 @@ export function useIngressControllers( 'ingresscontrollers', ], async () => - namespace ? getIngressControllers(environmentId, namespace) : [], + namespace + ? getIngressControllers(environmentId, namespace, allowedOnly) + : [], { enabled: !!namespace, ...withError('Unable to get ingress controllers'), diff --git a/app/react/kubernetes/ingresses/service.ts b/app/react/kubernetes/ingresses/service.ts index 2cd4d3960..4552d4a2e 100644 --- a/app/react/kubernetes/ingresses/service.ts +++ b/app/react/kubernetes/ingresses/service.ts @@ -34,11 +34,13 @@ export async function getIngresses( export async function getIngressControllers( environmentId: EnvironmentId, - namespace: string + namespace: string, + allowedOnly?: boolean ) { try { const { data: ingresscontrollers } = await axios.get( - `kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers` + `kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`, + allowedOnly ? { params: { allowedOnly: true } } : undefined ); return ingresscontrollers; } catch (e) { diff --git a/app/react/kubernetes/namespaces/CreateView/.keep b/app/react/kubernetes/namespaces/CreateView/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx new file mode 100644 index 000000000..280c6b1d0 --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.tsx @@ -0,0 +1,119 @@ +import { Formik } from 'formik'; +import { useRouter } from '@uirouter/react'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { notifySuccess } from '@/portainer/services/notifications'; +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; + +import { Widget, WidgetBody } from '@@/Widget'; + +import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap'; +import { NamespaceInnerForm } from '../components/NamespaceInnerForm'; +import { useNamespacesQuery } from '../queries/useNamespacesQuery'; + +import { + CreateNamespaceFormValues, + CreateNamespacePayload, + UpdateRegistryPayload, +} from './types'; +import { useClusterResourceLimitsQuery } from './queries/useResourceLimitsQuery'; +import { getNamespaceValidationSchema } from './CreateNamespaceForm.validation'; +import { transformFormValuesToNamespacePayload } from './utils'; +import { useCreateNamespaceMutation } from './queries/useCreateNamespaceMutation'; + +export function CreateNamespaceForm() { + const router = useRouter(); + const environmentId = useEnvironmentId(); + const { data: environment, ...environmentQuery } = useCurrentEnvironment(); + const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId); + const { data: registries } = useEnvironmentRegistries(environmentId, { + hideDefault: true, + }); + // for namespace create, show ingress classes that are allowed in the current environment. + // the ingressClasses show the none option, so we don't need to add it here. + const { data: ingressClasses } = useIngressControllerClassMapQuery({ + environmentId, + allowedOnly: true, + }); + + const { data: namespaces } = useNamespacesQuery(environmentId); + const namespaceNames = Object.keys(namespaces || {}); + + const createNamespaceMutation = useCreateNamespaceMutation(environmentId); + + if (resourceLimitsQuery.isLoading || environmentQuery.isLoading) { + return null; + } + + const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0; + + const initialValues: CreateNamespaceFormValues = { + name: '', + ingressClasses: ingressClasses ?? [], + resourceQuota: { + enabled: false, + memory: '0', + cpu: '0', + }, + registries: [], + }; + + return ( + + + + {NamespaceInnerForm} + + + + ); + + function handleSubmit(values: CreateNamespaceFormValues) { + const createNamespacePayload: CreateNamespacePayload = + transformFormValuesToNamespacePayload(values); + const updateRegistriesPayload: UpdateRegistryPayload[] = + values.registries.flatMap((registryFormValues) => { + // find the matching registry from the cluster registries + const selectedRegistry = registries?.find( + (registry) => registryFormValues.Id === registry.Id + ); + if (!selectedRegistry) { + return []; + } + const envNamespacesWithAccess = + selectedRegistry.RegistryAccesses[`${environmentId}`]?.Namespaces || + []; + return { + Id: selectedRegistry.Id, + Namespaces: [...envNamespacesWithAccess, values.name], + }; + }); + + createNamespaceMutation.mutate( + { + createNamespacePayload, + updateRegistriesPayload, + namespaceIngressControllerPayload: values.ingressClasses, + }, + { + onSuccess: () => { + notifySuccess( + 'Success', + `Namespace '${values.name}' created successfully` + ); + router.stateService.go('kubernetes.resourcePools'); + }, + } + ); + } +} diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.validation.tsx b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.validation.tsx new file mode 100644 index 000000000..1eb8572f6 --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceForm.validation.tsx @@ -0,0 +1,27 @@ +import { string, object, array, SchemaOf } from 'yup'; + +import { registriesValidationSchema } from '../components/RegistriesFormSection/registriesValidationSchema'; +import { getResourceQuotaValidationSchema } from '../components/ResourceQuotaFormSection/getResourceQuotaValidationSchema'; + +import { CreateNamespaceFormValues } from './types'; + +export function getNamespaceValidationSchema( + memoryLimit: number, + namespaceNames: string[] +): SchemaOf { + return object({ + name: string() + .matches( + /^[a-z0-9](?:[-a-z0-9]{0,251}[a-z0-9])?$/, + "This field must consist of lower case alphanumeric characters or '-', and contain at most 63 characters, and must start and end with an alphanumeric character." + ) + .max(63, 'Name must be at most 63 characters.') + // must not have the same name as an existing namespace + .notOneOf(namespaceNames, 'Name must be unique.') + .required('Name is required.'), + resourceQuota: getResourceQuotaValidationSchema(memoryLimit), + // ingress classes table is constrained already, and doesn't need validation + ingressClasses: array(), + registries: registriesValidationSchema, + }); +} diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceView.tsx b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceView.tsx new file mode 100644 index 000000000..7799e7c1a --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/CreateNamespaceView.tsx @@ -0,0 +1,41 @@ +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; +import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect'; + +import { PageHeader } from '@@/PageHeader'; + +import { CreateNamespaceForm } from './CreateNamespaceForm'; + +export function CreateNamespaceView() { + const environmentId = useEnvironmentId(); + + useUnauthorizedRedirect( + { + authorizations: 'K8sResourcePoolsW', + forceEnvironmentId: environmentId, + adminOnlyCE: !isBE, + }, + { + to: 'kubernetes.resourcePools', + params: { + id: environmentId, + }, + } + ); + + return ( +
+ + +
+
+ +
+
+
+ ); +} diff --git a/app/react/kubernetes/namespaces/CreateView/index.tsx b/app/react/kubernetes/namespaces/CreateView/index.tsx new file mode 100644 index 000000000..b3f5ca68c --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/index.tsx @@ -0,0 +1 @@ +export { CreateNamespaceView } from './CreateNamespaceView'; diff --git a/app/react/kubernetes/namespaces/CreateView/queries/useCreateNamespaceMutation.ts b/app/react/kubernetes/namespaces/CreateView/queries/useCreateNamespaceMutation.ts new file mode 100644 index 000000000..5dbbbcc91 --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/queries/useCreateNamespaceMutation.ts @@ -0,0 +1,83 @@ +import { useMutation } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withError } from '@/react-tools/react-query'; +import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { IngressControllerClassMap } from '../../../cluster/ingressClass/types'; +import { updateIngressControllerClassMap } from '../../../cluster/ingressClass/useIngressControllerClassMap'; +import { Namespaces } from '../../types'; +import { CreateNamespacePayload, UpdateRegistryPayload } from '../types'; + +export function useCreateNamespaceMutation(environmentId: EnvironmentId) { + return useMutation( + async ({ + createNamespacePayload, + updateRegistriesPayload, + namespaceIngressControllerPayload, + }: { + createNamespacePayload: CreateNamespacePayload; + updateRegistriesPayload: UpdateRegistryPayload[]; + namespaceIngressControllerPayload: IngressControllerClassMap[]; + }) => { + try { + // create the namespace first, so that it exists before referencing it in the registry access request + await createNamespace(environmentId, createNamespacePayload); + } catch (e) { + throw new Error(e as string); + } + + // collect promises + const updateRegistriesPromises = updateRegistriesPayload.map( + ({ Id, Namespaces }) => + updateEnvironmentRegistryAccess(environmentId, Id, { + Namespaces, + }) + ); + const updateIngressControllerPromise = + namespaceIngressControllerPayload.length > 0 + ? updateIngressControllerClassMap( + environmentId, + namespaceIngressControllerPayload, + createNamespacePayload.Name + ) + : Promise.resolve(); + + // return combined promises + return Promise.allSettled([ + updateIngressControllerPromise, + ...updateRegistriesPromises, + ]); + }, + { + ...withError('Unable to create namespace'), + } + ); +} + +// createNamespace is used to create a namespace using the Portainer backend +async function createNamespace( + environmentId: EnvironmentId, + payload: CreateNamespacePayload +) { + try { + const { data: ns } = await axios.post( + buildUrl(environmentId), + payload + ); + return ns; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to create namespace'); + } +} + +function buildUrl(environmentId: EnvironmentId, namespace?: string) { + let url = `kubernetes/${environmentId}/namespaces`; + + if (namespace) { + url += `/${namespace}`; + } + + return url; +} diff --git a/app/react/kubernetes/namespaces/CreateView/queries/useResourceLimitsQuery.ts b/app/react/kubernetes/namespaces/CreateView/queries/useResourceLimitsQuery.ts new file mode 100644 index 000000000..74bf5e93d --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/queries/useResourceLimitsQuery.ts @@ -0,0 +1,37 @@ +import { useQuery } from 'react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { notifyError } from '@/portainer/services/notifications'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +type K8sNodeLimits = { + CPU: number; + Memory: number; +}; + +/** + * useClusterResourceLimitsQuery is used to retrieve the total resource limits for a cluster, minus the allocated resources taken by existing namespaces + * @returns the available resource limits for the cluster + * */ +export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) { + return useQuery( + ['environments', environmentId, 'kubernetes', 'max_resource_limits'], + () => getResourceLimits(environmentId), + { + onError: (err) => { + notifyError('Failure', err as Error, 'Unable to get resource limits'); + }, + } + ); +} + +async function getResourceLimits(environmentId: EnvironmentId) { + try { + const { data: limits } = await axios.get( + `/kubernetes/${environmentId}/max_resource_limits` + ); + return limits; + } catch (e) { + throw parseAxiosError(e, 'Unable to retrieve resource limits'); + } +} diff --git a/app/react/kubernetes/namespaces/CreateView/types.ts b/app/react/kubernetes/namespaces/CreateView/types.ts new file mode 100644 index 000000000..0901f25d9 --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/types.ts @@ -0,0 +1,24 @@ +import { Registry } from '@/react/portainer/registries/types'; + +import { IngressControllerClassMap } from '../../cluster/ingressClass/types'; +import { + ResourceQuotaFormValues, + ResourceQuotaPayload, +} from '../components/ResourceQuotaFormSection/types'; + +export type CreateNamespaceFormValues = { + name: string; + resourceQuota: ResourceQuotaFormValues; + ingressClasses: IngressControllerClassMap[]; + registries: Registry[]; +}; + +export type CreateNamespacePayload = { + Name: string; + ResourceQuota: ResourceQuotaPayload; +}; + +export type UpdateRegistryPayload = { + Id: number; + Namespaces: string[]; +}; diff --git a/app/react/kubernetes/namespaces/CreateView/utils.ts b/app/react/kubernetes/namespaces/CreateView/utils.ts new file mode 100644 index 000000000..fd2418148 --- /dev/null +++ b/app/react/kubernetes/namespaces/CreateView/utils.ts @@ -0,0 +1,16 @@ +import { CreateNamespaceFormValues, CreateNamespacePayload } from './types'; + +export function transformFormValuesToNamespacePayload( + createNamespaceFormValues: CreateNamespaceFormValues +): CreateNamespacePayload { + const memoryInBytes = + Number(createNamespaceFormValues.resourceQuota.memory) * 10 ** 6; + return { + Name: createNamespaceFormValues.name, + ResourceQuota: { + enabled: createNamespaceFormValues.resourceQuota.enabled, + cpu: createNamespaceFormValues.resourceQuota.cpu, + memory: `${memoryInBytes}`, + }, + }; +} diff --git a/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/LoadBalancerFormSection.tsx b/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/LoadBalancerFormSection.tsx new file mode 100644 index 000000000..7e1c612aa --- /dev/null +++ b/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/LoadBalancerFormSection.tsx @@ -0,0 +1,26 @@ +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { FormSection } from '@@/form-components/FormSection'; +import { SwitchField } from '@@/form-components/SwitchField'; +import { TextTip } from '@@/Tip/TextTip'; + +export function LoadBalancerFormSection() { + return ( + + + You can set a quota on the number of external load balancers that can be + created inside this namespace. Set this quota to 0 to effectively + disable the use of load balancers in this namespace. + + {}} + /> + + ); +} diff --git a/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/index.ts b/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/index.ts new file mode 100644 index 000000000..239976298 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/LoadBalancerFormSection/index.ts @@ -0,0 +1 @@ +export { LoadBalancerFormSection } from './LoadBalancerFormSection'; diff --git a/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx b/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx new file mode 100644 index 000000000..e19242ae7 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/NamespaceInnerForm.tsx @@ -0,0 +1,119 @@ +import { Field, Form, FormikProps } from 'formik'; +import { MultiValue } from 'react-select'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; +import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; +import { Registry } from '@/react/portainer/registries/types'; + +import { FormControl } from '@@/form-components/FormControl'; +import { FormSection } from '@@/form-components/FormSection'; +import { Input } from '@@/form-components/Input'; +import { FormActions } from '@@/form-components/FormActions'; + +import { IngressClassDatatable } from '../../cluster/ingressClass/IngressClassDatatable'; +import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap'; +import { CreateNamespaceFormValues } from '../CreateView/types'; +import { AnnotationsBeTeaser } from '../../annotations/AnnotationsBeTeaser'; + +import { LoadBalancerFormSection } from './LoadBalancerFormSection'; +import { NamespaceSummary } from './NamespaceSummary'; +import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection'; +import { ResourceQuotaFormSection } from './ResourceQuotaFormSection'; +import { RegistriesFormSection } from './RegistriesFormSection'; +import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types'; + +export function NamespaceInnerForm({ + errors, + isValid, + setFieldValue, + values, + isSubmitting, + initialValues, +}: FormikProps) { + 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 ( +
+ + + + + + setFieldValue('resourceQuota', resourceQuota) + } + errors={errors.resourceQuota} + /> + {useLoadBalancer && } + {enableIngressControllersPerNamespace && ( + + setFieldValue('ingressClasses', classes)} + values={values.ingressClasses} + description="Enable the ingress controllers that users can select when publishing applications in this namespace." + noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster." + view="namespace" + isLoading={ingressClassesQuery.isLoading} + initialValues={initialValues.ingressClasses} + /> + + )} + ) => + setFieldValue('registries', registries) + } + errors={errors.registries} + /> + {storageClasses.length > 0 && ( + + )} + + + + + + ); +} diff --git a/app/react/kubernetes/namespaces/components/NamespaceSummary.tsx b/app/react/kubernetes/namespaces/components/NamespaceSummary.tsx new file mode 100644 index 000000000..3ec92b25b --- /dev/null +++ b/app/react/kubernetes/namespaces/components/NamespaceSummary.tsx @@ -0,0 +1,46 @@ +import _ from 'lodash'; + +import { FormSection } from '@@/form-components/FormSection'; +import { TextTip } from '@@/Tip/TextTip'; + +import { CreateNamespaceFormValues } from '../CreateView/types'; + +interface Props { + initialValues: CreateNamespaceFormValues; + values: CreateNamespaceFormValues; + isValid: boolean; +} + +export function NamespaceSummary({ initialValues, values, isValid }: Props) { + const hasChanges = !_.isEqual(values, initialValues); + + if (!hasChanges || !isValid) { + return null; + } + + return ( + +
+
+ + 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/RegistriesFormSection/RegistriesFormSection.tsx b/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesFormSection.tsx new file mode 100644 index 000000000..002fa912a --- /dev/null +++ b/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesFormSection.tsx @@ -0,0 +1,47 @@ +import { FormikErrors } from 'formik'; +import { MultiValue } from 'react-select'; + +import { Registry } from '@/react/portainer/registries/types'; +import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries'; +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { InlineLoader } from '@@/InlineLoader'; +import { FormControl } from '@@/form-components/FormControl'; +import { FormSection } from '@@/form-components/FormSection'; + +import { RegistriesSelector } from './RegistriesSelector'; + +type Props = { + values: MultiValue; + onChange: (value: MultiValue) => void; + errors?: string | string[] | FormikErrors[]; +}; + +export function RegistriesFormSection({ values, onChange, errors }: Props) { + const environmentId = useEnvironmentId(); + const registriesQuery = useEnvironmentRegistries(environmentId, { + hideDefault: true, + }); + return ( + + + {registriesQuery.isLoading && ( + Loading registries... + )} + {registriesQuery.data && ( + onChange(registries)} + options={registriesQuery.data} + inputId="registries" + /> + )} + + + ); +} diff --git a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx b/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector.tsx similarity index 72% rename from app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx rename to app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector.tsx index 046871c32..5e6c5697f 100644 --- a/app/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector.tsx +++ b/app/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector.tsx @@ -1,15 +1,17 @@ +import { MultiValue } from 'react-select'; + import { Registry } from '@/react/portainer/registries/types'; import { Select } from '@@/form-components/ReactSelect'; interface Props { - value: Registry[]; - onChange(value: readonly Registry[]): void; + value: MultiValue; + onChange(value: MultiValue): void; options: Registry[]; inputId?: string; } -export function CreateNamespaceRegistriesSelector({ +export function RegistriesSelector({ value, onChange, options, @@ -26,7 +28,7 @@ export function CreateNamespaceRegistriesSelector({ onChange={onChange} inputId={inputId} data-cy="namespaceCreate-registrySelect" - placeholder="Select one or more registry" + placeholder="Select one or more registries" /> ); } diff --git a/app/react/kubernetes/namespaces/components/RegistriesFormSection/index.ts b/app/react/kubernetes/namespaces/components/RegistriesFormSection/index.ts new file mode 100644 index 000000000..04f934346 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/RegistriesFormSection/index.ts @@ -0,0 +1 @@ +export { RegistriesFormSection } from './RegistriesFormSection'; diff --git a/app/react/kubernetes/namespaces/components/RegistriesFormSection/registriesValidationSchema.ts b/app/react/kubernetes/namespaces/components/RegistriesFormSection/registriesValidationSchema.ts new file mode 100644 index 000000000..5206187f3 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/RegistriesFormSection/registriesValidationSchema.ts @@ -0,0 +1,10 @@ +import { SchemaOf, array, object, number, string } from 'yup'; + +import { Registry } from '@/react/portainer/registries/types'; + +export const registriesValidationSchema: SchemaOf = array( + object({ + Id: number().required('Registry ID is required.'), + Name: string().required('Registry name is required.'), + }) +); diff --git a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx new file mode 100644 index 000000000..14bbc9463 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/ResourceQuotaFormSection.tsx @@ -0,0 +1,121 @@ +import { FormikErrors } from 'formik'; + +import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; + +import { FormControl } from '@@/form-components/FormControl'; +import { FormError } from '@@/form-components/FormError'; +import { FormSection } from '@@/form-components/FormSection'; +import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; +import { Slider } from '@@/form-components/Slider'; +import { SwitchField } from '@@/form-components/SwitchField'; +import { TextTip } from '@@/Tip/TextTip'; +import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput'; + +import { useClusterResourceLimitsQuery } from '../../CreateView/queries/useResourceLimitsQuery'; + +import { ResourceQuotaFormValues } from './types'; + +interface Props { + values: ResourceQuotaFormValues; + onChange: (value: ResourceQuotaFormValues) => void; + enableResourceOverCommit?: boolean; + errors?: FormikErrors; +} + +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 ( + + {values.enabled ? ( + + A namespace is a logical abstraction of a Kubernetes cluster, to + provide for more flexible management of resources. Best practice is to + set a quota assignment as this ensures greatest security/stability; + alternatively, you can disable assigning a quota for unrestricted + access (not recommended). + + ) : ( + + A namespace is a logical abstraction of a Kubernetes cluster, to + provide for more flexible management of resources. Resource + over-commit is disabled, please assign a capped limit of resources to + this namespace. + + )} + + onChange({ ...values, enabled })} + /> + + {(values.enabled || !!enableResourceOverCommit) && ( +
+
+ Resource Limits +
+ {/* keep the FormError component present, but invisible to avoid layout shift */} + + {/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */} + {errors || 'error'} + + +
+ + onChange({ ...values, memory: `${value}` }) + } + max={memoryLimit} + step={128} + dataCy="k8sNamespaceCreate-memoryLimit" + visibleTooltip + /> + {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 new file mode 100644 index 000000000..d7565aa53 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/getResourceQuotaValidationSchema.ts @@ -0,0 +1,45 @@ +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/ResourceQuotaFormSection/index.ts b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/index.ts new file mode 100644 index 000000000..5a5cc6e46 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/index.ts @@ -0,0 +1 @@ +export { ResourceQuotaFormSection } from './ResourceQuotaFormSection'; diff --git a/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/types.ts b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/types.ts new file mode 100644 index 000000000..765bc3d04 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/ResourceQuotaFormSection/types.ts @@ -0,0 +1,18 @@ +/** + * @property enabled - Whether resource quota is enabled + * @property memory - Memory limit in bytes + * @property cpu - CPU limit in cores + * @property loadBalancer - Load balancer limit in number of load balancers + */ +export type ResourceQuotaFormValues = { + enabled: boolean; + memory?: string; + cpu?: string; +}; + +export type ResourceQuotaPayload = { + enabled: boolean; + memory?: string; + cpu?: string; + loadBalancerLimit?: string; +}; diff --git a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaFormSection.tsx b/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaFormSection.tsx new file mode 100644 index 000000000..454f226ec --- /dev/null +++ b/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaFormSection.tsx @@ -0,0 +1,27 @@ +import { StorageClass } from '@/react/portainer/environments/types'; + +import { FormSection } from '@@/form-components/FormSection'; +import { TextTip } from '@@/Tip/TextTip'; + +import { StorageQuotaItem } from './StorageQuotaItem'; + +interface Props { + storageClasses: StorageClass[]; +} + +export function StorageQuotaFormSection({ storageClasses }: Props) { + return ( + + + Quotas can be set on each storage option to prevent users from exceeding + a specific threshold when deploying applications. You can set a quota to + 0 to effectively prevent the usage of a specific storage option inside + this namespace. + + + {storageClasses.map((storageClass) => ( + + ))} + + ); +} diff --git a/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaItem.tsx b/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaItem.tsx new file mode 100644 index 000000000..58205eb47 --- /dev/null +++ b/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/StorageQuotaItem.tsx @@ -0,0 +1,40 @@ +import { Database } from 'lucide-react'; + +import { StorageClass } from '@/react/portainer/environments/types'; +import { FeatureId } from '@/react/portainer/feature-flags/enums'; + +import { Icon } from '@@/Icon'; +import { FormSectionTitle } from '@@/form-components/FormSectionTitle'; +import { SwitchField } from '@@/form-components/SwitchField'; + +type Props = { + storageClass: StorageClass; +}; + +export function StorageQuotaItem({ storageClass }: Props) { + return ( +
+ +
+ + {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/StorageQuotaFormSection/index.ts new file mode 100644 index 000000000..1db12278e --- /dev/null +++ b/app/react/kubernetes/namespaces/components/StorageQuotaFormSection/index.ts @@ -0,0 +1 @@ +export { StorageQuotaFormSection } from './StorageQuotaFormSection'; diff --git a/app/react/kubernetes/namespaces/getSelfSubjectAccessReview.ts b/app/react/kubernetes/namespaces/getSelfSubjectAccessReview.ts new file mode 100644 index 000000000..2d1c0575e --- /dev/null +++ b/app/react/kubernetes/namespaces/getSelfSubjectAccessReview.ts @@ -0,0 +1,51 @@ +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +interface SelfSubjectAccessReviewResponse { + status: { + allowed: boolean; + }; + spec: { + resourceAttributes: { + namespace: string; + }; + }; +} + +/** + * getSelfSubjectAccessReview is used to retrieve the self subject access review for a given namespace. + * It's great to use this to determine if a user has access to a namespace. + * @returns the self subject access review for the given namespace + * */ +export async function getSelfSubjectAccessReview( + environmentId: EnvironmentId, + namespaceName: string, + verb = 'list', + resource = 'deployments', + group = 'apps' +) { + try { + const { data: accessReview } = + await axios.post( + `endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`, + { + spec: { + resourceAttributes: { + group, + resource, + verb, + namespace: namespaceName, + }, + }, + apiVersion: 'authorization.k8s.io/v1', + kind: 'SelfSubjectAccessReview', + } + ); + return accessReview; + } catch (e) { + throw parseAxiosError( + e as Error, + 'Unable to retrieve self subject access review' + ); + } +} diff --git a/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts b/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts new file mode 100644 index 000000000..f9f115b5f --- /dev/null +++ b/app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts @@ -0,0 +1,37 @@ +import { useQuery } from 'react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { notifyError } from '@/portainer/services/notifications'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { Namespaces } from '../types'; + +export function useNamespaceQuery( + environmentId: EnvironmentId, + namespace: string +) { + return useQuery( + ['environments', environmentId, 'kubernetes', 'namespaces', namespace], + () => getNamespace(environmentId, namespace), + { + onError: (err) => { + notifyError('Failure', err as Error, 'Unable to get namespace.'); + }, + } + ); +} + +// getNamespace is used to retrieve a namespace using the Portainer backend +export async function getNamespace( + environmentId: EnvironmentId, + namespace: string +) { + try { + const { data: ns } = await axios.get( + `kubernetes/${environmentId}/namespaces/${namespace}` + ); + return ns; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve namespace'); + } +} diff --git a/app/react/kubernetes/namespaces/queries.ts b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts similarity index 69% rename from app/react/kubernetes/namespaces/queries.ts rename to app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts index 905248fca..28554159a 100644 --- a/app/react/kubernetes/namespaces/queries.ts +++ b/app/react/kubernetes/namespaces/queries/useNamespacesQuery.ts @@ -1,17 +1,13 @@ import { useQuery } from 'react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { error as notifyError } from '@/portainer/services/notifications'; import { withError } from '@/react-tools/react-query'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { - getNamespaces, - getNamespace, - getSelfSubjectAccessReview, -} from './service'; -import { Namespaces } from './types'; +import { Namespaces } from '../types'; +import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview'; -export function useNamespaces( +export function useNamespacesQuery( environmentId: EnvironmentId, options?: { autoRefreshRate?: number } ) { @@ -46,14 +42,14 @@ export function useNamespaces( ); } -export function useNamespace(environmentId: EnvironmentId, namespace: string) { - return useQuery( - ['environments', environmentId, 'kubernetes', 'namespaces', namespace], - () => getNamespace(environmentId, namespace), - { - onError: (err) => { - notifyError('Failure', err as Error, 'Unable to get namespace.'); - }, - } - ); +// getNamespaces is used to retrieve namespaces using the Portainer backend with caching +async function getNamespaces(environmentId: EnvironmentId) { + try { + const { data: namespaces } = await axios.get( + `kubernetes/${environmentId}/namespaces` + ); + return namespaces; + } catch (e) { + throw parseAxiosError(e as Error, 'Unable to retrieve namespaces'); + } } diff --git a/app/react/kubernetes/namespaces/service.ts b/app/react/kubernetes/namespaces/service.ts deleted file mode 100644 index 405eed82a..000000000 --- a/app/react/kubernetes/namespaces/service.ts +++ /dev/null @@ -1,74 +0,0 @@ -import axios, { parseAxiosError } from '@/portainer/services/axios'; -import { EnvironmentId } from '@/react/portainer/environments/types'; - -import { Namespaces, SelfSubjectAccessReviewResponse } from './types'; - -// getNamespace is used to retrieve a namespace using the Portainer backend -export async function getNamespace( - environmentId: EnvironmentId, - namespace: string -) { - try { - const { data: ns } = await axios.get( - buildUrl(environmentId, namespace) - ); - return ns; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve namespace'); - } -} - -// getNamespaces is used to retrieve namespaces using the Portainer backend with caching -export async function getNamespaces(environmentId: EnvironmentId) { - try { - const { data: namespaces } = await axios.get( - buildUrl(environmentId) - ); - return namespaces; - } catch (e) { - throw parseAxiosError(e as Error, 'Unable to retrieve namespaces'); - } -} - -export async function getSelfSubjectAccessReview( - environmentId: EnvironmentId, - namespaceName: string, - verb = 'list', - resource = 'deployments', - group = 'apps' -) { - try { - const { data: accessReview } = - await axios.post( - `endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`, - { - spec: { - resourceAttributes: { - group, - resource, - verb, - namespace: namespaceName, - }, - }, - apiVersion: 'authorization.k8s.io/v1', - kind: 'SelfSubjectAccessReview', - } - ); - return accessReview; - } catch (e) { - throw parseAxiosError( - e as Error, - 'Unable to retrieve self subject access review' - ); - } -} - -function buildUrl(environmentId: EnvironmentId, namespace?: string) { - let url = `kubernetes/${environmentId}/namespaces`; - - if (namespace) { - url += `/${namespace}`; - } - - return url; -} diff --git a/app/react/kubernetes/namespaces/types.ts b/app/react/kubernetes/namespaces/types.ts index 86aae0c48..20436c306 100644 --- a/app/react/kubernetes/namespaces/types.ts +++ b/app/react/kubernetes/namespaces/types.ts @@ -4,14 +4,3 @@ export interface Namespaces { IsSystem: boolean; }; } - -export interface SelfSubjectAccessReviewResponse { - status: { - allowed: boolean; - }; - spec: { - resourceAttributes: { - namespace: string; - }; - }; -} diff --git a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx index a8696bc09..21eea4d5a 100644 --- a/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx +++ b/app/react/kubernetes/services/ServicesView/ServicesDatatable/ServicesDatatable.tsx @@ -9,8 +9,8 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { pluralize } from '@/portainer/helpers/strings'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils'; -import { useNamespaces } from '@/react/kubernetes/namespaces/queries'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; +import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery'; import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; import { confirmDelete } from '@@/modals/confirm'; @@ -33,7 +33,8 @@ const settingsStore = createStore(storageKey); export function ServicesDatatable() { const tableState = useTableState(settingsStore, storageKey); const environmentId = useEnvironmentId(); - const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); + const { data: namespaces, ...namespacesQuery } = + useNamespacesQuery(environmentId); const namespaceNames = (namespaces && Object.keys(namespaces)) || []; const { data: services, ...servicesQuery } = useServicesForCluster( environmentId, diff --git a/app/react/portainer/environments/environment.service/registries.ts b/app/react/portainer/environments/environment.service/registries.ts index 4efc75ae9..875b31ad6 100644 --- a/app/react/portainer/environments/environment.service/registries.ts +++ b/app/react/portainer/environments/environment.service/registries.ts @@ -22,12 +22,12 @@ interface RegistryAccess { } export async function updateEnvironmentRegistryAccess( - id: EnvironmentId, + environmentId: EnvironmentId, registryId: RegistryId, - access: RegistryAccess + access: Partial ) { try { - await axios.put(buildRegistryUrl(id, registryId), access); + await axios.put(buildRegistryUrl(environmentId, registryId), access); } catch (e) { throw parseAxiosError(e as Error); } diff --git a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts index a465ff053..19588b5ca 100644 --- a/app/react/portainer/environments/queries/useEnvironmentRegistries.ts +++ b/app/react/portainer/environments/queries/useEnvironmentRegistries.ts @@ -3,13 +3,16 @@ import axios, { parseAxiosError } from '@/portainer/services/axios'; import { buildUrl } from '../environment.service/utils'; import { EnvironmentId } from '../types'; import { Registry } from '../../registries/types/registry'; -import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries'; +import { + GenericRegistriesQueryOptions, + useGenericRegistriesQuery, +} from '../../registries/queries/useRegistries'; import { environmentQueryKeys } from './query-keys'; export function useEnvironmentRegistries>( environmentId: EnvironmentId, - queryOptions: { select?(data: Array): T; enabled?: boolean } = {} + queryOptions: GenericRegistriesQueryOptions = {} ) { return useGenericRegistriesQuery( environmentQueryKeys.registries(environmentId), diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts index 464370b6c..f35a077ee 100644 --- a/app/react/portainer/environments/types.ts +++ b/app/react/portainer/environments/types.ts @@ -50,7 +50,7 @@ export type IngressClass = { Type: string; }; -interface StorageClass { +export interface StorageClass { Name: string; AccessModes: string[]; AllowVolumeExpansion: boolean; diff --git a/app/react/portainer/registries/queries/useRegistries.ts b/app/react/portainer/registries/queries/useRegistries.ts index e693ae129..e72502003 100644 --- a/app/react/portainer/registries/queries/useRegistries.ts +++ b/app/react/portainer/registries/queries/useRegistries.ts @@ -22,6 +22,16 @@ export function useRegistries( ); } +/** + * @field hideDefault - is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this. + */ +export type GenericRegistriesQueryOptions = { + enabled?: boolean; + select?: (registries: Registry[]) => T; + onSuccess?: (data: T) => void; + hideDefault?: boolean; +}; + export function useGenericRegistriesQuery( queryKey: QueryKey, fetcher: () => Promise>, @@ -29,18 +39,16 @@ export function useGenericRegistriesQuery( enabled, select, onSuccess, - }: { - enabled?: boolean; - select?: (registries: Registry[]) => T; - onSuccess?: (data: T) => void; - } = {} + hideDefault: hideDefaultOverride, + }: GenericRegistriesQueryOptions = {} ) { const hideDefaultRegistryQuery = usePublicSettings({ select: (settings) => settings.DefaultRegistry?.Hide, - enabled, + // We don't need the hideDefaultRegistry info if we're overriding it to true + enabled: enabled && !hideDefaultOverride, }); - const hideDefault = !!hideDefaultRegistryQuery.data; + const hideDefault = hideDefaultOverride || !!hideDefaultRegistryQuery.data; return useQuery( queryKey, @@ -66,7 +74,8 @@ export function useGenericRegistriesQuery( { select, ...withError('Unable to retrieve registries'), - enabled: hideDefaultRegistryQuery.isSuccess && enabled, + enabled: + (hideDefaultOverride || hideDefaultRegistryQuery.isSuccess) && enabled, onSuccess, } );