mirror of https://github.com/portainer/portainer
refactor(namespace): migrate namespace edit to react [r8s-125] (#38)
parent
40c7742e46
commit
ce7e0d8d60
|
@ -0,0 +1,51 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
)
|
||||
|
||||
// @id UpdateKubernetesNamespaceDeprecated
|
||||
// @summary Update a namespace
|
||||
// @description Update a namespace within the given environment.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param namespace path string true "Namespace"
|
||||
// @param body body models.K8sNamespaceDetails true "Namespace details"
|
||||
// @success 200 {object} portainer.K8sNamespaceInfo "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier or unable to find a specific namespace."
|
||||
// @failure 500 "Server error occurred while attempting to update the namespace."
|
||||
// @router /kubernetes/{id}/namespaces [put]
|
||||
func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
environmentId, err := request.RetrieveRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: id", err)
|
||||
}
|
||||
|
||||
// Restore the original body for further use
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
payload := models.K8sNamespaceDetails{}
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
|
||||
}
|
||||
namespaceName := payload.Name
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
return "/kubernetes/" + environmentId + "/namespaces/" + namespaceName, nil
|
||||
}
|
|
@ -81,11 +81,11 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||
endpointRouter.Handle("/services/delete", httperror.LoggerHandler(h.deleteKubernetesServices)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/rbac_enabled", httperror.LoggerHandler(h.getKubernetesRBACStatus)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.createKubernetesNamespace)).Methods(http.MethodPost)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.deleteKubernetesNamespace)).Methods(http.MethodDelete)
|
||||
endpointRouter.Handle("/namespaces", httperror.LoggerHandler(h.getKubernetesNamespaces)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/count", httperror.LoggerHandler(h.getKubernetesNamespacesCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.getKubernetesNamespace)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/namespaces/{namespace}", httperror.LoggerHandler(h.updateKubernetesNamespace)).Methods(http.MethodPut)
|
||||
endpointRouter.Handle("/volumes", httperror.LoggerHandler(h.GetAllKubernetesVolumes)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/volumes/count", httperror.LoggerHandler(h.getAllKubernetesVolumesCount)).Methods(http.MethodGet)
|
||||
endpointRouter.Handle("/service_accounts", httperror.LoggerHandler(h.getAllKubernetesServiceAccounts)).Methods(http.MethodGet)
|
||||
|
@ -115,8 +115,12 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.createKubernetesService)).Methods(http.MethodPost)
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.updateKubernetesService)).Methods(http.MethodPut)
|
||||
namespaceRouter.Handle("/services", httperror.LoggerHandler(h.getKubernetesServicesByNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/volumes", httperror.LoggerHandler(h.GetKubernetesVolumesInNamespace)).Methods(http.MethodGet)
|
||||
namespaceRouter.Handle("/volumes/{volume}", httperror.LoggerHandler(h.getKubernetesVolume)).Methods(http.MethodGet)
|
||||
|
||||
// Deprecated
|
||||
endpointRouter.Handle("/namespaces", middlewares.Deprecated(endpointRouter, deprecatedNamespaceParser)).Methods(http.MethodPut)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
|
|
@ -27,7 +27,7 @@ import (
|
|||
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes."
|
||||
// @router /kubernetes/{id}/volumes [get]
|
||||
func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
volumes, err := handler.getKubernetesVolumes(r)
|
||||
volumes, err := handler.getKubernetesVolumes(r, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ func (handler *Handler) GetAllKubernetesVolumes(w http.ResponseWriter, r *http.R
|
|||
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes count."
|
||||
// @router /kubernetes/{id}/volumes/count [get]
|
||||
func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
volumes, err := handler.getKubernetesVolumes(r)
|
||||
volumes, err := handler.getKubernetesVolumes(r, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -57,6 +57,36 @@ func (handler *Handler) getAllKubernetesVolumesCount(w http.ResponseWriter, r *h
|
|||
return response.JSON(w, len(volumes))
|
||||
}
|
||||
|
||||
// @id GetKubernetesVolumesInNamespace
|
||||
// @summary Get Kubernetes volumes within a namespace in the given Portainer environment
|
||||
// @description Get a list of kubernetes volumes within the specified namespace in the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
|
||||
// @description **Access policy**: Authenticated user.
|
||||
// @tags kubernetes
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param namespace path string true "Namespace identifier"
|
||||
// @param withApplications query boolean false "When set to True, include the applications that are using the volumes. It is set to false by default"
|
||||
// @success 200 {object} map[string]kubernetes.K8sVolumeInfo "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 403 "Unauthorized access or operation not allowed."
|
||||
// @failure 500 "Server error occurred while attempting to retrieve kubernetes volumes in the namespace."
|
||||
// @router /kubernetes/{id}/namespaces/{namespace}/volumes [get]
|
||||
func (handler *Handler) GetKubernetesVolumesInNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetKubernetesVolumesInNamespace").Msg("Unable to retrieve namespace identifier")
|
||||
return httperror.BadRequest("Invalid namespace identifier", err)
|
||||
}
|
||||
|
||||
volumes, httpErr := handler.getKubernetesVolumes(r, namespace)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
return response.JSON(w, volumes)
|
||||
}
|
||||
|
||||
// @id GetKubernetesVolume
|
||||
// @summary Get a Kubernetes volume within the given Portainer environment
|
||||
// @description Get a Kubernetes volume within the given environment (Endpoint). The Endpoint ID must be a valid Portainer environment identifier.
|
||||
|
@ -109,7 +139,7 @@ func (handler *Handler) getKubernetesVolume(w http.ResponseWriter, r *http.Reque
|
|||
return response.JSON(w, volume)
|
||||
}
|
||||
|
||||
func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
|
||||
func (handler *Handler) getKubernetesVolumes(r *http.Request, namespace string) ([]models.K8sVolumeInfo, *httperror.HandlerError) {
|
||||
withApplications, err := request.RetrieveBooleanQueryParameter(r, "withApplications", true)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Bool("withApplications", withApplications).Msg("Unable to parse query parameter")
|
||||
|
@ -122,7 +152,7 @@ func (handler *Handler) getKubernetesVolumes(r *http.Request) ([]models.K8sVolum
|
|||
return nil, httperror.InternalServerError("Failed to prepare Kubernetes client", httpErr)
|
||||
}
|
||||
|
||||
volumes, err := cli.GetVolumes("")
|
||||
volumes, err := cli.GetVolumes(namespace)
|
||||
if err != nil {
|
||||
if k8serrors.IsUnauthorized(err) {
|
||||
log.Error().Err(err).Str("context", "GetKubernetesVolumes").Msg("Unauthorized access")
|
||||
|
|
|
@ -47,7 +47,9 @@ func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, e
|
|||
|
||||
// fetchNamespacesForNonAdmin gets the namespaces in the current k8s environment(endpoint) for the non-admin user.
|
||||
func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
log.Debug().Msgf("Fetching namespaces for non-admin user: %v", kcl.NonAdminNamespaces)
|
||||
log.Debug().
|
||||
Str("context", "fetchNamespacesForNonAdmin").
|
||||
Msg("Fetching namespaces for non-admin user")
|
||||
|
||||
if len(kcl.NonAdminNamespaces) == 0 {
|
||||
return nil, nil
|
||||
|
@ -75,6 +77,11 @@ func (kcl *KubeClient) fetchNamespacesForNonAdmin() (map[string]portainer.K8sNam
|
|||
func (kcl *KubeClient) fetchNamespaces() (map[string]portainer.K8sNamespaceInfo, error) {
|
||||
namespaces, err := kcl.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "fetchNamespaces").
|
||||
Err(err).
|
||||
Msg("Failed to list namespaces")
|
||||
|
||||
return nil, fmt.Errorf("an error occurred during the fetchNamespacesForAdmin operation, unable to list namespaces for the admin user: %w", err)
|
||||
}
|
||||
|
||||
|
@ -92,6 +99,7 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
|
|||
Id: string(namespace.UID),
|
||||
Name: namespace.Name,
|
||||
Status: namespace.Status,
|
||||
Annotations: namespace.Annotations,
|
||||
CreationDate: namespace.CreationTimestamp.Format(time.RFC3339),
|
||||
NamespaceOwner: namespace.Labels[namespaceOwnerLabel],
|
||||
IsSystem: isSystemNamespace(namespace),
|
||||
|
@ -103,13 +111,18 @@ func parseNamespace(namespace *corev1.Namespace) portainer.K8sNamespaceInfo {
|
|||
func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, error) {
|
||||
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "GetNamespace").
|
||||
Str("namespace", name).
|
||||
Err(err).
|
||||
Msg("Failed to get namespace")
|
||||
return portainer.K8sNamespaceInfo{}, err
|
||||
}
|
||||
|
||||
return parseNamespace(namespace), nil
|
||||
}
|
||||
|
||||
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
|
||||
// CreateNamespace creates a new namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
|
||||
portainerLabels := map[string]string{
|
||||
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
|
||||
|
@ -125,52 +138,127 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) (*corev1
|
|||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("context", "CreateNamespace").
|
||||
Str("Namespace", info.Name).
|
||||
Msg("Failed to create the namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info.ResourceQuota != nil && info.ResourceQuota.Enabled {
|
||||
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
|
||||
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
|
||||
|
||||
resourceQuota := &corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: corev1.ResourceQuotaSpec{
|
||||
Hard: corev1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
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[corev1.ResourceLimitsMemory] = memQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota
|
||||
}
|
||||
|
||||
if cpu.Value() > 0 {
|
||||
cpuQuota := cpu
|
||||
resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota
|
||||
resourceQuota.Spec.Hard[corev1.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 nil, err
|
||||
}
|
||||
if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("context", "CreateNamespace").
|
||||
Str("name", info.Name).
|
||||
Msg("failed to create or update resource quota for namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return namespace, nil
|
||||
}
|
||||
|
||||
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
|
||||
portainerLabels := map[string]string{
|
||||
namespaceNameLabel: stackutils.SanitizeLabel(info.Name),
|
||||
namespaceOwnerLabel: stackutils.SanitizeLabel(info.Owner),
|
||||
}
|
||||
|
||||
namespace := corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: info.Name,
|
||||
Annotations: info.Annotations,
|
||||
},
|
||||
}
|
||||
|
||||
updatedNamespace, err := kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "UpdateNamespace").
|
||||
Str("namespace", info.Name).
|
||||
Err(err).
|
||||
Msg("Failed to update namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := kcl.createOrUpdateNamespaceResourceQuota(info, portainerLabels); err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("context", "UpdateNamespace").
|
||||
Str("name", info.Name).
|
||||
Msg("failed to create or update resource quota for namespace")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedNamespace, nil
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) createOrUpdateNamespaceResourceQuota(info models.K8sNamespaceDetails, portainerLabels map[string]string) error {
|
||||
if !info.ResourceQuota.Enabled {
|
||||
if err := kcl.deleteNamespaceResourceQuota(info.Name); err != nil {
|
||||
log.Debug().Err(err).Str("context", "createOrUpdateNamespaceResourceQuota").Str("name", info.Name).Msg("failed to delete resource quota for namespace")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
resourceQuota := &corev1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: corev1.ResourceQuotaSpec{
|
||||
Hard: corev1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
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[corev1.ResourceLimitsMemory] = memQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsMemory] = memQuota
|
||||
}
|
||||
|
||||
if cpu.Value() > 0 {
|
||||
cpuQuota := cpu
|
||||
resourceQuota.Spec.Hard[corev1.ResourceLimitsCPU] = cpuQuota
|
||||
resourceQuota.Spec.Hard[corev1.ResourceRequestsCPU] = cpuQuota
|
||||
}
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Update(context.Background(), resourceQuota, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
if k8serrors.IsNotFound(err) {
|
||||
log.Warn().
|
||||
Str("context", "createOrUpdateNamespaceResourceQuota").
|
||||
Str("name", info.Name).
|
||||
Msg("resource quota not found, creating")
|
||||
_, err = kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) deleteNamespaceResourceQuota(namespaceName string) error {
|
||||
err := kcl.cli.CoreV1().ResourceQuotas(namespaceName).Delete(context.Background(), "portainer-rq-"+namespaceName, metav1.DeleteOptions{})
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
log.Error().
|
||||
Str("context", "deleteNamespaceResourceQuota").
|
||||
Str("name", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed to delete resource quota for namespace")
|
||||
return err
|
||||
}
|
||||
log.Warn().
|
||||
Str("context", "deleteNamespaceResourceQuota").
|
||||
Str("name", namespaceName).
|
||||
Msg("resource quota to delete not found")
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSystemNamespace(namespace *corev1.Namespace) bool {
|
||||
systemLabelValue, hasSystemLabel := namespace.Labels[systemNamespaceLabel]
|
||||
if hasSystemLabel {
|
||||
|
@ -180,7 +268,6 @@ func isSystemNamespace(namespace *corev1.Namespace) bool {
|
|||
systemNamespaces := defaultSystemNamespaces()
|
||||
|
||||
_, isSystem := systemNamespaces[namespace.Name]
|
||||
|
||||
return isSystem
|
||||
}
|
||||
|
||||
|
@ -201,10 +288,13 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
|||
return nil
|
||||
}
|
||||
|
||||
nsService := kcl.cli.CoreV1().Namespaces()
|
||||
|
||||
namespace, err := nsService.Get(context.TODO(), namespaceName, metav1.GetOptions{})
|
||||
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.TODO(), namespaceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "ToggleSystemState").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed to get namespace")
|
||||
return errors.Wrap(err, "failed fetching namespace object")
|
||||
}
|
||||
|
||||
|
@ -218,8 +308,12 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
|||
|
||||
namespace.Labels[systemNamespaceLabel] = strconv.FormatBool(isSystem)
|
||||
|
||||
_, err = nsService.Update(context.TODO(), namespace, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
if _, err := kcl.cli.CoreV1().Namespaces().Update(context.TODO(), namespace, metav1.UpdateOptions{}); err != nil {
|
||||
log.Error().
|
||||
Str("context", "ToggleSystemState").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed updating namespace object")
|
||||
return errors.Wrap(err, "failed updating namespace object")
|
||||
}
|
||||
|
||||
|
@ -228,29 +322,26 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
|||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) (*corev1.Namespace, error) {
|
||||
namespace := corev1.Namespace{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: info.Name,
|
||||
Annotations: info.Annotations,
|
||||
},
|
||||
}
|
||||
|
||||
return kcl.cli.CoreV1().Namespaces().Update(context.Background(), &namespace, metav1.UpdateOptions{})
|
||||
}
|
||||
|
||||
func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace, error) {
|
||||
namespace, err := kcl.cli.CoreV1().Namespaces().Get(context.Background(), namespaceName, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "DeleteNamespace").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed fetching namespace object")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = kcl.cli.CoreV1().Namespaces().Delete(context.Background(), namespaceName, metav1.DeleteOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Str("context", "DeleteNamespace").
|
||||
Str("namespace", namespaceName).
|
||||
Err(err).
|
||||
Msg("failed deleting namespace object")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
@ -261,6 +352,10 @@ func (kcl *KubeClient) DeleteNamespace(namespaceName string) (*corev1.Namespace,
|
|||
func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string]portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
|
||||
resourceQuotas, err := kcl.GetResourceQuotas("")
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
log.Error().
|
||||
Str("context", "CombineNamespacesWithResourceQuotas").
|
||||
Err(err).
|
||||
Msg("unable to retrieve resource quotas from the Kubernetes for an admin user")
|
||||
return httperror.InternalServerError("an error occurred during the CombineNamespacesWithResourceQuotas operation, unable to retrieve resource quotas from the Kubernetes for an admin user. Error: ", err)
|
||||
}
|
||||
|
||||
|
@ -275,6 +370,11 @@ func (kcl *KubeClient) CombineNamespacesWithResourceQuotas(namespaces map[string
|
|||
func (kcl *KubeClient) CombineNamespaceWithResourceQuota(namespace portainer.K8sNamespaceInfo, w http.ResponseWriter) *httperror.HandlerError {
|
||||
resourceQuota, err := kcl.GetPortainerResourceQuota(namespace.Name)
|
||||
if err != nil && !k8serrors.IsNotFound(err) {
|
||||
log.Error().
|
||||
Str("context", "CombineNamespaceWithResourceQuota").
|
||||
Str("namespace", namespace.Name).
|
||||
Err(err).
|
||||
Msg("unable to retrieve the resource quota associated with the namespace")
|
||||
return httperror.InternalServerError(fmt.Sprintf("an error occurred during the CombineNamespaceWithResourceQuota operation, unable to retrieve the resource quota associated with the namespace: %s for a non-admin user. Error: ", namespace.Name), err)
|
||||
}
|
||||
|
||||
|
|
|
@ -611,6 +611,7 @@ type (
|
|||
Id string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
Status corev1.NamespaceStatus `json:"Status"`
|
||||
Annotations map[string]string `json:"Annotations"`
|
||||
CreationDate string `json:"CreationDate"`
|
||||
NamespaceOwner string `json:"NamespaceOwner"`
|
||||
IsSystem bool `json:"IsSystem"`
|
||||
|
|
|
@ -478,10 +478,10 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
|||
|
||||
const resourcePool = {
|
||||
name: 'kubernetes.resourcePools.resourcePool',
|
||||
url: '/:id',
|
||||
url: '/:id?tab',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesResourcePoolView',
|
||||
component: 'namespaceView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
|
|
|
@ -17,6 +17,6 @@ export const clusterManagementModule = angular
|
|||
'resourceEventsDatatable',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withCurrentUser(ResourceEventsDatatable))),
|
||||
['resourceId', 'storageKey', 'namespace']
|
||||
['resourceId', 'storageKey', 'namespace', 'noWidget']
|
||||
)
|
||||
).name;
|
||||
|
|
|
@ -4,7 +4,6 @@ import { r2a } from '@/react-tools/react2angular';
|
|||
import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular';
|
||||
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
|
||||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
|
||||
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
||||
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
|
@ -106,15 +105,6 @@ export const ngModule = angular
|
|||
'name',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'createNamespaceRegistriesSelector',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(RegistriesSelector))), [
|
||||
'inputId',
|
||||
'onChange',
|
||||
'options',
|
||||
'value',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'kubeNodesDatatable',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NodesDatatable))), [])
|
||||
|
|
|
@ -3,26 +3,11 @@ import angular from 'angular';
|
|||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
import { withReactQuery } from '@/react-tools/withReactQuery';
|
||||
import { NamespacesDatatable } from '@/react/kubernetes/namespaces/ListView/NamespacesDatatable';
|
||||
import { NamespaceAppsDatatable } from '@/react/kubernetes/namespaces/ItemView/NamespaceAppsDatatable';
|
||||
import { AccessDatatable } from '@/react/kubernetes/namespaces/AccessView/AccessDatatable/AccessDatatable';
|
||||
|
||||
export const namespacesModule = angular
|
||||
.module('portainer.kubernetes.react.components.namespaces', [])
|
||||
.component(
|
||||
'kubernetesNamespacesDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(NamespacesDatatable)), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesNamespaceApplicationsDatatable',
|
||||
r2a(withUIRouter(withCurrentUser(NamespaceAppsDatatable)), [
|
||||
'dataset',
|
||||
'isLoading',
|
||||
'onRefresh',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'namespaceAccessDatatable',
|
||||
r2a(withUIRouter(withReactQuery(AccessDatatable)), [])
|
||||
).name;
|
||||
|
|
|
@ -19,6 +19,7 @@ import { ServiceAccountsView } from '@/react/kubernetes/more-resources/ServiceAc
|
|||
import { ClusterRolesView } from '@/react/kubernetes/more-resources/ClusterRolesView';
|
||||
import { RolesView } from '@/react/kubernetes/more-resources/RolesView';
|
||||
import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
|
||||
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
||||
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
||||
|
||||
export const viewsModule = angular
|
||||
|
@ -27,6 +28,10 @@ export const viewsModule = angular
|
|||
'kubernetesCreateNamespaceView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), [])
|
||||
)
|
||||
.component(
|
||||
'namespaceView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespaceView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesNamespacesView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(NamespacesView))), [])
|
||||
|
|
|
@ -3,7 +3,7 @@ import _ from 'lodash-es';
|
|||
import filesizeParser from 'filesize-parser';
|
||||
import * as JsonPatch from 'fast-json-patch';
|
||||
import { RegistryTypes } from '@/portainer/models/registryTypes';
|
||||
import { getServices } from '@/react/kubernetes/networks/services/service';
|
||||
import { getServices } from '@/react/kubernetes/services/useNamespaceServices';
|
||||
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
|
||||
import { getGlobalDeploymentOptions } from '@/react/portainer/settings/settings.service';
|
||||
|
||||
|
@ -25,11 +25,11 @@ import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
|||
import { KubernetesNodeHelper } from 'Kubernetes/node/helper';
|
||||
import { updateIngress, getIngresses } from '@/react/kubernetes/ingresses/service';
|
||||
import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateView/UpdateIngressPrompt';
|
||||
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
||||
import { isVolumeUsed } from '@/react/kubernetes/volumes/utils';
|
||||
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
|
||||
import { isVolumeUsed } from '@/react/kubernetes/volumes/utils';
|
||||
|
||||
class KubernetesCreateApplicationController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
|
|
@ -1,14 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import controller from './storage-class-switch.controller.js';
|
||||
|
||||
export const storageClassSwitch = {
|
||||
templateUrl: './storage-class-switch.html',
|
||||
controller,
|
||||
bindings: {
|
||||
value: '<',
|
||||
onChange: '<',
|
||||
name: '<',
|
||||
},
|
||||
};
|
||||
|
||||
angular.module('portainer.kubernetes').component('storageClassSwitch', storageClassSwitch);
|
|
@ -1,16 +0,0 @@
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
class StorageClassSwitchController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.featureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
}
|
||||
|
||||
handleChange(value) {
|
||||
this.onChange(this.name, value);
|
||||
}
|
||||
}
|
||||
|
||||
export default StorageClassSwitchController;
|
|
@ -1,13 +0,0 @@
|
|||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-enableQuotaToggle'"
|
||||
label="'Enable quota'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-storagequota'"
|
||||
feature-id="$ctrl.featureId"
|
||||
checked="$ctrl.value"
|
||||
on-change="($ctrl.handleChange)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
|
@ -1,278 +0,0 @@
|
|||
<page-header
|
||||
ng-if="$ctrl.state.viewReady"
|
||||
title="'Create a namespace'"
|
||||
breadcrumbs="[{ label:'Namespaces', link:'kubernetes.resourcePools' }, 'Create a namespace']"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="$ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" autocomplete="off" name="resourcePoolCreationForm">
|
||||
<!-- #region NAME INPUT -->
|
||||
<div class="form-group">
|
||||
<label for="pool_name" class="col-sm-3 col-lg-2 control-label required text-left">Name</label>
|
||||
<div class="col-sm-8">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="pool_name"
|
||||
ng-model="$ctrl.formValues.Name"
|
||||
ng-pattern="/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/"
|
||||
ng-change="$ctrl.onChangeName()"
|
||||
placeholder="my-project"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
required
|
||||
auto-focus
|
||||
/>
|
||||
<span class="help-block">
|
||||
<div class="form-group" ng-show="resourcePoolCreationForm.pool_name.$invalid || $ctrl.state.isAlreadyExist || $ctrl.state.hasPrefixKube">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
|
||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required.</p>
|
||||
<p class="vertical-center" ng-message="pattern"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> 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.</p
|
||||
>
|
||||
</div>
|
||||
<p class="vertical-center" ng-if="$ctrl.state.hasPrefixKube"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Prefix "kube-" is reserved for Kubernetes system namespaces.</p
|
||||
>
|
||||
<p class="vertical-center" ng-if="$ctrl.state.isAlreadyExist">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A namespace with the same name already exists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<!-- #endregion -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Quota </div>
|
||||
<!-- #region QUOTA -->
|
||||
<!-- quotas-switch -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon class="vertical-center" icon="'info'" mode="'primary'"></pr-icon>
|
||||
A namespace segments the underlying physical Kubernetes cluster into smaller virtual clusters. You should assign a capped limit of resources to this namespace or
|
||||
disable for the safe operation of your platform.
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-resourceAssignmentToggle'"
|
||||
label="'Resource assignment'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-resourcequota'"
|
||||
checked="$ctrl.formValues.HasQuota"
|
||||
on-change="($ctrl.onToggleResourceQuota)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !quotas-switch -->
|
||||
<div ng-if="$ctrl.formValues.HasQuota">
|
||||
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small text-warning" ng-switch on="$ctrl.formValues.HasQuota && !$ctrl.isQuotaValid()">
|
||||
<p class="vertical-center mb-0" ng-switch-when="true"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
|
||||
</p>
|
||||
<p class="vertical-center mb-0" ng-switch-default></p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group !mb-0 flex flex-row">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left"> Memory limit (MB) </label>
|
||||
<div class="col-xs-6">
|
||||
<por-slider
|
||||
min="$ctrl.defaults.MemoryLimit"
|
||||
max="$ctrl.state.sliderMaxMemory"
|
||||
step="128"
|
||||
ng-if="$ctrl.state.sliderMaxMemory"
|
||||
value="$ctrl.formValues.MemoryLimit"
|
||||
on-change="($ctrl.handleMemoryLimitChange)"
|
||||
visible-tooltip="true"
|
||||
data-cy="k8sNamespaceCreate-memoryLimitSlider"
|
||||
></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2 vertical-center pt-6">
|
||||
<input
|
||||
name="memory_limit"
|
||||
type="number"
|
||||
min="{{ $ctrl.defaults.MemoryLimit }}"
|
||||
max="{{ $ctrl.state.sliderMaxMemory }}"
|
||||
class="form-control"
|
||||
ng-model="$ctrl.formValues.MemoryLimit"
|
||||
id="memory-limit"
|
||||
data-cy="k8sNamespaceCreate-memoryLimitInput"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row">
|
||||
<span class="col-sm-3 col-lg-2"></span>
|
||||
<span class="help-block col-sm-9 col-lg-10">
|
||||
<div ng-show="resourcePoolCreationForm.memory_limit.$invalid">
|
||||
<div class="small text-warning">
|
||||
<div ng-messages="resourcePoolCreationForm.pool_name.$error">
|
||||
<p class="vertical-center"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between {{ $ctrl.defaults.MemoryLimit }} and
|
||||
{{ $ctrl.state.sliderMaxMemory }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !memory-limit-input -->
|
||||
<!-- cpu-limit-input -->
|
||||
<div class="form-group flex flex-row">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left"> CPU limit </label>
|
||||
<div class="col-xs-8">
|
||||
<por-slider
|
||||
min="$ctrl.defaults.CpuLimit"
|
||||
max="$ctrl.state.sliderMaxCpu"
|
||||
step="0.1"
|
||||
ng-if="$ctrl.state.sliderMaxCpu"
|
||||
value="$ctrl.formValues.CpuLimit"
|
||||
on-change="($ctrl.handleCpuLimitChange)"
|
||||
data-cy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
visible-tooltip="true"
|
||||
></por-slider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region LOAD-BALANCERS -->
|
||||
<div class="col-sm-12 form-section-title"> Load balancers </div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'" class="vertical-center"></pr-icon>
|
||||
You can set a quota on the amount 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.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
|
||||
label="'Load Balancer quota'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-lbquota'"
|
||||
feature-id="$ctrl.LBQuotaFeatureId"
|
||||
checked="$ctrl.formValues.UseLoadBalancersQuota"
|
||||
on-change="($ctrl.onToggleLoadBalancerQuota)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<div ng-if="$ctrl.state.ingressAvailabilityPerNamespace">
|
||||
<!-- #region INGRESSES -->
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<ingress-class-datatable
|
||||
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
|
||||
on-change-controllers="($ctrl.onChangeIngressControllerAvailability)"
|
||||
ingress-controllers="$ctrl.ingressControllers"
|
||||
initial-ingress-controllers="$ctrl.initialIngressControllers"
|
||||
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
|
||||
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
|
||||
view="'namespace'"
|
||||
></ingress-class-datatable>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
|
||||
<!-- #region REGISTRIES -->
|
||||
<div class="col-sm-12 form-section-title"> Registries </div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Define which registries can be used by users who have access to this namespace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 col-lg-2 control-label !pt-0 text-left" for="registries-selector"> Select registries </label>
|
||||
<div class="col-sm-8 col-lg-9">
|
||||
<span class="small text-muted" ng-if="!$ctrl.registries.length && $ctrl.state.isAdmin">
|
||||
No registries available. Head over to the <a ui-sref="portainer.registries">registry view</a> to define a container registry.
|
||||
</span>
|
||||
<span class="small text-muted" ng-if="!$ctrl.registries.length && !$ctrl.state.isAdmin">
|
||||
No registries available. Contact your administrator to create a container registry.
|
||||
</span>
|
||||
<create-namespace-registries-selector
|
||||
input-id="'registries-selector'"
|
||||
value="$ctrl.formValues.Registries"
|
||||
on-change="($ctrl.onRegistriesChange)"
|
||||
options="$ctrl.registries"
|
||||
>
|
||||
</create-namespace-registries-selector>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region STORAGES -->
|
||||
<div class="col-sm-12 form-section-title"> Storage </div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title vertical-center">
|
||||
<pr-icon icon="'svg-route'"></pr-icon>
|
||||
standard
|
||||
</div>
|
||||
|
||||
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"></storage-class-switch>
|
||||
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view ng-if="resourcePoolCreationForm.$valid && !$ctrl.isCreateButtonDisabled()" form-values="$ctrl.formValues"></kubernetes-summary-view>
|
||||
<!-- !summary -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Actions </div>
|
||||
<!-- #region ACTIONS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm !ml-0"
|
||||
ng-disabled="!resourcePoolCreationForm.$valid || $ctrl.isCreateButtonDisabled()"
|
||||
ng-click="$ctrl.createResourcePool()"
|
||||
button-spinner="$ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="$ctrl.state.actionInProgress" data-cy="k8sNamespace-createNamespaceButton">Create namespace</span>
|
||||
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- #endregion -->
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -1,236 +0,0 @@
|
|||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
|
||||
|
||||
class KubernetesCreateResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
/* @ngInject */
|
||||
constructor($async, $state, $scope, Notifications, KubernetesNodeService, KubernetesResourcePoolService, KubernetesIngressService, Authentication, EndpointService) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
Notifications,
|
||||
KubernetesNodeService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesIngressService,
|
||||
Authentication,
|
||||
EndpointService,
|
||||
});
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
this.EndpointService = EndpointService;
|
||||
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
|
||||
|
||||
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
|
||||
this.onToggleLoadBalancerQuota = this.onToggleLoadBalancerQuota.bind(this);
|
||||
this.onToggleResourceQuota = this.onToggleResourceQuota.bind(this);
|
||||
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
|
||||
this.onRegistriesChange = this.onRegistriesChange.bind(this);
|
||||
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
|
||||
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onRegistriesChange(registries) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.Registries = registries;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleStorageQuota(storageClassName, enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
|
||||
});
|
||||
}
|
||||
|
||||
onToggleLoadBalancerQuota(enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.UseLoadBalancersQuota = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleResourceQuota(enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.HasQuota = enabled;
|
||||
});
|
||||
}
|
||||
|
||||
/* #region INGRESS MANAGEMENT */
|
||||
onChangeIngressControllerAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
isCreateButtonDisabled() {
|
||||
return (
|
||||
this.state.actionInProgress ||
|
||||
(this.formValues.HasQuota && !this.isQuotaValid()) ||
|
||||
this.state.isAlreadyExist ||
|
||||
this.state.hasPrefixKube ||
|
||||
this.state.duplicates.ingressHosts.hasRefs
|
||||
);
|
||||
}
|
||||
|
||||
onChangeName() {
|
||||
this.state.isAlreadyExist = _.find(this.resourcePools, (resourcePool) => resourcePool.Namespace.Name === this.formValues.Name) !== undefined;
|
||||
this.state.hasPrefixKube = /^kube-/.test(this.formValues.Name);
|
||||
}
|
||||
|
||||
isQuotaValid() {
|
||||
if (
|
||||
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
|
||||
this.state.sliderMaxMemory < this.formValues.MemoryLimit ||
|
||||
(this.formValues.CpuLimit === 0 && this.formValues.MemoryLimit === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
checkDefaults() {
|
||||
if (this.formValues.CpuLimit < this.defaults.CpuLimit) {
|
||||
this.formValues.CpuLimit = this.defaults.CpuLimit;
|
||||
}
|
||||
if (this.formValues.MemoryLimit < KubernetesResourceReservationHelper.megaBytesValue(this.defaults.MemoryLimit)) {
|
||||
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(this.defaults.MemoryLimit);
|
||||
}
|
||||
}
|
||||
|
||||
handleMemoryLimitChange(memoryLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = memoryLimit;
|
||||
});
|
||||
}
|
||||
|
||||
handleCpuLimitChange(cpuLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.CpuLimit = cpuLimit;
|
||||
});
|
||||
}
|
||||
|
||||
/* #region CREATE NAMESPACE */
|
||||
createResourcePool() {
|
||||
return this.$async(async () => {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
this.checkDefaults();
|
||||
this.formValues.Owner = this.Authentication.getUserDetails().username;
|
||||
await this.KubernetesResourcePoolService.create(this.formValues);
|
||||
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
|
||||
this.Notifications.success('Namespace successfully created', this.formValues.Name);
|
||||
this.$state.go('kubernetes.resourcePools');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET INGRESSES */
|
||||
getIngresses() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.allIngresses = await this.KubernetesIngressService.get();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve ingresses.');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET NAMESPACES */
|
||||
getResourcePools() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespaces');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET REGISTRIES */
|
||||
getRegistries() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.registries = await this.EndpointService.registries(this.endpoint.Id);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region ON INIT */
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
||||
this.defaults = KubernetesResourceQuotaDefaults;
|
||||
this.formValues = new KubernetesResourcePoolFormValues(this.defaults);
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
this.formValues.HasQuota = false;
|
||||
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
sliderMaxMemory: 0,
|
||||
sliderMaxCpu: 0,
|
||||
viewReady: false,
|
||||
isAlreadyExist: false,
|
||||
hasPrefixKube: false,
|
||||
canUseIngress: false,
|
||||
duplicates: {
|
||||
ingressHosts: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
isAdmin: this.Authentication.isAdmin(),
|
||||
ingressAvailabilityPerNamespace: endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
};
|
||||
|
||||
const nodes = await this.KubernetesNodeService.get();
|
||||
|
||||
this.ingressControllers = [];
|
||||
if (this.state.ingressAvailabilityPerNamespace) {
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, allowedOnly: true });
|
||||
this.initialIngressControllers = structuredClone(this.ingressControllers);
|
||||
}
|
||||
|
||||
_.forEach(nodes, (item) => {
|
||||
this.state.sliderMaxMemory += filesizeParser(item.Memory);
|
||||
this.state.sliderMaxCpu += item.CPU;
|
||||
});
|
||||
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
|
||||
await this.getResourcePools();
|
||||
if (this.state.canUseIngress) {
|
||||
await this.getIngresses();
|
||||
const ingressClasses = endpoint.Kubernetes.Configuration.IngressClasses;
|
||||
this.formValues.IngressClasses = KubernetesIngressConverter.ingressClassesToFormValues(ingressClasses);
|
||||
}
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
if (ic.Hosts.length === 0) {
|
||||
ic.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
});
|
||||
|
||||
await this.getRegistries();
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
}
|
||||
|
||||
export default KubernetesCreateResourcePoolController;
|
|
@ -1,302 +0,0 @@
|
|||
<page-header
|
||||
ng-if="ctrl.state.viewReady"
|
||||
title="'Namespace details'"
|
||||
breadcrumbs="[{ label:'Namespaces', link:'kubernetes.resourcePools' }, ctrl.pool.Namespace.Name]"
|
||||
reload="true"
|
||||
></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<uib-tabset active="ctrl.state.activeTab" justified="true" type="pills">
|
||||
<uib-tab index="0" classes="btn-sm" select="ctrl.selectTab(0)">
|
||||
<uib-tab-heading class="vertical-center"> <pr-icon icon="'layers'"></pr-icon> Namespace </uib-tab-heading>
|
||||
<form class="form-horizontal widget-body" autocomplete="off" name="resourcePoolEditForm" style="margin-top: 10px">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="w-[40%]">Name</td>
|
||||
<td>
|
||||
{{ ctrl.pool.Namespace.Name }}
|
||||
<span class="label label-info image-tag label-margins" ng-if="ctrl.isSystem">system</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="col-sm-12 !p-0">
|
||||
<annotations-be-teaser></annotations-be-teaser>
|
||||
</div>
|
||||
|
||||
<!-- !name-input -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="col-sm-12 form-section-title">Resource quota</div>
|
||||
<!-- quotas-switch -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable" class="form-group">
|
||||
<div class="col-sm-12 mt-2" ng-if="ctrl.state.resourceOverCommitEnabled">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3 col-lg-2">
|
||||
<label class="control-label text-left"> Resource assignment </label>
|
||||
</div>
|
||||
<div class="col-sm-9 pt-2">
|
||||
<label class="switch">
|
||||
<input type="checkbox" ng-model="ctrl.formValues.HasQuota" />
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="ctrl.formValues.HasQuota">
|
||||
<kubernetes-resource-reservation
|
||||
ng-if="ctrl.pool.Quota"
|
||||
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||
cpu-reservation="ctrl.state.resourceReservation.CPU"
|
||||
memory-reservation="ctrl.state.resourceReservation.Memory"
|
||||
cpu-limit="ctrl.formValues.CpuLimit"
|
||||
memory-limit="ctrl.formValues.MemoryLimit"
|
||||
display-usage="ctrl.state.useServerMetrics"
|
||||
cpu-usage="ctrl.state.resourceUsage.CPU"
|
||||
memory-usage="ctrl.state.resourceUsage.Memory"
|
||||
>
|
||||
</kubernetes-resource-reservation>
|
||||
</div>
|
||||
<!-- !quotas-switch -->
|
||||
<div ng-if="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable">
|
||||
<div class="col-sm-12 form-section-title"> Resource limits </div>
|
||||
<div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 small text-warning" ng-switch on="ctrl.formValues.HasQuota && ctrl.isAdmin && ctrl.isEditable && !ctrl.isQuotaValid()">
|
||||
<p class="vertical-center mb-0" ng-switch-when="true"
|
||||
><pr-icon class="vertical-center" icon="'alert-triangle'" mode="'warning'"></pr-icon> At least a single limit must be set for the quota to be valid.
|
||||
</p>
|
||||
<p class="vertical-center mb-0" ng-switch-default></p>
|
||||
</span>
|
||||
</div>
|
||||
<!-- memory-limit-input -->
|
||||
<div class="form-group flex">
|
||||
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label vertical-center text-left"> Memory limit (MB) </label>
|
||||
<div class="col-sm-6">
|
||||
<por-slider
|
||||
min="ctrl.ResourceQuotaDefaults.MemoryLimit"
|
||||
max="ctrl.state.sliderMaxMemory"
|
||||
step="128"
|
||||
ng-if="ctrl.state.sliderMaxMemory"
|
||||
value="ctrl.formValues.MemoryLimit"
|
||||
on-change="(ctrl.handleMemoryLimitChange)"
|
||||
visible-tooltip="true"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitSlider"
|
||||
></por-slider>
|
||||
</div>
|
||||
<div class="col-sm-2 vertical-center pt-6">
|
||||
<input
|
||||
name="memory_limit"
|
||||
type="number"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitInput"
|
||||
min="{{ ctrl.ResourceQuotaDefaults.MemoryLimit }}"
|
||||
max="{{ ctrl.state.sliderMaxMemory }}"
|
||||
class="form-control"
|
||||
ng-model="ctrl.formValues.MemoryLimit"
|
||||
id="memory-limit"
|
||||
data-cy="k8sNamespaceEdit-memoryLimitInput"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="resourcePoolEditForm.memory_limit.$invalid">
|
||||
<div class="col-sm-3 col-lg-2"></div>
|
||||
<div class="col-sm-8 small text-warning">
|
||||
<div ng-messages="resourcePoolEditForm.pool_name.$error">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Value must be between {{ ctrl.ResourceQuotaDefaults.MemoryLimit }} and
|
||||
{{ ctrl.state.sliderMaxMemory }}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !memory-limit-input -->
|
||||
<!-- cpu-limit-input -->
|
||||
<div class="form-group">
|
||||
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> CPU limit </label>
|
||||
<div class="col-sm-8">
|
||||
<por-slider
|
||||
min="ctrl.ResourceQuotaDefaults.CpuLimit"
|
||||
max="ctrl.state.sliderMaxCpu"
|
||||
step="0.1"
|
||||
ng-if="ctrl.state.sliderMaxCpu"
|
||||
value="ctrl.formValues.CpuLimit"
|
||||
on-change="(ctrl.handleCpuLimitChange)"
|
||||
data-cy="k8sNamespaceEdit-cpuLimitSlider"
|
||||
visible-tooltip="true"
|
||||
></por-slider>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !cpu-limit-input -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- #region LOADBALANCERS -->
|
||||
<div class="col-sm-12 form-section-title"> Load balancers </div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
You can set a quota on the amount 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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
data-cy="'k8sNamespaceCreate-loadBalancerQuotaToggle'"
|
||||
label="'Load Balancer quota'"
|
||||
label-class="'col-sm-3 col-lg-2'"
|
||||
name="'k8s-resourcepool-Lbquota'"
|
||||
feature-id="ctrl.LBQuotaFeatureId"
|
||||
checked="ctrl.formValues.UseLoadBalancersQuota"
|
||||
on-change="(ctrl.onToggleLoadBalancersQuota)"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.ingressAvailabilityPerNamespace">
|
||||
<!-- #region INGRESSES -->
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<ingress-class-datatable
|
||||
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
|
||||
on-change-controllers="(ctrl.onChangeIngressControllerAvailability)"
|
||||
ingress-controllers="ctrl.ingressControllers"
|
||||
initial-ingress-controllers="$ctrl.initialIngressControllers"
|
||||
description="'Enable the ingress controllers that users can select when publishing applications in this namespace.'"
|
||||
no-ingress-controller-label="'No ingress controllers found in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster.'"
|
||||
view="'namespace'"
|
||||
></ingress-class-datatable>
|
||||
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
<!-- #region REGISTRIES -->
|
||||
<div>
|
||||
<div class="col-sm-12 form-section-title"> Registries </div>
|
||||
|
||||
<div class="form-group" ng-if="!ctrl.isAdmin || ctrl.isSystem">
|
||||
<label class="col-sm-3 col-lg-2 control-label text-left" style="padding-top: 0"> Selected registries </label>
|
||||
<div class="col-sm-9 col-lg-4"> {{ ctrl.selectedRegistries ? ctrl.selectedRegistries : 'None' }} </div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.isAdmin && !ctrl.isSystem">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Define which registries can be used by users who have access to this namespace.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 col-lg-2 control-label !pt-0 text-left" for="registries-selector"> Select registries </label>
|
||||
<div class="col-sm-9 col-lg-4">
|
||||
<create-namespace-registries-selector
|
||||
input-id="'registries-selector'"
|
||||
value="ctrl.formValues.Registries"
|
||||
on-change="(ctrl.onRegistriesChange)"
|
||||
options="ctrl.registries"
|
||||
>
|
||||
</create-namespace-registries-selector>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- #region STORAGES -->
|
||||
<div class="col-sm-12 form-section-title"> Storage </div>
|
||||
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
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.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title text-muted col-sm-12" style="width: 100%">
|
||||
<div class="vertical-center"> <pr-icon icon="'svg-route'"></pr-icon>standard </div>
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<storage-class-switch value="sc.Selected" name="sc.Name" on-change="(ctrl.onToggleStorageQuota)" authorization="K8sResourcePoolDetailsW"> </storage-class-switch>
|
||||
|
||||
<!-- #endregion -->
|
||||
|
||||
<!-- summary -->
|
||||
<kubernetes-summary-view
|
||||
ng-if="resourcePoolEditForm.$valid && !ctrl.isUpdateButtonDisabled()"
|
||||
form-values="ctrl.formValues"
|
||||
old-form-values="ctrl.savedFormValues"
|
||||
></kubernetes-summary-view>
|
||||
<!-- !summary -->
|
||||
|
||||
<!-- actions -->
|
||||
<div ng-if="ctrl.isAdmin" class="col-sm-12 form-section-title"> Actions </div>
|
||||
<div ng-if="ctrl.isAdmin" class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button
|
||||
type="button"
|
||||
ng-if="!ctrl.isSystem"
|
||||
class="btn btn-primary btn-sm !ml-0 !mr-1"
|
||||
ng-disabled="!resourcePoolEditForm.$valid || ctrl.isUpdateButtonDisabled()"
|
||||
ng-click="ctrl.updateResourcePool()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
>
|
||||
<span ng-hide="ctrl.state.actionInProgress" data-cy="k8sNamespaceEdit-updateNamespaceButton">Update namespace</span>
|
||||
<span ng-show="ctrl.state.actionInProgress">Update in progress...</span>
|
||||
</button>
|
||||
<button
|
||||
ng-if="!ctrl.isDefaultNamespace"
|
||||
type="button"
|
||||
class="btn btn-light btn-sm !ml-0"
|
||||
ng-click="ctrl.markUnmarkAsSystem()"
|
||||
button-spinner="ctrl.state.actionInProgress"
|
||||
data-cy="k8sNamespaceEdit-markSystem"
|
||||
>
|
||||
<span ng-if="ctrl.isSystem">Unmark as system</span>
|
||||
<span ng-if="!ctrl.isSystem">Mark as system</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
||||
</uib-tab>
|
||||
<uib-tab index="1" classes="btn-sm" select="ctrl.selectTab(1)">
|
||||
<uib-tab-heading class="vertical-center">
|
||||
<pr-icon icon="'history'"></pr-icon> Events
|
||||
<div ng-if="ctrl.hasEventWarnings()">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'" class-name="'mr-0.5'"></pr-icon>
|
||||
{{ ctrl.state.eventWarningCount }} warning(s)
|
||||
</div>
|
||||
</uib-tab-heading>
|
||||
<resource-events-datatable namespace="ctrl.pool.Namespace.Name" storage-key="'kubernetes.resourcepool.events'"> </resource-events-datatable>
|
||||
</uib-tab>
|
||||
<uib-tab index="2" ng-if="ctrl.pool.Yaml" select="ctrl.showEditor()" classes="btn-sm">
|
||||
<uib-tab-heading class="vertical-center"><pr-icon icon="'code'"></pr-icon> YAML </uib-tab-heading>
|
||||
<div class="px-5" ng-if="ctrl.state.showEditorTab">
|
||||
<kube-yaml-inspector identifier="'namespace-yaml'" data="ctrl.pool.Yaml" data-cy="k8sNamespaceEdit-yamlEditor" hide-message="true" />
|
||||
</div>
|
||||
</uib-tab>
|
||||
</uib-tabset>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.applications && ctrl.applications.length > 0">
|
||||
<kubernetes-namespace-applications-datatable dataset="ctrl.applications" on-refresh="(ctrl.getApplications)" is-loading="ctrl.state.applicationsLoading">
|
||||
</kubernetes-namespace-applications-datatable>
|
||||
</div>
|
||||
</div>
|
|
@ -1,8 +0,0 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesResourcePoolView', {
|
||||
templateUrl: './resourcePool.html',
|
||||
controller: 'KubernetesResourcePoolController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
|
@ -1,405 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import _ from 'lodash-es';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quota/models';
|
||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
|
||||
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { confirmUpdate } from '@@/modals/confirm';
|
||||
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
|
||||
import { getMetricsForAllPods } from '@/react/kubernetes/metrics/metrics.ts';
|
||||
|
||||
class KubernetesResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
/* @ngInject */
|
||||
constructor(
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
Authentication,
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
EndpointService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesEventService,
|
||||
KubernetesPodService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesIngressService,
|
||||
KubernetesVolumeService,
|
||||
KubernetesNamespaceService,
|
||||
KubernetesNodeService
|
||||
) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
$state,
|
||||
$scope,
|
||||
Authentication,
|
||||
Notifications,
|
||||
LocalStorage,
|
||||
EndpointService,
|
||||
KubernetesResourceQuotaService,
|
||||
KubernetesResourcePoolService,
|
||||
KubernetesEventService,
|
||||
KubernetesPodService,
|
||||
KubernetesApplicationService,
|
||||
KubernetesIngressService,
|
||||
KubernetesVolumeService,
|
||||
KubernetesNamespaceService,
|
||||
KubernetesNodeService,
|
||||
});
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
this.ResourceQuotaDefaults = KubernetesResourceQuotaDefaults;
|
||||
this.EndpointService = EndpointService;
|
||||
|
||||
this.LBQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_LB_QUOTA;
|
||||
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
this.StorageQuotaFeatureId = FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA;
|
||||
|
||||
this.updateResourcePoolAsync = this.updateResourcePoolAsync.bind(this);
|
||||
this.getEvents = this.getEvents.bind(this);
|
||||
this.onToggleLoadBalancersQuota = this.onToggleLoadBalancersQuota.bind(this);
|
||||
this.onToggleStorageQuota = this.onToggleStorageQuota.bind(this);
|
||||
this.onChangeIngressControllerAvailability = this.onChangeIngressControllerAvailability.bind(this);
|
||||
this.onRegistriesChange = this.onRegistriesChange.bind(this);
|
||||
this.handleMemoryLimitChange = this.handleMemoryLimitChange.bind(this);
|
||||
this.handleCpuLimitChange = this.handleCpuLimitChange.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onRegistriesChange(registries) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.Registries = registries;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleLoadBalancersQuota(checked) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.UseLoadBalancersQuota = checked;
|
||||
});
|
||||
}
|
||||
|
||||
onToggleStorageQuota(storageClassName, enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.StorageClasses = this.formValues.StorageClasses.map((sClass) => (sClass.Name !== storageClassName ? sClass : { ...sClass, Selected: enabled }));
|
||||
});
|
||||
}
|
||||
|
||||
onChangeIngressControllerAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
|
||||
selectTab(index) {
|
||||
this.LocalStorage.storeActiveTab('resourcePool', index);
|
||||
}
|
||||
|
||||
isUpdateButtonDisabled() {
|
||||
return this.state.actionInProgress || (this.formValues.HasQuota && !this.isQuotaValid()) || this.state.duplicates.ingressHosts.hasRefs;
|
||||
}
|
||||
|
||||
isQuotaValid() {
|
||||
if (
|
||||
this.state.sliderMaxCpu < this.formValues.CpuLimit ||
|
||||
this.state.sliderMaxMemory < this.formValues.MemoryLimit ||
|
||||
(this.formValues.CpuLimit === 0 && this.formValues.MemoryLimit === 0)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
checkDefaults() {
|
||||
if (this.formValues.CpuLimit < KubernetesResourceQuotaDefaults.CpuLimit) {
|
||||
this.formValues.CpuLimit = KubernetesResourceQuotaDefaults.CpuLimit;
|
||||
}
|
||||
if (this.formValues.MemoryLimit < KubernetesResourceReservationHelper.megaBytesValue(KubernetesResourceQuotaDefaults.MemoryLimit)) {
|
||||
this.formValues.MemoryLimit = KubernetesResourceReservationHelper.megaBytesValue(KubernetesResourceQuotaDefaults.MemoryLimit);
|
||||
}
|
||||
}
|
||||
|
||||
handleMemoryLimitChange(memoryLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.MemoryLimit = memoryLimit;
|
||||
});
|
||||
}
|
||||
|
||||
handleCpuLimitChange(cpuLimit) {
|
||||
return this.$async(async () => {
|
||||
this.formValues.CpuLimit = cpuLimit;
|
||||
});
|
||||
}
|
||||
|
||||
showEditor() {
|
||||
this.state.showEditorTab = true;
|
||||
this.selectTab(2);
|
||||
}
|
||||
|
||||
hasResourceQuotaBeenReduced() {
|
||||
if (this.formValues.HasQuota && this.oldQuota) {
|
||||
const cpuLimit = this.formValues.CpuLimit;
|
||||
const memoryLimit = KubernetesResourceReservationHelper.bytesValue(this.formValues.MemoryLimit);
|
||||
if (cpuLimit < this.oldQuota.CpuLimit || memoryLimit < this.oldQuota.MemoryLimit) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* #region UPDATE NAMESPACE */
|
||||
async updateResourcePoolAsync(oldFormValues, newFormValues) {
|
||||
this.state.actionInProgress = true;
|
||||
try {
|
||||
this.checkDefaults();
|
||||
await this.KubernetesResourcePoolService.patch(oldFormValues, newFormValues);
|
||||
await updateIngressControllerClassMap(this.endpoint.Id, this.ingressControllers || [], this.formValues.Name);
|
||||
this.Notifications.success('Namespace successfully updated', this.pool.Namespace.Name);
|
||||
this.$state.reload(this.$state.current);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
updateResourcePool() {
|
||||
const ingressesToDelete = _.filter(this.formValues.IngressClasses, { WasSelected: true, Selected: false });
|
||||
const registriesToDelete = _.filter(this.registries, { WasChecked: true, Checked: false });
|
||||
const warnings = {
|
||||
quota: this.hasResourceQuotaBeenReduced(),
|
||||
ingress: ingressesToDelete.length !== 0,
|
||||
registries: registriesToDelete.length !== 0,
|
||||
};
|
||||
|
||||
if (warnings.quota || warnings.registries) {
|
||||
confirmUpdateNamespace(warnings.quota, warnings.ingress, warnings.registries).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.updateResourcePoolAsync, this.savedFormValues, this.formValues);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return this.$async(this.updateResourcePoolAsync, this.savedFormValues, this.formValues);
|
||||
}
|
||||
}
|
||||
|
||||
async confirmMarkUnmarkAsSystem() {
|
||||
const message = this.isSystem
|
||||
? 'Unmarking this namespace as system will allow non administrator users to manage it and the resources in contains depending on the access control settings. Are you sure?'
|
||||
: 'Marking this namespace as a system namespace will prevent non administrator users from managing it and the resources it contains. Are you sure?';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
confirmUpdate(message, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
markUnmarkAsSystem() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const namespaceName = this.$state.params.id;
|
||||
this.state.actionInProgress = true;
|
||||
|
||||
const confirmed = await this.confirmMarkUnmarkAsSystem();
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
await this.KubernetesResourcePoolService.toggleSystem(this.endpoint.Id, namespaceName, !this.isSystem);
|
||||
|
||||
this.Notifications.success('Namespace successfully updated', namespaceName);
|
||||
this.$state.reload(this.$state.current);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to create namespace');
|
||||
} finally {
|
||||
this.state.actionInProgress = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
hasEventWarnings() {
|
||||
return this.state.eventWarningCount;
|
||||
}
|
||||
|
||||
/* #region GET EVENTS */
|
||||
getEvents() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.eventsLoading = true;
|
||||
this.events = await this.KubernetesEventService.get(this.pool.Namespace.Name);
|
||||
this.state.eventWarningCount = KubernetesEventHelper.warningCount(this.events);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespace related events');
|
||||
} finally {
|
||||
this.state.eventsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET APPLICATIONS */
|
||||
getApplications() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.state.applicationsLoading = true;
|
||||
this.applications = await this.KubernetesApplicationService.get(this.pool.Namespace.Name);
|
||||
this.applications = _.map(this.applications, (app) => {
|
||||
const resourceReservation = KubernetesResourceReservationHelper.computeResourceReservation(app.Pods);
|
||||
app.CPU = resourceReservation.CPU;
|
||||
app.Memory = resourceReservation.Memory;
|
||||
return app;
|
||||
});
|
||||
|
||||
if (this.state.useServerMetrics) {
|
||||
await this.getResourceUsage(this.pool.Namespace.Name);
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications.');
|
||||
} finally {
|
||||
this.state.applicationsLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region GET REGISTRIES */
|
||||
getRegistries() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
const namespace = this.$state.params.id;
|
||||
|
||||
if (this.isAdmin) {
|
||||
this.registries = await this.EndpointService.registries(this.endpoint.Id);
|
||||
this.registries.forEach((reg) => {
|
||||
if (reg.RegistryAccesses && reg.RegistryAccesses[this.endpoint.Id] && reg.RegistryAccesses[this.endpoint.Id].Namespaces.includes(namespace)) {
|
||||
reg.Checked = true;
|
||||
reg.WasChecked = true;
|
||||
this.formValues.Registries.push(reg);
|
||||
}
|
||||
});
|
||||
this.selectedRegistries = this.formValues.Registries.map((r) => r.Name).join(', ');
|
||||
return;
|
||||
}
|
||||
|
||||
const registries = await this.EndpointService.registries(this.endpoint.Id, namespace);
|
||||
this.selectedRegistries = registries.map((r) => r.Name).join(', ');
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve registries');
|
||||
}
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
async getResourceUsage(namespace) {
|
||||
try {
|
||||
const namespaceMetrics = await getMetricsForAllPods(this.$state.params.endpointId, namespace);
|
||||
// extract resource usage of all containers within each pod of the namespace
|
||||
const containerResourceUsageList = namespaceMetrics.items.flatMap((i) => i.containers.map((c) => c.usage));
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce((total, u) => {
|
||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
||||
return total;
|
||||
}, new KubernetesResourceReservation());
|
||||
this.state.resourceUsage = namespaceResourceUsage;
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve namespace resource usage');
|
||||
}
|
||||
}
|
||||
|
||||
/* #region ON INIT */
|
||||
$onInit() {
|
||||
return this.$async(async () => {
|
||||
try {
|
||||
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
||||
this.isAdmin = this.Authentication.isAdmin();
|
||||
|
||||
this.state = {
|
||||
actionInProgress: false,
|
||||
sliderMaxMemory: 0,
|
||||
sliderMaxCpu: 0,
|
||||
cpuUsage: 0,
|
||||
memoryUsage: 0,
|
||||
resourceReservation: { CPU: 0, Memory: 0 },
|
||||
activeTab: 0,
|
||||
currentName: this.$state.$current.name,
|
||||
showEditorTab: false,
|
||||
eventsLoading: true,
|
||||
applicationsLoading: true,
|
||||
ingressesLoading: true,
|
||||
viewReady: false,
|
||||
eventWarningCount: 0,
|
||||
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
|
||||
duplicates: {
|
||||
ingressHosts: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
ingressAvailabilityPerNamespace: this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
|
||||
|
||||
const name = this.$state.params.id;
|
||||
|
||||
const [nodes, pool] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get(name)]);
|
||||
|
||||
this.ingressControllers = [];
|
||||
if (this.state.ingressAvailabilityPerNamespace) {
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
|
||||
this.initialIngressControllers = structuredClone(this.ingressControllers);
|
||||
}
|
||||
|
||||
this.pool = pool;
|
||||
this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults);
|
||||
this.formValues.Name = this.pool.Namespace.Name;
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
this.formValues.IsSystem = this.pool.Namespace.IsSystem;
|
||||
|
||||
_.forEach(nodes, (item) => {
|
||||
this.state.sliderMaxMemory += filesizeParser(item.Memory);
|
||||
this.state.sliderMaxCpu += item.CPU;
|
||||
});
|
||||
this.state.sliderMaxMemory = KubernetesResourceReservationHelper.megaBytesValue(this.state.sliderMaxMemory);
|
||||
|
||||
const quota = this.pool.Quota;
|
||||
if (quota) {
|
||||
this.oldQuota = angular.copy(quota);
|
||||
this.formValues = KubernetesResourceQuotaConverter.quotaToResourcePoolFormValues(quota);
|
||||
this.formValues.EndpointId = this.endpoint.Id;
|
||||
|
||||
this.state.resourceReservation.CPU = quota.CpuLimitUsed;
|
||||
this.state.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(quota.MemoryLimitUsed);
|
||||
}
|
||||
this.isSystem = KubernetesNamespaceHelper.isSystemNamespace(this.pool.Namespace.Name);
|
||||
this.isDefaultNamespace = KubernetesNamespaceHelper.isDefaultNamespace(this.pool.Namespace.Name);
|
||||
this.isEditable = !this.isSystem && !this.isDefaultNamespace;
|
||||
|
||||
await this.getEvents();
|
||||
await this.getApplications();
|
||||
|
||||
await this.getRegistries();
|
||||
|
||||
this.savedFormValues = angular.copy(this.formValues);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to load view data');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
$onDestroy() {
|
||||
if (this.state.currentName !== this.$state.$current.name) {
|
||||
this.LocalStorage.storeActiveTab('resourcePool', 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesResourcePoolController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolController', KubernetesResourcePoolController);
|
|
@ -1,7 +0,0 @@
|
|||
<page-header ng-if="ctrl.state.viewReady" title="'Namespace list'" breadcrumbs="['Namespaces']" on-reload="(ctrl.onReload)" reload="true"></page-header>
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<kubernetes-namespaces-datatable dataset="ctrl.resourcePools" on-remove="(ctrl.removeAction)" on-refresh="(ctrl.getResourcePools)"></kubernetes-namespaces-datatable>
|
||||
</div>
|
|
@ -1,8 +0,0 @@
|
|||
angular.module('portainer.kubernetes').component('kubernetesResourcePoolsView', {
|
||||
templateUrl: './resourcePools.html',
|
||||
controller: 'KubernetesResourcePoolsController',
|
||||
controllerAs: 'ctrl',
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
},
|
||||
});
|
|
@ -1,109 +0,0 @@
|
|||
import angular from 'angular';
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper';
|
||||
|
||||
class KubernetesResourcePoolsController {
|
||||
/* @ngInject */
|
||||
constructor($async, $state, Notifications, KubernetesResourcePoolService, KubernetesNamespaceService) {
|
||||
this.$async = $async;
|
||||
this.$state = $state;
|
||||
this.Notifications = Notifications;
|
||||
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
|
||||
this.KubernetesNamespaceService = KubernetesNamespaceService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.getResourcePools = this.getResourcePools.bind(this);
|
||||
this.getResourcePoolsAsync = this.getResourcePoolsAsync.bind(this);
|
||||
this.removeAction = this.removeAction.bind(this);
|
||||
this.removeActionAsync = this.removeActionAsync.bind(this);
|
||||
this.onReload = this.onReload.bind(this);
|
||||
}
|
||||
|
||||
async onReload() {
|
||||
this.$state.reload(this.$state.current);
|
||||
}
|
||||
|
||||
async removeActionAsync(selectedItems) {
|
||||
let actionCount = selectedItems.length;
|
||||
for (const pool of selectedItems) {
|
||||
try {
|
||||
const isTerminating = pool.Namespace.Status === 'Terminating';
|
||||
if (isTerminating) {
|
||||
const ns = await this.KubernetesNamespaceService.getJSONAsync(pool.Namespace.Name);
|
||||
ns.$promise.then(async (namespace) => {
|
||||
const n = JSON.parse(namespace.data);
|
||||
if (n.spec && n.spec.finalizers) {
|
||||
delete n.spec.finalizers;
|
||||
}
|
||||
await this.KubernetesNamespaceService.updateFinalizeAsync(n);
|
||||
});
|
||||
} else {
|
||||
await this.KubernetesResourcePoolService.delete(pool);
|
||||
}
|
||||
this.Notifications.success('Namespace successfully removed', pool.Namespace.Name);
|
||||
const index = this.resourcePools.indexOf(pool);
|
||||
this.resourcePools.splice(index, 1);
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to remove namespace');
|
||||
} finally {
|
||||
--actionCount;
|
||||
if (actionCount === 0) {
|
||||
this.$state.reload(this.$state.current);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeAction(selectedItems) {
|
||||
const isTerminatingNS = selectedItems.some((pool) => pool.Namespace.Status === 'Terminating');
|
||||
const message = isTerminatingNS
|
||||
? 'At least one namespace is in a terminating state. For terminating state namespaces, you may continue and force removal, but doing so without having properly cleaned up may lead to unstable and unpredictable behavior. Are you sure you wish to proceed?'
|
||||
: 'Do you want to remove the selected namespace(s)? All the resources associated to the selected namespace(s) will be removed too. Are you sure you wish to proceed?';
|
||||
confirm({
|
||||
title: isTerminatingNS ? 'Force namespace removal' : 'Are you sure?',
|
||||
message,
|
||||
confirmButton: buildConfirmButton('Remove', 'danger'),
|
||||
|
||||
modalType: ModalType.Destructive,
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
return this.$async(this.removeActionAsync, selectedItems);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getResourcePoolsAsync() {
|
||||
try {
|
||||
this.resourcePools = await this.KubernetesResourcePoolService.get('', { getQuota: true });
|
||||
// make sure table refreshes with fresh data when namespaces are in a terminating state
|
||||
if (this.resourcePools.some((namespace) => namespace.Namespace.Status === 'Terminating')) {
|
||||
dispatchCacheRefreshEvent();
|
||||
}
|
||||
} catch (err) {
|
||||
this.Notifications.error('Failure', err, 'Unable to retreive namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
getResourcePools() {
|
||||
return this.$async(this.getResourcePoolsAsync);
|
||||
}
|
||||
|
||||
async onInit() {
|
||||
this.state = {
|
||||
viewReady: false,
|
||||
};
|
||||
|
||||
await this.getResourcePools();
|
||||
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesResourcePoolsController;
|
||||
angular.module('portainer.kubernetes').controller('KubernetesResourcePoolsController', KubernetesResourcePoolsController);
|
|
@ -78,6 +78,11 @@ export function createMockEnvironment(): Environment {
|
|||
URL: 'url',
|
||||
Snapshots: [],
|
||||
Kubernetes: {
|
||||
Flags: {
|
||||
IsServerMetricsDetected: true,
|
||||
IsServerIngressClassDetected: true,
|
||||
IsServerStorageDetected: true,
|
||||
},
|
||||
Snapshots: [],
|
||||
Configuration: {
|
||||
IngressClasses: [],
|
||||
|
@ -85,6 +90,9 @@ export function createMockEnvironment(): Environment {
|
|||
AllowNoneIngressClass: false,
|
||||
},
|
||||
},
|
||||
UserAccessPolicies: {},
|
||||
TeamAccessPolicies: {},
|
||||
ComposeSyntaxMaxVersion: '0',
|
||||
EdgeKey: '',
|
||||
EnableGPUManagement: false,
|
||||
Id: 3,
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import clsx from 'clsx';
|
||||
|
||||
type Step = { value: number; color?: string; className?: string };
|
||||
type StepWithPercent = Step & { percent: number };
|
||||
interface Props {
|
||||
steps: Array<Step>;
|
||||
total: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ steps, total, className }: Props) {
|
||||
const { steps: reducedSteps } = steps.reduce<{
|
||||
steps: Array<StepWithPercent>;
|
||||
total: number;
|
||||
totalPercent: number;
|
||||
}>(
|
||||
(acc, cur) => {
|
||||
const value =
|
||||
acc.total + cur.value > total ? total - acc.total : cur.value;
|
||||
// If the remaining acc.total + the current value adds up to the total, then make sure the percentage will fill the remaining bar space
|
||||
const percent =
|
||||
acc.total + value === total
|
||||
? 100 - acc.totalPercent
|
||||
: Math.floor((value / total) * 100);
|
||||
return {
|
||||
steps: [
|
||||
...acc.steps,
|
||||
{
|
||||
...cur,
|
||||
value,
|
||||
percent,
|
||||
},
|
||||
],
|
||||
total: acc.total + value,
|
||||
totalPercent: acc.totalPercent + percent,
|
||||
};
|
||||
},
|
||||
{ steps: [], total: 0, totalPercent: 0 }
|
||||
);
|
||||
|
||||
const sum = steps.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'progress shadow-none h-2.5 rounded-full',
|
||||
sum > 100 ? 'text-blue-8' : 'text-error-7',
|
||||
className
|
||||
)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
role="progressbar"
|
||||
>
|
||||
{reducedSteps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx('progress-bar shadow-none', step.className)}
|
||||
style={{
|
||||
width: `${step.percent}%`,
|
||||
backgroundColor: step.color,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ProgressBar } from './ProgressBar';
|
|
@ -11,7 +11,7 @@ interface Props {
|
|||
annotations: Annotation[];
|
||||
handleAnnotationChange: (
|
||||
index: number,
|
||||
key: 'Key' | 'Value',
|
||||
key: 'key' | 'value',
|
||||
val: string
|
||||
) => void;
|
||||
removeAnnotation: (index: number) => void;
|
||||
|
@ -33,7 +33,7 @@ export function AnnotationsForm({
|
|||
return (
|
||||
<>
|
||||
{annotations.map((annotation, i) => (
|
||||
<div className="row" key={annotation.ID}>
|
||||
<div className="row" key={annotation.id}>
|
||||
<div className="form-group col-sm-4 !m-0 !pl-0">
|
||||
<div className="input-group input-group-sm">
|
||||
<span className="input-group-addon required">Key</span>
|
||||
|
@ -42,16 +42,16 @@ export function AnnotationsForm({
|
|||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder={placeholder[0]}
|
||||
defaultValue={annotation.Key}
|
||||
defaultValue={annotation.key}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleAnnotationChange(i, 'Key', e.target.value)
|
||||
handleAnnotationChange(i, 'key', e.target.value)
|
||||
}
|
||||
data-cy={`annotation-key-${i}`}
|
||||
/>
|
||||
</div>
|
||||
{annotationErrors?.[i]?.Key && (
|
||||
{annotationErrors?.[i]?.key && (
|
||||
<FormError className="!mb-0 mt-1">
|
||||
{annotationErrors[i]?.Key}
|
||||
{annotationErrors[i]?.key}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
@ -63,16 +63,16 @@ export function AnnotationsForm({
|
|||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
placeholder={placeholder[1]}
|
||||
defaultValue={annotation.Value}
|
||||
defaultValue={annotation.value}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
handleAnnotationChange(i, 'Value', e.target.value)
|
||||
handleAnnotationChange(i, 'value', e.target.value)
|
||||
}
|
||||
data-cy={`annotation-value-${i}`}
|
||||
/>
|
||||
</div>
|
||||
{annotationErrors?.[i]?.Value && (
|
||||
{annotationErrors?.[i]?.value && (
|
||||
<FormError className="!mb-0 mt-1">
|
||||
{annotationErrors[i]?.Value}
|
||||
{annotationErrors[i]?.value}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
export interface Annotation {
|
||||
Key: string;
|
||||
Value: string;
|
||||
ID: string;
|
||||
key: string;
|
||||
value: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AnnotationsPayload = Record<string, string>;
|
||||
|
|
|
@ -11,12 +11,12 @@ export const annotationsSchema: SchemaOf<Annotation[]> = array(
|
|||
).test(
|
||||
'unique',
|
||||
'Duplicate keys are not allowed.',
|
||||
buildUniquenessTest(() => 'Duplicate keys are not allowed.', 'Key')
|
||||
buildUniquenessTest(() => 'Duplicate keys are not allowed.', 'key')
|
||||
);
|
||||
|
||||
function getAnnotationValidation(): SchemaOf<Annotation> {
|
||||
return object({
|
||||
Key: string()
|
||||
key: string()
|
||||
.required('Key is required.')
|
||||
.test('is-valid', (value, { createError }) => {
|
||||
if (!value) {
|
||||
|
@ -62,7 +62,7 @@ function getAnnotationValidation(): SchemaOf<Annotation> {
|
|||
}
|
||||
return true;
|
||||
}),
|
||||
Value: string().required('Value is required.'),
|
||||
ID: string().required('ID is required.'),
|
||||
value: string().required('Value is required.'),
|
||||
id: string().required('ID is required.'),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -70,6 +70,9 @@ export async function updateIngressControllerClassMap(
|
|||
ingressControllerClassMap: IngressControllerClassMap[],
|
||||
namespace?: string
|
||||
) {
|
||||
if (ingressControllerClassMap.length === 0) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.put<
|
||||
IngressControllerClassMap[]
|
||||
|
|
|
@ -10,6 +10,7 @@ type Props = {
|
|||
resourceId?: string;
|
||||
/** if undefined, events are fetched for the cluster */
|
||||
namespace?: string;
|
||||
noWidget?: boolean;
|
||||
};
|
||||
|
||||
/** ResourceEventsDatatable returns the EventsDatatable for all events that relate to a specific resource id */
|
||||
|
@ -17,6 +18,7 @@ export function ResourceEventsDatatable({
|
|||
storageKey,
|
||||
resourceId,
|
||||
namespace,
|
||||
noWidget = true,
|
||||
}: Props) {
|
||||
const tableState = useKubeStore(storageKey, {
|
||||
id: 'Date',
|
||||
|
@ -47,7 +49,7 @@ export function ResourceEventsDatatable({
|
|||
tableState={tableState}
|
||||
isLoading={resourceEventsQuery.isLoading}
|
||||
data-cy="k8sNodeDetail-eventsTable"
|
||||
noWidget
|
||||
noWidget={noWidget}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import { debounce } from 'lodash';
|
|||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useK8sSecrets } from '@/react/kubernetes/configs/queries/useK8sSecrets';
|
||||
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { Annotation } from '@/react/kubernetes/annotations/types';
|
||||
|
@ -24,6 +23,7 @@ import {
|
|||
useUpdateIngress,
|
||||
useIngressControllers,
|
||||
} from '../queries';
|
||||
import { useNamespaceServices } from '../../services/useNamespaceServices';
|
||||
|
||||
import {
|
||||
Rule,
|
||||
|
@ -410,13 +410,13 @@ export function CreateIngressView() {
|
|||
const duplicatedAnnotations: string[] = [];
|
||||
const re = /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/;
|
||||
rule.Annotations?.forEach((a, i) => {
|
||||
if (!a.Key) {
|
||||
if (!a.key) {
|
||||
errors[`annotations.key[${i}]`] = 'Key is required.';
|
||||
} else if (duplicatedAnnotations.includes(a.Key)) {
|
||||
} else if (duplicatedAnnotations.includes(a.key)) {
|
||||
errors[`annotations.key[${i}]`] =
|
||||
'Key is a duplicate of an existing one.';
|
||||
} else {
|
||||
const key = a.Key.split('/');
|
||||
const key = a.key.split('/');
|
||||
if (key.length > 2) {
|
||||
errors[`annotations.key[${i}]`] =
|
||||
'Two segments are allowed, separated by a slash (/): a prefix (optional) and a name.';
|
||||
|
@ -441,10 +441,10 @@ export function CreateIngressView() {
|
|||
}
|
||||
}
|
||||
}
|
||||
if (!a.Value) {
|
||||
if (!a.value) {
|
||||
errors[`annotations.value[${i}]`] = 'Value is required.';
|
||||
}
|
||||
duplicatedAnnotations.push(a.Key);
|
||||
duplicatedAnnotations.push(a.key);
|
||||
});
|
||||
|
||||
const duplicatedHosts: string[] = [];
|
||||
|
@ -677,7 +677,7 @@ export function CreateIngressView() {
|
|||
|
||||
function handleAnnotationChange(
|
||||
index: number,
|
||||
key: 'Key' | 'Value',
|
||||
key: 'key' | 'value',
|
||||
val: string
|
||||
) {
|
||||
setIngressRule((prevRules) => {
|
||||
|
@ -685,8 +685,8 @@ export function CreateIngressView() {
|
|||
|
||||
rules.Annotations = rules.Annotations || [];
|
||||
rules.Annotations[index] = rules.Annotations[index] || {
|
||||
Key: '',
|
||||
Value: '',
|
||||
key: '',
|
||||
value: '',
|
||||
};
|
||||
rules.Annotations[index][key] = val;
|
||||
|
||||
|
@ -760,22 +760,22 @@ export function CreateIngressView() {
|
|||
const rule = { ...ingressRule };
|
||||
|
||||
const annotation: Annotation = {
|
||||
Key: '',
|
||||
Value: '',
|
||||
ID: uuidv4(),
|
||||
key: '',
|
||||
value: '',
|
||||
id: uuidv4(),
|
||||
};
|
||||
switch (type) {
|
||||
case 'rewrite':
|
||||
annotation.Key = 'nginx.ingress.kubernetes.io/rewrite-target';
|
||||
annotation.Value = '/$1';
|
||||
annotation.key = 'nginx.ingress.kubernetes.io/rewrite-target';
|
||||
annotation.value = '/$1';
|
||||
break;
|
||||
case 'regex':
|
||||
annotation.Key = 'nginx.ingress.kubernetes.io/use-regex';
|
||||
annotation.Value = 'true';
|
||||
annotation.key = 'nginx.ingress.kubernetes.io/use-regex';
|
||||
annotation.value = 'true';
|
||||
break;
|
||||
case 'ingressClass':
|
||||
annotation.Key = 'kubernetes.io/ingress.class';
|
||||
annotation.Value = '';
|
||||
annotation.key = 'kubernetes.io/ingress.class';
|
||||
annotation.value = '';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
|
|
@ -73,7 +73,7 @@ interface Props {
|
|||
) => void;
|
||||
handleAnnotationChange: (
|
||||
index: number,
|
||||
key: 'Key' | 'Value',
|
||||
key: 'key' | 'value',
|
||||
val: string
|
||||
) => void;
|
||||
handlePathChange: (
|
||||
|
|
|
@ -83,9 +83,9 @@ export function getAnnotationsForEdit(
|
|||
Object.keys(annotations).forEach((k) => {
|
||||
if (ignoreAnnotationsForEdit.indexOf(k) === -1) {
|
||||
result.push({
|
||||
Key: k,
|
||||
Value: annotations[k],
|
||||
ID: uuidv4(),
|
||||
key: k,
|
||||
value: annotations[k],
|
||||
id: uuidv4(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -32,20 +32,6 @@ export async function getMetricsForNode(
|
|||
}
|
||||
}
|
||||
|
||||
export async function getMetricsForAllPods(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: pods } = await axios.get(
|
||||
`kubernetes/${environmentId}/metrics/pods/namespace/${namespace}`
|
||||
);
|
||||
return pods;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to retrieve metrics for all pods');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMetricsForPod(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { queryKeys as namespaceQueryKeys } from '@/react/kubernetes/namespaces/queries/queryKeys';
|
||||
|
||||
export const queryKeys = {
|
||||
namespaceMetrics: (environmentId: EnvironmentId, namespaceName: string) => [
|
||||
...namespaceQueryKeys.namespace(environmentId, namespaceName),
|
||||
'metrics',
|
||||
],
|
||||
};
|
|
@ -0,0 +1,34 @@
|
|||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { PodMetrics } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
export function useMetricsForNamespace<T = PodMetrics>(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
queryOptions?: UseQueryOptions<PodMetrics, unknown, T>
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.namespaceMetrics(environmentId, namespaceName),
|
||||
queryFn: () => getMetricsForNamespace(environmentId, namespaceName),
|
||||
...queryOptions,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getMetricsForNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string
|
||||
) {
|
||||
try {
|
||||
const { data: pods } = await axios.get<PodMetrics>(
|
||||
`kubernetes/${environmentId}/metrics/pods/namespace/${namespaceName}`
|
||||
);
|
||||
return pods;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to retrieve metrics for all pods');
|
||||
}
|
||||
}
|
|
@ -1,3 +1,20 @@
|
|||
export type PodMetrics = {
|
||||
items: PodMetric[];
|
||||
};
|
||||
|
||||
export type PodMetric = {
|
||||
containers: ContainerMetric[];
|
||||
};
|
||||
|
||||
type ContainerMetric = {
|
||||
usage: ResourceUsage;
|
||||
};
|
||||
|
||||
type ResourceUsage = {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
};
|
||||
|
||||
export type NodeMetrics = {
|
||||
items: NodeMetric[];
|
||||
};
|
||||
|
|
|
@ -10,18 +10,17 @@ import { useCurrentUser } from '@/react/hooks/useUser';
|
|||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { NamespaceInnerForm } from '../components/NamespaceInnerForm';
|
||||
import { NamespaceInnerForm } from '../components/NamespaceForm/NamespaceInnerForm';
|
||||
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../queries/useResourceLimitsQuery';
|
||||
import { useCreateNamespaceMutation } from '../queries/useCreateNamespaceMutation';
|
||||
import { getNamespaceValidationSchema } from '../components/NamespaceForm/NamespaceForm.validation';
|
||||
import { transformFormValuesToNamespacePayload } from '../components/NamespaceForm/utils';
|
||||
import {
|
||||
CreateNamespaceFormValues,
|
||||
CreateNamespacePayload,
|
||||
NamespaceFormValues,
|
||||
NamespacePayload,
|
||||
UpdateRegistryPayload,
|
||||
} from './types';
|
||||
import { useClusterResourceLimitsQuery } from './queries/useResourceLimitsQuery';
|
||||
import { getNamespaceValidationSchema } from './CreateNamespaceForm.validation';
|
||||
import { transformFormValuesToNamespacePayload } from './utils';
|
||||
import { useCreateNamespaceMutation } from './queries/useCreateNamespaceMutation';
|
||||
} from '../types';
|
||||
|
||||
export function CreateNamespaceForm() {
|
||||
const router = useRouter();
|
||||
|
@ -49,8 +48,8 @@ export function CreateNamespaceForm() {
|
|||
}
|
||||
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
const initialValues: CreateNamespaceFormValues = {
|
||||
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
|
||||
const initialValues: NamespaceFormValues = {
|
||||
name: '',
|
||||
ingressClasses: ingressClasses ?? [],
|
||||
resourceQuota: {
|
||||
|
@ -71,6 +70,7 @@ export function CreateNamespaceForm() {
|
|||
validateOnMount
|
||||
validationSchema={getNamespaceValidationSchema(
|
||||
memoryLimit,
|
||||
cpuLimit,
|
||||
namespaceNames
|
||||
)}
|
||||
>
|
||||
|
@ -80,8 +80,8 @@ export function CreateNamespaceForm() {
|
|||
</Widget>
|
||||
);
|
||||
|
||||
function handleSubmit(values: CreateNamespaceFormValues, userName: string) {
|
||||
const createNamespacePayload: CreateNamespacePayload =
|
||||
function handleSubmit(values: NamespaceFormValues, userName: string) {
|
||||
const createNamespacePayload: NamespacePayload =
|
||||
transformFormValuesToNamespacePayload(values, userName);
|
||||
const updateRegistriesPayload: UpdateRegistryPayload[] =
|
||||
values.registries.flatMap((registryFormValues) => {
|
||||
|
@ -93,7 +93,7 @@ export function CreateNamespaceForm() {
|
|||
return [];
|
||||
}
|
||||
const envNamespacesWithAccess =
|
||||
selectedRegistry.RegistryAccesses[`${environmentId}`]?.Namespaces ||
|
||||
selectedRegistry.RegistryAccesses?.[`${environmentId}`]?.Namespaces ||
|
||||
[];
|
||||
return {
|
||||
Id: selectedRegistry.Id,
|
||||
|
|
|
@ -4,7 +4,7 @@ import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
|
|||
import {
|
||||
ResourceQuotaFormValues,
|
||||
ResourceQuotaPayload,
|
||||
} from '../components/ResourceQuotaFormSection/types';
|
||||
} from '../components/NamespaceForm/ResourceQuotaFormSection/types';
|
||||
|
||||
export type CreateNamespaceFormValues = {
|
||||
name: string;
|
||||
|
|
|
@ -2,14 +2,16 @@ import { ModalType } from '@@/modals';
|
|||
import { confirm } from '@@/modals/confirm';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
export function confirmUpdateNamespace(
|
||||
quotaWarning: boolean,
|
||||
ingressWarning: boolean,
|
||||
registriesWarning: boolean
|
||||
) {
|
||||
type Warnings = {
|
||||
quota: boolean;
|
||||
ingress: boolean;
|
||||
registries: boolean;
|
||||
};
|
||||
|
||||
export function confirmUpdateNamespace(warnings: Warnings) {
|
||||
const message = (
|
||||
<>
|
||||
{quotaWarning && (
|
||||
{warnings.quota && (
|
||||
<p>
|
||||
Reducing the quota assigned to an "in-use" namespace may
|
||||
have unintended consequences, including preventing running
|
||||
|
@ -17,13 +19,13 @@ export function confirmUpdateNamespace(
|
|||
them from running at all.
|
||||
</p>
|
||||
)}
|
||||
{ingressWarning && (
|
||||
{warnings.ingress && (
|
||||
<p>
|
||||
Deactivating ingresses may cause applications to be unaccessible. All
|
||||
ingress configurations from affected applications will be removed.
|
||||
</p>
|
||||
)}
|
||||
{registriesWarning && (
|
||||
{warnings.registries && (
|
||||
<p>
|
||||
Some registries you removed might be used by one or more applications
|
||||
inside this environment. Removing the registries access could lead to
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Code } from 'lucide-react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { useRepeater } from '@@/datatables/useRepeater';
|
||||
import { TableSettingsMenuAutoRefresh } from '@@/datatables/TableSettingsMenuAutoRefresh';
|
||||
import { useTableStateWithStorage } from '@@/datatables/useTableState';
|
||||
import {
|
||||
|
@ -10,20 +11,14 @@ import {
|
|||
RefreshableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
import { NamespaceApp } from './types';
|
||||
import { useApplications } from '../../applications/queries/useApplications';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
|
||||
interface TableSettings extends BasicTableSettings, RefreshableTableSettings {}
|
||||
|
||||
export function NamespaceAppsDatatable({
|
||||
dataset,
|
||||
onRefresh,
|
||||
isLoading,
|
||||
}: {
|
||||
dataset: Array<NamespaceApp>;
|
||||
onRefresh: () => void;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
export function NamespaceAppsDatatable({ namespace }: { namespace: string }) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const tableState = useTableStateWithStorage<TableSettings>(
|
||||
'kube-namespace-apps',
|
||||
'Name',
|
||||
|
@ -31,18 +26,25 @@ export function NamespaceAppsDatatable({
|
|||
...refreshableSettings(set),
|
||||
})
|
||||
);
|
||||
useRepeater(tableState.autoRefreshRate, onRefresh);
|
||||
|
||||
const applicationsQuery = useApplications(environmentId, {
|
||||
refetchInterval: tableState.autoRefreshRate * 1000,
|
||||
namespace,
|
||||
withDependencies: true,
|
||||
});
|
||||
const applications = applicationsQuery.data ?? [];
|
||||
|
||||
const columns = useColumns();
|
||||
|
||||
return (
|
||||
<Datatable
|
||||
dataset={dataset}
|
||||
dataset={applications}
|
||||
settingsManager={tableState}
|
||||
columns={columns}
|
||||
disableSelect
|
||||
title="Applications running in this namespace"
|
||||
titleIcon={Code}
|
||||
isLoading={isLoading}
|
||||
isLoading={applicationsQuery.isLoading}
|
||||
renderTableSettings={() => (
|
||||
<TableSettingsMenu>
|
||||
<TableSettingsMenuAutoRefresh
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
import { AlertTriangle, Code, Layers, History } from 'lucide-react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { findSelectedTabIndex, Tab, WidgetTabs } from '@@/Widget/WidgetTabs';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { useEventWarningsCount } from '../../queries/useEvents';
|
||||
import { NamespaceYAMLEditor } from '../components/NamespaceYamlEditor';
|
||||
import { ResourceEventsDatatable } from '../../components/EventsDatatable/ResourceEventsDatatable';
|
||||
|
||||
import { UpdateNamespaceForm } from './UpdateNamespaceForm';
|
||||
import { NamespaceAppsDatatable } from './NamespaceAppsDatatable';
|
||||
|
||||
export function NamespaceView() {
|
||||
const stateAndParams = useCurrentStateAndParams();
|
||||
const {
|
||||
params: { id: namespace },
|
||||
} = stateAndParams;
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const eventWarningCount = useEventWarningsCount(environmentId, namespace);
|
||||
|
||||
const tabs: Tab[] = [
|
||||
{
|
||||
name: 'Namespace',
|
||||
icon: Layers,
|
||||
widget: <UpdateNamespaceForm />,
|
||||
selectedTabParam: 'namespace',
|
||||
},
|
||||
{
|
||||
name: (
|
||||
<div className="flex items-center gap-x-2">
|
||||
Events
|
||||
{eventWarningCount >= 1 && (
|
||||
<Badge type="warnSecondary">
|
||||
<Icon icon={AlertTriangle} className="!mr-1" />
|
||||
{eventWarningCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
icon: History,
|
||||
widget: (
|
||||
<ResourceEventsDatatable
|
||||
namespace={namespace}
|
||||
storageKey="kubernetes.namespace.events"
|
||||
noWidget={false}
|
||||
/>
|
||||
),
|
||||
selectedTabParam: 'events',
|
||||
},
|
||||
{
|
||||
name: 'YAML',
|
||||
icon: Code,
|
||||
widget: <NamespaceYAMLEditor />,
|
||||
selectedTabParam: 'YAML',
|
||||
},
|
||||
];
|
||||
const currentTabIndex = findSelectedTabIndex(stateAndParams, tabs);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Namespace details"
|
||||
breadcrumbs={[
|
||||
{ label: 'Namespaces', link: 'kubernetes.resourcePools' },
|
||||
namespace,
|
||||
]}
|
||||
reload
|
||||
/>
|
||||
<>
|
||||
<WidgetTabs tabs={tabs} currentTabIndex={currentTabIndex} />
|
||||
{tabs[currentTabIndex].widget}
|
||||
<NamespaceAppsDatatable namespace={namespace} />
|
||||
</>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,256 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { Loading, Widget, WidgetBody } from '@@/Widget';
|
||||
import { Alert } from '@@/Alert';
|
||||
|
||||
import { NamespaceInnerForm } from '../components/NamespaceForm/NamespaceInnerForm';
|
||||
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
|
||||
import { useClusterResourceLimitsQuery } from '../queries/useResourceLimitsQuery';
|
||||
import { NamespaceFormValues, NamespacePayload } from '../types';
|
||||
import { getNamespaceValidationSchema } from '../components/NamespaceForm/NamespaceForm.validation';
|
||||
import { transformFormValuesToNamespacePayload } from '../components/NamespaceForm/utils';
|
||||
import { useNamespaceQuery } from '../queries/useNamespaceQuery';
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { ResourceQuotaFormValues } from '../components/NamespaceForm/ResourceQuotaFormSection/types';
|
||||
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
|
||||
import { useUpdateNamespaceMutation } from '../queries/useUpdateNamespaceMutation';
|
||||
|
||||
import { useNamespaceFormValues } from './useNamespaceFormValues';
|
||||
import { confirmUpdateNamespace } from './ConfirmUpdateNamespace';
|
||||
import { createUpdateRegistriesPayload } from './createUpdateRegistriesPayload';
|
||||
|
||||
export function UpdateNamespaceForm() {
|
||||
const {
|
||||
params: { id: namespaceName },
|
||||
} = useCurrentStateAndParams();
|
||||
const router = useRouter();
|
||||
|
||||
// for initial values
|
||||
const { user } = useCurrentUser();
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const namespacesQuery = useNamespacesQuery(environmentId);
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const namespaceQuery = useNamespaceQuery(environmentId, namespaceName, {
|
||||
params: { withResourceQuota: 'true' },
|
||||
});
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
const ingressClassesQuery = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
namespace: namespaceName,
|
||||
allowedOnly: true,
|
||||
});
|
||||
const storageClasses =
|
||||
environmentQuery.data?.Kubernetes.Configuration.StorageClasses;
|
||||
const { data: namespaces } = namespacesQuery;
|
||||
const { data: resourceLimits } = resourceLimitsQuery;
|
||||
const { data: namespace } = namespaceQuery;
|
||||
const { data: registries } = registriesQuery;
|
||||
const { data: ingressClasses } = ingressClassesQuery;
|
||||
|
||||
const updateNamespaceMutation = useUpdateNamespaceMutation(environmentId);
|
||||
|
||||
const namespaceNames = Object.keys(namespaces || {});
|
||||
const memoryLimit = resourceLimits?.Memory ?? 0;
|
||||
const cpuLimit = resourceLimits?.CPU ?? 0;
|
||||
const initialValues = useNamespaceFormValues({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
storageClasses,
|
||||
namespace,
|
||||
registries,
|
||||
ingressClasses,
|
||||
});
|
||||
const isQueryLoading =
|
||||
environmentQuery.isLoading ||
|
||||
resourceLimitsQuery.isLoading ||
|
||||
namespacesQuery.isLoading ||
|
||||
namespaceQuery.isLoading ||
|
||||
registriesQuery.isLoading ||
|
||||
ingressClassesQuery.isLoading;
|
||||
|
||||
const isQueryError =
|
||||
environmentQuery.isError ||
|
||||
resourceLimitsQuery.isError ||
|
||||
namespacesQuery.isError ||
|
||||
namespaceQuery.isError ||
|
||||
registriesQuery.isError ||
|
||||
ingressClassesQuery.isError;
|
||||
|
||||
if (isQueryLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (isQueryError) {
|
||||
return (
|
||||
<Alert color="error" title="Error">
|
||||
Error loading namespace
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!initialValues) {
|
||||
return (
|
||||
<Alert color="warn" title="Warning">
|
||||
No data found for namespace
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={(values) => handleSubmit(values, user.Username)}
|
||||
validateOnMount
|
||||
validationSchema={getNamespaceValidationSchema(
|
||||
memoryLimit,
|
||||
cpuLimit,
|
||||
namespaceNames
|
||||
)}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<NamespaceInnerForm
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...formikProps}
|
||||
isEdit
|
||||
/>
|
||||
)}
|
||||
</Formik>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
async function handleSubmit(values: NamespaceFormValues, userName: string) {
|
||||
const createNamespacePayload: NamespacePayload =
|
||||
transformFormValuesToNamespacePayload(values, userName);
|
||||
const updateRegistriesPayload = createUpdateRegistriesPayload({
|
||||
registries,
|
||||
namespaceName,
|
||||
newRegistriesValues: values.registries,
|
||||
initialRegistriesValues: initialValues?.registries || [],
|
||||
environmentId,
|
||||
});
|
||||
|
||||
// give update warnings if needed
|
||||
const isNamespaceAccessRemoved = hasNamespaceAccessBeenRemoved(
|
||||
values.registries,
|
||||
initialValues?.registries || [],
|
||||
environmentId,
|
||||
values.name
|
||||
);
|
||||
const isIngressClassesRemoved = hasIngressClassesBeenRemoved(
|
||||
values.ingressClasses,
|
||||
initialValues?.ingressClasses || []
|
||||
);
|
||||
const warnings = {
|
||||
quota: hasResourceQuotaBeenReduced(
|
||||
values.resourceQuota,
|
||||
initialValues?.resourceQuota
|
||||
),
|
||||
ingress: isIngressClassesRemoved,
|
||||
registries: isNamespaceAccessRemoved,
|
||||
};
|
||||
if (Object.values(warnings).some(Boolean)) {
|
||||
const confirmed = await confirmUpdateNamespace(warnings);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// update the namespace
|
||||
updateNamespaceMutation.mutate(
|
||||
{
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload: values.ingressClasses,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
`Namespace '${values.name}' updated successfully`
|
||||
);
|
||||
router.stateService.reload();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasResourceQuotaBeenReduced(
|
||||
newResourceQuota: ResourceQuotaFormValues,
|
||||
initialResourceQuota?: ResourceQuotaFormValues
|
||||
) {
|
||||
if (!initialResourceQuota) {
|
||||
return false;
|
||||
}
|
||||
// if the new value is an empty string or '0', it's counted as 'unlimited'
|
||||
const unlimitedValue = String(Number.MAX_SAFE_INTEGER);
|
||||
return (
|
||||
(Number(initialResourceQuota.cpu) || unlimitedValue) >
|
||||
(Number(newResourceQuota.cpu) || unlimitedValue) ||
|
||||
(Number(initialResourceQuota.memory) || unlimitedValue) >
|
||||
(Number(newResourceQuota.memory) || unlimitedValue)
|
||||
);
|
||||
}
|
||||
|
||||
function hasNamespaceAccessBeenRemoved(
|
||||
newRegistries: Registry[],
|
||||
initialRegistries: Registry[],
|
||||
environmentId: number,
|
||||
namespaceName: string
|
||||
) {
|
||||
return initialRegistries.some((oldRegistry) => {
|
||||
// Check if the namespace was in the old registry's accesses
|
||||
const isNamespaceInOldAccesses =
|
||||
oldRegistry.RegistryAccesses?.[`${environmentId}`]?.Namespaces.includes(
|
||||
namespaceName
|
||||
);
|
||||
|
||||
if (!isNamespaceInOldAccesses) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the corresponding new registry
|
||||
const newRegistry = newRegistries.find((r) => r.Id === oldRegistry.Id);
|
||||
if (!newRegistry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the registry no longer exists or the namespace is not in its accesses, access has been removed
|
||||
const isNamespaceInNewAccesses =
|
||||
newRegistry.RegistryAccesses?.[`${environmentId}`]?.Namespaces.includes(
|
||||
namespaceName
|
||||
);
|
||||
|
||||
return !isNamespaceInNewAccesses;
|
||||
});
|
||||
}
|
||||
|
||||
function hasIngressClassesBeenRemoved(
|
||||
newIngressClasses: IngressControllerClassMap[],
|
||||
initialIngressClasses: IngressControllerClassMap[]
|
||||
) {
|
||||
// go through all old classes and check if their availability has changed
|
||||
return initialIngressClasses.some((oldClass) => {
|
||||
const newClass = newIngressClasses.find((c) => c.Name === oldClass.Name);
|
||||
return newClass?.Availability !== oldClass.Availability;
|
||||
});
|
||||
}
|
|
@ -2,18 +2,17 @@ import { createColumnHelper } from '@tanstack/react-table';
|
|||
import _ from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { humanize, truncate } from '@/portainer/filters/filters';
|
||||
import { usePublicSettings } from '@/react/portainer/settings/queries';
|
||||
import { humanize } from '@/portainer/filters/filters';
|
||||
|
||||
import { Link } from '@@/Link';
|
||||
import { ExternalBadge } from '@@/Badge/ExternalBadge';
|
||||
|
||||
import { isExternalApplication } from '../../applications/utils';
|
||||
import { cpuHumanValue } from '../../applications/utils/cpuHumanValue';
|
||||
import { Application } from '../../applications/ListView/ApplicationsDatatable/types';
|
||||
|
||||
import { NamespaceApp } from './types';
|
||||
|
||||
const columnHelper = createColumnHelper<NamespaceApp>();
|
||||
const columnHelper = createColumnHelper<Application>();
|
||||
|
||||
export function useColumns() {
|
||||
const hideStacksQuery = usePublicSettings<boolean>({
|
||||
|
@ -27,7 +26,7 @@ export function useColumns() {
|
|||
columnHelper.accessor('Name', {
|
||||
header: 'Name',
|
||||
cell: ({ row: { original: item } }) => (
|
||||
<>
|
||||
<div className="flex flex-0">
|
||||
<Link
|
||||
to="kubernetes.applications.application"
|
||||
params={{ name: item.Name, namespace: item.ResourcePool }}
|
||||
|
@ -40,7 +39,7 @@ export function useColumns() {
|
|||
<ExternalBadge />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
!hideStacksQuery.data &&
|
||||
|
@ -50,23 +49,34 @@ export function useColumns() {
|
|||
}),
|
||||
columnHelper.accessor('Image', {
|
||||
header: 'Image',
|
||||
cell: ({ row: { original: item } }) => (
|
||||
<>
|
||||
{truncate(item.Image, 64)}
|
||||
{item.Containers?.length > 1 && (
|
||||
<>+ {item.Containers.length - 1}</>
|
||||
)}
|
||||
</>
|
||||
cell: ({ getValue }) => (
|
||||
<div className="max-w-md truncate">{getValue()}</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor('CPU', {
|
||||
header: 'CPU',
|
||||
cell: ({ getValue }) => cpuHumanValue(getValue()),
|
||||
}),
|
||||
columnHelper.accessor('Memory', {
|
||||
header: 'Memory',
|
||||
cell: ({ getValue }) => humanize(getValue()),
|
||||
}),
|
||||
columnHelper.accessor(
|
||||
(row) =>
|
||||
row.Resource?.CpuRequest
|
||||
? cpuHumanValue(row.Resource?.CpuRequest)
|
||||
: '-',
|
||||
{
|
||||
header: 'CPU',
|
||||
cell: ({ getValue }) => getValue(),
|
||||
}
|
||||
),
|
||||
columnHelper.accessor(
|
||||
(row) =>
|
||||
row.Resource?.MemoryRequest ? row.Resource?.MemoryRequest : '-',
|
||||
{
|
||||
header: 'Memory',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
if (value === '-') {
|
||||
return value;
|
||||
}
|
||||
return humanize(value);
|
||||
},
|
||||
}
|
||||
),
|
||||
]),
|
||||
[hideStacksQuery.data]
|
||||
);
|
||||
|
|
|
@ -0,0 +1,518 @@
|
|||
import { createUpdateRegistriesPayload } from './createUpdateRegistriesPayload';
|
||||
|
||||
const tests: {
|
||||
testName: string;
|
||||
params: Parameters<typeof createUpdateRegistriesPayload>[0];
|
||||
expected: ReturnType<typeof createUpdateRegistriesPayload>;
|
||||
}[] = [
|
||||
{
|
||||
testName: 'Add new registry',
|
||||
params: {
|
||||
registries: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: [],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
namespaceName: 'newns',
|
||||
newRegistriesValues: [
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: [],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
initialRegistriesValues: [
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
environmentId: 7,
|
||||
},
|
||||
expected: [
|
||||
{
|
||||
Id: 2,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
testName: 'Remove a registry',
|
||||
params: {
|
||||
registries: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
namespaceName: 'newns',
|
||||
newRegistriesValues: [
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
initialRegistriesValues: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
environmentId: 7,
|
||||
},
|
||||
expected: [
|
||||
{
|
||||
Id: 1,
|
||||
Namespaces: [],
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
testName: 'Remove all registries',
|
||||
params: {
|
||||
registries: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
namespaceName: 'newns',
|
||||
newRegistriesValues: [],
|
||||
initialRegistriesValues: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'portainer',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Type: 3,
|
||||
Name: 'portainertest',
|
||||
URL: 'test123.com',
|
||||
BaseURL: '',
|
||||
Authentication: false,
|
||||
Username: '',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'7': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
environmentId: 7,
|
||||
},
|
||||
expected: [
|
||||
{
|
||||
Id: 1,
|
||||
Namespaces: [],
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Namespaces: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe('createUpdateRegistriesPayload', () => {
|
||||
tests.forEach(({ testName, params, expected }) => {
|
||||
it(`Should return the correct payload: ${testName}`, () => {
|
||||
expect(createUpdateRegistriesPayload(params)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,50 @@
|
|||
import { uniqBy } from 'lodash';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { UpdateRegistryPayload } from '../types';
|
||||
|
||||
export function createUpdateRegistriesPayload({
|
||||
registries,
|
||||
namespaceName,
|
||||
newRegistriesValues,
|
||||
initialRegistriesValues,
|
||||
environmentId,
|
||||
}: {
|
||||
registries: Registry[] | undefined;
|
||||
namespaceName: string;
|
||||
newRegistriesValues: Registry[];
|
||||
initialRegistriesValues: Registry[];
|
||||
environmentId: number;
|
||||
}): UpdateRegistryPayload[] {
|
||||
if (!registries) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all unique registries from both initial and new values
|
||||
const uniqueRegistries = uniqBy(
|
||||
[...initialRegistriesValues, ...newRegistriesValues],
|
||||
'Id'
|
||||
);
|
||||
|
||||
const payload = uniqueRegistries.map((registry) => {
|
||||
const currentNamespaces =
|
||||
registry.RegistryAccesses?.[`${environmentId}`]?.Namespaces || [];
|
||||
|
||||
const existsInNewValues = newRegistriesValues.some(
|
||||
(r) => r.Id === registry.Id
|
||||
);
|
||||
|
||||
// If registry is in new values, add namespace; if not, remove it
|
||||
const updatedNamespaces = existsInNewValues
|
||||
? [...new Set([...currentNamespaces, namespaceName])]
|
||||
: currentNamespaces.filter((ns) => ns !== namespaceName);
|
||||
|
||||
return {
|
||||
Id: registry.Id,
|
||||
Namespaces: updatedNamespaces,
|
||||
};
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
|
@ -0,0 +1,247 @@
|
|||
import { computeInitialValues } from './useNamespaceFormValues';
|
||||
|
||||
type NamespaceTestData = {
|
||||
testName: string;
|
||||
namespaceData: Parameters<typeof computeInitialValues>[0];
|
||||
expectedFormValues: ReturnType<typeof computeInitialValues>;
|
||||
};
|
||||
|
||||
// various namespace data from simple to complex
|
||||
const tests: NamespaceTestData[] = [
|
||||
{
|
||||
testName:
|
||||
'No resource quotas, registries, storage requests or ingress controllers',
|
||||
namespaceData: {
|
||||
namespaceName: 'test',
|
||||
environmentId: 4,
|
||||
storageClasses: [
|
||||
{
|
||||
Name: 'local-path',
|
||||
AccessModes: ['RWO'],
|
||||
Provisioner: 'rancher.io/local-path',
|
||||
AllowVolumeExpansion: false,
|
||||
},
|
||||
],
|
||||
namespace: {
|
||||
Id: '6110390e-f7cb-4f23-b219-197e4a1d0291',
|
||||
Name: 'test',
|
||||
Status: {
|
||||
phase: 'Active',
|
||||
},
|
||||
Annotations: null,
|
||||
CreationDate: '2024-10-17T17:50:08+13:00',
|
||||
NamespaceOwner: 'admin',
|
||||
IsSystem: false,
|
||||
IsDefault: false,
|
||||
},
|
||||
registries: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'aliharriss',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'4': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
ingressClasses: [
|
||||
{
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedFormValues: {
|
||||
name: 'test',
|
||||
ingressClasses: [
|
||||
{
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
},
|
||||
],
|
||||
resourceQuota: {
|
||||
enabled: false,
|
||||
memory: '0',
|
||||
cpu: '0',
|
||||
},
|
||||
registries: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
testName:
|
||||
'With annotations, registry, storage request, resource quota and disabled ingress controller',
|
||||
namespaceData: {
|
||||
namespaceName: 'newns',
|
||||
environmentId: 4,
|
||||
storageClasses: [
|
||||
{
|
||||
Name: 'local-path',
|
||||
AccessModes: ['RWO'],
|
||||
Provisioner: 'rancher.io/local-path',
|
||||
AllowVolumeExpansion: false,
|
||||
},
|
||||
],
|
||||
namespace: {
|
||||
Id: 'd5c3cb69-bf9b-4625-b754-d7ba6ce2c688',
|
||||
Name: 'newns',
|
||||
Status: {
|
||||
phase: 'Active',
|
||||
},
|
||||
Annotations: {
|
||||
asdf: 'asdf',
|
||||
},
|
||||
CreationDate: '2024-10-01T10:20:46+13:00',
|
||||
NamespaceOwner: 'admin',
|
||||
IsSystem: false,
|
||||
IsDefault: false,
|
||||
ResourceQuota: {
|
||||
metadata: {},
|
||||
spec: {
|
||||
hard: {
|
||||
'limits.cpu': '800m',
|
||||
'limits.memory': '768M',
|
||||
'local-path.storageclass.storage.k8s.io/requests.storage': '1G',
|
||||
'requests.cpu': '800m',
|
||||
'requests.memory': '768M',
|
||||
'services.loadbalancers': '1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
registries: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'aliharriss',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'4': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
ingressClasses: [
|
||||
{
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
expectedFormValues: {
|
||||
name: 'newns',
|
||||
ingressClasses: [
|
||||
{
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
},
|
||||
],
|
||||
resourceQuota: {
|
||||
enabled: true,
|
||||
memory: '768',
|
||||
cpu: '0.8',
|
||||
},
|
||||
registries: [
|
||||
{
|
||||
Id: 1,
|
||||
Type: 6,
|
||||
Name: 'dockerhub',
|
||||
URL: 'docker.io',
|
||||
BaseURL: '',
|
||||
Authentication: true,
|
||||
Username: 'aliharriss',
|
||||
Gitlab: {
|
||||
ProjectId: 0,
|
||||
InstanceURL: '',
|
||||
ProjectPath: '',
|
||||
},
|
||||
Quay: {
|
||||
OrganisationName: '',
|
||||
},
|
||||
Ecr: {
|
||||
Region: '',
|
||||
},
|
||||
RegistryAccesses: {
|
||||
'4': {
|
||||
UserAccessPolicies: null,
|
||||
TeamAccessPolicies: null,
|
||||
Namespaces: ['newns'],
|
||||
},
|
||||
},
|
||||
Github: {
|
||||
UseOrganisation: false,
|
||||
OrganisationName: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('useNamespaceFormValues', () => {
|
||||
tests.forEach((test) => {
|
||||
it(`should return the correct form values: ${test.testName}`, () => {
|
||||
const formValues = computeInitialValues(test.namespaceData);
|
||||
expect(formValues).toEqual(test.expectedFormValues);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,78 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { NamespaceFormValues, PortainerNamespace } from '../types';
|
||||
import { megaBytesValue, parseCPU } from '../resourceQuotaUtils';
|
||||
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
|
||||
|
||||
interface ComputeInitialValuesParams {
|
||||
namespaceName: string;
|
||||
environmentId: number;
|
||||
storageClasses?: StorageClass[];
|
||||
namespace?: PortainerNamespace;
|
||||
registries?: Registry[];
|
||||
ingressClasses?: IngressControllerClassMap[];
|
||||
}
|
||||
|
||||
export function computeInitialValues({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
namespace,
|
||||
registries,
|
||||
ingressClasses,
|
||||
}: ComputeInitialValuesParams): NamespaceFormValues | null {
|
||||
if (!namespace) {
|
||||
return null;
|
||||
}
|
||||
const memory = namespace.ResourceQuota?.spec?.hard?.['requests.memory'] ?? '';
|
||||
const cpu = namespace.ResourceQuota?.spec?.hard?.['requests.cpu'] ?? '';
|
||||
|
||||
const registriesUsed = registries?.filter(
|
||||
(registry) =>
|
||||
registry.RegistryAccesses?.[`${environmentId}`]?.Namespaces.includes(
|
||||
namespaceName
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
name: namespaceName,
|
||||
ingressClasses: ingressClasses ?? [],
|
||||
resourceQuota: {
|
||||
enabled: !!memory || !!cpu,
|
||||
memory: `${megaBytesValue(memory)}`,
|
||||
cpu: `${parseCPU(cpu)}`,
|
||||
},
|
||||
registries: registriesUsed ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function useNamespaceFormValues({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
storageClasses,
|
||||
namespace,
|
||||
registries,
|
||||
ingressClasses,
|
||||
}: ComputeInitialValuesParams): NamespaceFormValues | null {
|
||||
return useMemo(
|
||||
() =>
|
||||
computeInitialValues({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
storageClasses,
|
||||
namespace,
|
||||
registries,
|
||||
ingressClasses,
|
||||
}),
|
||||
[
|
||||
storageClasses,
|
||||
namespace,
|
||||
registries,
|
||||
namespaceName,
|
||||
ingressClasses,
|
||||
environmentId,
|
||||
]
|
||||
);
|
||||
}
|
|
@ -91,7 +91,7 @@ export function NamespacesDatatable() {
|
|||
|
||||
function TableActions({
|
||||
selectedItems,
|
||||
namespaces: namespacesQueryData,
|
||||
namespaces,
|
||||
}: {
|
||||
selectedItems: PortainerNamespace[];
|
||||
namespaces?: PortainerNamespace[];
|
||||
|
@ -168,18 +168,21 @@ function TableActions({
|
|||
|
||||
// Plain invalidation / refetching is confusing because namespaces hang in a terminating state
|
||||
// instead, optimistically update the cache manually to hide the deleting (terminating) namespaces
|
||||
const remainingNamespaces = deletedNamespaces.reduce(
|
||||
(acc, ns) => {
|
||||
const index = acc.findIndex((n) => n.Name === ns);
|
||||
if (index !== -1) {
|
||||
acc.splice(index, 1);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[...(namespaces ?? [])]
|
||||
);
|
||||
queryClient.setQueryData(
|
||||
queryKeys.list(environmentId, {
|
||||
withResourceQuota: true,
|
||||
}),
|
||||
() =>
|
||||
deletedNamespaces.reduce(
|
||||
(acc, ns) => {
|
||||
delete acc[ns as keyof typeof acc];
|
||||
return acc;
|
||||
},
|
||||
{ ...namespacesQueryData }
|
||||
)
|
||||
() => remainingNamespaces
|
||||
);
|
||||
},
|
||||
}
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import { string, object, array, SchemaOf } from 'yup';
|
||||
|
||||
import { registriesValidationSchema } from '../components/RegistriesFormSection/registriesValidationSchema';
|
||||
import { getResourceQuotaValidationSchema } from '../components/ResourceQuotaFormSection/getResourceQuotaValidationSchema';
|
||||
import { NamespaceFormValues } from '../../types';
|
||||
|
||||
import { CreateNamespaceFormValues } from './types';
|
||||
import { registriesValidationSchema } from './RegistriesFormSection/registriesValidationSchema';
|
||||
import { getResourceQuotaValidationSchema } from './ResourceQuotaFormSection/getResourceQuotaValidationSchema';
|
||||
|
||||
export function getNamespaceValidationSchema(
|
||||
memoryLimit: number,
|
||||
cpuLimit: number,
|
||||
namespaceNames: string[]
|
||||
): SchemaOf<CreateNamespaceFormValues> {
|
||||
): SchemaOf<NamespaceFormValues> {
|
||||
return object({
|
||||
name: string()
|
||||
.matches(
|
||||
|
@ -19,7 +20,7 @@ export function getNamespaceValidationSchema(
|
|||
// must not have the same name as an existing namespace
|
||||
.notOneOf(namespaceNames, 'Name must be unique.')
|
||||
.required('Name is required.'),
|
||||
resourceQuota: getResourceQuotaValidationSchema(memoryLimit),
|
||||
resourceQuota: getResourceQuotaValidationSchema(memoryLimit, cpuLimit),
|
||||
// ingress classes table is constrained already, and doesn't need validation
|
||||
ingressClasses: array(),
|
||||
registries: registriesValidationSchema,
|
|
@ -0,0 +1,164 @@
|
|||
import { Field, Form, FormikProps } from 'formik';
|
||||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
import { SystemBadge } from '@@/Badge/SystemBadge';
|
||||
|
||||
import { IngressClassDatatable } from '../../../cluster/ingressClass/IngressClassDatatable';
|
||||
import { useIngressControllerClassMapQuery } from '../../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { CreateNamespaceFormValues } from '../../CreateView/types';
|
||||
import { AnnotationsBeTeaser } from '../../../annotations/AnnotationsBeTeaser';
|
||||
import { isDefaultNamespace } from '../../isDefaultNamespace';
|
||||
import { useIsSystemNamespace } from '../../queries/useIsSystemNamespace';
|
||||
|
||||
import { NamespaceSummary } from './NamespaceSummary';
|
||||
import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection';
|
||||
import { RegistriesFormSection } from './RegistriesFormSection';
|
||||
import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types';
|
||||
import { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
||||
import { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
||||
import { ToggleSystemNamespaceButton } from './ToggleSystemNamespaceButton';
|
||||
|
||||
const namespaceWriteAuth = 'K8sResourcePoolDetailsW';
|
||||
|
||||
export function NamespaceInnerForm({
|
||||
errors,
|
||||
isValid,
|
||||
dirty,
|
||||
setFieldValue,
|
||||
values,
|
||||
isSubmitting,
|
||||
initialValues,
|
||||
isEdit,
|
||||
}: FormikProps<CreateNamespaceFormValues> & { isEdit?: boolean }) {
|
||||
const { authorized: hasNamespaceWriteAuth } = useAuthorizations(
|
||||
namespaceWriteAuth,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
const isSystemNamespace = useIsSystemNamespace(values.name, isEdit === true);
|
||||
const isEditingDisabled =
|
||||
!hasNamespaceWriteAuth ||
|
||||
isDefaultNamespace(values.name) ||
|
||||
isSystemNamespace;
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const ingressClassesQuery = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
namespace: values.name,
|
||||
allowedOnly: true,
|
||||
});
|
||||
|
||||
if (environmentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useLoadBalancer =
|
||||
environmentQuery.data?.Kubernetes.Configuration.UseLoadBalancer;
|
||||
const enableResourceOverCommit =
|
||||
environmentQuery.data?.Kubernetes.Configuration.EnableResourceOverCommit;
|
||||
const enableIngressControllersPerNamespace =
|
||||
environmentQuery.data?.Kubernetes.Configuration
|
||||
.IngressAvailabilityPerNamespace;
|
||||
const storageClasses =
|
||||
environmentQuery.data?.Kubernetes.Configuration.StorageClasses ?? [];
|
||||
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<FormControl
|
||||
inputId="namespace"
|
||||
label="Name"
|
||||
required={!isEdit}
|
||||
errors={errors.name}
|
||||
>
|
||||
{isEdit ? (
|
||||
<div className="flex gap-2 mt-2">
|
||||
{values.name}
|
||||
{isSystemNamespace && <SystemBadge />}
|
||||
</div>
|
||||
) : (
|
||||
<Field
|
||||
as={Input}
|
||||
id="namespace"
|
||||
name="name"
|
||||
disabled={isEdit}
|
||||
placeholder="e.g. my-namespace"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
<AnnotationsBeTeaser />
|
||||
{(values.resourceQuota.enabled || !isEditingDisabled) && (
|
||||
<ResourceQuotaFormSection
|
||||
isEdit={isEdit}
|
||||
enableResourceOverCommit={enableResourceOverCommit}
|
||||
values={values.resourceQuota}
|
||||
onChange={(resourceQuota: ResourceQuotaFormValues) =>
|
||||
setFieldValue('resourceQuota', resourceQuota)
|
||||
}
|
||||
errors={errors.resourceQuota}
|
||||
namespaceName={values.name}
|
||||
isEditingDisabled={isEditingDisabled}
|
||||
/>
|
||||
)}
|
||||
{useLoadBalancer && <LoadBalancerFormSection />}
|
||||
{enableIngressControllersPerNamespace && (
|
||||
<Authorized authorizations={[namespaceWriteAuth]}>
|
||||
<FormSection title="Networking">
|
||||
<IngressClassDatatable
|
||||
onChange={(classes) => setFieldValue('ingressClasses', classes)}
|
||||
values={values.ingressClasses}
|
||||
description="Enable the ingress controllers that users can select when publishing applications in this namespace."
|
||||
noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster."
|
||||
view="namespace"
|
||||
isLoading={ingressClassesQuery.isLoading}
|
||||
initialValues={initialValues.ingressClasses}
|
||||
/>
|
||||
</FormSection>
|
||||
</Authorized>
|
||||
)}
|
||||
<RegistriesFormSection
|
||||
values={values.registries}
|
||||
onChange={(registries: MultiValue<Registry>) =>
|
||||
setFieldValue('registries', registries)
|
||||
}
|
||||
errors={errors.registries}
|
||||
isEditingDisabled={isEditingDisabled}
|
||||
/>
|
||||
{storageClasses.length > 0 && (
|
||||
<StorageQuotaFormSection storageClasses={storageClasses} />
|
||||
)}
|
||||
<Authorized authorizations={[namespaceWriteAuth]}>
|
||||
<NamespaceSummary
|
||||
initialValues={initialValues}
|
||||
values={values}
|
||||
isValid={isValid}
|
||||
/>
|
||||
<FormActions
|
||||
submitLabel={isEdit ? 'Update namespace' : 'Create namespace'}
|
||||
loadingText={isEdit ? 'Updating namespace' : 'Creating namespace'}
|
||||
isLoading={isSubmitting}
|
||||
isValid={isValid && dirty}
|
||||
data-cy="k8sNamespaceCreate-submitButton"
|
||||
>
|
||||
{isEdit && (
|
||||
<ToggleSystemNamespaceButton
|
||||
isSystemNamespace={isSystemNamespace}
|
||||
isEdit={isEdit}
|
||||
environmentId={environmentId}
|
||||
namespaceName={values.name}
|
||||
/>
|
||||
)}
|
||||
</FormActions>
|
||||
</Authorized>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import { isEqual } from 'lodash';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { NamespaceFormValues } from '../../types';
|
||||
|
||||
interface Props {
|
||||
initialValues: NamespaceFormValues;
|
||||
values: NamespaceFormValues;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export function NamespaceSummary({ initialValues, values, isValid }: Props) {
|
||||
// only compare the values from k8s related resources
|
||||
const { registries: newRegistryValues, ...newK8sValues } = values;
|
||||
const { registries: oldRegistryValues, ...oldK8sValues } = initialValues;
|
||||
const hasChanges = !isEqual(newK8sValues, oldK8sValues);
|
||||
if (!hasChanges || !isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCreatingNamespace = !oldK8sValues.name && newK8sValues.name;
|
||||
|
||||
const enabledQuotaInitialValues = initialValues.resourceQuota.enabled;
|
||||
const enabledQuotaNewValues = values.resourceQuota.enabled;
|
||||
|
||||
const isCreatingResourceQuota =
|
||||
!enabledQuotaInitialValues && enabledQuotaNewValues;
|
||||
const isUpdatingResourceQuota =
|
||||
enabledQuotaInitialValues && enabledQuotaNewValues;
|
||||
const isDeletingResourceQuota =
|
||||
enabledQuotaInitialValues && !enabledQuotaNewValues;
|
||||
|
||||
return (
|
||||
<FormSection title="Summary" isFoldable defaultFolded={false}>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
Portainer will execute the following Kubernetes actions.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-12 small text-muted pt-1">
|
||||
<ul>
|
||||
{isCreatingNamespace && (
|
||||
<li>
|
||||
Create a <span className="bold">Namespace</span> named{' '}
|
||||
<code>{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
{isCreatingResourceQuota && (
|
||||
<li>
|
||||
Create a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
{isUpdatingResourceQuota && (
|
||||
<li>
|
||||
Update a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
{isDeletingResourceQuota && (
|
||||
<li>
|
||||
Delete a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -16,22 +16,30 @@ type Props = {
|
|||
values: MultiValue<Registry>;
|
||||
onChange: (value: MultiValue<Registry>) => void;
|
||||
errors?: string | string[] | FormikErrors<Registry>[];
|
||||
isEditingDisabled: boolean;
|
||||
};
|
||||
|
||||
export function RegistriesFormSection({ values, onChange, errors }: Props) {
|
||||
export function RegistriesFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
isEditingDisabled,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
return (
|
||||
<FormSection title="Registries">
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Define which registries can be used by users who have access to this
|
||||
namespace.
|
||||
</TextTip>
|
||||
{!isEditingDisabled && (
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Define which registries can be used by users who have access to this
|
||||
namespace.
|
||||
</TextTip>
|
||||
)}
|
||||
<FormControl
|
||||
inputId="registries"
|
||||
label="Select registries"
|
||||
label={isEditingDisabled ? 'Selected registries' : 'Select registries'}
|
||||
errors={errors}
|
||||
>
|
||||
{registriesQuery.isLoading && (
|
||||
|
@ -43,6 +51,7 @@ export function RegistriesFormSection({ values, onChange, errors }: Props) {
|
|||
onChange={(registries) => onChange(registries)}
|
||||
options={registriesQuery.data}
|
||||
inputId="registries"
|
||||
isEditingDisabled={isEditingDisabled}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
|
@ -0,0 +1,74 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options?: Registry[];
|
||||
inputId?: string;
|
||||
isEditingDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
inputId,
|
||||
isEditingDisabled,
|
||||
}: Props) {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<p className="text-muted mb-1 mt-2 text-xs">
|
||||
{isPureAdmin ? (
|
||||
<span>
|
||||
No registries available. Head over to the{' '}
|
||||
<Link
|
||||
to="portainer.registries"
|
||||
target="_blank"
|
||||
data-cy="namespace-permissions-registries-selector"
|
||||
>
|
||||
registry view
|
||||
</Link>{' '}
|
||||
to define a container registry.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
No registries available. Contact your administrator to create a
|
||||
container registry.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditingDisabled) {
|
||||
return (
|
||||
<p className="text-muted mb-1 mt-2 text-xs">
|
||||
{value.length === 0 ? 'None' : value.map((v) => v.Name).join(', ')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
isMulti
|
||||
getOptionLabel={(option) => option.Name}
|
||||
getOptionValue={(option) => String(option.Id)}
|
||||
options={options}
|
||||
value={value}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registries"
|
||||
isDisabled={isEditingDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceReservationUsage } from './ResourceReservationUsage';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
onChange: (value: ResourceQuotaFormValues) => void;
|
||||
enableResourceOverCommit?: boolean;
|
||||
errors?: FormikErrors<ResourceQuotaFormValues>;
|
||||
namespaceName?: string;
|
||||
isEdit?: boolean;
|
||||
isEditingDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function ResourceQuotaFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
isEdit,
|
||||
enableResourceOverCommit,
|
||||
namespaceName,
|
||||
isEditingDisabled,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
return (
|
||||
<FormSection title="Resource Quota">
|
||||
{!isEditingDisabled && (
|
||||
<>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
A resource quota sets boundaries on the compute resources a
|
||||
namespace can use. It's good practice to set a quota for a
|
||||
namespace to manage resources effectively. Alternatively, you can
|
||||
disable assigning a quota for unrestricted access (not recommended).
|
||||
</TextTip>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceCreate-resourceAssignmentToggle"
|
||||
disabled={!enableResourceOverCommit}
|
||||
label="Resource assignment"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
checked={values.enabled || !enableResourceOverCommit}
|
||||
onChange={(enabled) => onChange({ ...values, enabled })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(values.enabled || !enableResourceOverCommit) && !isEditingDisabled && (
|
||||
<div>
|
||||
<FormSectionTitle>Resource Limits</FormSectionTitle>
|
||||
{(!cpuLimit || !memoryLimit) && (
|
||||
<FormError>
|
||||
Not enough resources available in the cluster to apply a resource
|
||||
reservation.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
label="Memory limit (MB)"
|
||||
inputId="memory-limit"
|
||||
className="[&>label]:mt-8"
|
||||
errors={errors?.memory}
|
||||
>
|
||||
{memoryLimit >= 0 && (
|
||||
<SliderWithInput
|
||||
value={Number(values.memory) ?? 0}
|
||||
onChange={(value) =>
|
||||
onChange({ ...values, memory: `${value}` })
|
||||
}
|
||||
max={memoryLimit}
|
||||
min={0}
|
||||
step={128}
|
||||
dataCy="k8sNamespaceCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
inputId="memory-limit"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="CPU limit"
|
||||
inputId="cpu-limit"
|
||||
className="[&>label]:mt-8"
|
||||
errors={errors?.cpu}
|
||||
>
|
||||
{cpuLimit >= 0 && (
|
||||
<Slider
|
||||
min={0}
|
||||
max={cpuLimit / 1000}
|
||||
step={0.1}
|
||||
value={Number(values.cpu) ?? 0}
|
||||
onChange={(cpu) => {
|
||||
if (Array.isArray(cpu)) {
|
||||
return;
|
||||
}
|
||||
onChange({ ...values, cpu: cpu.toString() });
|
||||
}}
|
||||
dataCy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
visibleTooltip
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
|
||||
{cpuLimit && memoryLimit && typeof errors === 'string' ? (
|
||||
<FormError>{errors}</FormError>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{namespaceName && isEdit && (
|
||||
<ResourceReservationUsage
|
||||
namespaceName={namespaceName}
|
||||
environmentId={environmentId}
|
||||
resourceQuotaValues={values}
|
||||
/>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,150 @@
|
|||
import { round } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
|
||||
import { megaBytesValue, parseCPU } from '../../../resourceQuotaUtils';
|
||||
import { ResourceUsageItem } from '../../ResourceUsageItem';
|
||||
|
||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function ResourceReservationUsage({
|
||||
namespaceName,
|
||||
environmentId,
|
||||
resourceQuotaValues,
|
||||
}: {
|
||||
namespaceName: string;
|
||||
environmentId: EnvironmentId;
|
||||
resourceQuotaValues: ResourceQuotaFormValues;
|
||||
}) {
|
||||
const namespaceMetricsQuery = useMetricsForNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
{
|
||||
select: aggregatePodUsage,
|
||||
}
|
||||
);
|
||||
const usedResourceQuotaQuery = useResourceQuotaUsed(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
const { data: namespaceMetrics } = namespaceMetricsQuery;
|
||||
const { data: usedResourceQuota } = usedResourceQuotaQuery;
|
||||
|
||||
const memoryQuota = Number(resourceQuotaValues.memory) ?? 0;
|
||||
const cpuQuota = Number(resourceQuotaValues.cpu) ?? 0;
|
||||
|
||||
if (!resourceQuotaValues.enabled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FormSectionTitle>Resource reservation</FormSectionTitle>
|
||||
<TextTip color="blue" className="mb-2">
|
||||
Resource reservation represents the total amount of resource assigned to
|
||||
all the applications deployed inside this namespace.
|
||||
</TextTip>
|
||||
{!!usedResourceQuota && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory reservation"
|
||||
annotation={`${usedResourceQuota.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(usedResourceQuota.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && memoryQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.memory}
|
||||
total={getSafeValue(memoryQuota)}
|
||||
label="Memory used"
|
||||
annotation={`${namespaceMetrics.memory} / ${getSafeValue(
|
||||
memoryQuota
|
||||
)} MB ${getPercentageString(namespaceMetrics.memory, memoryQuota)}`}
|
||||
/>
|
||||
)}
|
||||
{!!usedResourceQuota && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={usedResourceQuota.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU reservation"
|
||||
annotation={`${
|
||||
usedResourceQuota.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
usedResourceQuota.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
{!!namespaceMetrics && cpuQuota > 0 && (
|
||||
<ResourceUsageItem
|
||||
value={namespaceMetrics.cpu}
|
||||
total={cpuQuota}
|
||||
label="CPU used"
|
||||
annotation={`${
|
||||
namespaceMetrics.cpu
|
||||
} / ${cpuQuota} ${getPercentageString(
|
||||
namespaceMetrics.cpu,
|
||||
cpuQuota
|
||||
)}`}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeValue(value: number | string) {
|
||||
const valueNumber = Number(value);
|
||||
if (Number.isNaN(valueNumber)) {
|
||||
return 0;
|
||||
}
|
||||
return valueNumber;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the percentage of the value over the total.
|
||||
* @param value - The value to calculate the percentage for.
|
||||
* @param total - The total value to compare the percentage to.
|
||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||
*/
|
||||
function getPercentageString(value: number, total?: number | string) {
|
||||
const totalNumber = Number(total);
|
||||
if (
|
||||
totalNumber === 0 ||
|
||||
total === undefined ||
|
||||
total === '' ||
|
||||
Number.isNaN(totalNumber)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
if (value > totalNumber) {
|
||||
return '- Exceeded';
|
||||
}
|
||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the resource usage of all the containers in the namespace.
|
||||
* @param podMetricsList - List of pod metrics
|
||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||
i.containers.map((c) => c.usage)
|
||||
);
|
||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||
(total, usage) => ({
|
||||
cpu: total.cpu + parseCPU(usage.cpu),
|
||||
memory: total.memory + megaBytesValue(usage.memory),
|
||||
}),
|
||||
{ cpu: 0, memory: 0 }
|
||||
);
|
||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||
return namespaceResourceUsage;
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { boolean, string, object, SchemaOf, TestContext } from 'yup';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function getResourceQuotaValidationSchema(
|
||||
memoryLimit: number,
|
||||
cpuLimit: number
|
||||
): SchemaOf<ResourceQuotaFormValues> {
|
||||
return object({
|
||||
enabled: boolean().required('Resource quota enabled status is required.'),
|
||||
memory: string()
|
||||
.test(
|
||||
'non-negative-memory-validation',
|
||||
'Existing namespaces already have memory limits exceeding what is available in the cluster. Before you can set values here, you must reduce amounts in namespaces (and you may have to turn on over-commit temporarily to do so).',
|
||||
() => nonNegativeLimit(memoryLimit)
|
||||
)
|
||||
.test(
|
||||
'memory-validation',
|
||||
`Value must be between 0 and ${memoryLimit}.`,
|
||||
memoryValidation
|
||||
),
|
||||
cpu: string()
|
||||
.test(
|
||||
'non-negative-memory-validation',
|
||||
'Existing namespaces already have CPU limits exceeding what is available in the cluster. Before you can set values here, you must reduce amounts in namespaces (and you may have to turn on over-commit temporarily to do so).',
|
||||
() => nonNegativeLimit(cpuLimit)
|
||||
)
|
||||
.test('cpu-validation', 'CPU limit value is required.', cpuValidation),
|
||||
}).test(
|
||||
'resource-quota-validation',
|
||||
'At least a single limit must be set.',
|
||||
oneLimitSet
|
||||
);
|
||||
|
||||
function oneLimitSet({
|
||||
enabled,
|
||||
memory,
|
||||
cpu,
|
||||
}: Partial<ResourceQuotaFormValues>) {
|
||||
return !enabled || (Number(memory) ?? 0) > 0 || (Number(cpu) ?? 0) > 0;
|
||||
}
|
||||
|
||||
function nonNegativeLimit(limit: number) {
|
||||
return limit >= 0;
|
||||
}
|
||||
|
||||
function memoryValidation(this: TestContext, memoryValue?: string) {
|
||||
const memory = Number(memoryValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || (memory >= 0 && memory <= memoryLimit);
|
||||
}
|
||||
|
||||
function cpuValidation(this: TestContext, cpuValue?: string) {
|
||||
const cpu = Number(cpuValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || cpu >= 0;
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@
|
|||
* @property enabled - Whether resource quota is enabled
|
||||
* @property memory - Memory limit in bytes
|
||||
* @property cpu - CPU limit in cores
|
||||
* @property loadBalancer - Load balancer limit in number of load balancers
|
||||
*/
|
||||
export type ResourceQuotaFormValues = {
|
||||
enabled: boolean;
|
|
@ -0,0 +1,38 @@
|
|||
import { round } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { useNamespaceQuery } from '../../../queries/useNamespaceQuery';
|
||||
import { parseCPU, megaBytesValue } from '../../../resourceQuotaUtils';
|
||||
import { PortainerNamespace } from '../../../types';
|
||||
|
||||
/**
|
||||
* Returns the resource quota used for a namespace.
|
||||
* @param environmentId - The environment ID.
|
||||
* @param namespaceName - The namespace name.
|
||||
* @param enabled - Whether the resource quota is enabled.
|
||||
* @returns The resource quota used for the namespace. CPU (cores) is rounded to 3 decimal places. Memory is in MB.
|
||||
*/
|
||||
export function useResourceQuotaUsed(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
enabled = true
|
||||
) {
|
||||
return useNamespaceQuery(environmentId, namespaceName, {
|
||||
select: parseResourceQuotaUsed,
|
||||
enabled,
|
||||
params: { withResourceQuota: 'true' },
|
||||
});
|
||||
}
|
||||
|
||||
function parseResourceQuotaUsed(namespace: PortainerNamespace) {
|
||||
return {
|
||||
cpu: round(
|
||||
parseCPU(namespace.ResourceQuota?.status?.used?.['requests.cpu'] ?? ''),
|
||||
3
|
||||
),
|
||||
memory: megaBytesValue(
|
||||
namespace.ResourceQuota?.status?.used?.['requests.memory'] ?? ''
|
||||
),
|
||||
};
|
||||
}
|
|
@ -1,9 +1,15 @@
|
|||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { StorageQuotaItem } from './StorageQuotaItem';
|
||||
|
||||
export function StorageQuotaFormSection() {
|
||||
interface Props {
|
||||
storageClasses: StorageClass[];
|
||||
}
|
||||
|
||||
export function StorageQuotaFormSection({ storageClasses }: Props) {
|
||||
return (
|
||||
<FormSection title="Storage">
|
||||
<TextTip color="blue">
|
||||
|
@ -13,7 +19,9 @@ export function StorageQuotaFormSection() {
|
|||
this namespace.
|
||||
</TextTip>
|
||||
|
||||
<StorageQuotaItem />
|
||||
{storageClasses.map((storageClass) => (
|
||||
<StorageQuotaItem key={storageClass.Name} storageClass={storageClass} />
|
||||
))}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import { Database } from 'lucide-react';
|
||||
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { Authorized } from '@/react/hooks/useUser';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
type Props = {
|
||||
storageClass: StorageClass;
|
||||
};
|
||||
|
||||
export function StorageQuotaItem({ storageClass }: Props) {
|
||||
return (
|
||||
<div key={storageClass.Name}>
|
||||
<FormSectionTitle>
|
||||
<div className="vertical-center text-muted inline-flex gap-1 align-top">
|
||||
<Icon icon={Database} className="!mt-0.5 flex-none" />
|
||||
<span>{storageClass.Name}</span>
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
<hr className="mb-0 mt-2 w-full" />
|
||||
<Authorized authorizations={['K8sResourcePoolDetailsW']}>
|
||||
<div className="form-group mb-4">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceEdit-storageClassQuota"
|
||||
disabled
|
||||
label="Enable quota"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Authorized>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { confirmUpdate } from '@@/modals/confirm';
|
||||
|
||||
import { useToggleSystemNamespaceMutation } from '../../queries/useToggleSystemNamespace';
|
||||
|
||||
export function ToggleSystemNamespaceButton({
|
||||
isSystemNamespace,
|
||||
isEdit,
|
||||
environmentId,
|
||||
namespaceName,
|
||||
}: {
|
||||
isSystemNamespace: boolean;
|
||||
isEdit: boolean;
|
||||
environmentId: EnvironmentId;
|
||||
namespaceName: string;
|
||||
}) {
|
||||
const toggleSystemNamespaceMutation = useToggleSystemNamespaceMutation(
|
||||
environmentId,
|
||||
namespaceName
|
||||
);
|
||||
if (!isEdit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LoadingButton
|
||||
onClick={markUnmarkAsSystem}
|
||||
className="!ml-0"
|
||||
data-cy="mark-as-system-button"
|
||||
color="default"
|
||||
type="button"
|
||||
loadingText={
|
||||
isSystemNamespace ? 'Unmarking as system' : 'Marking as system'
|
||||
}
|
||||
isLoading={toggleSystemNamespaceMutation.isLoading}
|
||||
>
|
||||
{isSystemNamespace ? 'Unmark as system' : 'Mark as system'}
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
async function markUnmarkAsSystem() {
|
||||
const confirmed = await confirmMarkUnmarkAsSystem(isSystemNamespace);
|
||||
if (confirmed) {
|
||||
toggleSystemNamespaceMutation.mutate(!isSystemNamespace, {
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Namespace updated');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMarkUnmarkAsSystem(isSystemNamespace: boolean) {
|
||||
const message = isSystemNamespace
|
||||
? 'Unmarking this namespace as system will allow non administrator users to manage it and the resources in contains depending on the access control settings. Are you sure?'
|
||||
: 'Marking this namespace as a system namespace will prevent non administrator users from managing it and the resources it contains. Are you sure?';
|
||||
|
||||
return new Promise((resolve) => {
|
||||
confirmUpdate(message, resolve);
|
||||
});
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
import { CreateNamespaceFormValues, CreateNamespacePayload } from './types';
|
||||
import { NamespaceFormValues, NamespacePayload } from '../../types';
|
||||
|
||||
export function transformFormValuesToNamespacePayload(
|
||||
createNamespaceFormValues: CreateNamespaceFormValues,
|
||||
createNamespaceFormValues: NamespaceFormValues,
|
||||
owner: string
|
||||
): CreateNamespacePayload {
|
||||
): NamespacePayload {
|
||||
const memoryInBytes =
|
||||
Number(createNamespaceFormValues.resourceQuota.memory) * 10 ** 6;
|
||||
return {
|
|
@ -1,115 +0,0 @@
|
|||
import { Field, Form, FormikProps } from 'formik';
|
||||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
|
||||
import { IngressClassDatatable } from '../../cluster/ingressClass/IngressClassDatatable';
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { CreateNamespaceFormValues } from '../CreateView/types';
|
||||
import { AnnotationsBeTeaser } from '../../annotations/AnnotationsBeTeaser';
|
||||
|
||||
import { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
||||
import { NamespaceSummary } from './NamespaceSummary';
|
||||
import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection';
|
||||
import { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
||||
import { RegistriesFormSection } from './RegistriesFormSection';
|
||||
import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types';
|
||||
|
||||
export function NamespaceInnerForm({
|
||||
errors,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
values,
|
||||
isSubmitting,
|
||||
initialValues,
|
||||
}: FormikProps<CreateNamespaceFormValues>) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const ingressClassesQuery = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
allowedOnly: true,
|
||||
});
|
||||
|
||||
if (environmentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useLoadBalancer =
|
||||
environmentQuery.data?.Kubernetes.Configuration.UseLoadBalancer;
|
||||
const enableResourceOverCommit =
|
||||
environmentQuery.data?.Kubernetes.Configuration.EnableResourceOverCommit;
|
||||
const enableIngressControllersPerNamespace =
|
||||
environmentQuery.data?.Kubernetes.Configuration
|
||||
.IngressAvailabilityPerNamespace;
|
||||
const storageClasses =
|
||||
environmentQuery.data?.Kubernetes.Configuration.StorageClasses ?? [];
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormControl
|
||||
inputId="namespace"
|
||||
label="Name"
|
||||
required
|
||||
errors={errors.name}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="namespace"
|
||||
name="name"
|
||||
placeholder="e.g. my-namespace"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
/>
|
||||
</FormControl>
|
||||
<AnnotationsBeTeaser />
|
||||
<ResourceQuotaFormSection
|
||||
enableResourceOverCommit={enableResourceOverCommit}
|
||||
values={values.resourceQuota}
|
||||
onChange={(resourceQuota: ResourceQuotaFormValues) =>
|
||||
setFieldValue('resourceQuota', resourceQuota)
|
||||
}
|
||||
errors={errors.resourceQuota}
|
||||
/>
|
||||
{useLoadBalancer && <LoadBalancerFormSection />}
|
||||
{enableIngressControllersPerNamespace && (
|
||||
<FormSection title="Networking">
|
||||
<IngressClassDatatable
|
||||
onChange={(classes) => setFieldValue('ingressClasses', classes)}
|
||||
values={values.ingressClasses}
|
||||
description="Enable the ingress controllers that users can select when publishing applications in this namespace."
|
||||
noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster."
|
||||
view="namespace"
|
||||
isLoading={ingressClassesQuery.isLoading}
|
||||
initialValues={initialValues.ingressClasses}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
<RegistriesFormSection
|
||||
values={values.registries}
|
||||
onChange={(registries: MultiValue<Registry>) =>
|
||||
setFieldValue('registries', registries)
|
||||
}
|
||||
errors={errors.registries}
|
||||
/>
|
||||
{storageClasses.length > 0 && <StorageQuotaFormSection />}
|
||||
<NamespaceSummary
|
||||
initialValues={initialValues}
|
||||
values={values}
|
||||
isValid={isValid}
|
||||
/>
|
||||
<FormActions
|
||||
submitLabel="Create namespace"
|
||||
loadingText="Creating namespace"
|
||||
isLoading={isSubmitting}
|
||||
isValid={isValid}
|
||||
data-cy="k8sNamespaceCreate-submitButton"
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -1,46 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { CreateNamespaceFormValues } from '../CreateView/types';
|
||||
|
||||
interface Props {
|
||||
initialValues: CreateNamespaceFormValues;
|
||||
values: CreateNamespaceFormValues;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export function NamespaceSummary({ initialValues, values, isValid }: Props) {
|
||||
const hasChanges = !_.isEqual(values, initialValues);
|
||||
|
||||
if (!hasChanges || !isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Summary" isFoldable defaultFolded={false}>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
Portainer will execute the following Kubernetes actions.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-12 small text-muted pt-1">
|
||||
<ul>
|
||||
<li>
|
||||
Create a <span className="bold">Namespace</span> named{' '}
|
||||
<code>{values.name}</code>
|
||||
</li>
|
||||
{values.resourceQuota.enabled && (
|
||||
<li>
|
||||
Create a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Widget } from '@@/Widget/Widget';
|
||||
import { WidgetBody } from '@@/Widget';
|
||||
|
||||
import { YAMLInspector } from '../../components/YAMLInspector';
|
||||
import { useNamespaceYAML } from '../queries/useNamespaceYAML';
|
||||
|
||||
export function NamespaceYAMLEditor() {
|
||||
const {
|
||||
params: { id: namespace, endpointId: environmentId },
|
||||
} = useCurrentStateAndParams();
|
||||
const { data: fullNamespaceYaml, isLoading: isNamespaceYAMLLoading } =
|
||||
useNamespaceYAML(environmentId, namespace);
|
||||
|
||||
if (isNamespaceYAMLLoading) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<InlineLoader>Loading namespace YAML...</InlineLoader>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<YAMLInspector
|
||||
identifier="namespace-yaml"
|
||||
data={fullNamespaceYaml || ''}
|
||||
hideMessage
|
||||
data-cy="namespace-yaml"
|
||||
/>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
interface Props {
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options?: Registry[];
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
inputId,
|
||||
}: Props) {
|
||||
const { isPureAdmin } = useCurrentUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
{options.length === 0 && (
|
||||
<p className="text-muted mb-1 mt-2 text-xs">
|
||||
{isPureAdmin ? (
|
||||
<span>
|
||||
No registries available. Head over to the{' '}
|
||||
<Link
|
||||
to="portainer.registries"
|
||||
target="_blank"
|
||||
data-cy="namespace-permissions-registries-selector"
|
||||
>
|
||||
registry view
|
||||
</Link>{' '}
|
||||
to define a container registry.
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
No registries available. Contact your administrator to create a
|
||||
container registry.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Select
|
||||
isMulti
|
||||
getOptionLabel={(option) => option.Name}
|
||||
getOptionValue={(option) => String(option.Id)}
|
||||
options={options}
|
||||
value={value}
|
||||
closeMenuOnSelect={false}
|
||||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registries"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,125 +0,0 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../../CreateView/queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
onChange: (value: ResourceQuotaFormValues) => void;
|
||||
enableResourceOverCommit?: boolean;
|
||||
errors?: FormikErrors<ResourceQuotaFormValues>;
|
||||
}
|
||||
|
||||
export function ResourceQuotaFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
enableResourceOverCommit,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
return (
|
||||
<FormSection title="Resource Quota">
|
||||
<TextTip color="blue">
|
||||
A resource quota sets boundaries on the compute resources a namespace
|
||||
can use. It's good practice to set a quota for a namespace to
|
||||
manage resources effectively. Alternatively, you can disable assigning a
|
||||
quota for unrestricted access (not recommended).
|
||||
</TextTip>
|
||||
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceCreate-resourceAssignmentToggle"
|
||||
disabled={!enableResourceOverCommit}
|
||||
label="Resource assignment"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={values.enabled || !enableResourceOverCommit}
|
||||
onChange={(enabled) => onChange({ ...values, enabled })}
|
||||
/>
|
||||
|
||||
{(values.enabled || !enableResourceOverCommit) && (
|
||||
<div className="pt-5">
|
||||
<div className="flex flex-row">
|
||||
<FormSectionTitle>Resource Limits</FormSectionTitle>
|
||||
</div>
|
||||
|
||||
{(!cpuLimit || !memoryLimit) && (
|
||||
<FormError>
|
||||
Not enough resources available in the cluster to apply a resource
|
||||
reservation.
|
||||
</FormError>
|
||||
)}
|
||||
|
||||
{/* keep the FormError component present, but invisible to avoid layout shift */}
|
||||
{cpuLimit && memoryLimit ? (
|
||||
<FormError
|
||||
className={typeof errors === 'string' ? 'visible' : 'invisible'}
|
||||
>
|
||||
{/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */}
|
||||
{typeof errors === 'string' ? errors : 'error'}
|
||||
</FormError>
|
||||
) : null}
|
||||
|
||||
<FormControl
|
||||
className="flex flex-row"
|
||||
label="Memory limit (MB)"
|
||||
inputId="memory-limit"
|
||||
>
|
||||
<div className="col-xs-8">
|
||||
{memoryLimit >= 0 && (
|
||||
<SliderWithInput
|
||||
value={Number(values.memory) ?? 0}
|
||||
onChange={(value) =>
|
||||
onChange({ ...values, memory: `${value}` })
|
||||
}
|
||||
max={memoryLimit}
|
||||
step={128}
|
||||
dataCy="k8sNamespaceCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
inputId="memory-limit"
|
||||
/>
|
||||
)}
|
||||
{errors?.memory && (
|
||||
<FormError className="pt-1">{errors.memory}</FormError>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormControl className="flex flex-row" label="CPU limit">
|
||||
<div className="col-xs-8">
|
||||
<Slider
|
||||
min={0}
|
||||
max={cpuLimit / 1000}
|
||||
step={0.1}
|
||||
value={Number(values.cpu) ?? 0}
|
||||
onChange={(cpu) => {
|
||||
if (Array.isArray(cpu)) {
|
||||
return;
|
||||
}
|
||||
onChange({ ...values, cpu: cpu.toString() });
|
||||
}}
|
||||
dataCy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
visibleTooltip
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
import { boolean, string, object, SchemaOf, TestContext } from 'yup';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function getResourceQuotaValidationSchema(
|
||||
memoryLimit: number
|
||||
): SchemaOf<ResourceQuotaFormValues> {
|
||||
return object({
|
||||
enabled: boolean().required('Resource quota enabled status is required.'),
|
||||
memory: string().test(
|
||||
'memory-validation',
|
||||
`Value must be between 0 and ${memoryLimit}.`,
|
||||
memoryValidation
|
||||
),
|
||||
cpu: string().test(
|
||||
'cpu-validation',
|
||||
'CPU limit value is required.',
|
||||
cpuValidation
|
||||
),
|
||||
}).test(
|
||||
'resource-quota-validation',
|
||||
'At least a single limit must be set.',
|
||||
oneLimitSet
|
||||
);
|
||||
|
||||
function oneLimitSet({
|
||||
enabled,
|
||||
memory,
|
||||
cpu,
|
||||
}: Partial<ResourceQuotaFormValues>) {
|
||||
return !enabled || (Number(memory) ?? 0) > 0 || (Number(cpu) ?? 0) > 0;
|
||||
}
|
||||
|
||||
function memoryValidation(this: TestContext, memoryValue?: string) {
|
||||
const memory = Number(memoryValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || (memory >= 0 && memory <= memoryLimit);
|
||||
}
|
||||
|
||||
function cpuValidation(this: TestContext, cpuValue?: string) {
|
||||
const cpu = Number(cpuValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || cpu >= 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import { ProgressBar } from '@@/ProgressBar';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
|
||||
interface ResourceUsageItemProps {
|
||||
value: number;
|
||||
total: number;
|
||||
annotation?: React.ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function ResourceUsageItem({
|
||||
value,
|
||||
total,
|
||||
annotation,
|
||||
label,
|
||||
}: ResourceUsageItemProps) {
|
||||
return (
|
||||
<FormControl label={label}>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<ProgressBar
|
||||
steps={[
|
||||
{
|
||||
value,
|
||||
},
|
||||
]}
|
||||
total={total}
|
||||
/>
|
||||
<div className="text-xs flex shrink-0">{annotation}</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
);
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import { Database } from 'lucide-react';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
export function StorageQuotaItem() {
|
||||
return (
|
||||
<div>
|
||||
<FormSectionTitle>
|
||||
<div className="vertical-center text-muted inline-flex gap-1 align-top">
|
||||
<Icon icon={Database} className="!mt-0.5 flex-none" />
|
||||
<span>standard</span>
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
<hr className="mb-0 mt-2 w-full" />
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceEdit-storageClassQuota"
|
||||
disabled={false}
|
||||
label="Enable quota"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,7 +1,12 @@
|
|||
import { compact } from 'lodash';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
export const queryKeys = {
|
||||
list: (environmentId: number, options?: { withResourceQuota?: boolean }) =>
|
||||
list: (
|
||||
environmentId: EnvironmentId,
|
||||
options?: { withResourceQuota?: boolean }
|
||||
) =>
|
||||
compact([
|
||||
'environments',
|
||||
environmentId,
|
||||
|
@ -9,7 +14,7 @@ export const queryKeys = {
|
|||
'namespaces',
|
||||
options?.withResourceQuota,
|
||||
]),
|
||||
namespace: (environmentId: number, namespace: string) =>
|
||||
namespace: (environmentId: EnvironmentId, namespace: string) =>
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
|
@ -17,4 +22,13 @@ export const queryKeys = {
|
|||
'namespaces',
|
||||
namespace,
|
||||
] as const,
|
||||
namespaceYAML: (environmentId: EnvironmentId, namespace: string) =>
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'namespaces',
|
||||
namespace,
|
||||
'yaml',
|
||||
] as const,
|
||||
};
|
||||
|
|
|
@ -1,23 +1,25 @@
|
|||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { IngressControllerClassMap } from '../../../cluster/ingressClass/types';
|
||||
import { updateIngressControllerClassMap } from '../../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { Namespaces } from '../../types';
|
||||
import { CreateNamespacePayload, UpdateRegistryPayload } from '../types';
|
||||
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
|
||||
import { updateIngressControllerClassMap } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { Namespaces, NamespacePayload, UpdateRegistryPayload } from '../types';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
async ({
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload,
|
||||
}: {
|
||||
createNamespacePayload: CreateNamespacePayload;
|
||||
createNamespacePayload: NamespacePayload;
|
||||
updateRegistriesPayload: UpdateRegistryPayload[];
|
||||
namespaceIngressControllerPayload: IngressControllerClassMap[];
|
||||
}) => {
|
||||
|
@ -51,7 +53,8 @@ export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
|
|||
]);
|
||||
},
|
||||
{
|
||||
...withError('Unable to create namespace'),
|
||||
...withGlobalError('Unable to create namespace'),
|
||||
...withInvalidate(queryClient, [queryKeys.list(environmentId)]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
@ -59,7 +62,7 @@ export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
|
|||
// createNamespace is used to create a namespace using the Portainer backend
|
||||
async function createNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
payload: CreateNamespacePayload
|
||||
payload: NamespacePayload
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.post<Namespaces>(
|
|
@ -4,10 +4,11 @@ import { PortainerNamespace } from '../types';
|
|||
|
||||
import { useNamespaceQuery } from './useNamespaceQuery';
|
||||
|
||||
export function useIsSystemNamespace(namespace: string) {
|
||||
export function useIsSystemNamespace(namespace: string, enabled = true) {
|
||||
const envId = useEnvironmentId();
|
||||
const query = useNamespaceQuery(envId, namespace, {
|
||||
select: (namespace) => namespace.IsSystem,
|
||||
enabled,
|
||||
});
|
||||
|
||||
return !!query.data;
|
||||
|
|
|
@ -8,19 +8,26 @@ import { PortainerNamespace } from '../types';
|
|||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
type QueryParams = 'withResourceQuota';
|
||||
|
||||
export function useNamespaceQuery<T = PortainerNamespace>(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
{
|
||||
select,
|
||||
enabled,
|
||||
params,
|
||||
}: {
|
||||
select?(namespace: PortainerNamespace): T;
|
||||
params?: Record<QueryParams, string>;
|
||||
enabled?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.namespace(environmentId, namespace),
|
||||
() => getNamespace(environmentId, namespace),
|
||||
() => getNamespace(environmentId, namespace, params),
|
||||
{
|
||||
enabled: !!environmentId && !!namespace && enabled,
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get namespace.');
|
||||
},
|
||||
|
@ -32,11 +39,15 @@ export function useNamespaceQuery<T = PortainerNamespace>(
|
|||
// getNamespace is used to retrieve a namespace using the Portainer backend
|
||||
export async function getNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
namespace: string,
|
||||
params?: Record<QueryParams, string>
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.get<PortainerNamespace>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}`
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}`,
|
||||
{
|
||||
params,
|
||||
}
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { isFulfilled } from '@/portainer/helpers/promise-utils';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../../axiosError';
|
||||
import { generateResourceQuotaName } from '../resourceQuotaUtils';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
/**
|
||||
* Gets the YAML for a namespace and its resource quota directly from the K8s proxy API.
|
||||
*/
|
||||
export function useNamespaceYAML(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.namespaceYAML(environmentId, namespaceName),
|
||||
queryFn: () => composeNamespaceYAML(environmentId, namespaceName),
|
||||
});
|
||||
}
|
||||
|
||||
async function composeNamespaceYAML(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
const settledPromises = await Promise.allSettled([
|
||||
getNamespaceYAML(environmentId, namespace),
|
||||
getResourceQuotaYAML(environmentId, namespace),
|
||||
]);
|
||||
const resolvedPromises = settledPromises.filter(isFulfilled);
|
||||
return resolvedPromises.map((p) => p.value).join('\n---\n');
|
||||
}
|
||||
|
||||
async function getNamespaceYAML(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: yaml } = await axios.get<string>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/yaml',
|
||||
},
|
||||
}
|
||||
);
|
||||
return yaml;
|
||||
} catch (error) {
|
||||
throw parseKubernetesAxiosError(error, 'Unable to retrieve namespace YAML');
|
||||
}
|
||||
}
|
||||
|
||||
async function getResourceQuotaYAML(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
const resourceQuotaName = generateResourceQuotaName(namespace);
|
||||
try {
|
||||
const { data: yaml } = await axios.get<string>(
|
||||
`/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/resourcequotas/${resourceQuotaName}`,
|
||||
{ headers: { Accept: 'application/yaml' } }
|
||||
);
|
||||
return yaml;
|
||||
} catch (e) {
|
||||
// silently ignore if resource quota does not exist
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
export function useToggleSystemNamespaceMutation(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (isSystem: boolean) =>
|
||||
toggleSystemNamespace(environmentId, namespaceName, isSystem),
|
||||
...withInvalidate(queryClient, [
|
||||
queryKeys.namespace(environmentId, namespaceName),
|
||||
]),
|
||||
...withGlobalError('Failed to update namespace'),
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleSystemNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
system: boolean
|
||||
) {
|
||||
const response = await axios.put(
|
||||
`/kubernetes/${environmentId}/namespaces/${namespaceName}/system`,
|
||||
{ system }
|
||||
);
|
||||
return response.data;
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withGlobalError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
|
||||
import { updateIngressControllerClassMap } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { Namespaces, NamespacePayload, UpdateRegistryPayload } from '../types';
|
||||
|
||||
import { queryKeys } from './queryKeys';
|
||||
|
||||
export function useUpdateNamespaceMutation(environmentId: EnvironmentId) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation(
|
||||
async ({
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload,
|
||||
}: {
|
||||
createNamespacePayload: NamespacePayload;
|
||||
updateRegistriesPayload: UpdateRegistryPayload[];
|
||||
namespaceIngressControllerPayload: IngressControllerClassMap[];
|
||||
}) => {
|
||||
const { Name: namespaceName } = createNamespacePayload;
|
||||
const updatedNamespace = await updateNamespace(
|
||||
environmentId,
|
||||
namespaceName,
|
||||
createNamespacePayload
|
||||
);
|
||||
|
||||
// collect promises
|
||||
const updateRegistriesPromises = updateRegistriesPayload.map(
|
||||
({ Id, Namespaces }) =>
|
||||
updateEnvironmentRegistryAccess(environmentId, Id, {
|
||||
Namespaces,
|
||||
})
|
||||
);
|
||||
const updateIngressControllerPromise = updateIngressControllerClassMap(
|
||||
environmentId,
|
||||
namespaceIngressControllerPayload,
|
||||
createNamespacePayload.Name
|
||||
);
|
||||
const results = await Promise.allSettled([
|
||||
updateIngressControllerPromise,
|
||||
...updateRegistriesPromises,
|
||||
]);
|
||||
// Check for any failures in the additional updates
|
||||
const failures = results.filter((result) => result.status === 'rejected');
|
||||
failures.forEach((failure) => {
|
||||
notifyError(
|
||||
'Unable to update namespace',
|
||||
undefined,
|
||||
failure.reason as string
|
||||
);
|
||||
});
|
||||
return updatedNamespace;
|
||||
},
|
||||
{
|
||||
...withGlobalError('Unable to update namespace'),
|
||||
...withInvalidate(queryClient, [queryKeys.list(environmentId)]),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// updateNamespace is used to update a namespace using the Portainer backend
|
||||
async function updateNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string,
|
||||
payload: NamespacePayload
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.put<Namespaces>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}`,
|
||||
payload
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create namespace');
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { parseCPU } from './resourceQuotaUtils';
|
||||
|
||||
// test parseCPU with '', '2', '100m', '100u'
|
||||
describe('parseCPU', () => {
|
||||
it('should return 0 for empty string', () => {
|
||||
expect(parseCPU('')).toBe(0);
|
||||
});
|
||||
it('should return 2 for 2', () => {
|
||||
expect(parseCPU('2')).toBe(2);
|
||||
});
|
||||
it('should return 0.1 for 100m', () => {
|
||||
expect(parseCPU('100m')).toBe(0.1);
|
||||
});
|
||||
it('should return 0.0001 for 100u', () => {
|
||||
expect(parseCPU('100u')).toBe(0.0001);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,64 @@
|
|||
import { endsWith } from 'lodash';
|
||||
import filesizeParser from 'filesize-parser';
|
||||
|
||||
export const KubernetesPortainerResourceQuotaPrefix = 'portainer-rq-';
|
||||
|
||||
export function generateResourceQuotaName(name: string) {
|
||||
return `${KubernetesPortainerResourceQuotaPrefix}${name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* parseCPU converts a CPU string to a number in cores.
|
||||
* It supports m (milli), u (micro), n (nano), p (pico) suffixes.
|
||||
*
|
||||
* If given an empty string, it returns 0.
|
||||
*/
|
||||
export function parseCPU(cpu: string) {
|
||||
let res = parseInt(cpu, 10);
|
||||
if (Number.isNaN(res)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (endsWith(cpu, 'm')) {
|
||||
// milli
|
||||
res /= 1000;
|
||||
} else if (endsWith(cpu, 'u')) {
|
||||
// micro
|
||||
res /= 1000000;
|
||||
} else if (endsWith(cpu, 'n')) {
|
||||
// nano
|
||||
res /= 1000000000;
|
||||
} else if (endsWith(cpu, 'p')) {
|
||||
// pico
|
||||
res /= 1000000000000;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export function terabytesValue(value: string | number) {
|
||||
return gigabytesValue(value) / 1000;
|
||||
}
|
||||
|
||||
export function gigabytesValue(value: string | number) {
|
||||
return megaBytesValue(value) / 1000;
|
||||
}
|
||||
|
||||
export function megaBytesValue(value: string | number) {
|
||||
return Math.floor(safeFilesizeParser(value, 10) / 1000 / 1000);
|
||||
}
|
||||
|
||||
export function bytesValue(mem: string | number) {
|
||||
return safeFilesizeParser(mem, 10) * 1000 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* The default base is 2, you can use base 10 if you want
|
||||
* https://github.com/patrickkettner/filesize-parser#readme
|
||||
*/
|
||||
function safeFilesizeParser(value: string | number, base: 2 | 10 = 2) {
|
||||
if (!value || Number.isNaN(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return filesizeParser(value, { base });
|
||||
}
|
|
@ -1,10 +1,17 @@
|
|||
import { NamespaceStatus, ResourceQuota } from 'kubernetes-types/core/v1';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types/registry';
|
||||
|
||||
import { IngressControllerClassMap } from '../cluster/ingressClass/types';
|
||||
|
||||
import { ResourceQuotaFormValues } from './components/NamespaceForm/ResourceQuotaFormSection/types';
|
||||
|
||||
export interface PortainerNamespace {
|
||||
Id: string;
|
||||
Name: string;
|
||||
Status: NamespaceStatus;
|
||||
CreationDate: number;
|
||||
Annotations: Record<string, string> | null;
|
||||
CreationDate: string;
|
||||
NamespaceOwner: string;
|
||||
IsSystem: boolean;
|
||||
IsDefault: boolean;
|
||||
|
@ -14,3 +21,21 @@ export interface PortainerNamespace {
|
|||
// type returned via the internal portainer namespaces api, with simplified fields
|
||||
// it is a record currently (legacy reasons), but it should be an array
|
||||
export type Namespaces = Record<string, PortainerNamespace>;
|
||||
|
||||
export type NamespaceFormValues = {
|
||||
name: string;
|
||||
resourceQuota: ResourceQuotaFormValues;
|
||||
ingressClasses: IngressControllerClassMap[];
|
||||
registries: Registry[];
|
||||
};
|
||||
|
||||
export type NamespacePayload = {
|
||||
Name: string;
|
||||
Owner: string;
|
||||
ResourceQuota: ResourceQuotaFormValues;
|
||||
};
|
||||
|
||||
export type UpdateRegistryPayload = {
|
||||
Id: number;
|
||||
Namespaces: string[];
|
||||
};
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
|
||||
import { getServices } from './service';
|
||||
import { Service } from './types';
|
||||
|
||||
export function useNamespaceServices(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
'environments',
|
||||
environmentId,
|
||||
'kubernetes',
|
||||
'namespaces',
|
||||
namespace,
|
||||
'services',
|
||||
],
|
||||
() =>
|
||||
namespace ? getServices(environmentId, namespace) : ([] as Service[]),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get services');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Service } from './types';
|
||||
|
||||
export async function getServices(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: services } = await axios.get<Service[]>(
|
||||
buildUrl(environmentId, namespace)
|
||||
);
|
||||
return services;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve services');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace: string) {
|
||||
const url = `kubernetes/${environmentId}/namespaces/${namespace}/services`;
|
||||
return url;
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
export interface Port {
|
||||
Name: string;
|
||||
Protocol: string;
|
||||
Port: number;
|
||||
TargetPort: number;
|
||||
NodePort?: number;
|
||||
}
|
||||
|
||||
export interface IngressIP {
|
||||
IP: string;
|
||||
}
|
||||
|
||||
export interface LoadBalancer {
|
||||
Ingress: IngressIP[];
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
LoadBalancer: LoadBalancer;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
Annotations?: Document;
|
||||
CreationTimestamp?: string;
|
||||
Labels?: Document;
|
||||
Name: string;
|
||||
Namespace: string;
|
||||
UID: string;
|
||||
AllocateLoadBalancerNodePorts?: boolean;
|
||||
Ports?: Port[];
|
||||
Selector?: Document;
|
||||
Type: string;
|
||||
Status?: Status;
|
||||
}
|
|
@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import axios from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withGlobalError } from '@/react-tools/react-query';
|
||||
|
||||
import { parseKubernetesAxiosError } from '../axiosError';
|
||||
|
||||
|
@ -71,7 +71,7 @@ export function useEvents(
|
|||
queryKeys.base(environmentId, { params, namespace }),
|
||||
() => getEvents(environmentId, { params, namespace }),
|
||||
{
|
||||
...withError('Unable to retrieve events'),
|
||||
...withGlobalError('Unable to retrieve events'),
|
||||
refetchInterval() {
|
||||
return queryOptions?.autoRefreshRate ?? false;
|
||||
},
|
||||
|
@ -79,6 +79,17 @@ export function useEvents(
|
|||
);
|
||||
}
|
||||
|
||||
export function useEventWarningsCount(
|
||||
environmentId: EnvironmentId,
|
||||
namespace?: string
|
||||
) {
|
||||
const resourceEventsQuery = useEvents(environmentId, {
|
||||
namespace,
|
||||
});
|
||||
const events = resourceEventsQuery.data || [];
|
||||
return events.filter((e) => e.type === 'Warning').length;
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||
return namespace
|
||||
? `/endpoints/${environmentId}/kubernetes/api/v1/namespaces/${namespace}/events`
|
||||
|
|
|
@ -26,6 +26,7 @@ import { Service } from '../../types';
|
|||
|
||||
import { columns } from './columns';
|
||||
import { createStore } from './datatable-store';
|
||||
import { ServiceRowData } from './types';
|
||||
|
||||
const storageKey = 'k8sServicesDatatable';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
@ -104,7 +105,7 @@ export function ServicesDatatable() {
|
|||
function useServicesRowData(
|
||||
services: Service[],
|
||||
namespaces?: Namespaces
|
||||
): Service[] {
|
||||
): ServiceRowData[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
services.map((service) => ({
|
||||
|
@ -119,9 +120,12 @@ function useServicesRowData(
|
|||
|
||||
// needed to apply custom styling to the row cells and not globally.
|
||||
// required in the AC's for this ticket.
|
||||
function servicesRenderRow(row: Row<Service>, highlightedItemId?: string) {
|
||||
function servicesRenderRow(
|
||||
row: Row<ServiceRowData>,
|
||||
highlightedItemId?: string
|
||||
) {
|
||||
return (
|
||||
<Table.Row<Service>
|
||||
<Table.Row<ServiceRowData>
|
||||
cells={row.getVisibleCells()}
|
||||
className={clsx('[&>td]:!py-4 [&>td]:!align-top', {
|
||||
active: highlightedItemId === row.id,
|
||||
|
@ -136,7 +140,7 @@ interface SelectedService {
|
|||
}
|
||||
|
||||
type TableActionsProps = {
|
||||
selectedItems: Service[];
|
||||
selectedItems: ServiceRowData[];
|
||||
};
|
||||
|
||||
function TableActions({ selectedItems }: TableActionsProps) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
|||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
import { ServiceRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -17,7 +17,7 @@ export const application = columnHelper.accessor(
|
|||
}
|
||||
);
|
||||
|
||||
function Cell({ row, getValue }: CellContext<Service, string>) {
|
||||
function Cell({ row, getValue }: CellContext<ServiceRowData, string>) {
|
||||
const appName = getValue();
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
import { ServiceRowData } from '../types';
|
||||
|
||||
import { ExternalIPLink } from './ExternalIPLink';
|
||||
import { columnHelper } from './helper';
|
||||
|
@ -46,7 +46,7 @@ export const externalIP = columnHelper.accessor(
|
|||
}
|
||||
);
|
||||
|
||||
function Cell({ row }: CellContext<Service, string>) {
|
||||
function Cell({ row }: CellContext<ServiceRowData, string>) {
|
||||
if (row.original.Type === 'ExternalName') {
|
||||
if (row.original.ExternalName) {
|
||||
const linkTo = `http://${row.original.ExternalName}`;
|
||||
|
@ -106,7 +106,7 @@ function Cell({ row }: CellContext<Service, string>) {
|
|||
|
||||
// calculate the scheme based on the ports of the service
|
||||
// favour https over http.
|
||||
function getSchemeAndPort(svc: Service): [string, number] {
|
||||
function getSchemeAndPort(svc: ServiceRowData): [string, number] {
|
||||
let scheme = '';
|
||||
let servicePort = 0;
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
import { ServiceRowData } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<Service>();
|
||||
export const columnHelper = createColumnHelper<ServiceRowData>();
|
||||
|
|
|
@ -4,7 +4,7 @@ import { filterHOC } from '@/react/components/datatables/Filter';
|
|||
|
||||
import { Link } from '@@/Link';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
import { ServiceRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -31,6 +31,9 @@ export const namespace = columnHelper.accessor('Namespace', {
|
|||
filter: filterHOC('Filter by namespace'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
|
||||
filterValue.length === 0 || filterValue.includes(row.original.Namespace),
|
||||
filterFn: (
|
||||
row: Row<ServiceRowData>,
|
||||
columnId: string,
|
||||
filterValue: string[]
|
||||
) => filterValue.length === 0 || filterValue.includes(row.original.Namespace),
|
||||
});
|
||||
|
|
|
@ -2,7 +2,7 @@ import { Row } from '@tanstack/react-table';
|
|||
|
||||
import { filterHOC } from '@@/datatables/Filter';
|
||||
|
||||
import { Service } from '../../../types';
|
||||
import { ServiceRowData } from '../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -13,6 +13,9 @@ export const type = columnHelper.accessor('Type', {
|
|||
filter: filterHOC('Filter by type'),
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
filterFn: (row: Row<Service>, columnId: string, filterValue: string[]) =>
|
||||
filterValue.length === 0 || filterValue.includes(row.original.Type),
|
||||
filterFn: (
|
||||
row: Row<ServiceRowData>,
|
||||
columnId: string,
|
||||
filterValue: string[]
|
||||
) => filterValue.length === 0 || filterValue.includes(row.original.Type),
|
||||
});
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import { Service } from '../../types';
|
||||
|
||||
export type ServiceRowData = Service & {
|
||||
IsSystem: boolean;
|
||||
};
|
|
@ -26,18 +26,17 @@ export type ServiceType =
|
|||
export type Service = {
|
||||
Name: string;
|
||||
UID: string;
|
||||
Type: ServiceType;
|
||||
Namespace: string;
|
||||
Annotations?: Record<string, string>;
|
||||
CreationDate: string;
|
||||
Labels?: Record<string, string>;
|
||||
Type: ServiceType;
|
||||
AllocateLoadBalancerNodePorts?: boolean;
|
||||
Ports?: Array<ServicePort>;
|
||||
Selector?: Record<string, string>;
|
||||
ClusterIPs?: Array<string>;
|
||||
IngressStatus?: Array<IngressStatus>;
|
||||
Applications?: Application[];
|
||||
ClusterIPs?: Array<string>;
|
||||
ExternalName?: string;
|
||||
ExternalIPs?: Array<string>;
|
||||
CreationDate: string;
|
||||
Applications?: Application[];
|
||||
|
||||
IsSystem: boolean;
|
||||
};
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue