feat(ingress): autodetect ingress controllers EE-673 (#7712)
parent
c96551e410
commit
89eda13eb3
|
@ -12,6 +12,7 @@ type (
|
|||
Type string `json:"Type"`
|
||||
Availability bool `json:"Availability"`
|
||||
New bool `json:"New"`
|
||||
Used bool `json:"Used"`
|
||||
}
|
||||
|
||||
K8sIngressControllers []K8sIngressController
|
||||
|
|
|
@ -2,11 +2,11 @@ package models
|
|||
|
||||
import "net/http"
|
||||
|
||||
type K8sNamespaceInfo struct {
|
||||
type K8sNamespaceDetails struct {
|
||||
Name string `json:"Name"`
|
||||
Annotations map[string]string `json:"Annotations"`
|
||||
}
|
||||
|
||||
func (r *K8sNamespaceInfo) Validate(request *http.Request) error {
|
||||
func (r *K8sNamespaceDetails) Validate(request *http.Request) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -6,7 +6,11 @@ import (
|
|||
)
|
||||
|
||||
func (m *Migrator) migrateDBVersionToDB70() error {
|
||||
// foreach endpoint
|
||||
log.Info().Msg("- add IngressAvailabilityPerNamespace field")
|
||||
if err := m.addIngressAvailabilityPerNamespaceFieldDB70(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -45,3 +49,20 @@ func (m *Migrator) migrateDBVersionToDB70() error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Migrator) addIngressAvailabilityPerNamespaceFieldDB70() error {
|
||||
endpoints, err := m.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = true
|
||||
|
||||
err = m.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@
|
|||
"Kubernetes": {
|
||||
"Configuration": {
|
||||
"EnableResourceOverCommit": false,
|
||||
"IngressAvailabilityPerNamespace": true,
|
||||
"IngressClasses": null,
|
||||
"ResourceOverCommitPercentage": 0,
|
||||
"RestrictDefaultNamespace": false,
|
||||
|
|
|
@ -13,20 +13,18 @@ func (handler *Handler) getKubernetesConfigMaps(w http.ResponseWriter, r *http.R
|
|||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
configmaps, err := cli.GetConfigMapsAndSecrets(namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return response.JSON(w, configmaps)
|
||||
|
|
|
@ -24,10 +24,10 @@ type Handler struct {
|
|||
*mux.Router
|
||||
authorizationService *authorization.Service
|
||||
dataStore dataservices.DataStore
|
||||
jwtService dataservices.JWTService
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
KubernetesClient portainer.KubeClient
|
||||
kubernetesClientFactory *cli.ClientFactory
|
||||
jwtService dataservices.JWTService
|
||||
kubeClusterAccessService kubernetes.KubeClusterAccessService
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to process pre-proxied requests to external APIs.
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package kubernetes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
|
@ -15,35 +14,39 @@ import (
|
|||
func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid environment identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid environment identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainerDsErrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.NotFound(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
allowedOnly, err := request.RetrieveBooleanQueryParameter(r, "allowedOnly", true)
|
||||
if err != nil {
|
||||
return httperror.BadRequest(
|
||||
"Invalid allowedOnly boolean query parameter",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to create Kubernetes client",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to create Kubernetes client",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
controllers := cli.GetIngressControllers()
|
||||
|
@ -66,11 +69,44 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
|
|||
}
|
||||
|
||||
if controllers[i].ClassName == a.Name {
|
||||
controllers[i].Availability = !a.Blocked
|
||||
controllers[i].Availability = !a.GloballyBlocked
|
||||
}
|
||||
}
|
||||
// TODO: Update existingClasses to take care of New and remove no longer
|
||||
// existing classes.
|
||||
}
|
||||
|
||||
// Update the database to match the list of found + modified controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
var newClasses []portainer.KubernetesIngressClassConfig
|
||||
for _, controller := range controllers {
|
||||
var class portainer.KubernetesIngressClassConfig
|
||||
class.Name = controller.ClassName
|
||||
class.Type = controller.Type
|
||||
class.GloballyBlocked = !controller.Availability
|
||||
class.BlockedNamespaces = []string{}
|
||||
newClasses = append(newClasses, class)
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = newClasses
|
||||
err = handler.dataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(
|
||||
"Unable to store found IngressClasses inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
// If the allowedOnly query parameter was set. We need to prune out
|
||||
// disallowed controllers from the response.
|
||||
if allowedOnly {
|
||||
var allowedControllers models.K8sIngressControllers
|
||||
for _, controller := range controllers {
|
||||
if controller.Availability {
|
||||
allowedControllers = append(allowedControllers, controller)
|
||||
}
|
||||
}
|
||||
controllers = allowedControllers
|
||||
}
|
||||
return response.JSON(w, controllers)
|
||||
}
|
||||
|
@ -78,80 +114,84 @@ func (handler *Handler) getKubernetesIngressControllers(w http.ResponseWriter, r
|
|||
func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid environment identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid environment identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainerDsErrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.NotFound(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to create Kubernetes client",
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
controllers := cli.GetIngressControllers()
|
||||
cli := handler.KubernetesClient
|
||||
currentControllers := cli.GetIngressControllers()
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
for i := range controllers {
|
||||
controllers[i].Availability = true
|
||||
controllers[i].New = true
|
||||
var updatedClasses []portainer.KubernetesIngressClassConfig
|
||||
var controllers models.K8sIngressControllers
|
||||
for i := range currentControllers {
|
||||
var globallyblocked bool
|
||||
currentControllers[i].Availability = true
|
||||
currentControllers[i].New = true
|
||||
|
||||
var updatedClass portainer.KubernetesIngressClassConfig
|
||||
updatedClass.Name = currentControllers[i].ClassName
|
||||
updatedClass.Type = currentControllers[i].Type
|
||||
|
||||
// Check if the controller is blocked globally or in the current
|
||||
// namespace.
|
||||
for _, a := range existingClasses {
|
||||
if controllers[i].ClassName != a.Name {
|
||||
for _, existingClass := range existingClasses {
|
||||
if currentControllers[i].ClassName != existingClass.Name {
|
||||
continue
|
||||
}
|
||||
controllers[i].New = false
|
||||
currentControllers[i].New = false
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
|
||||
// If it's not blocked we're all done!
|
||||
if !a.Blocked {
|
||||
continue
|
||||
}
|
||||
globallyblocked = existingClass.GloballyBlocked
|
||||
|
||||
// Global blocks.
|
||||
if len(a.BlockedNamespaces) == 0 {
|
||||
controllers[i].Availability = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Also check the current namespace.
|
||||
for _, ns := range a.BlockedNamespaces {
|
||||
// Check if the current namespace is blocked.
|
||||
for _, ns := range existingClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
controllers[i].Availability = false
|
||||
currentControllers[i].Availability = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Update existingClasses to take care of New and remove no longer
|
||||
// existing classes.
|
||||
if !globallyblocked {
|
||||
controllers = append(controllers, currentControllers[i])
|
||||
}
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
|
||||
// Update the database to match the list of found controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.dataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(
|
||||
"Unable to store found IngressClasses inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return response.JSON(w, controllers)
|
||||
}
|
||||
|
@ -159,167 +199,205 @@ func (handler *Handler) getKubernetesIngressControllersByNamespace(w http.Respon
|
|||
func (handler *Handler) updateKubernetesIngressControllers(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid environment identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid environment identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainerDsErrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.NotFound(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var payload models.K8sIngressControllers
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
classes := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
for _, p := range payload {
|
||||
for i := range classes {
|
||||
if p.ClassName == classes[i].Name {
|
||||
classes[i].Blocked = !p.Availability
|
||||
cli, err := handler.kubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(
|
||||
"Unable to create Kubernetes client",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
controllers := cli.GetIngressControllers()
|
||||
for i := range controllers {
|
||||
// Set existing class data. So that we don't accidentally overwrite it
|
||||
// with blank data that isn't in the payload.
|
||||
for ii := range existingClasses {
|
||||
if controllers[i].ClassName == existingClasses[ii].Name {
|
||||
controllers[i].Availability = !existingClasses[ii].GloballyBlocked
|
||||
}
|
||||
}
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = classes
|
||||
fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses)
|
||||
|
||||
for _, p := range payload {
|
||||
for i := range controllers {
|
||||
// Now set new payload data
|
||||
if p.ClassName == controllers[i].ClassName {
|
||||
controllers[i].Availability = p.Availability
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the database to match the list of found + modified controllers.
|
||||
// This includes pruning out controllers which no longer exist.
|
||||
var newClasses []portainer.KubernetesIngressClassConfig
|
||||
for _, controller := range controllers {
|
||||
var class portainer.KubernetesIngressClassConfig
|
||||
class.Name = controller.ClassName
|
||||
class.Type = controller.Type
|
||||
class.GloballyBlocked = !controller.Availability
|
||||
class.BlockedNamespaces = []string{}
|
||||
newClasses = append(newClasses, class)
|
||||
}
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = newClasses
|
||||
err = handler.dataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to update the BlockedIngressClasses inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to update the BlockedIngressClasses inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateKubernetesIngressControllersByNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid environment identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid environment identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
endpoint, err := handler.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if err == portainerDsErrors.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.NotFound(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to find an environment with the specified identifier inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to find an environment with the specified identifier inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var payload models.K8sIngressControllers
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
classes := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
existingClasses := endpoint.Kubernetes.Configuration.IngressClasses
|
||||
var updatedClasses []portainer.KubernetesIngressClassConfig
|
||||
PayloadLoop:
|
||||
for _, p := range payload {
|
||||
for i := range classes {
|
||||
if p.ClassName == classes[i].Name {
|
||||
if p.Availability == true {
|
||||
classes[i].Blocked = false
|
||||
classes[i].BlockedNamespaces = []string{}
|
||||
continue PayloadLoop
|
||||
}
|
||||
for _, existingClass := range existingClasses {
|
||||
if p.ClassName != existingClass.Name {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
continue
|
||||
}
|
||||
var updatedClass portainer.KubernetesIngressClassConfig
|
||||
updatedClass.Name = existingClass.Name
|
||||
updatedClass.Type = existingClass.Type
|
||||
updatedClass.GloballyBlocked = existingClass.GloballyBlocked
|
||||
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
classes[i].Blocked = true
|
||||
for _, ns := range classes[i].BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
continue PayloadLoop
|
||||
// Handle "allow"
|
||||
if p.Availability == true {
|
||||
// remove the namespace from the list of blocked namespaces
|
||||
// in the existingClass.
|
||||
for _, blockedNS := range existingClass.BlockedNamespaces {
|
||||
if blockedNS != namespace {
|
||||
updatedClass.BlockedNamespaces = append(updatedClass.BlockedNamespaces, blockedNS)
|
||||
}
|
||||
}
|
||||
classes[i].BlockedNamespaces = append(
|
||||
classes[i].BlockedNamespaces,
|
||||
namespace,
|
||||
)
|
||||
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
continue PayloadLoop
|
||||
}
|
||||
|
||||
// Handle "disallow"
|
||||
// If it's meant to be blocked we need to add the current
|
||||
// namespace. First, check if it's already in the
|
||||
// BlockedNamespaces and if not we append it.
|
||||
updatedClass.BlockedNamespaces = existingClass.BlockedNamespaces
|
||||
for _, ns := range updatedClass.BlockedNamespaces {
|
||||
if namespace == ns {
|
||||
updatedClasses = append(updatedClasses, existingClass)
|
||||
continue PayloadLoop
|
||||
}
|
||||
}
|
||||
updatedClass.BlockedNamespaces = append(
|
||||
updatedClass.BlockedNamespaces,
|
||||
namespace,
|
||||
)
|
||||
updatedClasses = append(updatedClasses, updatedClass)
|
||||
}
|
||||
}
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = classes
|
||||
fmt.Printf("%#v\n", endpoint.Kubernetes.Configuration.IngressClasses)
|
||||
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = updatedClasses
|
||||
err = handler.dataStore.Endpoint().UpdateEndpoint(
|
||||
portainer.EndpointID(endpointID),
|
||||
endpoint,
|
||||
)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to update the BlockedIngressClasses inside the database",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to update the BlockedIngressClasses inside the database",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli := handler.KubernetesClient
|
||||
ingresses, err := cli.GetIngresses(namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return response.JSON(w, ingresses)
|
||||
|
@ -328,33 +406,30 @@ func (handler *Handler) getKubernetesIngresses(w http.ResponseWriter, r *http.Re
|
|||
func (handler *Handler) createKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var payload models.K8sIngressInfo
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli := handler.KubernetesClient
|
||||
err = cli.CreateIngress(namespace, payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
|
@ -368,43 +443,39 @@ func (handler *Handler) deleteKubernetesIngresses(w http.ResponseWriter, r *http
|
|||
|
||||
err = cli.DeleteIngresses(payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateKubernetesIngress(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var payload models.K8sIngressInfo
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli := handler.KubernetesClient
|
||||
err = cli.UpdateIngress(namespace, payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
|
|
@ -14,11 +14,10 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
|
|||
|
||||
namespaces, err := cli.GetNamespaces()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return response.JSON(w, namespaces)
|
||||
|
@ -27,23 +26,21 @@ func (handler *Handler) getKubernetesNamespaces(w http.ResponseWriter, r *http.R
|
|||
func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
cli := handler.KubernetesClient
|
||||
|
||||
var payload models.K8sNamespaceInfo
|
||||
var payload models.K8sNamespaceDetails
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
err = cli.CreateNamespace(payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -53,20 +50,18 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
|
|||
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
err = cli.DeleteNamespace(namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@ -75,7 +70,7 @@ func (handler *Handler) deleteKubernetesNamespaces(w http.ResponseWriter, r *htt
|
|||
func (handler *Handler) updateKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
cli := handler.KubernetesClient
|
||||
|
||||
var payload models.K8sNamespaceInfo
|
||||
var payload models.K8sNamespaceDetails
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
|
|
|
@ -12,21 +12,19 @@ import (
|
|||
func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli := handler.KubernetesClient
|
||||
services, err := cli.GetServices(namespace)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve services",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve services",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return response.JSON(w, services)
|
||||
|
@ -35,31 +33,28 @@ func (handler *Handler) getKubernetesServices(w http.ResponseWriter, r *http.Req
|
|||
func (handler *Handler) createKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var payload models.K8sServiceInfo
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli := handler.KubernetesClient
|
||||
err = cli.CreateService(namespace, payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -70,20 +65,18 @@ func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.
|
|||
var payload models.K8sServiceDeleteRequests
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
err = cli.DeleteServices(payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -91,31 +84,28 @@ func (handler *Handler) deleteKubernetesServices(w http.ResponseWriter, r *http.
|
|||
func (handler *Handler) updateKubernetesService(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
namespace, err := request.RetrieveRouteVariableValue(r, "namespace")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid namespace identifier route variable",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid namespace identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
var payload models.K8sServiceInfo
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Message: "Invalid request payload",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.BadRequest(
|
||||
"Invalid request payload",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
cli := handler.KubernetesClient
|
||||
err = cli.UpdateService(namespace, payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
Message: "Unable to retrieve nodes limits",
|
||||
Err: err,
|
||||
}
|
||||
return httperror.InternalServerError(
|
||||
"Unable to retrieve nodes limits",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -20,10 +20,34 @@ func (kcl *KubeClient) GetIngressControllers() models.K8sIngressControllers {
|
|||
return nil
|
||||
}
|
||||
|
||||
// We want to know which of these controllers is in use.
|
||||
var ingresses []models.K8sIngressInfo
|
||||
namespaces, err := kcl.GetNamespaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for namespace := range namespaces {
|
||||
t, err := kcl.GetIngresses(namespace)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
ingresses = append(ingresses, t...)
|
||||
}
|
||||
usedClasses := make(map[string]struct{})
|
||||
for _, ingress := range ingresses {
|
||||
usedClasses[ingress.ClassName] = struct{}{}
|
||||
}
|
||||
|
||||
for _, class := range classList.Items {
|
||||
var controller models.K8sIngressController
|
||||
controller.Name = class.Spec.Controller
|
||||
controller.ClassName = class.Name
|
||||
|
||||
// If the class is used mark it as such.
|
||||
if _, ok := usedClasses[class.Name]; ok {
|
||||
controller.Used = true
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(controller.Name, "nginx"):
|
||||
controller.Type = "nginx"
|
||||
|
|
|
@ -45,7 +45,7 @@ func (kcl *KubeClient) GetNamespaces() (map[string]portainer.K8sNamespaceInfo, e
|
|||
}
|
||||
|
||||
// CreateIngress creates a new ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceInfo) error {
|
||||
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
|
||||
client := kcl.cli.CoreV1().Namespaces()
|
||||
|
||||
var ns v1.Namespace
|
||||
|
@ -108,7 +108,7 @@ func (kcl *KubeClient) ToggleSystemState(namespaceName string, isSystem bool) er
|
|||
}
|
||||
|
||||
// UpdateIngress updates an ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceInfo) error {
|
||||
func (kcl *KubeClient) UpdateNamespace(info models.K8sNamespaceDetails) error {
|
||||
client := kcl.cli.CoreV1().Namespaces()
|
||||
|
||||
var ns v1.Namespace
|
||||
|
|
|
@ -546,13 +546,14 @@ type (
|
|||
|
||||
// KubernetesConfiguration represents the configuration of a Kubernetes environment(endpoint)
|
||||
KubernetesConfiguration struct {
|
||||
UseLoadBalancer bool `json:"UseLoadBalancer"`
|
||||
UseServerMetrics bool `json:"UseServerMetrics"`
|
||||
EnableResourceOverCommit bool `json:"EnableResourceOverCommit"`
|
||||
ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"`
|
||||
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
|
||||
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
|
||||
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
|
||||
UseLoadBalancer bool `json:"UseLoadBalancer"`
|
||||
UseServerMetrics bool `json:"UseServerMetrics"`
|
||||
EnableResourceOverCommit bool `json:"EnableResourceOverCommit"`
|
||||
ResourceOverCommitPercentage int `json:"ResourceOverCommitPercentage"`
|
||||
StorageClasses []KubernetesStorageClassConfig `json:"StorageClasses"`
|
||||
IngressClasses []KubernetesIngressClassConfig `json:"IngressClasses"`
|
||||
RestrictDefaultNamespace bool `json:"RestrictDefaultNamespace"`
|
||||
IngressAvailabilityPerNamespace bool `json:"IngressAvailabilityPerNamespace"`
|
||||
}
|
||||
|
||||
// KubernetesStorageClassConfig represents a Kubernetes Storage Class configuration
|
||||
|
@ -567,7 +568,7 @@ type (
|
|||
KubernetesIngressClassConfig struct {
|
||||
Name string `json:"Name"`
|
||||
Type string `json:"Type"`
|
||||
Blocked bool `json:"Blocked"`
|
||||
GloballyBlocked bool `json:"Blocked"`
|
||||
BlockedNamespaces []string `json:"BlockedNamespaces"`
|
||||
}
|
||||
|
||||
|
@ -1349,11 +1350,14 @@ type (
|
|||
CreateUserShellPod(ctx context.Context, serviceAccountName, shellPodImage string) (*KubernetesShellPod, error)
|
||||
StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
|
||||
|
||||
CreateNamespace(info models.K8sNamespaceInfo) error
|
||||
UpdateNamespace(info models.K8sNamespaceInfo) error
|
||||
HasStackName(namespace string, stackName string) (bool, error)
|
||||
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
|
||||
CreateNamespace(info models.K8sNamespaceDetails) error
|
||||
UpdateNamespace(info models.K8sNamespaceDetails) error
|
||||
GetNamespaces() (map[string]K8sNamespaceInfo, error)
|
||||
DeleteNamespace(namespace string) error
|
||||
GetConfigMapsAndSecrets(namespace string) ([]models.K8sConfigMapOrSecret, error)
|
||||
GetIngressControllers() models.K8sIngressControllers
|
||||
CreateIngress(namespace string, info models.K8sIngressInfo) error
|
||||
UpdateIngress(namespace string, info models.K8sIngressInfo) error
|
||||
GetIngresses(namespace string) ([]models.K8sIngressInfo, error)
|
||||
|
@ -1362,10 +1366,6 @@ type (
|
|||
UpdateService(namespace string, service models.K8sServiceInfo) error
|
||||
GetServices(namespace string) ([]models.K8sServiceInfo, error)
|
||||
DeleteServices(reqs models.K8sServiceDeleteRequests) error
|
||||
GetIngressControllers() models.K8sIngressControllers
|
||||
|
||||
HasStackName(namespace string, stackName string) (bool, error)
|
||||
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
|
||||
GetNodesLimits() (K8sNodesLimits, error)
|
||||
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
||||
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
|
||||
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
|
||||
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/StorageAccessModeSelector';
|
||||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||
|
@ -8,6 +9,16 @@ import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces
|
|||
|
||||
export const componentsModule = angular
|
||||
.module('portainer.kubernetes.react.components', [])
|
||||
.component(
|
||||
'ingressClassDatatable',
|
||||
r2a(IngressClassDatatable, [
|
||||
'onChangeAvailability',
|
||||
'description',
|
||||
'ingressControllers',
|
||||
'noIngressControllerLabel',
|
||||
'view',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'namespacesSelector',
|
||||
r2a(NamespacesSelector, [
|
||||
|
|
|
@ -158,10 +158,12 @@ export function CreateIngressView() {
|
|||
);
|
||||
const ingressClassOptions: Option<string>[] = [
|
||||
{ label: 'Select an ingress class', value: '' },
|
||||
...(ingressControllersResults.data?.map((cls) => ({
|
||||
label: cls.ClassName,
|
||||
value: cls.ClassName,
|
||||
})) || []),
|
||||
...(ingressControllersResults.data
|
||||
?.filter((cls) => cls.Availability)
|
||||
.map((cls) => ({
|
||||
label: cls.ClassName,
|
||||
value: cls.ClassName,
|
||||
})) || []),
|
||||
];
|
||||
|
||||
if (!existingIngressClass && ingressRule.IngressClassName) {
|
||||
|
|
|
@ -65,14 +65,12 @@ export function useIngresses(
|
|||
'ingress',
|
||||
],
|
||||
async () => {
|
||||
const ingresses: Ingress[] = [];
|
||||
for (let i = 0; i < namespaces.length; i += 1) {
|
||||
const ings = await getIngresses(environmentId, namespaces[i]);
|
||||
if (ings) {
|
||||
ingresses.push(...ings);
|
||||
}
|
||||
}
|
||||
return ingresses;
|
||||
const ingresses = await Promise.all(
|
||||
namespaces.map((namespace) => getIngresses(environmentId, namespace))
|
||||
);
|
||||
// flatten the array and remove empty ingresses
|
||||
const filteredIngresses = ingresses.flat().filter((ing) => ing);
|
||||
return filteredIngresses;
|
||||
},
|
||||
{
|
||||
enabled: namespaces.length > 0,
|
||||
|
|
|
@ -35,124 +35,35 @@
|
|||
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left col-sm-5 col-lg-4 px-0"> Allow users to use external load balancer </label>
|
||||
<label class="switch mb-0 col-sm-8">
|
||||
<label class="switch col-sm-8 mb-0">
|
||||
<input type="checkbox" ng-model="ctrl.formValues.UseLoadBalancer" /><span class="slider round" data-cy="kubeSetup-loadBalancerToggle"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 text-muted small mt-4">
|
||||
<p> Configuring ingress controllers will allow users to expose application they deploy over a HTTP route. </p>
|
||||
<p class="mt-1 vertical-center">
|
||||
<pr-icon icon="'alert-circle'" mode="'warning'" feather="true"></pr-icon>
|
||||
Ingress classes must be manually specified for each controller you want to use in the cluster. Make sure that each controller is running inside your cluster.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ingress-class-datatable
|
||||
on-change-availability="(ctrl.onChangeAvailability)"
|
||||
ingress-controllers="ctrl.originalIngressControllers"
|
||||
description="'Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here.'"
|
||||
no-ingress-controller-label="'No supported ingress controllers found.'"
|
||||
view="'cluster'"
|
||||
></ingress-class-datatable>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Ingress controller</label>
|
||||
<span class="label label-default interactive vertical-center" style="margin-left: 10px" ng-click="ctrl.addIngressClass()" data-cy="kubeSetup-congifIngressButton">
|
||||
<pr-icon icon="'plus'" feather="true" size="'sm'" class="vertical-center"></pr-icon> configure ingress controller
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
||||
<div ng-repeat-start="ingressClass in ctrl.formValues.IngressClasses" style="margin-top: 2px">
|
||||
<div class="col-sm-7 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
|
||||
<span class="input-group-addon">Ingress class</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="ingress_class_name_{{ $index }}"
|
||||
ng-model="ingressClass.Name"
|
||||
placeholder="nginx"
|
||||
ng-pattern="/^[a-z]([-a-z0-9]*[a-z0-9])?$/"
|
||||
ng-change="ctrl.onChangeIngressClassName($index)"
|
||||
required
|
||||
data-cy="kubeSetup-ingressClassName"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-3 input-group input-group-sm" ng-class="{ striked: ingressClass.NeedsDeletion }">
|
||||
<span class="input-group-addon">Type</span>
|
||||
<select
|
||||
class="form-control"
|
||||
name="ingress_class_type_{{ $index }}"
|
||||
ng-model="ingressClass.Type"
|
||||
ng-options="value as value for (key, value) in ctrl.IngressClassTypes"
|
||||
required
|
||||
data-cy="kubeSetup-ingressType"
|
||||
>
|
||||
<option selected disabled hidden value="">Select a type</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm">
|
||||
<button
|
||||
ng-if="!ingressClass.NeedsDeletion"
|
||||
class="btn btn-dangerlight btn-only-icon"
|
||||
type="button"
|
||||
ng-click="ctrl.removeIngressClass($index)"
|
||||
data-cy="kubeSetup-deleteIngress"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" feather="true"></pr-icon>
|
||||
</button>
|
||||
<button ng-if="ingressClass.NeedsDeletion" class="btn btn-only-icon btn-light" type="button" ng-click="ctrl.restoreIngressClass($index)">
|
||||
<pr-icon icon="'rotate-ccw'" feather="true"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ng-repeat-end
|
||||
ng-show="
|
||||
kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid ||
|
||||
kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid ||
|
||||
ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined
|
||||
"
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
|
||||
name="'ingressAvailabilityPerNamespace'"
|
||||
label="'Configure ingress controller availability per namespace'"
|
||||
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
|
||||
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8'"
|
||||
>
|
||||
<div class="col-sm-7 input-group">
|
||||
<div
|
||||
class="small"
|
||||
style="margin-top: 5px"
|
||||
ng-if="kubernetesClusterSetupForm['ingress_class_name_' + $index].$invalid || ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined"
|
||||
>
|
||||
<div ng-messages="kubernetesClusterSetupForm['ingress_class_name_'+$index].$error">
|
||||
<p ng-message="required" class="vertical-center text-warning"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress class name is required.</p
|
||||
>
|
||||
<p ng-message="pattern" class="vertical-center text-warning"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This field must consist of lower case alphanumeric characters or '-', start
|
||||
with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').</p
|
||||
>
|
||||
</div>
|
||||
<p ng-if="ctrl.state.duplicates.ingressClasses.refs[$index] !== undefined" class="vertical-center text-warning">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> This ingress class is already defined.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-3 input-group">
|
||||
<div class="small" style="margin-top: 5px" ng-if="kubernetesClusterSetupForm['ingress_class_type_' + $index].$invalid">
|
||||
<div ng-messages="kubernetesClusterSetupForm['ingress_class_type_'+$index].$error">
|
||||
<p ng-message="required" class="vertical-center text-warning"
|
||||
><pr-icon icon="'alert-triangle'" mode="'warning'" feather="true"></pr-icon> Ingress class type is required.</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.hasTraefikIngress()">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
<p>
|
||||
<i class="fa fa-flask blue-icon" aria-hidden="true" style="margin-right: 2px"></i>
|
||||
Traefik support is experimental.
|
||||
</p>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- auto update window -->
|
||||
<div class="col-sm-12 form-section-title"> Change Window Settings </div>
|
||||
|
||||
|
@ -163,7 +74,7 @@
|
|||
name="'disableSysctlSettingForRegularUsers'"
|
||||
label="'Enable Change Window'"
|
||||
feature-id="ctrl.limitedFeatureAutoWindow"
|
||||
tooltip="'Specify a timeframe during which automatic updates can occur in this environment.'"
|
||||
tooltip="'Automatic updates to stacks or applications outside the defined change window will not occur.'"
|
||||
on-change="(ctrl.onToggleAutoUpdate)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8 text-muted'"
|
||||
|
|
|
@ -2,12 +2,12 @@ import _ from 'lodash-es';
|
|||
import angular from 'angular';
|
||||
import { KubernetesStorageClass, KubernetesStorageClassAccessPolicies } from 'Kubernetes/models/storage-class/models';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClass } from 'Kubernetes/ingress/models';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
|
||||
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
|
||||
|
||||
class KubernetesConfigureController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
||||
|
@ -41,10 +41,15 @@ class KubernetesConfigureController {
|
|||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.configureAsync = this.configureAsync.bind(this);
|
||||
this.areControllersChanged = this.areControllersChanged.bind(this);
|
||||
this.areFormValuesChanged = this.areFormValuesChanged.bind(this);
|
||||
this.onBeforeOnload = this.onBeforeOnload.bind(this);
|
||||
this.limitedFeature = FeatureId.K8S_SETUP_DEFAULT;
|
||||
this.limitedFeatureAutoWindow = FeatureId.HIDE_AUTO_UPDATE_WINDOW;
|
||||
this.onToggleAutoUpdate = this.onToggleAutoUpdate.bind(this);
|
||||
this.onChangeAvailability = this.onChangeAvailability.bind(this);
|
||||
this.onChangeEnableResourceOverCommit = this.onChangeEnableResourceOverCommit.bind(this);
|
||||
this.onToggleIngressAvailabilityPerNamespace = this.onToggleIngressAvailabilityPerNamespace.bind(this);
|
||||
this.onChangeStorageClassAccessMode = this.onChangeStorageClassAccessMode.bind(this);
|
||||
}
|
||||
/* #endregion */
|
||||
|
@ -66,48 +71,23 @@ class KubernetesConfigureController {
|
|||
/* #endregion */
|
||||
|
||||
/* #region INGRESS CLASSES UI MANAGEMENT */
|
||||
addIngressClass() {
|
||||
this.formValues.IngressClasses.push(new KubernetesIngressClass());
|
||||
this.onChangeIngressClass();
|
||||
}
|
||||
|
||||
restoreIngressClass(index) {
|
||||
this.formValues.IngressClasses[index].NeedsDeletion = false;
|
||||
this.onChangeIngressClass();
|
||||
}
|
||||
|
||||
removeIngressClass(index) {
|
||||
if (!this.formValues.IngressClasses[index].IsNew) {
|
||||
this.formValues.IngressClasses[index].NeedsDeletion = true;
|
||||
} else {
|
||||
this.formValues.IngressClasses.splice(index, 1);
|
||||
}
|
||||
this.onChangeIngressClass();
|
||||
}
|
||||
|
||||
onChangeIngressClass() {
|
||||
const state = this.state.duplicates.ingressClasses;
|
||||
const source = _.map(this.formValues.IngressClasses, (ic) => (ic.NeedsDeletion ? undefined : ic.Name));
|
||||
const duplicates = KubernetesFormValidationHelper.getDuplicates(source);
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = Object.keys(duplicates).length > 0;
|
||||
}
|
||||
|
||||
onChangeIngressClassName(index) {
|
||||
const fv = this.formValues.IngressClasses[index];
|
||||
if (_.includes(fv.Name, KubernetesIngressClassTypes.NGINX)) {
|
||||
fv.Type = KubernetesIngressClassTypes.NGINX;
|
||||
} else if (_.includes(fv.Name, KubernetesIngressClassTypes.TRAEFIK)) {
|
||||
fv.Type = KubernetesIngressClassTypes.TRAEFIK;
|
||||
}
|
||||
this.onChangeIngressClass();
|
||||
onChangeAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
|
||||
hasTraefikIngress() {
|
||||
return _.find(this.formValues.IngressClasses, { Type: this.IngressClassTypes.TRAEFIK });
|
||||
}
|
||||
|
||||
onToggleIngressAvailabilityPerNamespace() {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.IngressAvailabilityPerNamespace = !this.formValues.IngressAvailabilityPerNamespace;
|
||||
});
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region RESOURCES AND METRICS */
|
||||
|
||||
onChangeEnableResourceOverCommit(enabled) {
|
||||
this.$scope.$evalAsync(() => {
|
||||
this.formValues.EnableResourceOverCommit = enabled;
|
||||
|
@ -117,13 +97,19 @@ class KubernetesConfigureController {
|
|||
});
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region CONFIGURE */
|
||||
assignFormValuesToEndpoint(endpoint, storageClasses, ingressClasses) {
|
||||
endpoint.Kubernetes.Configuration.StorageClasses = storageClasses;
|
||||
endpoint.Kubernetes.Configuration.UseLoadBalancer = this.formValues.UseLoadBalancer;
|
||||
endpoint.Kubernetes.Configuration.UseServerMetrics = this.formValues.UseServerMetrics;
|
||||
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = this.formValues.EnableResourceOverCommit;
|
||||
endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage = this.formValues.ResourceOverCommitPercentage;
|
||||
endpoint.Kubernetes.Configuration.IngressClasses = ingressClasses;
|
||||
endpoint.Kubernetes.Configuration.RestrictDefaultNamespace = this.formValues.RestrictDefaultNamespace;
|
||||
endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace = this.formValues.IngressAvailabilityPerNamespace;
|
||||
endpoint.ChangeWindow = this.state.autoUpdateSettings;
|
||||
}
|
||||
|
||||
transformFormValues() {
|
||||
|
@ -150,11 +136,9 @@ class KubernetesConfigureController {
|
|||
|
||||
async removeIngressesAcrossNamespaces() {
|
||||
const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true });
|
||||
|
||||
if (!ingressesToDel.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const promises = [];
|
||||
const oldEndpointID = this.EndpointProvider.endpointID();
|
||||
this.EndpointProvider.setEndpointID(this.endpoint.Id);
|
||||
|
@ -213,7 +197,9 @@ class KubernetesConfigureController {
|
|||
|
||||
this.assignFormValuesToEndpoint(this.endpoint, storageClasses, ingressClasses);
|
||||
await this.EndpointService.updateEndpoint(this.endpoint.Id, this.endpoint);
|
||||
|
||||
// updateIngressControllerClassMap must be done after updateEndpoint, as a hacky workaround. A better solution: saving ingresscontrollers somewhere else, is being discussed
|
||||
await updateIngressControllerClassMap(this.state.endpointId, this.ingressControllers);
|
||||
this.state.isSaving = true;
|
||||
const storagePromises = _.map(storageClasses, (storageClass) => {
|
||||
const oldStorageClass = _.find(this.oldStorageClasses, { Name: storageClass.Name });
|
||||
if (oldStorageClass) {
|
||||
|
@ -291,19 +277,31 @@ class KubernetesConfigureController {
|
|||
isServerRunning: false,
|
||||
userClick: false,
|
||||
},
|
||||
timeZone: '',
|
||||
isSaving: false,
|
||||
};
|
||||
|
||||
this.formValues = {
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
EnableResourceOverCommit: true,
|
||||
ResourceOverCommitPercentage: 20,
|
||||
IngressClasses: [],
|
||||
RestrictDefaultNamespace: false,
|
||||
enableAutoUpdateTimeWindow: false,
|
||||
IngressAvailabilityPerNamespace: false,
|
||||
};
|
||||
|
||||
try {
|
||||
this.availableAccessModes = new KubernetesStorageClassAccessPolicies();
|
||||
|
||||
[this.StorageClasses, this.endpoint] = await Promise.all([this.KubernetesStorageService.get(this.state.endpointId), this.EndpointService.endpoint(this.state.endpointId)]);
|
||||
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.state.endpointId });
|
||||
this.originalIngressControllers = structuredClone(this.ingressControllers);
|
||||
|
||||
this.state.autoUpdateSettings = this.endpoint.ChangeWindow;
|
||||
|
||||
_.forEach(this.StorageClasses, (item) => {
|
||||
const storage = _.find(this.endpoint.Kubernetes.Configuration.StorageClasses, (sc) => sc.Name === item.Name);
|
||||
if (storage) {
|
||||
|
@ -316,12 +314,15 @@ class KubernetesConfigureController {
|
|||
|
||||
this.formValues.UseLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
|
||||
this.formValues.UseServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
||||
this.formValues.EnableResourceOverCommit = this.endpoint.Kubernetes.Configuration.EnableResourceOverCommit;
|
||||
this.formValues.ResourceOverCommitPercentage = this.endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage;
|
||||
this.formValues.RestrictDefaultNamespace = this.endpoint.Kubernetes.Configuration.RestrictDefaultNamespace;
|
||||
this.formValues.IngressClasses = _.map(this.endpoint.Kubernetes.Configuration.IngressClasses, (ic) => {
|
||||
ic.IsNew = false;
|
||||
ic.NeedsDeletion = false;
|
||||
return ic;
|
||||
});
|
||||
this.formValues.IngressAvailabilityPerNamespace = this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace;
|
||||
|
||||
this.oldFormValues = Object.assign({}, this.formValues);
|
||||
} catch (err) {
|
||||
|
@ -329,12 +330,48 @@ class KubernetesConfigureController {
|
|||
} finally {
|
||||
this.state.viewReady = true;
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', this.onBeforeOnload);
|
||||
}
|
||||
|
||||
$onInit() {
|
||||
return this.$async(this.onInit);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
$onDestroy() {
|
||||
window.removeEventListener('beforeunload', this.onBeforeOnload);
|
||||
}
|
||||
|
||||
areControllersChanged() {
|
||||
return !_.isEqual(this.ingressControllers, this.originalIngressControllers);
|
||||
}
|
||||
|
||||
areFormValuesChanged() {
|
||||
return !_.isEqual(this.formValues, this.oldFormValues);
|
||||
}
|
||||
|
||||
onBeforeOnload(event) {
|
||||
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
}
|
||||
|
||||
uiCanExit() {
|
||||
if (!this.state.isSaving && (this.areControllersChanged() || this.areFormValuesChanged())) {
|
||||
return this.ModalService.confirmAsync({
|
||||
title: 'Are you sure?',
|
||||
message: 'You currently have unsaved changes in the cluster setup view. Are you sure you want to leave?',
|
||||
buttons: {
|
||||
confirm: {
|
||||
label: 'Yes',
|
||||
className: 'btn-danger',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default KubernetesConfigureController;
|
||||
|
|
|
@ -180,214 +180,17 @@
|
|||
</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'" feather="true"></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 -->
|
||||
|
||||
<div ng-if="$ctrl.state.canUseIngress">
|
||||
<div class="col-sm-12 form-section-title"> Ingresses </div>
|
||||
<div ng-if="$ctrl.state.ingressAvailabilityPerNamespace">
|
||||
<!-- #region INGRESSES -->
|
||||
<div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length === 0">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
The ingress feature must be enabled in the
|
||||
<a ui-sref="kubernetes.cluster.setup">environment configuration view</a> to be able to register ingresses inside this namespace.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-if="$ctrl.formValues.IngressClasses.length > 0">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
|
||||
Enable and configure ingresses available to users when deploying applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-repeat-start="ic in $ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<span class="text-muted vertical-center"><pr-icon icon="'svg-route'"></pr-icon> {{ ic.IngressClass.Name }}</span>
|
||||
<hr class="mt-2 mb-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-3 col-lg-2">
|
||||
<label class="control-label text-left"> Allow users to use this ingress </label>
|
||||
</div>
|
||||
<div class="col-sm-8 pt-2">
|
||||
<label class="switch">
|
||||
<input type="checkbox" ng-model="ic.Selected" /><span class="slider round" data-cy="namespaceCreate-ingressToggle{{ ic.IngressClass.Name }}"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ic.Selected">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Hostnames
|
||||
<portainer-tooltip
|
||||
message="'Hostnames associated to the ingress inside this namespace. Users will be able to expose and access their applications over the ingress via one of these hostname.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<span class="label label-default interactive" ng-click="$ctrl.addHostname(ic)" data-cy="namespaceCreate-addHostButton{{ ic.IngressClass.Name }}">
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add hostname
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 pt-4">
|
||||
<div ng-repeat="item in ic.Hosts track by $index">
|
||||
<div class="form-inline">
|
||||
<div class="col-sm-8 input-group input-group-sm pt-2">
|
||||
<span class="input-group-addon required">Hostname</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
|
||||
ng-model="item.Host"
|
||||
ng-change="$ctrl.onChangeIngressHostname()"
|
||||
placeholder="foo"
|
||||
pattern="[\*a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*"
|
||||
required
|
||||
data-cy="namespaceCreate-hostnameInput{{ ic.IngressClass.Name }}_{{ $index }}"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm !pt-2" ng-if="$index > 0">
|
||||
<button class="btn btn-md btn-dangerlight btn-only-icon" type="button" ng-click="$ctrl.removeHostname(ic, $index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'" feather="true"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="small text-warning pt-1"
|
||||
ng-show="
|
||||
resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid ||
|
||||
$ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined
|
||||
"
|
||||
>
|
||||
<ng-messages for="resourcePoolCreationForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
|
||||
<p class="vertical-center" ng-message="required"><pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon> Hostname is required.</p>
|
||||
<p class="vertical-center" ng-message="pattern">
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
|
||||
This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g. 'example.com').
|
||||
</p>
|
||||
</ng-messages>
|
||||
<p class="vertical-center" ng-if="$ctrl.state.duplicates.ingressHosts.refs[ic.IngressClass.Name][$index] !== undefined">
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon> This hostname is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-repeat-end class="form-group" ng-if="ic.Selected">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
|
||||
You can specify a list of annotations that will be associated to the ingress.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Annotations </label>
|
||||
<label class="control-label text-left">
|
||||
<span class="label label-default interactive" ng-click="$ctrl.addAnnotation(ic)" data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}">
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add annotation
|
||||
</span>
|
||||
<portainer-tooltip
|
||||
message="'Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<label class="control-label text-left">
|
||||
<span
|
||||
class="label label-default interactive"
|
||||
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
|
||||
ng-click="$ctrl.addRewriteAnnotation(ic)"
|
||||
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
|
||||
>
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add rewrite annotation
|
||||
</span>
|
||||
<portainer-tooltip
|
||||
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
|
||||
message="'When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<label class="control-label text-left">
|
||||
<span
|
||||
class="label label-default interactive"
|
||||
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
|
||||
ng-click="$ctrl.addUseregexAnnotation(ic)"
|
||||
data-cy="namespaceCreate-addAnnotation{{ ic.IngressClass.Name }}"
|
||||
>
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add regular expression annotation
|
||||
</span>
|
||||
<portainer-tooltip
|
||||
ng-if="ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX"
|
||||
message="'Enable use of regular expressions in ingress paths (set in the ingress details of an application). Use this along with rewrite-target to specify the regex capturing group to be replaced, e.g. path regex of ^/foo/(,*) and rewrite-target of /bar/$1 rewrites example.com/foo/account to example.com/bar/account.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col-sm-12 form-inline pt-4">
|
||||
<div class="pt-2" ng-repeat="annotation in ic.Annotations track by $index">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">Key</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="annotation.Key"
|
||||
placeholder="{{
|
||||
ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX
|
||||
? 'e.g. nginx.ingress.kubernetes.io/enable-rewrite-log'
|
||||
: 'e.g. traefik.ingress.kubernetes.io/router.priority'
|
||||
}}"
|
||||
required
|
||||
data-cy="namespaceCreate-annotationKey{{ ic.IngressClass.Name }}"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group input-group-sm col-sm-5">
|
||||
<span class="input-group-addon">Value</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="annotation.Value"
|
||||
placeholder="{{ ic.IngressClass.Type === $ctrl.IngressClassTypes.NGINX ? 'e.g. true or false' : 'e.g. 42' }}"
|
||||
required
|
||||
data-cy="namespaceCreate-annotationValue{{ ic.IngressClass.Name }}"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group input-group-sm col-sm-1">
|
||||
<button
|
||||
class="btn btn-md btn-dangerlight btn-only-icon"
|
||||
type="button"
|
||||
ng-click="$ctrl.removeAnnotation(ic, $index)"
|
||||
data-cy="namespaceCreate-deleteAnnotationButton{{ ic.IngressClass.Name }}"
|
||||
>
|
||||
<pr-icon icon="'trash-2'" size="'md'" feather="true"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<ingress-class-datatable
|
||||
ng-if="$ctrl.state.ingressAvailabilityPerNamespace"
|
||||
on-change-availability="($ctrl.onChangeIngressControllerAvailability)"
|
||||
ingress-controllers="$ctrl.ingressControllers"
|
||||
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>
|
||||
|
||||
|
@ -422,6 +225,25 @@
|
|||
</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'" feather="true"></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 -->
|
||||
|
|
|
@ -2,20 +2,12 @@ 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,
|
||||
KubernetesResourcePoolIngressClassAnnotationFormValue,
|
||||
KubernetesResourcePoolIngressClassHostFormValue,
|
||||
KubernetesResourcePoolNginxRewriteAnnotationFormValue,
|
||||
KubernetesResourcePoolNginxUseregexAnnotationFormValue,
|
||||
KubernetesResourcePoolTraefikRewriteAnnotationFormValue,
|
||||
} from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { getIngressControllerClassMap, updateIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
|
||||
|
||||
class KubernetesCreateResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
@ -39,10 +31,17 @@ class KubernetesCreateResourcePoolController {
|
|||
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);
|
||||
}
|
||||
/* #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 }));
|
||||
|
@ -61,74 +60,9 @@ class KubernetesCreateResourcePoolController {
|
|||
});
|
||||
}
|
||||
|
||||
onChangeIngressHostname() {
|
||||
const state = this.state.duplicates.ingressHosts;
|
||||
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
|
||||
const hostnames = _.compact(hosts.map((h) => h.Host));
|
||||
const hostnamesWithoutRemoved = _.filter(hostnames, (h) => !h.NeedsDeletion);
|
||||
const allHosts = _.flatMap(this.allIngresses, 'Hosts');
|
||||
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnamesWithoutRemoved);
|
||||
_.forEach(hostnames, (host, idx) => {
|
||||
if (host !== undefined && _.includes(allHosts, host)) {
|
||||
formDuplicates[idx] = host;
|
||||
}
|
||||
});
|
||||
const duplicates = {};
|
||||
let count = 0;
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
duplicates[ic.IngressClass.Name] = {};
|
||||
_.forEach(ic.Hosts, (hostFV, hostIdx) => {
|
||||
if (hostFV.Host === formDuplicates[count]) {
|
||||
duplicates[ic.IngressClass.Name][hostIdx] = hostFV.Host;
|
||||
}
|
||||
count++;
|
||||
});
|
||||
});
|
||||
state.refs = duplicates;
|
||||
state.hasRefs = false;
|
||||
_.forIn(duplicates, (value) => {
|
||||
if (Object.keys(value).length > 0) {
|
||||
state.hasRefs = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onRegistriesChange(registries) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.Registries = registries;
|
||||
});
|
||||
}
|
||||
|
||||
addHostname(ingressClass) {
|
||||
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
|
||||
removeHostname(ingressClass, index) {
|
||||
ingressClass.Hosts.splice(index, 1);
|
||||
this.onChangeIngressHostname();
|
||||
}
|
||||
|
||||
/* #region ANNOTATIONS MANAGEMENT */
|
||||
addAnnotation(ingressClass) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
|
||||
}
|
||||
|
||||
addRewriteAnnotation(ingressClass) {
|
||||
if (ingressClass.IngressClass.Type === this.IngressClassTypes.NGINX) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolNginxRewriteAnnotationFormValue());
|
||||
}
|
||||
|
||||
if (ingressClass.IngressClass.Type === this.IngressClassTypes.TRAEFIK) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolTraefikRewriteAnnotationFormValue());
|
||||
}
|
||||
}
|
||||
|
||||
addUseregexAnnotation(ingressClass) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolNginxUseregexAnnotationFormValue());
|
||||
}
|
||||
|
||||
removeAnnotation(ingressClass, index) {
|
||||
ingressClass.Annotations.splice(index, 1);
|
||||
/* #region INGRESS MANAGEMENT */
|
||||
onChangeIngressControllerAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
|
@ -175,6 +109,7 @@ class KubernetesCreateResourcePoolController {
|
|||
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) {
|
||||
|
@ -239,15 +174,21 @@ class KubernetesCreateResourcePoolController {
|
|||
viewReady: false,
|
||||
isAlreadyExist: false,
|
||||
hasPrefixKube: false,
|
||||
canUseIngress: endpoint.Kubernetes.Configuration.IngressClasses.length,
|
||||
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 });
|
||||
}
|
||||
|
||||
_.forEach(nodes, (item) => {
|
||||
this.state.sliderMaxMemory += filesizeParser(item.Memory);
|
||||
this.state.sliderMaxCpu += item.CPU;
|
||||
|
|
|
@ -156,193 +156,20 @@
|
|||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.canUseIngress">
|
||||
<div class="col-sm-12 form-section-title"> Ingresses </div>
|
||||
<div ng-if="ctrl.isAdmin && ctrl.isEditable && ctrl.state.ingressAvailabilityPerNamespace">
|
||||
<!-- #region INGRESSES -->
|
||||
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length === 0">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
The ingress feature must be enabled in the
|
||||
<a ui-sref="kubernetes.cluster.setup">environment configuration view</a> to be able to register ingresses inside this namespace.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<ingress-class-datatable
|
||||
ng-if="ctrl.state.ingressAvailabilityPerNamespace"
|
||||
on-change-availability="(ctrl.onChangeIngressControllerAvailability)"
|
||||
ingress-controllers="ctrl.ingressControllers"
|
||||
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>
|
||||
|
||||
<div class="form-group" ng-if="ctrl.formValues.IngressClasses.length > 0">
|
||||
<div class="col-sm-12 small text-muted">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
|
||||
Enable and configure ingresses available to users when deploying applications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-repeat-start="ic in ctrl.formValues.IngressClasses track by ic.IngressClass.Name">
|
||||
<div class="text-muted col-sm-12" style="width: 100%">
|
||||
<pr-icon icon="'svg-route'" mode="'primary'"></pr-icon>
|
||||
{{ ic.IngressClass.Name }}
|
||||
<hr />
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-3 col-lg-2">
|
||||
<label class="control-label text-left"> Allow users to use this ingress </label>
|
||||
</div>
|
||||
<div class="col-sm-9 pt-2">
|
||||
<label class="switch">
|
||||
<input type="checkbox" ng-model="ic.Selected" />
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-if="ic.Selected">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Hostnames
|
||||
<portainer-tooltip
|
||||
message="'Hostnames associated to the ingress inside this namespace. Users will be able to expose and access their applications over the ingress via one of these hostname.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</label>
|
||||
<span class="vertical-center label label-default interactive" style="margin-left: 10px" ng-click="ctrl.addHostname(ic)">
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add hostname
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12" style="margin-top: 10px">
|
||||
<div ng-repeat="item in ic.Hosts track by $index" style="margin-top: 2px">
|
||||
<div class="form-inline">
|
||||
<div class="col-sm-10 input-group input-group-sm" ng-class="{ striked: item.NeedsDeletion }">
|
||||
<span class="input-group-addon required">Hostname</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
name="hostname_{{ ic.IngressClass.Name }}_{{ $index }}"
|
||||
ng-model="item.Host"
|
||||
ng-change="ctrl.onChangeIngressHostname()"
|
||||
placeholder="foo"
|
||||
pattern="[\*a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm" ng-if="$index > 0">
|
||||
<button ng-if="!item.NeedsDeletion" class="btn btn-sm btn-dangerlight" type="button" ng-click="ctrl.removeHostname(ic, $index)">
|
||||
<pr-icon icon="'trash-2'" feather="true"></pr-icon>
|
||||
</button>
|
||||
<button ng-if="item.NeedsDeletion" class="btn btn-sm btn-primary" type="button" ng-click="ctrl.restoreHostname(item)">
|
||||
<pr-icon icon="'svg-restore'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="small text-warning"
|
||||
style="margin-top: 5px"
|
||||
ng-show="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$invalid || item.Duplicate"
|
||||
>
|
||||
<ng-messages for="resourcePoolEditForm['hostname_' + ic.IngressClass.Name + '_' + $index].$error">
|
||||
<p ng-message="required" class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
|
||||
Hostname is required.
|
||||
</p>
|
||||
<p ng-message="pattern" class="vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
|
||||
This field must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character (e.g.
|
||||
'example.com').
|
||||
</p>
|
||||
</ng-messages>
|
||||
<p class="vertical-center" ng-if="item.Duplicate">
|
||||
<pr-icon icon="'alert-triangle'" feather="true" mode="'warning'"></pr-icon>
|
||||
This hostname is already used.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-repeat-end class="form-group" ng-if="ic.Selected" style="margin-bottom: 20px">
|
||||
<div class="col-sm-12 small text-muted" style="margin-top: 5px">
|
||||
<p class="vertical-center">
|
||||
<pr-icon icon="'info'" mode="'primary'" feather="true"></pr-icon>
|
||||
You can specify a list of annotations that will be associated to the ingress.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">Annotations</label>
|
||||
<span class="vertical-center label label-default interactive" style="margin-left: 10px" ng-click="ctrl.addAnnotation(ic)">
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add annotation
|
||||
</span>
|
||||
<portainer-tooltip
|
||||
message="'Use annotations to configure options for an ingress. Review Nginx or Traefik documentation to find the annotations supported by your choice of ingress type.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
<span
|
||||
class="vertical-center label label-default interactive"
|
||||
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
|
||||
style="margin-left: 10px"
|
||||
ng-click="ctrl.addRewriteAnnotation(ic)"
|
||||
>
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add rewrite annotation
|
||||
</span>
|
||||
<portainer-tooltip
|
||||
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
|
||||
message="'When the exposed URLs for your applications differ from the specified paths in the ingress, use the rewrite target annotation to denote the path to redirect to.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
<span
|
||||
class="vertical-center label label-default interactive"
|
||||
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
|
||||
style="margin-left: 10px"
|
||||
ng-click="ctrl.addUseregexAnnotation(ic)"
|
||||
>
|
||||
<pr-icon icon="'plus'" feather="true"></pr-icon> add regular expression annotation
|
||||
</span>
|
||||
<portainer-tooltip
|
||||
ng-if="ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX"
|
||||
message="'Enable use of regular expressions in ingress paths (set in the ingress details of an application). Use this along with rewrite-target to specify the regex capturing group to be replaced, e.g. path regex of ^/foo/(,*) and rewrite-target of /bar/$1 rewrites example.com/foo/account to example.com/bar/account.'"
|
||||
>
|
||||
</portainer-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-inline" style="margin-top: 10px">
|
||||
<div ng-repeat="annotation in ic.Annotations track by $index" style="margin-top: 2px">
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">Key</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="annotation.Key"
|
||||
placeholder="{{
|
||||
ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX
|
||||
? 'e.g. nginx.ingress.kubernetes.io/enable-rewrite-log'
|
||||
: 'e.g. traefik.ingress.kubernetes.io/router.priority'
|
||||
}}"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group col-sm-5 input-group-sm">
|
||||
<span class="input-group-addon">Value</span>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="annotation.Value"
|
||||
placeholder="{{ ic.IngressClass.Type === ctrl.IngressClassTypes.NGINX ? 'e.g. true or false' : 'e.g. 42' }}"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-1 input-group input-group-sm">
|
||||
<button class="btn btn-md btn-dangerlight btn-only-icon" type="button" ng-click="ctrl.removeAnnotation(ic, $index)">
|
||||
<pr-icon icon="'trash-2'" feather="true" size="'md'"></pr-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
</div>
|
||||
|
||||
<!-- #region REGISTRIES -->
|
||||
<div>
|
||||
<div class="col-sm-12 form-section-title"> Registries </div>
|
||||
|
|
|
@ -5,21 +5,15 @@ import { KubernetesResourceQuotaDefaults } from 'Kubernetes/models/resource-quot
|
|||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
||||
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
|
||||
import {
|
||||
KubernetesResourcePoolFormValues,
|
||||
KubernetesResourcePoolIngressClassAnnotationFormValue,
|
||||
KubernetesResourcePoolIngressClassHostFormValue,
|
||||
KubernetesResourcePoolNginxRewriteAnnotationFormValue,
|
||||
KubernetesResourcePoolNginxUseregexAnnotationFormValue,
|
||||
KubernetesResourcePoolTraefikRewriteAnnotationFormValue,
|
||||
} from 'Kubernetes/models/resource-pool/formValues';
|
||||
|
||||
import { KubernetesResourcePoolFormValues, KubernetesResourcePoolIngressClassHostFormValue } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesIngressConverter } from 'Kubernetes/ingress/converter';
|
||||
import { KubernetesFormValidationReferences } from 'Kubernetes/models/application/formValues';
|
||||
import KubernetesFormValidationHelper from 'Kubernetes/helpers/formValidationHelper';
|
||||
import { KubernetesIngressClassTypes } from 'Kubernetes/ingress/constants';
|
||||
import KubernetesResourceQuotaConverter from 'Kubernetes/converters/resourceQuota';
|
||||
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
|
||||
import { FeatureId } from '@/portainer/feature-flags/enums';
|
||||
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
|
||||
|
||||
class KubernetesResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
|
@ -73,35 +67,11 @@ class KubernetesResourcePoolController {
|
|||
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);
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
/* #region ANNOTATIONS MANAGEMENT */
|
||||
addAnnotation(ingressClass) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolIngressClassAnnotationFormValue());
|
||||
}
|
||||
|
||||
addRewriteAnnotation(ingressClass) {
|
||||
if (ingressClass.IngressClass.Type === this.IngressClassTypes.NGINX) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolNginxRewriteAnnotationFormValue());
|
||||
}
|
||||
|
||||
if (ingressClass.IngressClass.Type === this.IngressClassTypes.TRAEFIK) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolTraefikRewriteAnnotationFormValue());
|
||||
}
|
||||
}
|
||||
|
||||
addUseregexAnnotation(ingressClass) {
|
||||
ingressClass.Annotations.push(new KubernetesResourcePoolNginxUseregexAnnotationFormValue());
|
||||
}
|
||||
|
||||
removeAnnotation(ingressClass, index) {
|
||||
ingressClass.Annotations.splice(index, 1);
|
||||
this.onChangeIngressHostname();
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
onRegistriesChange(registries) {
|
||||
return this.$scope.$evalAsync(() => {
|
||||
this.formValues.Registries = registries;
|
||||
|
@ -120,55 +90,10 @@ class KubernetesResourcePoolController {
|
|||
});
|
||||
}
|
||||
|
||||
/* #region INGRESS MANAGEMENT */
|
||||
onChangeIngressHostname() {
|
||||
const state = this.state.duplicates.ingressHosts;
|
||||
const otherIngresses = _.without(this.allIngresses, ...this.ingresses);
|
||||
const allHosts = _.flatMap(otherIngresses, 'Hosts');
|
||||
|
||||
const hosts = _.flatMap(this.formValues.IngressClasses, 'Hosts');
|
||||
const hostsWithoutRemoved = _.filter(hosts, { NeedsDeletion: false });
|
||||
const hostnames = _.map(hostsWithoutRemoved, 'Host');
|
||||
const formDuplicates = KubernetesFormValidationHelper.getDuplicates(hostnames);
|
||||
_.forEach(hostnames, (host, idx) => {
|
||||
if (host !== undefined && _.includes(allHosts, host)) {
|
||||
formDuplicates[idx] = host;
|
||||
}
|
||||
});
|
||||
const duplicatedHostnames = Object.values(formDuplicates);
|
||||
state.hasRefs = false;
|
||||
_.forEach(this.formValues.IngressClasses, (ic) => {
|
||||
_.forEach(ic.Hosts, (hostFV) => {
|
||||
if (_.includes(duplicatedHostnames, hostFV.Host) && hostFV.NeedsDeletion === false) {
|
||||
hostFV.Duplicate = true;
|
||||
state.hasRefs = true;
|
||||
} else {
|
||||
hostFV.Duplicate = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
onChangeIngressControllerAvailability(controllerClassMap) {
|
||||
this.ingressControllers = controllerClassMap;
|
||||
}
|
||||
|
||||
addHostname(ingressClass) {
|
||||
ingressClass.Hosts.push(new KubernetesResourcePoolIngressClassHostFormValue());
|
||||
}
|
||||
|
||||
removeHostname(ingressClass, index) {
|
||||
if (!ingressClass.Hosts[index].IsNew) {
|
||||
ingressClass.Hosts[index].NeedsDeletion = true;
|
||||
} else {
|
||||
ingressClass.Hosts.splice(index, 1);
|
||||
}
|
||||
this.onChangeIngressHostname();
|
||||
}
|
||||
|
||||
restoreHostname(host) {
|
||||
if (!host.IsNew) {
|
||||
host.NeedsDeletion = false;
|
||||
}
|
||||
}
|
||||
/* #endregion*/
|
||||
|
||||
selectTab(index) {
|
||||
this.LocalStorage.storeActiveTab('resourcePool', index);
|
||||
}
|
||||
|
@ -219,6 +144,7 @@ class KubernetesResourcePoolController {
|
|||
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) {
|
||||
|
@ -426,11 +352,12 @@ class KubernetesResourcePoolController {
|
|||
ingressesLoading: true,
|
||||
viewReady: false,
|
||||
eventWarningCount: 0,
|
||||
canUseIngress: this.endpoint.Kubernetes.Configuration.IngressClasses.length,
|
||||
canUseIngress: false,
|
||||
useServerMetrics: this.endpoint.Kubernetes.Configuration.UseServerMetrics,
|
||||
duplicates: {
|
||||
ingressHosts: new KubernetesFormValidationReferences(),
|
||||
},
|
||||
ingressAvailabilityPerNamespace: this.endpoint.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
};
|
||||
|
||||
this.state.activeTab = this.LocalStorage.getActiveTab('resourcePool');
|
||||
|
@ -439,6 +366,11 @@ class KubernetesResourcePoolController {
|
|||
|
||||
const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get()]);
|
||||
|
||||
this.ingressControllers = [];
|
||||
if (this.state.ingressAvailabilityPerNamespace) {
|
||||
this.ingressControllers = await getIngressControllerClassMap({ environmentId: this.endpoint.Id, namespace: name });
|
||||
}
|
||||
|
||||
this.pool = _.find(pools, { Namespace: { Name: name } });
|
||||
this.formValues = new KubernetesResourcePoolFormValues(KubernetesResourceQuotaDefaults);
|
||||
this.formValues.Name = this.pool.Namespace.Name;
|
||||
|
|
|
@ -25,6 +25,7 @@ import { Loading } from '@@/Widget/Loading';
|
|||
import { PasswordCheckHint } from '@@/PasswordCheckHint';
|
||||
import { ViewLoading } from '@@/ViewLoading';
|
||||
import { Tooltip } from '@@/Tip/Tooltip';
|
||||
import { Badge } from '@@/Badge';
|
||||
import { TableColumnHeaderAngular } from '@@/datatables/TableHeaderCell';
|
||||
import { DashboardItem } from '@@/DashboardItem';
|
||||
import { SearchBar } from '@@/datatables/SearchBar';
|
||||
|
@ -47,6 +48,7 @@ export const componentsModule = angular
|
|||
'portainerTooltip',
|
||||
r2a(Tooltip, ['message', 'position', 'className'])
|
||||
)
|
||||
.component('badge', r2a(Badge, ['type', 'className']))
|
||||
.component('fileUploadField', fileUploadField)
|
||||
.component('porSwitchField', switchField)
|
||||
.component(
|
||||
|
|
|
@ -99,7 +99,7 @@
|
|||
|
||||
<div class="form-group overflow-auto" ng-if="ctrl.state.showStandardLogin">
|
||||
<!-- login button -->
|
||||
<div class="col-sm-12 d-flex">
|
||||
<div class="col-sm-12 d-flex py-1">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-lg btn-block"
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
import { Meta } from '@storybook/react';
|
||||
|
||||
import { Badge, Props } from './Badge';
|
||||
|
||||
export default {
|
||||
component: Badge,
|
||||
title: 'Components/Badge',
|
||||
argTypes: {
|
||||
type: {
|
||||
control: {
|
||||
type: 'select',
|
||||
options: ['success', 'danger', 'warn', 'info'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as Meta<Props>;
|
||||
|
||||
// : JSX.IntrinsicAttributes & PropsWithChildren<Props>
|
||||
function Template({ type = 'success' }: Props) {
|
||||
const message = {
|
||||
success: 'success badge',
|
||||
danger: 'danger badge',
|
||||
warn: 'warn badge',
|
||||
info: 'info badge',
|
||||
};
|
||||
return <Badge type={type}>{message[type]}</Badge>;
|
||||
}
|
||||
|
||||
export const Example = Template.bind({});
|
|
@ -0,0 +1,59 @@
|
|||
import clsx from 'clsx';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
type BadgeType = 'success' | 'danger' | 'warn' | 'info';
|
||||
|
||||
export interface Props {
|
||||
type?: BadgeType;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// this component is used in tables and lists in portainer. It looks like this:
|
||||
// https://www.figma.com/file/g5TUMngrblkXM7NHSyQsD1/New-UI?node-id=76%3A2
|
||||
export function Badge({ type, className, children }: PropsWithChildren<Props>) {
|
||||
const baseClasses =
|
||||
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
|
||||
const typeClasses = getClasses(type);
|
||||
|
||||
return (
|
||||
<span className={clsx(baseClasses, typeClasses, className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// classes in full to prevent a dev server bug, where tailwind doesn't render the interpolated classes
|
||||
function getClasses(type: BadgeType | undefined) {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return clsx(
|
||||
`text-success-9 bg-success-2`,
|
||||
`th-dark:text-success-3 th-dark:bg-success-10`,
|
||||
`th-highcontrast:text-success-3 th-highcontrast:bg-success-10`
|
||||
);
|
||||
case 'warn':
|
||||
return clsx(
|
||||
`text-warning-9 bg-warning-2`,
|
||||
`th-dark:text-warning-3 th-dark:bg-warning-10`,
|
||||
`th-highcontrast:text-warning-3 th-highcontrast:bg-warning-10`
|
||||
);
|
||||
case 'danger':
|
||||
return clsx(
|
||||
`text-error-9 bg-error-2`,
|
||||
`th-dark:text-error-3 th-dark:bg-error-10`,
|
||||
`th-highcontrast:text-error-3 th-highcontrast:bg-error-10`
|
||||
);
|
||||
case 'info':
|
||||
return clsx(
|
||||
`text-blue-9 bg-blue-2`,
|
||||
`th-dark:text-blue-3 th-dark:bg-blue-10`,
|
||||
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
|
||||
);
|
||||
default:
|
||||
return clsx(
|
||||
`text-blue-9 bg-blue-2`,
|
||||
`th-dark:text-blue-3 th-dark:bg-blue-10`,
|
||||
`th-highcontrast:text-blue-3 th-highcontrast:bg-blue-10`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { Badge } from './Badge';
|
|
@ -54,6 +54,7 @@ interface Props<
|
|||
initialTableState?: Partial<TableState<D>>;
|
||||
isLoading?: boolean;
|
||||
totalCount?: number;
|
||||
description?: JSX.Element;
|
||||
}
|
||||
|
||||
export function Datatable<
|
||||
|
@ -74,6 +75,7 @@ export function Datatable<
|
|||
initialTableState = {},
|
||||
isLoading,
|
||||
totalCount = dataset.length,
|
||||
description,
|
||||
}: Props<D, TSettings>) {
|
||||
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
|
||||
|
||||
|
@ -145,6 +147,7 @@ export function Datatable<
|
|||
label={titleOptions.title}
|
||||
icon={titleOptions.icon}
|
||||
featherIcon={titleOptions.featherIcon}
|
||||
description={description}
|
||||
>
|
||||
<SearchBar value={searchBarValue} onChange={setGlobalFilter} />
|
||||
{renderTableActions && (
|
||||
|
|
|
@ -20,8 +20,8 @@ export function SearchBar({
|
|||
const [searchValue, setSearchValue] = useDebounce(value, onChange);
|
||||
|
||||
return (
|
||||
<div className="searchBar items-center flex">
|
||||
<Search className="searchIcon feather" />
|
||||
<div className="searchBar items-center flex min-w-[90px]">
|
||||
<Search className="searchIcon feather shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
className="searchInput"
|
||||
|
|
|
@ -22,7 +22,7 @@ export function TableHeaderCell({
|
|||
render,
|
||||
onSortClick,
|
||||
isSorted,
|
||||
isSortedDesc,
|
||||
isSortedDesc = true,
|
||||
canFilter,
|
||||
renderFilter,
|
||||
}: Props) {
|
||||
|
@ -55,7 +55,7 @@ function SortWrapper({
|
|||
children,
|
||||
onClick = () => {},
|
||||
isSorted,
|
||||
isSortedDesc,
|
||||
isSortedDesc = true,
|
||||
}: PropsWithChildren<SortWrapperProps>) {
|
||||
if (!canSort) {
|
||||
return <>{children}</>;
|
||||
|
@ -93,7 +93,7 @@ export function TableColumnHeaderAngular({
|
|||
canSort,
|
||||
isSorted,
|
||||
colTitle,
|
||||
isSortedDesc,
|
||||
isSortedDesc = true,
|
||||
}: TableColumnHeaderAngularProps) {
|
||||
return (
|
||||
<div className="flex flex-row flex-nowrap h-full">
|
||||
|
|
|
@ -6,6 +6,7 @@ interface Props {
|
|||
icon?: ReactNode | ComponentType<unknown>;
|
||||
featherIcon?: boolean;
|
||||
label: string;
|
||||
description?: JSX.Element;
|
||||
}
|
||||
|
||||
export function TableTitle({
|
||||
|
@ -13,23 +14,27 @@ export function TableTitle({
|
|||
featherIcon,
|
||||
label,
|
||||
children,
|
||||
description,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div className="toolBar">
|
||||
<div className="toolBarTitle">
|
||||
{icon && (
|
||||
<div className="widget-icon">
|
||||
<Icon
|
||||
icon={icon}
|
||||
feather={featherIcon}
|
||||
className="space-right feather"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="toolBar flex-col">
|
||||
<div className="flex gap-1 p-0 w-full items-center">
|
||||
<div className="toolBarTitle">
|
||||
{icon && (
|
||||
<div className="widget-icon">
|
||||
<Icon
|
||||
icon={icon}
|
||||
feather={featherIcon}
|
||||
className="space-right feather"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{label}
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
{description && description}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,221 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { confirmWarn } from '@/portainer/services/modal.service/confirm';
|
||||
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { Button, ButtonGroup } from '@@/buttons';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { IngressControllerClassMap } from '../types';
|
||||
|
||||
import { useColumns } from './columns';
|
||||
import { createStore } from './datatable-store';
|
||||
|
||||
const useStore = createStore('ingressClasses');
|
||||
|
||||
interface Props {
|
||||
onChangeAvailability: (
|
||||
controllerClassMap: IngressControllerClassMap[]
|
||||
) => void; // angular function to save the ingress class list
|
||||
description: string;
|
||||
ingressControllers: IngressControllerClassMap[] | undefined;
|
||||
noIngressControllerLabel: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
export function IngressClassDatatable({
|
||||
onChangeAvailability,
|
||||
description,
|
||||
ingressControllers,
|
||||
noIngressControllerLabel,
|
||||
view,
|
||||
}: Props) {
|
||||
const [ingControllerFormValues, setIngControllerFormValues] =
|
||||
useState(ingressControllers);
|
||||
const settings = useStore();
|
||||
const columns = useColumns();
|
||||
|
||||
return (
|
||||
<div className="-mx-[15px]">
|
||||
<Datatable
|
||||
dataset={ingControllerFormValues || []}
|
||||
storageKey="ingressClasses"
|
||||
columns={columns}
|
||||
settingsStore={settings}
|
||||
isLoading={!ingControllerFormValues}
|
||||
emptyContentLabel={noIngressControllerLabel}
|
||||
titleOptions={{
|
||||
icon: 'database',
|
||||
title: 'Ingress controllers',
|
||||
featherIcon: true,
|
||||
}}
|
||||
getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`}
|
||||
renderTableActions={(selectedRows) => renderTableActions(selectedRows)}
|
||||
description={renderIngressClassDescription()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
disabled={
|
||||
selectedRows.filter((row) => row.Availability === true).length ===
|
||||
0
|
||||
}
|
||||
color="dangerlight"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
Disallow selected
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
selectedRows.filter((row) => row.Availability === false)
|
||||
.length === 0
|
||||
}
|
||||
color="default"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
Allow selected
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIngressClassDescription() {
|
||||
return (
|
||||
<div className="flex flex-col !text-xs text-muted w-full">
|
||||
<div className="mt-1">{description}</div>
|
||||
{ingressControllers &&
|
||||
ingControllerFormValues &&
|
||||
isUnsavedChanges(ingressControllers, ingControllerFormValues) && (
|
||||
<span className="flex items-center text-warning mt-1">
|
||||
<Icon icon="alert-triangle" feather className="!mr-1" />
|
||||
<span className="text-warning">Unsaved changes.</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateIngressControllers(
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
ingControllerFormValues: IngressControllerClassMap[],
|
||||
availability: boolean
|
||||
) {
|
||||
const updatedIngressControllers = getUpdatedIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
availability
|
||||
);
|
||||
|
||||
if (ingressControllers && ingressControllers.length) {
|
||||
const newAllowed = updatedIngressControllers.map(
|
||||
(ingController) => ingController.Availability
|
||||
);
|
||||
if (view === 'namespace') {
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeAvailability(updatedIngressControllers);
|
||||
return;
|
||||
}
|
||||
|
||||
const usedControllersToDisallow = ingressControllers.filter(
|
||||
(ingController, index) => {
|
||||
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
|
||||
if (
|
||||
ingController.Availability &&
|
||||
ingController.Used &&
|
||||
!newAllowed[index]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
if (usedControllersToDisallow.length > 0) {
|
||||
const usedControllerHtmlListItems = usedControllersToDisallow.map(
|
||||
(controller) => `<li>${controller.ClassName}</li>`
|
||||
);
|
||||
const usedControllerHtmlList = `<ul class="ml-6">${usedControllerHtmlListItems.join(
|
||||
''
|
||||
)}</ul>`;
|
||||
confirmWarn({
|
||||
title: 'Disallow in-use ingress controllers?',
|
||||
message: `
|
||||
<div>
|
||||
<p>There are ingress controllers you want to disallow that are in use:</p>
|
||||
${usedControllerHtmlList}
|
||||
<p>No new ingress rules can be created for the disallowed controllers.</p>
|
||||
</div>`,
|
||||
buttons: {
|
||||
cancel: {
|
||||
label: 'Cancel',
|
||||
className: 'btn-default',
|
||||
},
|
||||
confirm: {
|
||||
label: 'Disallow',
|
||||
className: 'btn-warning',
|
||||
},
|
||||
},
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeAvailability(updatedIngressControllers);
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeAvailability(updatedIngressControllers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedChanges(
|
||||
oldIngressControllers: IngressControllerClassMap[],
|
||||
newIngressControllers: IngressControllerClassMap[]
|
||||
) {
|
||||
for (let i = 0; i < oldIngressControllers.length; i += 1) {
|
||||
if (
|
||||
oldIngressControllers[i].Availability !==
|
||||
newIngressControllers[i].Availability
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getUpdatedIngressControllers(
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
allRows: IngressControllerClassMap[],
|
||||
allow: boolean
|
||||
) {
|
||||
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
|
||||
const updatedIngressControllers = allRows?.map((row) => {
|
||||
if (selectedRowClassNames.includes(row.ClassName)) {
|
||||
return { ...row, Availability: allow };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
return updatedIngressControllers;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
import { Environment } from '@/portainer/environments/types';
|
||||
|
||||
import { createRowContext } from '@@/datatables/RowContext';
|
||||
|
||||
interface RowContextState {
|
||||
environment: Environment;
|
||||
}
|
||||
|
||||
const { RowProvider, useRowContext } = createRowContext<RowContextState>();
|
||||
|
||||
export { RowProvider, useRowContext };
|
|
@ -0,0 +1,27 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
export const availability: Column<IngressControllerClassMap> = {
|
||||
Header: 'Availability',
|
||||
accessor: 'Availability',
|
||||
Cell: AvailailityCell,
|
||||
id: 'availability',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
sortInverted: true,
|
||||
sortType: 'basic',
|
||||
Filter: () => null,
|
||||
};
|
||||
|
||||
function AvailailityCell({ value }: CellProps<IngressControllerClassMap>) {
|
||||
return (
|
||||
<Badge type={value ? 'success' : 'danger'}>
|
||||
<Icon icon={value ? 'check' : 'x'} feather className="!mr-1" />
|
||||
{value ? 'Allowed' : 'Disallowed'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { availability } from './availability';
|
||||
import { type } from './type';
|
||||
import { name } from './name';
|
||||
|
||||
export function useColumns() {
|
||||
return useMemo(() => [name, type, availability], []);
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
export const name: Column<IngressControllerClassMap> = {
|
||||
Header: 'Ingress class',
|
||||
accessor: 'ClassName',
|
||||
Cell: NameCell,
|
||||
id: 'name',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
Filter: () => null,
|
||||
sortType: 'string',
|
||||
};
|
||||
|
||||
function NameCell({ row }: CellProps<IngressControllerClassMap>) {
|
||||
return (
|
||||
<span className="flex flex-nowrap">
|
||||
{row.original.ClassName}
|
||||
{row.original.New && <Badge className="ml-1">Newly detected</Badge>}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { CellProps, Column } from 'react-table';
|
||||
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
export const type: Column<IngressControllerClassMap> = {
|
||||
Header: 'Ingress controller type',
|
||||
accessor: 'Type',
|
||||
Cell: ({ row }: CellProps<IngressControllerClassMap>) =>
|
||||
row.original.Type || '-',
|
||||
id: 'type',
|
||||
disableFilters: true,
|
||||
canHide: true,
|
||||
Filter: () => null,
|
||||
};
|
|
@ -0,0 +1,26 @@
|
|||
import create from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
import { keyBuilder } from '@/portainer/hooks/useLocalStorage';
|
||||
import {
|
||||
paginationSettings,
|
||||
sortableSettings,
|
||||
} from '@/react/components/datatables/types';
|
||||
|
||||
import { TableSettings } from './types';
|
||||
|
||||
export const TRUNCATE_LENGTH = 32;
|
||||
|
||||
export function createStore(storageKey: string) {
|
||||
return create<TableSettings>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...sortableSettings(set),
|
||||
...paginationSettings(set),
|
||||
}),
|
||||
{
|
||||
name: keyBuilder(storageKey),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export * from './IngressClassDatatable';
|
|
@ -0,0 +1,8 @@
|
|||
import {
|
||||
PaginationTableSettings,
|
||||
SortableTableSettings,
|
||||
} from '@/react/components/datatables/types';
|
||||
|
||||
export interface TableSettings
|
||||
extends SortableTableSettings,
|
||||
PaginationTableSettings {}
|
|
@ -0,0 +1,23 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import {
|
||||
KubernetesApiListResponse,
|
||||
V1IngressClass,
|
||||
} from '@/react/kubernetes/services/kubernetes/types';
|
||||
|
||||
export async function getAllIngressClasses(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const {
|
||||
data: { items },
|
||||
} = await axios.get<KubernetesApiListResponse<V1IngressClass[]>>(
|
||||
urlBuilder(environmentId)
|
||||
);
|
||||
return items;
|
||||
} catch (error) {
|
||||
throw parseAxiosError(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
function urlBuilder(environmentId: EnvironmentId) {
|
||||
return `endpoints/${environmentId}/kubernetes/apis/networking.k8s.io/v1/ingressclasses`;
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
type SupportedIngControllerNames = 'nginx' | 'traefik' | 'unknown';
|
||||
|
||||
export interface IngressControllerClassMap extends Record<string, unknown> {
|
||||
Name: string;
|
||||
ClassName: string;
|
||||
Type: SupportedIngControllerNames;
|
||||
Availability: boolean;
|
||||
New: boolean;
|
||||
Used: boolean; // if the controller is used by any ingress in the cluster
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { EnvironmentId } from '@/portainer/environments/types';
|
||||
import PortainerError from '@/portainer/error';
|
||||
import axios from '@/portainer/services/axios';
|
||||
|
||||
import { IngressControllerClassMap } from '../types';
|
||||
|
||||
// get all supported ingress classes and controllers for the cluster
|
||||
// allowedOnly set to true will hide globally disallowed ingresscontrollers
|
||||
export async function getIngressControllerClassMap({
|
||||
environmentId,
|
||||
namespace,
|
||||
allowedOnly,
|
||||
}: {
|
||||
environmentId: EnvironmentId;
|
||||
namespace?: string;
|
||||
allowedOnly?: boolean;
|
||||
}) {
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.get<
|
||||
IngressControllerClassMap[]
|
||||
>(
|
||||
buildUrl(environmentId, namespace),
|
||||
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
||||
);
|
||||
return controllerMaps;
|
||||
} catch (e) {
|
||||
throw new PortainerError('Unable to get ingress controllers.', e as Error);
|
||||
}
|
||||
}
|
||||
|
||||
// get all supported ingress classes and controllers for the cluster
|
||||
export async function updateIngressControllerClassMap(
|
||||
environmentId: EnvironmentId,
|
||||
ingressControllerClassMap: IngressControllerClassMap[],
|
||||
namespace?: string
|
||||
) {
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.put<
|
||||
IngressControllerClassMap[]
|
||||
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
|
||||
return controllerMaps;
|
||||
} catch (e) {
|
||||
throw new PortainerError(
|
||||
'Unable to update ingress controllers.',
|
||||
e as Error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||
let url = `kubernetes/${environmentId}/`;
|
||||
if (namespace) {
|
||||
url += `namespaces/${namespace}/`;
|
||||
}
|
||||
url += 'ingresscontrollers';
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
export * from './v1IngressClass';
|
||||
export * from './v1ObjectMeta';
|
||||
|
||||
export type KubernetesApiListResponse<T> = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
items: T;
|
||||
metadata: {
|
||||
resourceVersion?: string;
|
||||
};
|
||||
};
|
|
@ -0,0 +1,48 @@
|
|||
import { V1ObjectMeta } from './v1ObjectMeta';
|
||||
|
||||
/**
|
||||
* IngressClassParametersReference identifies an API object. This can be used to specify a cluster or namespace-scoped resource.
|
||||
*/
|
||||
type V1IngressClassParametersReference = {
|
||||
/**
|
||||
* APIGroup is the group for the resource being referenced. If APIGroup is not specified, the specified Kind must be in the core API group. For any other third-party types, APIGroup is required.
|
||||
*/
|
||||
apiGroup?: string;
|
||||
/**
|
||||
* Kind is the type of resource being referenced.
|
||||
*/
|
||||
kind: string;
|
||||
/**
|
||||
* Name is the name of resource being referenced.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Namespace is the namespace of the resource being referenced. This field is required when scope is set to \"Namespace\" and must be unset when scope is set to \"Cluster\".
|
||||
*/
|
||||
namespace?: string;
|
||||
/**
|
||||
* Scope represents if this refers to a cluster or namespace scoped resource. This may be set to \"Cluster\" (default) or \"Namespace\".
|
||||
*/
|
||||
scope?: string;
|
||||
};
|
||||
|
||||
type V1IngressClassSpec = {
|
||||
controller?: string;
|
||||
parameters?: V1IngressClassParametersReference;
|
||||
};
|
||||
|
||||
/**
|
||||
* IngressClass represents the class of the Ingress, referenced by the Ingress Spec. The `ingressclass.kubernetes.io/is-default-class` annotation can be used to indicate that an IngressClass should be considered default. When a single IngressClass resource has this annotation set to true, new Ingress resources without a class specified will be assigned this default class.
|
||||
*/
|
||||
export type V1IngressClass = {
|
||||
/**
|
||||
* APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
*/
|
||||
apiVersion?: string;
|
||||
/**
|
||||
* Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
*/
|
||||
kind?: string;
|
||||
metadata?: V1ObjectMeta;
|
||||
spec?: V1IngressClassSpec;
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
// type definitions taken from https://github.com/kubernetes-client/javascript/blob/master/src/gen/model/v1ObjectMeta.ts
|
||||
// and simplified to only include the types we need
|
||||
|
||||
export type V1ObjectMeta = {
|
||||
/**
|
||||
* Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: http://kubernetes.io/docs/user-guide/annotations
|
||||
*/
|
||||
annotations?: { [key: string]: string };
|
||||
/**
|
||||
* Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: http://kubernetes.io/docs/user-guide/labels
|
||||
*/
|
||||
labels?: { [key: string]: string };
|
||||
/**
|
||||
* Deprecated: ClusterName is a legacy field that was always cleared by the system and never used; it will be removed completely in 1.25. The name in the go struct is changed to help clients detect accidental use.
|
||||
*/
|
||||
clusterName?: string;
|
||||
/**
|
||||
* Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/identifiers#names
|
||||
*/
|
||||
name?: string;
|
||||
/**
|
||||
* Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. Must be a DNS_LABEL. Cannot be updated. More info: http://kubernetes.io/docs/user-guide/namespaces
|
||||
*/
|
||||
namespace?: string;
|
||||
/**
|
||||
* An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
|
||||
*/
|
||||
resourceVersion?: string;
|
||||
/**
|
||||
* UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. Populated by the system. Read-only. More info: http://kubernetes.io/docs/user-guide/identifiers#uids
|
||||
*/
|
||||
uid?: string;
|
||||
};
|
Loading…
Reference in New Issue