1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(namespace): migrate create ns to react [EE-2226] (#10377)

This commit is contained in:
Ali 2023-10-11 20:32:02 +01:00 committed by GitHub
parent 31bcba96c6
commit 7218eb0892
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
83 changed files with 1869 additions and 358 deletions

View file

@ -53,6 +53,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Use(h.kubeClient) endpointRouter.Use(h.kubeClient)
endpointRouter.PathPrefix("/nodes_limits").Handler(httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet) endpointRouter.PathPrefix("/nodes_limits").Handler(httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet)
endpointRouter.PathPrefix("/max_resource_limits").Handler(httperror.LoggerHandler(h.getKubernetesMaxResourceLimits)).Methods(http.MethodGet)
endpointRouter.Path("/metrics/nodes").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllNodes)).Methods(http.MethodGet) endpointRouter.Path("/metrics/nodes").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllNodes)).Methods(http.MethodGet)
endpointRouter.Path("/metrics/nodes/{name}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForNode)).Methods(http.MethodGet) endpointRouter.Path("/metrics/nodes/{name}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForNode)).Methods(http.MethodGet)
endpointRouter.Path("/metrics/pods/namespace/{namespace}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllPods)).Methods(http.MethodGet) endpointRouter.Path("/metrics/pods/namespace/{namespace}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllPods)).Methods(http.MethodGet)

View file

@ -121,7 +121,7 @@ func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Re
// @success 200 {string} string "Success" // @success 200 {string} string "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 500 "Server error" // @failure 500 "Server error"
// @router /kubernetes/{id}/namespaces/{namespace} [post] // @router /kubernetes/{id}/namespaces [post]
func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id") endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
@ -157,6 +157,7 @@ func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http
err, err,
) )
} }
return nil return nil
} }

View file

@ -51,3 +51,38 @@ func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.
return response.JSON(w, nodesLimits) return response.JSON(w, nodesLimits)
} }
func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest(
"Invalid environment identifier route variable",
err,
)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if handler.DataStore.IsErrObjectNotFound(err) {
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
} else if err != nil {
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
}
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
if err != nil {
return httperror.InternalServerError(
"Failed to lookup KubeClient",
err,
)
}
overCommit := endpoint.Kubernetes.Configuration.EnableResourceOverCommit
overCommitPercent := endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage
// name is set to "" so all namespaces resources are considered when calculating max resource limits
resourceLimit, err := cli.GetMaxResourceLimits("", overCommit, overCommitPercent)
if err != nil {
return httperror.InternalServerError("Unable to retrieve max resource limit", err)
}
return response.JSON(w, resourceLimit)
}

View file

@ -1,12 +1,36 @@
package kubernetes package kubernetes
import "net/http" import (
"fmt"
"net/http"
"k8s.io/apimachinery/pkg/api/resource"
)
type K8sNamespaceDetails struct { type K8sNamespaceDetails struct {
Name string `json:"Name"` Name string `json:"Name"`
Annotations map[string]string `json:"Annotations"` Annotations map[string]string `json:"Annotations"`
ResourceQuota *K8sResourceQuota `json:"ResourceQuota"`
}
type K8sResourceQuota struct {
Enabled bool `json:"enabled"`
Memory string `json:"memory"`
CPU string `json:"cpu"`
} }
func (r *K8sNamespaceDetails) Validate(request *http.Request) error { func (r *K8sNamespaceDetails) Validate(request *http.Request) error {
if r.ResourceQuota != nil && r.ResourceQuota.Enabled {
_, err := resource.ParseQuantity(r.ResourceQuota.Memory)
if err != nil {
return fmt.Errorf("error parsing memory quota value: %w", err)
}
_, err = resource.ParseQuantity(r.ResourceQuota.CPU)
if err != nil {
return fmt.Errorf("error parsing cpu quota value: %w", err)
}
}
return nil return nil
} }

View file

@ -8,7 +8,9 @@ import (
"github.com/pkg/errors" "github.com/pkg/errors"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
models "github.com/portainer/portainer/api/http/models/kubernetes" models "github.com/portainer/portainer/api/http/models/kubernetes"
"github.com/rs/zerolog/log"
v1 "k8s.io/api/core/v1" v1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
@ -61,14 +63,59 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint. // CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error { func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
client := kcl.cli.CoreV1().Namespaces()
var ns v1.Namespace var ns v1.Namespace
ns.Name = info.Name ns.Name = info.Name
ns.Annotations = info.Annotations ns.Annotations = info.Annotations
_, err := client.Create(context.Background(), &ns, metav1.CreateOptions{}) resourceQuota := &v1.ResourceQuota{
return err ObjectMeta: metav1.ObjectMeta{
Name: "portainer-rq-" + info.Name,
Namespace: info.Name,
},
Spec: v1.ResourceQuotaSpec{
Hard: v1.ResourceList{},
},
}
_, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
if err != nil {
log.Error().
Err(err).
Str("Namespace", info.Name).
Interface("ResourceQuota", resourceQuota).
Msg("Failed to create the namespace due to a resource quota issue.")
return err
}
if info.ResourceQuota != nil {
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
if info.ResourceQuota.Enabled {
memory := resource.MustParse(info.ResourceQuota.Memory)
cpu := resource.MustParse(info.ResourceQuota.CPU)
if memory.Value() > 0 {
memQuota := memory
resourceQuota.Spec.Hard[v1.ResourceLimitsMemory] = memQuota
resourceQuota.Spec.Hard[v1.ResourceRequestsMemory] = memQuota
}
if cpu.Value() > 0 {
cpuQuota := cpu
resourceQuota.Spec.Hard[v1.ResourceLimitsCPU] = cpuQuota
resourceQuota.Spec.Hard[v1.ResourceRequestsCPU] = cpuQuota
}
}
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
if err != nil {
log.Error().Msgf("Failed to create resource quota for namespace %s: %s", info.Name, err)
return err
}
}
return nil
} }
func isSystemNamespace(namespace v1.Namespace) bool { func isSystemNamespace(namespace v1.Namespace) bool {

View file

@ -4,6 +4,7 @@ import (
"context" "context"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/rs/zerolog/log"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
) )
@ -42,3 +43,62 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
return nodesLimits, nil return nodesLimits, nil
} }
// GetMaxResourceLimits gets the maximum CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection, minus the accumulated resourcequotas for all namespaces except the one we're editing (skipNamespace)
// if skipNamespace is set to "" then all namespaces are considered
func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) {
limits := portainer.K8sNodeLimits{}
nodes, err := client.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return limits, err
}
// accumulated node limits
memory := int64(0)
for _, node := range nodes.Items {
limits.CPU += node.Status.Allocatable.Cpu().MilliValue()
memory += node.Status.Allocatable.Memory().Value()
}
limits.Memory = memory / 1000000 // B to MB
if !overCommitEnabled {
namespaces, err := client.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
if err != nil {
return limits, err
}
reservedPercent := float64(resourceOverCommitPercent) / 100.0
reserved := portainer.K8sNodeLimits{}
for _, namespace := range namespaces.Items {
// skip the namespace we're editing
if namespace.Name == skipNamespace {
continue
}
// minus accumulated resourcequotas for all namespaces except the one we're editing
resourceQuota, err := client.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{})
if err != nil {
log.Debug().Msgf("error getting resourcequota for namespace %s: %s", namespace.Name, err)
continue // skip it
}
for _, rq := range resourceQuota.Items {
hardLimits := rq.Status.Hard
for resourceType, limit := range hardLimits {
switch resourceType {
case "limits.cpu":
reserved.CPU += limit.MilliValue()
case "limits.memory":
reserved.Memory += limit.ScaledValue(6) // MB
}
}
}
}
limits.CPU = limits.CPU - int64(float64(limits.CPU)*reservedPercent) - reserved.CPU
limits.Memory = limits.Memory - int64(float64(limits.Memory)*reservedPercent) - reserved.Memory
}
return limits, nil
}

View file

@ -1495,6 +1495,7 @@ type (
GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error) GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error)
DeleteServices(reqs models.K8sServiceDeleteRequests) error DeleteServices(reqs models.K8sServiceDeleteRequests) error
GetNodesLimits() (K8sNodesLimits, error) GetNodesLimits() (K8sNodesLimits, error)
GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error) GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
DeleteRegistrySecret(registry *Registry, namespace string) error DeleteRegistrySecret(registry *Registry, namespace string) error

View file

@ -1,5 +1,5 @@
import { EnvironmentStatus } from '@/react/portainer/environments/types'; import { EnvironmentStatus } from '@/react/portainer/environments/types';
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/service'; import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models'; import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
@ -375,12 +375,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
}, },
}; };
const resourcePoolCreation = { const namespaceCreation = {
name: 'kubernetes.resourcePools.new', name: 'kubernetes.resourcePools.new',
url: '/new', url: '/new',
views: { views: {
'content@': { 'content@': {
component: 'kubernetesCreateResourcePoolView', component: 'kubernetesCreateNamespaceView',
}, },
}, },
}; };
@ -504,7 +504,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
$stateRegistryProvider.register(node); $stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeStats); $stateRegistryProvider.register(nodeStats);
$stateRegistryProvider.register(resourcePools); $stateRegistryProvider.register(resourcePools);
$stateRegistryProvider.register(resourcePoolCreation); $stateRegistryProvider.register(namespaceCreation);
$stateRegistryProvider.register(resourcePool); $stateRegistryProvider.register(resourcePool);
$stateRegistryProvider.register(resourcePoolAccess); $stateRegistryProvider.register(resourcePoolAccess);
$stateRegistryProvider.register(volumes); $stateRegistryProvider.register(volumes);

View file

@ -1,11 +1,11 @@
import angular from 'angular'; import angular from 'angular';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable'; import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular';
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector'; import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector'; import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector';
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector'; import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector'; import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector'; import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm'; import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation'; import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
@ -27,7 +27,7 @@ export const ngModule = angular
.module('portainer.kubernetes.react.components', []) .module('portainer.kubernetes.react.components', [])
.component( .component(
'ingressClassDatatable', 'ingressClassDatatable',
r2a(IngressClassDatatable, [ r2a(IngressClassDatatableAngular, [
'onChangeControllers', 'onChangeControllers',
'description', 'description',
'ingressControllers', 'ingressControllers',
@ -74,12 +74,7 @@ export const ngModule = angular
) )
.component( .component(
'createNamespaceRegistriesSelector', 'createNamespaceRegistriesSelector',
r2a(CreateNamespaceRegistriesSelector, [ r2a(RegistriesSelector, ['inputId', 'onChange', 'options', 'value'])
'inputId',
'onChange',
'options',
'value',
])
) )
.component( .component(
'kubeApplicationAccessPolicySelector', 'kubeApplicationAccessPolicySelector',

View file

@ -10,11 +10,16 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView';
import { ServicesView } from '@/react/kubernetes/services/ServicesView'; import { ServicesView } from '@/react/kubernetes/services/ServicesView';
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView'; import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView'; import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView';
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView'; import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView'; import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView';
export const viewsModule = angular export const viewsModule = angular
.module('portainer.kubernetes.react.views', []) .module('portainer.kubernetes.react.views', [])
.component(
'kubernetesCreateNamespaceView',
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), [])
)
.component( .component(
'kubernetesServicesView', 'kubernetesServicesView',
r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), []) r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), [])

View file

@ -1,10 +0,0 @@
import angular from 'angular';
import KubernetesCreateResourcePoolController from './createResourcePoolController';
angular.module('portainer.kubernetes').component('kubernetesCreateResourcePoolView', {
templateUrl: './createResourcePool.html',
controller: KubernetesCreateResourcePoolController,
bindings: {
endpoint: '<',
},
});

View file

@ -339,7 +339,7 @@
refresh-callback="ctrl.getIngresses" refresh-callback="ctrl.getIngresses"
loading="ctrl.state.ingressesLoading" loading="ctrl.state.ingressesLoading"
title-text="Ingress routes and applications" title-text="Ingress routes and applications"
title-icon="svg-route" title-icon="database"
> >
</kubernetes-resource-pool-ingresses-datatable> </kubernetes-resource-pool-ingresses-datatable>
</div> </div>

View file

@ -29,13 +29,19 @@ type OptionalReadonly<T> = T | Readonly<T>;
export function withInvalidate( export function withInvalidate(
queryClient: QueryClient, queryClient: QueryClient,
queryKeysToInvalidate: Array<OptionalReadonly<Array<string | number>>> queryKeysToInvalidate: Array<OptionalReadonly<Array<string | number>>>,
// skipRefresh will set the mutation state to success without waiting for the invalidated queries to refresh
// see the following for info: https://tkdodo.eu/blog/mastering-mutations-in-react-query#awaited-promises
{ skipRefresh }: { skipRefresh?: boolean } = {}
) { ) {
return { return {
onSuccess() { onSuccess() {
return Promise.all( const promise = Promise.all(
queryKeysToInvalidate.map((keys) => queryClient.invalidateQueries(keys)) queryKeysToInvalidate.map((keys) => queryClient.invalidateQueries(keys))
); );
return skipRefresh
? undefined // don't wait for queries to refresh before setting state to success
: promise; // stay loading until all queries are refreshed
}, },
}; };
} }

View file

@ -36,7 +36,7 @@ export function Slider({
return ( return (
<div className={styles.root}> <div className={styles.root}>
<RcSlider <RcSlider
handleRender={sliderTooltip} handleRender={visible ? sliderTooltip : undefined}
min={min} min={min}
max={max} max={max}
marks={marks} marks={marks}

View file

@ -6,10 +6,16 @@ export function SliderWithInput({
value, value,
onChange, onChange,
max, max,
step = 1,
dataCy,
visibleTooltip = false,
}: { }: {
value: number; value: number;
onChange: (value: number) => void; onChange: (value: number) => void;
max: number; max: number;
dataCy: string;
step?: number;
visibleTooltip?: boolean;
}) { }) {
return ( return (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -22,7 +28,9 @@ export function SliderWithInput({
value={value} value={value}
min={0} min={0}
max={max} max={max}
step={256} step={step}
dataCy={`${dataCy}Slider`}
visibleTooltip={visibleTooltip}
/> />
</div> </div>
)} )}
@ -33,6 +41,7 @@ export function SliderWithInput({
value={value} value={value}
onChange={(e) => onChange(e.target.valueAsNumber)} onChange={(e) => onChange(e.target.valueAsNumber)}
className="w-32" className="w-32"
data-cy={`${dataCy}Input`}
/> />
</div> </div>
); );

View file

@ -0,0 +1,18 @@
import { FormikErrors } from 'formik';
export function isErrorType<T>(
error: string | FormikErrors<T> | undefined
): error is FormikErrors<T> {
return error !== undefined && typeof error !== 'string';
}
export function isArrayErrorType<T>(
error:
| string[]
| FormikErrors<T>[]
| string
| undefined
| (FormikErrors<T> | undefined)[]
): error is FormikErrors<T>[] {
return error !== undefined && typeof error !== 'string';
}

View file

@ -0,0 +1,56 @@
import _ from 'lodash';
import { AnySchema, TestContext, TypeOf, ValidationError } from 'yup';
import Lazy from 'yup/lib/Lazy';
import { AnyObject } from 'yup/lib/types';
/**
* Builds a uniqueness test for yup.
* @param errorMessage The error message to display for duplicates.
* @param path The path to the value to test for uniqueness (if the list item is an object).
* @returns A function that can be passed to yup's `test` method.
*/
export function buildUniquenessTest<
T extends AnySchema | Lazy<AnySchema, AnySchema>,
>(errorMessage: (errorIndex: number) => string, path = '') {
return (
list: Array<TypeOf<T>> | undefined,
testContext: TestContext<AnyObject>
) => {
if (!list) {
return true;
}
const values = list.map(mapper);
// check for duplicates, adding the index of each duplicate to an array
const seen = new Set<TypeOf<T>>();
const duplicates: number[] = [];
values.forEach((value, i) => {
if (seen.has(value)) {
duplicates.push(i);
} else {
seen.add(value);
}
});
// create an array of yup validation errors for each duplicate
const errors = duplicates.map((i) => {
const error = new ValidationError(
errorMessage(i),
list[i],
`${testContext.path}[${i}]${path}`
);
return error;
});
if (errors.length > 0) {
throw new ValidationError(errors);
}
return true;
};
function mapper(a: TypeOf<T>) {
return path ? _.get(a, path) : a;
}
}

View file

@ -38,6 +38,8 @@ export function ResourceFieldset({
value={values.reservation} value={values.reservation}
onChange={(value) => onChange({ ...values, reservation: value })} onChange={(value) => onChange({ ...values, reservation: value })}
max={maxMemory} max={maxMemory}
step={256}
dataCy="k8sNamespaceCreate-resourceReservationMemory"
/> />
</FormControl> </FormControl>
@ -46,6 +48,8 @@ export function ResourceFieldset({
value={values.limit} value={values.limit}
onChange={(value) => onChange({ ...values, limit: value })} onChange={(value) => onChange({ ...values, limit: value })}
max={maxMemory} max={maxMemory}
step={256}
dataCy="k8sNamespaceCreate-resourceLimitMemory"
/> />
</FormControl> </FormControl>

View file

@ -0,0 +1,48 @@
import { useRouter } from '@uirouter/react';
import { useEffect } from 'react';
import { EnvironmentId } from '../portainer/environments/types';
import { useAuthorizations } from './useUser';
type AuthorizationOptions = {
authorizations: string | string[];
forceEnvironmentId?: EnvironmentId;
adminOnlyCE?: boolean;
};
type RedirectOptions = {
to: string;
params: Record<string, unknown>;
};
/**
* Redirects to the given route if the user is not authorized.
* @param authorizations The authorizations to check.
* @param forceEnvironmentId The environment id to use for the check.
* @param adminOnlyCE Whether to check only for admin authorizations in CE.
* @param to The route to redirect to.
* @param params The params to pass to the route.
*/
export function useUnauthorizedRedirect(
{
authorizations,
forceEnvironmentId,
adminOnlyCE = false,
}: AuthorizationOptions,
{ to, params }: RedirectOptions
) {
const router = useRouter();
const isAuthorized = useAuthorizations(
authorizations,
forceEnvironmentId,
adminOnlyCE
);
useEffect(() => {
if (!isAuthorized) {
router.stateService.go(to, params);
}
}, [isAuthorized, params, to, router.stateService]);
}

View file

@ -1,10 +1,11 @@
import { ChangeEvent, ReactNode } from 'react'; import { ChangeEvent } from 'react';
import { Trash2 } from 'lucide-react'; import { Trash2 } from 'lucide-react';
import { FormError } from '@@/form-components/FormError'; import { FormError } from '@@/form-components/FormError';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { isArrayErrorType } from '@@/form-components/formikUtils';
import { Annotation } from './types'; import { Annotation, AnnotationErrors } from './types';
interface Props { interface Props {
annotations: Annotation[]; annotations: Annotation[];
@ -14,17 +15,21 @@ interface Props {
val: string val: string
) => void; ) => void;
removeAnnotation: (index: number) => void; removeAnnotation: (index: number) => void;
errors: Record<string, ReactNode>; errors: AnnotationErrors;
placeholder: string[]; placeholder: string[];
} }
export function Annotations({ export function AnnotationsForm({
annotations, annotations,
handleAnnotationChange, handleAnnotationChange,
removeAnnotation, removeAnnotation,
errors, errors,
placeholder, placeholder,
}: Props) { }: Props) {
const annotationErrors = isArrayErrorType<Annotation>(errors)
? errors
: undefined;
return ( return (
<> <>
{annotations.map((annotation, i) => ( {annotations.map((annotation, i) => (
@ -43,9 +48,9 @@ export function Annotations({
} }
/> />
</div> </div>
{errors[`annotations.key[${i}]`] && ( {annotationErrors?.[i]?.Key && (
<FormError className="!mb-0 mt-1"> <FormError className="mt-1 !mb-0">
{errors[`annotations.key[${i}]`]} {annotationErrors[i]?.Key}
</FormError> </FormError>
)} )}
</div> </div>
@ -63,9 +68,9 @@ export function Annotations({
} }
/> />
</div> </div>
{errors[`annotations.value[${i}]`] && ( {annotationErrors?.[i]?.Value && (
<FormError className="!mb-0 mt-1"> <FormError className="mt-1 !mb-0">
{errors[`annotations.value[${i}]`]} {annotationErrors[i]?.Value}
</FormError> </FormError>
)} )}
</div> </div>

View file

@ -0,0 +1,15 @@
import { FormikErrors } from 'formik';
export interface Annotation {
Key: string;
Value: string;
ID: string;
}
export type AnnotationsPayload = Record<string, string>;
export type AnnotationErrors =
| string
| string[]
| FormikErrors<Annotation>[]
| undefined;

View file

@ -0,0 +1,68 @@
import { SchemaOf, array, object, string } from 'yup';
import { buildUniquenessTest } from '@@/form-components/validate-unique';
import { Annotation } from './types';
const re = /^([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9]$/;
export const annotationsSchema: SchemaOf<Annotation[]> = array(
getAnnotationValidation()
).test(
'unique',
'Duplicate keys are not allowed.',
buildUniquenessTest(() => 'Duplicate keys are not allowed.', 'Key')
);
function getAnnotationValidation(): SchemaOf<Annotation> {
return object({
Key: string()
.required('Key is required.')
.test('is-valid', (value, { createError }) => {
if (!value) {
return true;
}
const keySegments = value.split('/');
if (keySegments.length > 2) {
return createError({
message:
'Two segments are allowed, separated by a slash (/): a prefix (optional) and a name.',
});
}
if (keySegments.length === 2) {
if (keySegments[0].length > 253) {
return createError({
message: "Prefix (before the slash) can't exceed 253 characters.",
});
}
if (keySegments[1].length > 63) {
return createError({
message: "Name (after the slash) can't exceed 63 characters.",
});
}
if (!re.test(keySegments[1])) {
return createError({
message:
'Start and end with alphanumeric characters only, limiting characters in between to dashes, underscores, and alphanumerics.',
});
}
} else if (keySegments.length === 1) {
if (keySegments[0].length > 63) {
return createError({
message:
"Name (the segment after a slash (/), or only segment if no slash) can't exceed 63 characters.",
});
}
if (!re.test(keySegments[0])) {
return createError({
message:
'Start and end with alphanumeric characters only, limiting characters in between to dashes, underscores, and alphanumerics.',
});
}
}
return true;
}),
Value: string().required('Value is required.'),
ID: string().required('ID is required.'),
});
}

View file

@ -7,8 +7,9 @@ import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { Card } from '@@/Card'; import { Card } from '@@/Card';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
import { isErrorType } from '@@/form-components/formikUtils';
import { isErrorType, newPort } from '../utils'; import { newPort } from '../utils';
import { import {
ServiceFormValues, ServiceFormValues,
ServicePort, ServicePort,

View file

@ -8,8 +8,9 @@ import { Button } from '@@/buttons';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
import { Card } from '@@/Card'; import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup'; import { InputGroup } from '@@/form-components/InputGroup';
import { isErrorType } from '@@/form-components/formikUtils';
import { isErrorType, newPort } from '../utils'; import { newPort } from '../utils';
import { ContainerPortInput } from '../components/ContainerPortInput'; import { ContainerPortInput } from '../components/ContainerPortInput';
import { import {
ServiceFormValues, ServiceFormValues,

View file

@ -8,8 +8,9 @@ import { Button } from '@@/buttons';
import { Widget } from '@@/Widget'; import { Widget } from '@@/Widget';
import { Card } from '@@/Card'; import { Card } from '@@/Card';
import { InputGroup } from '@@/form-components/InputGroup'; import { InputGroup } from '@@/form-components/InputGroup';
import { isErrorType } from '@@/form-components/formikUtils';
import { isErrorType, newPort } from '../utils'; import { newPort } from '../utils';
import { ContainerPortInput } from '../components/ContainerPortInput'; import { ContainerPortInput } from '../components/ContainerPortInput';
import { import {
ServiceFormValues, ServiceFormValues,

View file

@ -1,15 +1,7 @@
import { FormikErrors } from 'formik';
import { Ingress } from '@/react/kubernetes/ingresses/types'; import { Ingress } from '@/react/kubernetes/ingresses/types';
import { ServiceFormValues, ServicePort } from './types'; import { ServiceFormValues, ServicePort } from './types';
export function isErrorType<T>(
error: string | FormikErrors<T> | undefined
): error is FormikErrors<T> {
return error !== undefined && typeof error !== 'string';
}
export function newPort(serviceName?: string) { export function newPort(serviceName?: string) {
return { return {
port: undefined, port: undefined,

View file

@ -21,11 +21,9 @@ import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap'; import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
import { import { IngressControllerClassMap } from '../../ingressClass/types';
IngressControllerClassMap,
IngressControllerClassMapRowData,
} from '../../ingressClass/types';
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled'; import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils';
import { useStorageClassesFormValues } from './useStorageClassesFormValues'; import { useStorageClassesFormValues } from './useStorageClassesFormValues';
import { ConfigureFormValues, StorageClassFormValues } from './types'; import { ConfigureFormValues, StorageClassFormValues } from './types';
@ -176,15 +174,10 @@ function InnerForm({
</FormSection> </FormSection>
<FormSection title="Networking - Ingresses"> <FormSection title="Networking - Ingresses">
<IngressClassDatatable <IngressClassDatatable
onChangeControllers={onChangeControllers} onChange={onChangeControllers}
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." 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."
ingressControllers={ values={values.ingressClasses}
values.ingressClasses as IngressControllerClassMapRowData[] initialValues={initialValues.ingressClasses}
}
initialIngressControllers={
initialValues.ingressClasses as IngressControllerClassMapRowData[]
}
allowNoneIngressClass={values.allowNoneIngressClass}
isLoading={isIngressClassesLoading} isLoading={isIngressClassesLoading}
noIngressControllerLabel="No supported ingress controllers found." noIngressControllerLabel="No supported ingress controllers found."
view="cluster" view="cluster"
@ -198,9 +191,19 @@ function InnerForm({
tooltip='This allows users setting up ingresses to select "none" as the ingress class.' tooltip='This allows users setting up ingresses to select "none" as the ingress class.'
labelClass="col-sm-5 col-lg-4" labelClass="col-sm-5 col-lg-4"
checked={values.allowNoneIngressClass} checked={values.allowNoneIngressClass}
onChange={(checked) => onChange={(checked) => {
setFieldValue('allowNoneIngressClass', checked) setFieldValue('allowNoneIngressClass', checked);
} // add or remove the none ingress class from the ingress classes list
if (checked) {
setFieldValue(
'ingressClasses',
getIngressClassesFormValues(
checked,
initialValues.ingressClasses
)
);
}
}}
/> />
</div> </div>
</div> </div>
@ -376,12 +379,15 @@ function InnerForm({
function useInitialValues( function useInitialValues(
environment?: Environment | null, environment?: Environment | null,
storageClassFormValues?: StorageClassFormValues[], storageClassFormValues?: StorageClassFormValues[],
ingressClasses?: IngressControllerClassMapRowData[] ingressClasses?: IngressControllerClassMap[]
): ConfigureFormValues | undefined { ): ConfigureFormValues | undefined {
return useMemo(() => { return useMemo(() => {
if (!environment) { if (!environment) {
return undefined; return undefined;
} }
const allowNoneIngressClass =
!!environment.Kubernetes.Configuration.AllowNoneIngressClass;
return { return {
storageClasses: storageClassFormValues || [], storageClasses: storageClassFormValues || [],
useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer, useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer,
@ -396,9 +402,10 @@ function useInitialValues(
!!environment.Kubernetes.Configuration.RestrictStandardUserIngressW, !!environment.Kubernetes.Configuration.RestrictStandardUserIngressW,
ingressAvailabilityPerNamespace: ingressAvailabilityPerNamespace:
!!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace, !!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
allowNoneIngressClass: allowNoneIngressClass,
!!environment.Kubernetes.Configuration.AllowNoneIngressClass, ingressClasses:
ingressClasses: ingressClasses || [], getIngressClassesFormValues(allowNoneIngressClass, ingressClasses) ||
[],
}; };
}, [environment, ingressClasses, storageClassFormValues]); }, [environment, ingressClasses, storageClassFormValues]);
} }

View file

@ -8,8 +8,6 @@ import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries
import { Environment } from '@/react/portainer/environments/types'; import { Environment } from '@/react/portainer/environments/types';
import { TrackEventProps } from '@/angulartics.matomo/analytics-services'; import { TrackEventProps } from '@/angulartics.matomo/analytics-services';
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
import { ConfigureFormValues, StorageClassFormValues } from './types'; import { ConfigureFormValues, StorageClassFormValues } from './types';
import { ConfigureClusterPayloads } from './useConfigureClusterMutation'; import { ConfigureClusterPayloads } from './useConfigureClusterMutation';
@ -64,10 +62,8 @@ export async function handleSubmitConfigureCluster(
{ {
id: environment.Id, id: environment.Id,
updateEnvironmentPayload: updatedEnvironment, updateEnvironmentPayload: updatedEnvironment,
initialIngressControllers: initialIngressControllers: initialValues?.ingressClasses ?? [],
initialValues?.ingressClasses as IngressControllerClassMapRowData[], ingressControllers: values.ingressClasses,
ingressControllers:
values.ingressClasses as IngressControllerClassMapRowData[],
storageClassPatches, storageClassPatches,
}, },
{ {

View file

@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import _ from 'lodash'; import _ from 'lodash';
import { withError } from '@/react-tools/react-query'; import { withError, withInvalidate } from '@/react-tools/react-query';
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys'; import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
import { import {
UpdateEnvironmentPayload, UpdateEnvironmentPayload,
@ -12,13 +12,13 @@ import axios from '@/portainer/services/axios';
import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError'; import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError';
import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap'; import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap';
import { IngressControllerClassMapRowData } from '../../ingressClass/types'; import { IngressControllerClassMap } from '../../ingressClass/types';
export type ConfigureClusterPayloads = { export type ConfigureClusterPayloads = {
id: number; id: number;
updateEnvironmentPayload: Partial<UpdateEnvironmentPayload>; updateEnvironmentPayload: Partial<UpdateEnvironmentPayload>;
initialIngressControllers: IngressControllerClassMapRowData[]; initialIngressControllers: IngressControllerClassMap[];
ingressControllers: IngressControllerClassMapRowData[]; ingressControllers: IngressControllerClassMap[];
storageClassPatches: { storageClassPatches: {
name: string; name: string;
patch: Operation[]; patch: Operation[];
@ -48,10 +48,9 @@ export function useConfigureClusterMutation() {
} }
}, },
{ {
onSuccess: () => { ...withInvalidate(queryClient, [environmentQueryKeys.base()], {
// not returning the promise here because we don't want to wait for the invalidateQueries to complete (longer than the mutation itself) skipRefresh: true,
queryClient.invalidateQueries(environmentQueryKeys.base()); }),
},
...withError('Unable to apply configuration', 'Failure'), ...withError('Unable to apply configuration', 'Failure'),
} }
); );

View file

@ -1,4 +1,5 @@
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment'; import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody } from '@@/Widget'; import { Widget, WidgetBody } from '@@/Widget';
@ -8,7 +9,19 @@ import { ConfigureForm } from './ConfigureForm';
export function ConfigureView() { export function ConfigureView() {
const { data: environment } = useCurrentEnvironment(); const { data: environment } = useCurrentEnvironment();
// get the initial values useUnauthorizedRedirect(
{
authorizations: 'K8sClusterW',
forceEnvironmentId: environment?.Id,
adminOnlyCE: false,
},
{
params: {
id: environment?.Id,
},
to: 'kubernetes.dashboard',
}
);
return ( return (
<> <>

View file

@ -1,5 +1,3 @@
import { useEffect, useState } from 'react';
import Route from '@/assets/ico/route.svg?c'; import Route from '@/assets/ico/route.svg?c';
import { confirm } from '@@/modals/confirm'; import { confirm } from '@@/modals/confirm';
@ -11,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils';
import { useTableState } from '@@/datatables/useTableState'; import { useTableState } from '@@/datatables/useTableState';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { IngressControllerClassMapRowData } from '../types'; import { IngressControllerClassMap } from '../types';
import { columns } from './columns'; import { columns } from './columns';
@ -19,82 +17,31 @@ const storageKey = 'ingressClasses';
const settingsStore = createPersistedStore(storageKey, 'name'); const settingsStore = createPersistedStore(storageKey, 'name');
interface Props { interface Props {
onChangeControllers: ( onChange: (controllerClassMap: IngressControllerClassMap[]) => void; // angular function to save the ingress class list
controllerClassMap: IngressControllerClassMapRowData[]
) => void; // angular function to save the ingress class list
description: string; description: string;
ingressControllers: IngressControllerClassMapRowData[] | undefined; values: IngressControllerClassMap[] | undefined;
initialIngressControllers: IngressControllerClassMapRowData[] | undefined; initialValues: IngressControllerClassMap[] | undefined;
allowNoneIngressClass: boolean;
isLoading: boolean; isLoading: boolean;
noIngressControllerLabel: string; noIngressControllerLabel: string;
view: string; view: string;
} }
export function IngressClassDatatable({ export function IngressClassDatatable({
onChangeControllers, onChange,
description, description,
initialIngressControllers, initialValues,
ingressControllers, values,
allowNoneIngressClass,
isLoading, isLoading,
noIngressControllerLabel, noIngressControllerLabel,
view, view,
}: Props) { }: Props) {
const tableState = useTableState(settingsStore, storageKey); const tableState = useTableState(settingsStore, storageKey);
const [ingControllerFormValues, setIngControllerFormValues] = useState(
ingressControllers || []
);
// set the ingress controller form values when the ingress controller list changes
// and the ingress controller form values are not set
useEffect(() => {
if (
ingressControllers &&
ingControllerFormValues.length !== ingressControllers.length
) {
setIngControllerFormValues(ingressControllers);
}
}, [ingressControllers, ingControllerFormValues]);
useEffect(() => {
if (allowNoneIngressClass === undefined || isLoading) {
return;
}
let newIngFormValues: IngressControllerClassMapRowData[];
const isCustomTypeExist = ingControllerFormValues.some(
(ic) => ic.Type === 'custom'
);
if (allowNoneIngressClass) {
newIngFormValues = [...ingControllerFormValues];
// add the ingress controller type 'custom' with a 'none' ingress class name
if (!isCustomTypeExist) {
newIngFormValues.push({
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
});
}
} else {
newIngFormValues = ingControllerFormValues.filter(
(ingController) => ingController.ClassName !== 'none'
);
}
setIngControllerFormValues(newIngFormValues);
onChangeControllers(newIngFormValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowNoneIngressClass, onChangeControllers]);
return ( return (
<div className="-mx-[15px]"> <div className="-mx-[15px]">
<Datatable <Datatable
settingsManager={tableState} settingsManager={tableState}
dataset={ingControllerFormValues || []} dataset={values || []}
columns={columns} columns={columns}
isLoading={isLoading} isLoading={isLoading}
emptyContentLabel={noIngressControllerLabel} emptyContentLabel={noIngressControllerLabel}
@ -107,9 +54,7 @@ export function IngressClassDatatable({
</div> </div>
); );
function renderTableActions( function renderTableActions(selectedRows: IngressControllerClassMap[]) {
selectedRows: IngressControllerClassMapRowData[]
) {
return ( return (
<div className="flex items-start"> <div className="flex items-start">
<ButtonGroup> <ButtonGroup>
@ -121,11 +66,7 @@ export function IngressClassDatatable({
color="dangerlight" color="dangerlight"
size="small" size="small"
onClick={() => onClick={() =>
updateIngressControllers( updateIngressControllers(selectedRows, values || [], false)
selectedRows,
ingControllerFormValues || [],
false
)
} }
> >
Disallow selected Disallow selected
@ -138,11 +79,7 @@ export function IngressClassDatatable({
color="default" color="default"
size="small" size="small"
onClick={() => onClick={() =>
updateIngressControllers( updateIngressControllers(selectedRows, values || [], true)
selectedRows,
ingControllerFormValues || [],
true
)
} }
> >
Allow selected Allow selected
@ -156,38 +93,34 @@ export function IngressClassDatatable({
return ( return (
<div className="text-muted flex w-full flex-col !text-xs"> <div className="text-muted flex w-full flex-col !text-xs">
<div className="mt-1">{description}</div> <div className="mt-1">{description}</div>
{initialIngressControllers && {initialValues && values && isUnsavedChanges(initialValues, values) && (
ingControllerFormValues && <TextTip>Unsaved changes.</TextTip>
isUnsavedChanges( )}
initialIngressControllers,
ingControllerFormValues
) && <TextTip>Unsaved changes.</TextTip>}
</div> </div>
); );
} }
async function updateIngressControllers( async function updateIngressControllers(
selectedRows: IngressControllerClassMapRowData[], selectedRows: IngressControllerClassMap[],
ingControllerFormValues: IngressControllerClassMapRowData[], values: IngressControllerClassMap[],
availability: boolean availability: boolean
) { ) {
const updatedIngressControllers = getUpdatedIngressControllers( const updatedIngressControllers = getUpdatedIngressControllers(
selectedRows, selectedRows,
ingControllerFormValues || [], values || [],
availability availability
); );
if (ingressControllers && ingressControllers.length) { if (values && values.length) {
const newAllowed = updatedIngressControllers.map( const newAllowed = updatedIngressControllers.map(
(ingController) => ingController.Availability (ingController) => ingController.Availability
); );
if (view === 'namespace') { if (view === 'namespace') {
setIngControllerFormValues(updatedIngressControllers); onChange(updatedIngressControllers);
onChangeControllers(updatedIngressControllers);
return; return;
} }
const usedControllersToDisallow = ingressControllers.filter( const usedControllersToDisallow = values.filter(
(ingController, index) => { (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 any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
if ( if (
@ -229,15 +162,14 @@ export function IngressClassDatatable({
return; return;
} }
} }
setIngControllerFormValues(updatedIngressControllers); onChange(updatedIngressControllers);
onChangeControllers(updatedIngressControllers);
} }
} }
} }
function isUnsavedChanges( function isUnsavedChanges(
oldIngressControllers: IngressControllerClassMapRowData[], oldIngressControllers: IngressControllerClassMap[],
newIngressControllers: IngressControllerClassMapRowData[] newIngressControllers: IngressControllerClassMap[]
) { ) {
if (oldIngressControllers.length !== newIngressControllers.length) { if (oldIngressControllers.length !== newIngressControllers.length) {
return true; return true;
@ -254,8 +186,8 @@ function isUnsavedChanges(
} }
function getUpdatedIngressControllers( function getUpdatedIngressControllers(
selectedRows: IngressControllerClassMapRowData[], selectedRows: IngressControllerClassMap[],
allRows: IngressControllerClassMapRowData[], allRows: IngressControllerClassMap[],
allow: boolean allow: boolean
) { ) {
const selectedRowClassNames = selectedRows.map((row) => row.ClassName); const selectedRowClassNames = selectedRows.map((row) => row.ClassName);

View file

@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import Route from '@/assets/ico/route.svg?c';
import { confirm } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { Datatable } from '@@/datatables';
import { Button, ButtonGroup } from '@@/buttons';
import { createPersistedStore } from '@@/datatables/types';
import { buildConfirmButton } from '@@/modals/utils';
import { useTableState } from '@@/datatables/useTableState';
import { TextTip } from '@@/Tip/TextTip';
import { IngressControllerClassMap } from '../types';
import { columns } from './columns';
const storageKey = 'ingressClasses';
const settingsStore = createPersistedStore(storageKey, 'name');
interface Props {
onChangeControllers: (
controllerClassMap: IngressControllerClassMap[]
) => void; // angular function to save the ingress class list
description: string;
ingressControllers: IngressControllerClassMap[] | undefined;
initialIngressControllers: IngressControllerClassMap[] | undefined;
allowNoneIngressClass: boolean;
isLoading: boolean;
noIngressControllerLabel: string;
view: string;
}
// This is a legacy component that has more state logic than the new one, for angular views
// Delete this component when the namespace edit view is migrated to react
export function IngressClassDatatableAngular({
onChangeControllers,
description,
initialIngressControllers,
ingressControllers,
allowNoneIngressClass,
isLoading,
noIngressControllerLabel,
view,
}: Props) {
const tableState = useTableState(settingsStore, storageKey);
const [ingControllerFormValues, setIngControllerFormValues] = useState(
ingressControllers || []
);
// set the ingress controller form values when the ingress controller list changes
// and the ingress controller form values are not set
useEffect(() => {
if (
ingressControllers &&
ingControllerFormValues.length !== ingressControllers.length
) {
setIngControllerFormValues(ingressControllers);
}
}, [ingressControllers, ingControllerFormValues]);
useEffect(() => {
if (allowNoneIngressClass === undefined || isLoading) {
return;
}
let newIngFormValues: IngressControllerClassMap[];
const isCustomTypeExist = ingControllerFormValues.some(
(ic) => ic.Type === 'custom'
);
if (allowNoneIngressClass) {
newIngFormValues = [...ingControllerFormValues];
// add the ingress controller type 'custom' with a 'none' ingress class name
if (!isCustomTypeExist) {
newIngFormValues.push({
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
});
}
} else {
newIngFormValues = ingControllerFormValues.filter(
(ingController) => ingController.ClassName !== 'none'
);
}
setIngControllerFormValues(newIngFormValues);
onChangeControllers(newIngFormValues);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allowNoneIngressClass, onChangeControllers]);
return (
<div className="-mx-[15px]">
<Datatable
settingsManager={tableState}
dataset={ingControllerFormValues || []}
columns={columns}
isLoading={isLoading}
emptyContentLabel={noIngressControllerLabel}
title="Ingress Controllers"
titleIcon={Route}
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="text-muted flex w-full flex-col !text-xs">
<div className="mt-1">{description}</div>
{initialIngressControllers &&
ingControllerFormValues &&
isUnsavedChanges(
initialIngressControllers,
ingControllerFormValues
) && <TextTip>Unsaved changes.</TextTip>}
</div>
);
}
async function updateIngressControllers(
selectedRows: IngressControllerClassMap[],
ingControllerFormValues: IngressControllerClassMap[],
availability: boolean
) {
const updatedIngressControllers = getUpdatedIngressControllers(
selectedRows,
ingControllerFormValues || [],
availability
);
if (ingressControllers && ingressControllers.length) {
const newAllowed = updatedIngressControllers.map(
(ingController) => ingController.Availability
);
if (view === 'namespace') {
setIngControllerFormValues(updatedIngressControllers);
onChangeControllers(updatedIngressControllers);
return;
}
const usedControllersToDisallow = ingressControllers.filter(
(ingController, index) => {
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
if (
ingController.Availability &&
ingController.Used &&
!newAllowed[index]
) {
return true;
}
return false;
}
);
if (usedControllersToDisallow.length > 0) {
const confirmed = await confirm({
title: 'Disallow in-use ingress controllers?',
modalType: ModalType.Warn,
message: (
<div>
<p>
There are ingress controllers you want to disallow that are in
use:
</p>
<ul className="ml-6">
{usedControllersToDisallow.map((controller) => (
<li key={controller.ClassName}>{controller.ClassName}</li>
))}
</ul>
<p>
No new ingress rules can be created for the disallowed
controllers.
</p>
</div>
),
confirmButton: buildConfirmButton('Disallow', 'warning'),
});
if (!confirmed) {
return;
}
}
setIngControllerFormValues(updatedIngressControllers);
onChangeControllers(updatedIngressControllers);
}
}
}
function isUnsavedChanges(
oldIngressControllers: IngressControllerClassMap[],
newIngressControllers: IngressControllerClassMap[]
) {
if (oldIngressControllers.length !== newIngressControllers.length) {
return true;
}
for (let i = 0; i < newIngressControllers.length; i += 1) {
if (
oldIngressControllers[i]?.Availability !==
newIngressControllers[i]?.Availability
) {
return true;
}
}
return false;
}
function getUpdatedIngressControllers(
selectedRows: IngressControllerClassMap[],
allRows: IngressControllerClassMap[],
allow: boolean
) {
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
const updatedIngressControllers = allRows?.map((row) => {
if (selectedRowClassNames.includes(row.ClassName)) {
return { ...row, Availability: allow };
}
return row;
});
return updatedIngressControllers;
}

View file

@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
import { Badge } from '@@/Badge'; import { Badge } from '@@/Badge';
import { Icon } from '@@/Icon'; import { Icon } from '@@/Icon';
import type { IngressControllerClassMapRowData } from '../../types'; import type { IngressControllerClassMap } from '../../types';
import { columnHelper } from './helper'; import { columnHelper } from './helper';
@ -16,9 +16,7 @@ export const availability = columnHelper.accessor('Availability', {
sortingFn: 'basic', sortingFn: 'basic',
}); });
function Cell({ function Cell({ getValue }: CellContext<IngressControllerClassMap, boolean>) {
getValue,
}: CellContext<IngressControllerClassMapRowData, boolean>) {
const availability = getValue(); const availability = getValue();
return ( return (

View file

@ -1,6 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table'; import { createColumnHelper } from '@tanstack/react-table';
import { IngressControllerClassMapRowData } from '../../types'; import { IngressControllerClassMap } from '../../types';
export const columnHelper = export const columnHelper = createColumnHelper<IngressControllerClassMap>();
createColumnHelper<IngressControllerClassMapRowData>();

View file

@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table';
import { Badge } from '@@/Badge'; import { Badge } from '@@/Badge';
import type { IngressControllerClassMapRowData } from '../../types'; import type { IngressControllerClassMap } from '../../types';
import { columnHelper } from './helper'; import { columnHelper } from './helper';
@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', {
function NameCell({ function NameCell({
row, row,
getValue, getValue,
}: CellContext<IngressControllerClassMapRowData, string>) { }: CellContext<IngressControllerClassMap, string>) {
const className = getValue(); const className = getValue();
return ( return (

View file

@ -0,0 +1,37 @@
import { IngressControllerClassMap } from '../types';
export function getIngressClassesFormValues(
allowNoneIngressClass: boolean,
ingressClasses?: IngressControllerClassMap[]
) {
const ingressClassesFormValues = ingressClasses ? [...ingressClasses] : [];
const noneIngressClassIndex = ingressClassesFormValues.findIndex(
(ingressClass) =>
ingressClass.Name === 'none' &&
ingressClass.ClassName === 'none' &&
ingressClass.Type === 'custom'
);
// add the none ingress class if it doesn't exist
if (allowNoneIngressClass && noneIngressClassIndex === -1) {
return [
...ingressClassesFormValues,
{
Name: 'none',
ClassName: 'none',
Type: 'custom',
Availability: true,
New: false,
Used: false,
},
];
}
// remove the none ingress class if it exists
if (!allowNoneIngressClass && noneIngressClassIndex > -1) {
return [
...ingressClassesFormValues.slice(0, noneIngressClassIndex),
...ingressClassesFormValues.slice(noneIngressClassIndex + 1),
];
}
// otherwise return the ingress classes as is
return ingressClassesFormValues;
}

View file

@ -4,7 +4,6 @@ export type SupportedIngControllerTypes =
| 'other' | 'other'
| 'custom'; | 'custom';
// Not having 'extends Record<string, unknown>' fixes validation type errors from yup
export interface IngressControllerClassMap { export interface IngressControllerClassMap {
Name: string; Name: string;
ClassName: string; ClassName: string;
@ -13,8 +12,3 @@ export interface IngressControllerClassMap {
New: boolean; New: boolean;
Used: boolean; // if the controller is used by any ingress in the cluster Used: boolean; // if the controller is used by any ingress in the cluster
} }
// Record<string, unknown> fixes type errors when using the type with a react datatable
export interface IngressControllerClassMapRowData
extends Record<string, unknown>,
IngressControllerClassMap {}

View file

@ -5,7 +5,7 @@ import PortainerError from '@/portainer/error';
import axios from '@/portainer/services/axios'; import axios from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query'; import { withError } from '@/react-tools/react-query';
import { IngressControllerClassMapRowData } from './types'; import { IngressControllerClassMap } from './types';
export function useIngressControllerClassMapQuery({ export function useIngressControllerClassMapQuery({
environmentId, environmentId,
@ -54,7 +54,7 @@ export async function getIngressControllerClassMap({
}) { }) {
try { try {
const { data: controllerMaps } = await axios.get< const { data: controllerMaps } = await axios.get<
IngressControllerClassMapRowData[] IngressControllerClassMap[]
>( >(
buildUrl(environmentId, namespace), buildUrl(environmentId, namespace),
allowedOnly ? { params: { allowedOnly: true } } : undefined allowedOnly ? { params: { allowedOnly: true } } : undefined
@ -68,12 +68,12 @@ export async function getIngressControllerClassMap({
// get all supported ingress classes and controllers for the cluster // get all supported ingress classes and controllers for the cluster
export async function updateIngressControllerClassMap( export async function updateIngressControllerClassMap(
environmentId: EnvironmentId, environmentId: EnvironmentId,
ingressControllerClassMap: IngressControllerClassMapRowData[], ingressControllerClassMap: IngressControllerClassMap[],
namespace?: string namespace?: string
) { ) {
try { try {
const { data: controllerMaps } = await axios.put< const { data: controllerMaps } = await axios.put<
IngressControllerClassMapRowData[] IngressControllerClassMap[]
>(buildUrl(environmentId, namespace), ingressControllerClassMap); >(buildUrl(environmentId, namespace), ingressControllerClassMap);
return controllerMaps; return controllerMaps;
} catch (e) { } catch (e) {

View file

@ -4,7 +4,6 @@ import { ConfigMap } from 'kubernetes-types/core/v1';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries'; import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
import { Application } from '@/react/kubernetes/applications/types'; import { Application } from '@/react/kubernetes/applications/types';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
import { Datatable, TableSettingsMenu } from '@@/datatables'; import { Datatable, TableSettingsMenu } from '@@/datatables';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
@ -40,7 +40,7 @@ export function ConfigMapsDatatable() {
); );
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const { data: namespaces, ...namespacesQuery } = useNamespaces( const { data: namespaces, ...namespacesQuery } = useNamespacesQuery(
environmentId, environmentId,
{ {
autoRefreshRate: tableState.autoRefreshRate * 1000, autoRefreshRate: tableState.autoRefreshRate * 1000,

View file

@ -4,7 +4,6 @@ import { Secret } from 'kubernetes-types/core/v1';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { Authorized, useAuthorizations } from '@/react/hooks/useUser'; import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store'; import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries'; import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
import { Application } from '@/react/kubernetes/applications/types'; import { Application } from '@/react/kubernetes/applications/types';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
import { Datatable, TableSettingsMenu } from '@@/datatables'; import { Datatable, TableSettingsMenu } from '@@/datatables';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
@ -40,7 +40,7 @@ export function SecretsDatatable() {
); );
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const { data: namespaces, ...namespacesQuery } = useNamespaces( const { data: namespaces, ...namespacesQuery } = useNamespacesQuery(
environmentId, environmentId,
{ {
autoRefreshRate: tableState.autoRefreshRate * 1000, autoRefreshRate: tableState.autoRefreshRate * 1000,

View file

@ -8,20 +8,21 @@ import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
import { DashboardItem } from '@@/DashboardItem/DashboardItem'; import { DashboardItem } from '@@/DashboardItem/DashboardItem';
import { PageHeader } from '@@/PageHeader'; import { PageHeader } from '@@/PageHeader';
import { useNamespaces } from '../namespaces/queries';
import { useApplicationsForCluster } from '../applications/application.queries'; import { useApplicationsForCluster } from '../applications/application.queries';
import { usePVCsForCluster } from '../volumes/queries'; import { usePVCsForCluster } from '../volumes/queries';
import { useServicesForCluster } from '../services/service'; import { useServicesForCluster } from '../services/service';
import { useIngresses } from '../ingresses/queries'; import { useIngresses } from '../ingresses/queries';
import { useConfigMapsForCluster } from '../configs/configmap.service'; import { useConfigMapsForCluster } from '../configs/configmap.service';
import { useSecretsForCluster } from '../configs/secret.service'; import { useSecretsForCluster } from '../configs/secret.service';
import { useNamespacesQuery } from '../namespaces/queries/useNamespacesQuery';
import { EnvironmentInfo } from './EnvironmentInfo'; import { EnvironmentInfo } from './EnvironmentInfo';
export function DashboardView() { export function DashboardView() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); const { data: namespaces, ...namespacesQuery } =
useNamespacesQuery(environmentId);
const namespaceNames = namespaces && Object.keys(namespaces); const namespaceNames = namespaces && Object.keys(namespaces);
const { data: applications, ...applicationsQuery } = const { data: applications, ...applicationsQuery } =
useApplicationsForCluster(environmentId, namespaceNames); useApplicationsForCluster(environmentId, namespaceNames);

View file

@ -1,5 +0,0 @@
export interface Annotation {
Key: string;
Value: string;
ID: string;
}

View file

@ -1,11 +1,10 @@
import { useState, useEffect, useMemo, ReactNode, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback, ReactNode } from 'react';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react'; import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useConfigurations } from '@/react/kubernetes/configs/queries'; import { useConfigurations } from '@/react/kubernetes/configs/queries';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries'; import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { useAuthorizations } from '@/react/hooks/useUser'; import { useAuthorizations } from '@/react/hooks/useUser';
@ -15,6 +14,7 @@ import { PageHeader } from '@@/PageHeader';
import { Option } from '@@/form-components/Input/Select'; import { Option } from '@@/form-components/Input/Select';
import { Button } from '@@/buttons'; import { Button } from '@@/buttons';
import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery';
import { Ingress, IngressController } from '../types'; import { Ingress, IngressController } from '../types';
import { import {
useCreateIngress, useCreateIngress,
@ -22,8 +22,15 @@ import {
useUpdateIngress, useUpdateIngress,
useIngressControllers, useIngressControllers,
} from '../queries'; } from '../queries';
import { Annotation } from '../../annotations/types';
import { Rule, Path, Host, GroupedServiceOptions } from './types'; import {
Rule,
Path,
Host,
GroupedServiceOptions,
IngressErrors,
} from './types';
import { IngressForm } from './IngressForm'; import { IngressForm } from './IngressForm';
import { import {
prepareTLS, prepareTLS,
@ -32,7 +39,6 @@ import {
prepareRuleFromIngress, prepareRuleFromIngress,
checkIfPathExistsWithHost, checkIfPathExistsWithHost,
} from './utils'; } from './utils';
import { Annotation } from './Annotations/types';
export function CreateIngressView() { export function CreateIngressView() {
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
@ -56,11 +62,10 @@ export function CreateIngressView() {
// isEditClassNameSet is used to prevent premature validation of the classname in the edit view // isEditClassNameSet is used to prevent premature validation of the classname in the edit view
const [isEditClassNameSet, setIsEditClassNameSet] = useState<boolean>(false); const [isEditClassNameSet, setIsEditClassNameSet] = useState<boolean>(false);
const [errors, setErrors] = useState<Record<string, ReactNode>>( const [errors, setErrors] = useState<IngressErrors>({});
{} as Record<string, string>
);
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); const { data: namespaces, ...namespacesQuery } =
useNamespacesQuery(environmentId);
const { data: allServices } = useNamespaceServices(environmentId, namespace); const { data: allServices } = useNamespaceServices(environmentId, namespace);
const configResults = useConfigurations(environmentId, namespace); const configResults = useConfigurations(environmentId, namespace);

View file

@ -1,4 +1,4 @@
import { ChangeEvent, ReactNode, useEffect } from 'react'; import { ChangeEvent, useEffect } from 'react';
import { Plus, RefreshCw, Trash2 } from 'lucide-react'; import { Plus, RefreshCw, Trash2 } from 'lucide-react';
import Route from '@/assets/ico/route.svg?c'; import Route from '@/assets/ico/route.svg?c';
@ -16,8 +16,14 @@ import { InputGroup } from '@@/form-components/InputGroup';
import { InlineLoader } from '@@/InlineLoader'; import { InlineLoader } from '@@/InlineLoader';
import { Select } from '@@/form-components/ReactSelect'; import { Select } from '@@/form-components/ReactSelect';
import { Annotations } from './Annotations'; import { AnnotationsForm } from '../../annotations/AnnotationsForm';
import { GroupedServiceOptions, Rule, ServicePorts } from './types';
import {
GroupedServiceOptions,
IngressErrors,
Rule,
ServicePorts,
} from './types';
import '../style.css'; import '../style.css';
@ -36,7 +42,7 @@ interface Props {
environmentID: number; environmentID: number;
rule: Rule; rule: Rule;
errors: Record<string, ReactNode>; errors: IngressErrors;
isEdit: boolean; isEdit: boolean;
namespace: string; namespace: string;
@ -298,12 +304,12 @@ export function IngressForm({
</div> </div>
{rule?.Annotations && ( {rule?.Annotations && (
<Annotations <AnnotationsForm
placeholder={placeholderAnnotation} placeholder={placeholderAnnotation}
annotations={rule.Annotations} annotations={rule.Annotations}
handleAnnotationChange={handleAnnotationChange} handleAnnotationChange={handleAnnotationChange}
removeAnnotation={removeAnnotation} removeAnnotation={removeAnnotation}
errors={errors} errors={errors.annotations}
/> />
)} )}

View file

@ -1,6 +1,8 @@
import { ReactNode } from 'react';
import { Option } from '@@/form-components/Input/Select'; import { Option } from '@@/form-components/Input/Select';
import { Annotation } from './Annotations/types'; import { Annotation, AnnotationErrors } from '../../annotations/types';
export interface Path { export interface Path {
Key: string; Key: string;
@ -40,3 +42,7 @@ export type GroupedServiceOptions = {
label: string; label: string;
options: ServiceOption[]; options: ServiceOption[];
}[]; }[];
export type IngressErrors = Record<string, ReactNode> & {
annotations?: AnnotationErrors;
};

View file

@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types'; import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
import { TLS, Ingress } from '../types'; import { TLS, Ingress } from '../types';
import { Annotation } from '../../annotations/types';
import { Annotation } from './Annotations/types';
import { Host, Rule } from './types'; import { Host, Rule } from './types';
const ignoreAnnotationsForEdit = [ const ignoreAnnotationsForEdit = [

View file

@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId'; import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { useAuthorizations, Authorized } from '@/react/hooks/useUser'; import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
import Route from '@/assets/ico/route.svg?c'; import Route from '@/assets/ico/route.svg?c';
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
@ -19,6 +18,7 @@ import { useTableState } from '@@/datatables/useTableState';
import { DeleteIngressesRequest, Ingress } from '../types'; import { DeleteIngressesRequest, Ingress } from '../types';
import { useDeleteIngresses, useIngresses } from '../queries'; import { useDeleteIngresses, useIngresses } from '../queries';
import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery';
import { columns } from './columns'; import { columns } from './columns';
@ -39,7 +39,8 @@ export function IngressDatatable() {
const canAccessSystemResources = useAuthorizations( const canAccessSystemResources = useAuthorizations(
'K8sAccessSystemNamespaces' 'K8sAccessSystemNamespaces'
); );
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); const { data: namespaces, ...namespacesQuery } =
useNamespacesQuery(environmentId);
const { data: ingresses, ...ingressesQuery } = useIngresses( const { data: ingresses, ...ingressesQuery } = useIngresses(
environmentId, environmentId,
Object.keys(namespaces || {}), Object.keys(namespaces || {}),

View file

@ -181,7 +181,8 @@ export function useDeleteIngresses() {
*/ */
export function useIngressControllers( export function useIngressControllers(
environmentId: EnvironmentId, environmentId: EnvironmentId,
namespace?: string namespace?: string,
allowedOnly?: boolean
) { ) {
return useQuery( return useQuery(
[ [
@ -193,7 +194,9 @@ export function useIngressControllers(
'ingresscontrollers', 'ingresscontrollers',
], ],
async () => async () =>
namespace ? getIngressControllers(environmentId, namespace) : [], namespace
? getIngressControllers(environmentId, namespace, allowedOnly)
: [],
{ {
enabled: !!namespace, enabled: !!namespace,
...withError('Unable to get ingress controllers'), ...withError('Unable to get ingress controllers'),

View file

@ -34,11 +34,13 @@ export async function getIngresses(
export async function getIngressControllers( export async function getIngressControllers(
environmentId: EnvironmentId, environmentId: EnvironmentId,
namespace: string namespace: string,
allowedOnly?: boolean
) { ) {
try { try {
const { data: ingresscontrollers } = await axios.get<IngressController[]>( const { data: ingresscontrollers } = await axios.get<IngressController[]>(
`kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers` `kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`,
allowedOnly ? { params: { allowedOnly: true } } : undefined
); );
return ingresscontrollers; return ingresscontrollers;
} catch (e) { } catch (e) {

View file

@ -0,0 +1,119 @@
import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { notifySuccess } from '@/portainer/services/notifications';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { Widget, WidgetBody } from '@@/Widget';
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
import { NamespaceInnerForm } from '../components/NamespaceInnerForm';
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
import {
CreateNamespaceFormValues,
CreateNamespacePayload,
UpdateRegistryPayload,
} from './types';
import { useClusterResourceLimitsQuery } from './queries/useResourceLimitsQuery';
import { getNamespaceValidationSchema } from './CreateNamespaceForm.validation';
import { transformFormValuesToNamespacePayload } from './utils';
import { useCreateNamespaceMutation } from './queries/useCreateNamespaceMutation';
export function CreateNamespaceForm() {
const router = useRouter();
const environmentId = useEnvironmentId();
const { data: environment, ...environmentQuery } = useCurrentEnvironment();
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
const { data: registries } = useEnvironmentRegistries(environmentId, {
hideDefault: true,
});
// for namespace create, show ingress classes that are allowed in the current environment.
// the ingressClasses show the none option, so we don't need to add it here.
const { data: ingressClasses } = useIngressControllerClassMapQuery({
environmentId,
allowedOnly: true,
});
const { data: namespaces } = useNamespacesQuery(environmentId);
const namespaceNames = Object.keys(namespaces || {});
const createNamespaceMutation = useCreateNamespaceMutation(environmentId);
if (resourceLimitsQuery.isLoading || environmentQuery.isLoading) {
return null;
}
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
const initialValues: CreateNamespaceFormValues = {
name: '',
ingressClasses: ingressClasses ?? [],
resourceQuota: {
enabled: false,
memory: '0',
cpu: '0',
},
registries: [],
};
return (
<Widget>
<WidgetBody>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={handleSubmit}
validateOnMount
validationSchema={getNamespaceValidationSchema(
memoryLimit,
namespaceNames
)}
>
{NamespaceInnerForm}
</Formik>
</WidgetBody>
</Widget>
);
function handleSubmit(values: CreateNamespaceFormValues) {
const createNamespacePayload: CreateNamespacePayload =
transformFormValuesToNamespacePayload(values);
const updateRegistriesPayload: UpdateRegistryPayload[] =
values.registries.flatMap((registryFormValues) => {
// find the matching registry from the cluster registries
const selectedRegistry = registries?.find(
(registry) => registryFormValues.Id === registry.Id
);
if (!selectedRegistry) {
return [];
}
const envNamespacesWithAccess =
selectedRegistry.RegistryAccesses[`${environmentId}`]?.Namespaces ||
[];
return {
Id: selectedRegistry.Id,
Namespaces: [...envNamespacesWithAccess, values.name],
};
});
createNamespaceMutation.mutate(
{
createNamespacePayload,
updateRegistriesPayload,
namespaceIngressControllerPayload: values.ingressClasses,
},
{
onSuccess: () => {
notifySuccess(
'Success',
`Namespace '${values.name}' created successfully`
);
router.stateService.go('kubernetes.resourcePools');
},
}
);
}
}

View file

@ -0,0 +1,27 @@
import { string, object, array, SchemaOf } from 'yup';
import { registriesValidationSchema } from '../components/RegistriesFormSection/registriesValidationSchema';
import { getResourceQuotaValidationSchema } from '../components/ResourceQuotaFormSection/getResourceQuotaValidationSchema';
import { CreateNamespaceFormValues } from './types';
export function getNamespaceValidationSchema(
memoryLimit: number,
namespaceNames: string[]
): SchemaOf<CreateNamespaceFormValues> {
return object({
name: string()
.matches(
/^[a-z0-9](?:[-a-z0-9]{0,251}[a-z0-9])?$/,
"This field must consist of lower case alphanumeric characters or '-', and contain at most 63 characters, and must start and end with an alphanumeric character."
)
.max(63, 'Name must be at most 63 characters.')
// must not have the same name as an existing namespace
.notOneOf(namespaceNames, 'Name must be unique.')
.required('Name is required.'),
resourceQuota: getResourceQuotaValidationSchema(memoryLimit),
// ingress classes table is constrained already, and doesn't need validation
ingressClasses: array(),
registries: registriesValidationSchema,
});
}

View file

@ -0,0 +1,41 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
import { PageHeader } from '@@/PageHeader';
import { CreateNamespaceForm } from './CreateNamespaceForm';
export function CreateNamespaceView() {
const environmentId = useEnvironmentId();
useUnauthorizedRedirect(
{
authorizations: 'K8sResourcePoolsW',
forceEnvironmentId: environmentId,
adminOnlyCE: !isBE,
},
{
to: 'kubernetes.resourcePools',
params: {
id: environmentId,
},
}
);
return (
<div className="form-horizontal">
<PageHeader
title="Create a namespace"
breadcrumbs="Create a namespace"
reload
/>
<div className="row">
<div className="col-sm-12">
<CreateNamespaceForm />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export { CreateNamespaceView } from './CreateNamespaceView';

View file

@ -0,0 +1,83 @@
import { useMutation } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { withError } from '@/react-tools/react-query';
import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { IngressControllerClassMap } from '../../../cluster/ingressClass/types';
import { updateIngressControllerClassMap } from '../../../cluster/ingressClass/useIngressControllerClassMap';
import { Namespaces } from '../../types';
import { CreateNamespacePayload, UpdateRegistryPayload } from '../types';
export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
return useMutation(
async ({
createNamespacePayload,
updateRegistriesPayload,
namespaceIngressControllerPayload,
}: {
createNamespacePayload: CreateNamespacePayload;
updateRegistriesPayload: UpdateRegistryPayload[];
namespaceIngressControllerPayload: IngressControllerClassMap[];
}) => {
try {
// create the namespace first, so that it exists before referencing it in the registry access request
await createNamespace(environmentId, createNamespacePayload);
} catch (e) {
throw new Error(e as string);
}
// collect promises
const updateRegistriesPromises = updateRegistriesPayload.map(
({ Id, Namespaces }) =>
updateEnvironmentRegistryAccess(environmentId, Id, {
Namespaces,
})
);
const updateIngressControllerPromise =
namespaceIngressControllerPayload.length > 0
? updateIngressControllerClassMap(
environmentId,
namespaceIngressControllerPayload,
createNamespacePayload.Name
)
: Promise.resolve();
// return combined promises
return Promise.allSettled([
updateIngressControllerPromise,
...updateRegistriesPromises,
]);
},
{
...withError('Unable to create namespace'),
}
);
}
// createNamespace is used to create a namespace using the Portainer backend
async function createNamespace(
environmentId: EnvironmentId,
payload: CreateNamespacePayload
) {
try {
const { data: ns } = await axios.post<Namespaces>(
buildUrl(environmentId),
payload
);
return ns;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create namespace');
}
}
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
let url = `kubernetes/${environmentId}/namespaces`;
if (namespace) {
url += `/${namespace}`;
}
return url;
}

View file

@ -0,0 +1,37 @@
import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { notifyError } from '@/portainer/services/notifications';
import axios, { parseAxiosError } from '@/portainer/services/axios';
type K8sNodeLimits = {
CPU: number;
Memory: number;
};
/**
* useClusterResourceLimitsQuery is used to retrieve the total resource limits for a cluster, minus the allocated resources taken by existing namespaces
* @returns the available resource limits for the cluster
* */
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
return useQuery(
['environments', environmentId, 'kubernetes', 'max_resource_limits'],
() => getResourceLimits(environmentId),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get resource limits');
},
}
);
}
async function getResourceLimits(environmentId: EnvironmentId) {
try {
const { data: limits } = await axios.get<K8sNodeLimits>(
`/kubernetes/${environmentId}/max_resource_limits`
);
return limits;
} catch (e) {
throw parseAxiosError(e, 'Unable to retrieve resource limits');
}
}

View file

@ -0,0 +1,24 @@
import { Registry } from '@/react/portainer/registries/types';
import { IngressControllerClassMap } from '../../cluster/ingressClass/types';
import {
ResourceQuotaFormValues,
ResourceQuotaPayload,
} from '../components/ResourceQuotaFormSection/types';
export type CreateNamespaceFormValues = {
name: string;
resourceQuota: ResourceQuotaFormValues;
ingressClasses: IngressControllerClassMap[];
registries: Registry[];
};
export type CreateNamespacePayload = {
Name: string;
ResourceQuota: ResourceQuotaPayload;
};
export type UpdateRegistryPayload = {
Id: number;
Namespaces: string[];
};

View file

@ -0,0 +1,16 @@
import { CreateNamespaceFormValues, CreateNamespacePayload } from './types';
export function transformFormValuesToNamespacePayload(
createNamespaceFormValues: CreateNamespaceFormValues
): CreateNamespacePayload {
const memoryInBytes =
Number(createNamespaceFormValues.resourceQuota.memory) * 10 ** 6;
return {
Name: createNamespaceFormValues.name,
ResourceQuota: {
enabled: createNamespaceFormValues.resourceQuota.enabled,
cpu: createNamespaceFormValues.resourceQuota.cpu,
memory: `${memoryInBytes}`,
},
};
}

View file

@ -0,0 +1,26 @@
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { FormSection } from '@@/form-components/FormSection';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
export function LoadBalancerFormSection() {
return (
<FormSection title="Load balancers">
<TextTip color="blue">
You can set a quota on the number of external load balancers that can be
created inside this namespace. Set this quota to 0 to effectively
disable the use of load balancers in this namespace.
</TextTip>
<SwitchField
dataCy="k8sNamespaceCreate-loadBalancerQuotaToggle"
label="Load balancer quota"
labelClass="col-sm-3 col-lg-2"
fieldClass="pt-2"
checked={false}
featureId={FeatureId.K8S_RESOURCE_POOL_LB_QUOTA}
onChange={() => {}}
/>
</FormSection>
);
}

View file

@ -0,0 +1 @@
export { LoadBalancerFormSection } from './LoadBalancerFormSection';

View file

@ -0,0 +1,119 @@
import { Field, Form, FormikProps } from 'formik';
import { MultiValue } from 'react-select';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { Registry } from '@/react/portainer/registries/types';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { Input } from '@@/form-components/Input';
import { FormActions } from '@@/form-components/FormActions';
import { IngressClassDatatable } from '../../cluster/ingressClass/IngressClassDatatable';
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
import { CreateNamespaceFormValues } from '../CreateView/types';
import { AnnotationsBeTeaser } from '../../annotations/AnnotationsBeTeaser';
import { LoadBalancerFormSection } from './LoadBalancerFormSection';
import { NamespaceSummary } from './NamespaceSummary';
import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection';
import { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
import { RegistriesFormSection } from './RegistriesFormSection';
import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types';
export function NamespaceInnerForm({
errors,
isValid,
setFieldValue,
values,
isSubmitting,
initialValues,
}: FormikProps<CreateNamespaceFormValues>) {
const environmentId = useEnvironmentId();
const environmentQuery = useCurrentEnvironment();
const ingressClassesQuery = useIngressControllerClassMapQuery({
environmentId,
allowedOnly: true,
});
if (environmentQuery.isLoading) {
return null;
}
const useLoadBalancer =
environmentQuery.data?.Kubernetes.Configuration.UseLoadBalancer;
const enableResourceOverCommit =
environmentQuery.data?.Kubernetes.Configuration.EnableResourceOverCommit;
const enableIngressControllersPerNamespace =
environmentQuery.data?.Kubernetes.Configuration
.IngressAvailabilityPerNamespace;
const storageClasses =
environmentQuery.data?.Kubernetes.Configuration.StorageClasses ?? [];
return (
<Form>
<FormControl
inputId="namespace"
label="Name"
required
errors={errors.name}
>
<Field
as={Input}
id="namespace"
name="name"
placeholder="e.g. my-namespace"
data-cy="k8sNamespaceCreate-namespaceNameInput"
/>
</FormControl>
<AnnotationsBeTeaser />
<ResourceQuotaFormSection
enableResourceOverCommit={enableResourceOverCommit}
values={values.resourceQuota}
onChange={(resourceQuota: ResourceQuotaFormValues) =>
setFieldValue('resourceQuota', resourceQuota)
}
errors={errors.resourceQuota}
/>
{useLoadBalancer && <LoadBalancerFormSection />}
{enableIngressControllersPerNamespace && (
<FormSection title="Networking">
<IngressClassDatatable
onChange={(classes) => setFieldValue('ingressClasses', classes)}
values={values.ingressClasses}
description="Enable the ingress controllers that users can select when publishing applications in this namespace."
noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster."
view="namespace"
isLoading={ingressClassesQuery.isLoading}
initialValues={initialValues.ingressClasses}
/>
</FormSection>
)}
<RegistriesFormSection
values={values.registries}
onChange={(registries: MultiValue<Registry>) =>
setFieldValue('registries', registries)
}
errors={errors.registries}
/>
{storageClasses.length > 0 && (
<StorageQuotaFormSection storageClasses={storageClasses} />
)}
<NamespaceSummary
initialValues={initialValues}
values={values}
isValid={isValid}
/>
<FormSection title="Actions">
<FormActions
submitLabel="Create namespace"
loadingText="Creating namespace"
isLoading={isSubmitting}
isValid={isValid}
data-cy="k8sNamespaceCreate-submitButton"
/>
</FormSection>
</Form>
);
}

View file

@ -0,0 +1,46 @@
import _ from 'lodash';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { CreateNamespaceFormValues } from '../CreateView/types';
interface Props {
initialValues: CreateNamespaceFormValues;
values: CreateNamespaceFormValues;
isValid: boolean;
}
export function NamespaceSummary({ initialValues, values, isValid }: Props) {
const hasChanges = !_.isEqual(values, initialValues);
if (!hasChanges || !isValid) {
return null;
}
return (
<FormSection title="Summary" isFoldable defaultFolded={false}>
<div className="form-group">
<div className="col-sm-12">
<TextTip color="blue">
Portainer will execute the following Kubernetes actions.
</TextTip>
</div>
</div>
<div className="col-sm-12 small text-muted pt-1">
<ul>
<li>
Create a <span className="bold">Namespace</span> named{' '}
<code>{values.name}</code>
</li>
{values.resourceQuota.enabled && (
<li>
Create a <span className="bold">ResourceQuota</span> named{' '}
<code>portainer-rq-{values.name}</code>
</li>
)}
</ul>
</div>
</FormSection>
);
}

View file

@ -0,0 +1,47 @@
import { FormikErrors } from 'formik';
import { MultiValue } from 'react-select';
import { Registry } from '@/react/portainer/registries/types';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { InlineLoader } from '@@/InlineLoader';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
import { RegistriesSelector } from './RegistriesSelector';
type Props = {
values: MultiValue<Registry>;
onChange: (value: MultiValue<Registry>) => void;
errors?: string | string[] | FormikErrors<Registry>[];
};
export function RegistriesFormSection({ values, onChange, errors }: Props) {
const environmentId = useEnvironmentId();
const registriesQuery = useEnvironmentRegistries(environmentId, {
hideDefault: true,
});
return (
<FormSection title="Registries">
<FormControl
inputId="registries"
label="Select registries"
required
errors={errors}
>
{registriesQuery.isLoading && (
<InlineLoader>Loading registries...</InlineLoader>
)}
{registriesQuery.data && (
<RegistriesSelector
value={values}
onChange={(registries) => onChange(registries)}
options={registriesQuery.data}
inputId="registries"
/>
)}
</FormControl>
</FormSection>
);
}

View file

@ -1,15 +1,17 @@
import { MultiValue } from 'react-select';
import { Registry } from '@/react/portainer/registries/types'; import { Registry } from '@/react/portainer/registries/types';
import { Select } from '@@/form-components/ReactSelect'; import { Select } from '@@/form-components/ReactSelect';
interface Props { interface Props {
value: Registry[]; value: MultiValue<Registry>;
onChange(value: readonly Registry[]): void; onChange(value: MultiValue<Registry>): void;
options: Registry[]; options: Registry[];
inputId?: string; inputId?: string;
} }
export function CreateNamespaceRegistriesSelector({ export function RegistriesSelector({
value, value,
onChange, onChange,
options, options,
@ -26,7 +28,7 @@ export function CreateNamespaceRegistriesSelector({
onChange={onChange} onChange={onChange}
inputId={inputId} inputId={inputId}
data-cy="namespaceCreate-registrySelect" data-cy="namespaceCreate-registrySelect"
placeholder="Select one or more registry" placeholder="Select one or more registries"
/> />
); );
} }

View file

@ -0,0 +1 @@
export { RegistriesFormSection } from './RegistriesFormSection';

View file

@ -0,0 +1,10 @@
import { SchemaOf, array, object, number, string } from 'yup';
import { Registry } from '@/react/portainer/registries/types';
export const registriesValidationSchema: SchemaOf<Registry[]> = array(
object({
Id: number().required('Registry ID is required.'),
Name: string().required('Registry name is required.'),
})
);

View file

@ -0,0 +1,121 @@
import { FormikErrors } from 'formik';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { FormControl } from '@@/form-components/FormControl';
import { FormError } from '@@/form-components/FormError';
import { FormSection } from '@@/form-components/FormSection';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { Slider } from '@@/form-components/Slider';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
import { useClusterResourceLimitsQuery } from '../../CreateView/queries/useResourceLimitsQuery';
import { ResourceQuotaFormValues } from './types';
interface Props {
values: ResourceQuotaFormValues;
onChange: (value: ResourceQuotaFormValues) => void;
enableResourceOverCommit?: boolean;
errors?: FormikErrors<ResourceQuotaFormValues>;
}
export function ResourceQuotaFormSection({
values,
onChange,
errors,
enableResourceOverCommit,
}: Props) {
const environmentId = useEnvironmentId();
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
return (
<FormSection title="Resource Quota">
{values.enabled ? (
<TextTip color="blue">
A namespace is a logical abstraction of a Kubernetes cluster, to
provide for more flexible management of resources. Best practice is to
set a quota assignment as this ensures greatest security/stability;
alternatively, you can disable assigning a quota for unrestricted
access (not recommended).
</TextTip>
) : (
<TextTip color="blue">
A namespace is a logical abstraction of a Kubernetes cluster, to
provide for more flexible management of resources. Resource
over-commit is disabled, please assign a capped limit of resources to
this namespace.
</TextTip>
)}
<SwitchField
data-cy="k8sNamespaceCreate-resourceAssignmentToggle"
disabled={enableResourceOverCommit}
label="Resource assignment"
labelClass="col-sm-3 col-lg-2"
fieldClass="pt-2"
checked={values.enabled || !!enableResourceOverCommit}
onChange={(enabled) => onChange({ ...values, enabled })}
/>
{(values.enabled || !!enableResourceOverCommit) && (
<div className="pt-5">
<div className="flex flex-row">
<FormSectionTitle>Resource Limits</FormSectionTitle>
</div>
{/* keep the FormError component present, but invisible to avoid layout shift */}
<FormError
className={typeof errors === 'string' ? 'visible' : 'invisible'}
>
{/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */}
{errors || 'error'}
</FormError>
<FormControl
className="flex flex-row"
label="Memory limit (MB)"
inputId="memory-limit"
>
<div className="col-xs-8">
<SliderWithInput
value={Number(values.memory) ?? 0}
onChange={(value) =>
onChange({ ...values, memory: `${value}` })
}
max={memoryLimit}
step={128}
dataCy="k8sNamespaceCreate-memoryLimit"
visibleTooltip
/>
{errors?.memory && (
<FormError className="pt-1">{errors.memory}</FormError>
)}
</div>
</FormControl>
<FormControl className="flex flex-row" label="CPU limit">
<div className="col-xs-8">
<Slider
min={0}
max={cpuLimit / 1000}
step={0.1}
value={Number(values.cpu) ?? 0}
onChange={(cpu) => {
if (Array.isArray(cpu)) {
return;
}
onChange({ ...values, cpu: cpu.toString() });
}}
dataCy="k8sNamespaceCreate-cpuLimitSlider"
visibleTooltip
/>
</div>
</FormControl>
</div>
)}
</FormSection>
);
}

View file

@ -0,0 +1,45 @@
import { boolean, string, object, SchemaOf, TestContext } from 'yup';
import { ResourceQuotaFormValues } from './types';
export function getResourceQuotaValidationSchema(
memoryLimit: number
): SchemaOf<ResourceQuotaFormValues> {
return object({
enabled: boolean().required('Resource quota enabled status is required.'),
memory: string().test(
'memory-validation',
`Value must be between 0 and ${memoryLimit}.`,
memoryValidation
),
cpu: string().test(
'cpu-validation',
'CPU limit value is required.',
cpuValidation
),
}).test(
'resource-quota-validation',
'At least a single limit must be set.',
oneLimitSet
);
function oneLimitSet({
enabled,
memory,
cpu,
}: Partial<ResourceQuotaFormValues>) {
return !enabled || (Number(memory) ?? 0) > 0 || (Number(cpu) ?? 0) > 0;
}
function memoryValidation(this: TestContext, memoryValue?: string) {
const memory = Number(memoryValue) ?? 0;
const { enabled } = this.parent;
return !enabled || (memory >= 0 && memory <= memoryLimit);
}
function cpuValidation(this: TestContext, cpuValue?: string) {
const cpu = Number(cpuValue) ?? 0;
const { enabled } = this.parent;
return !enabled || cpu >= 0;
}
}

View file

@ -0,0 +1 @@
export { ResourceQuotaFormSection } from './ResourceQuotaFormSection';

View file

@ -0,0 +1,18 @@
/**
* @property enabled - Whether resource quota is enabled
* @property memory - Memory limit in bytes
* @property cpu - CPU limit in cores
* @property loadBalancer - Load balancer limit in number of load balancers
*/
export type ResourceQuotaFormValues = {
enabled: boolean;
memory?: string;
cpu?: string;
};
export type ResourceQuotaPayload = {
enabled: boolean;
memory?: string;
cpu?: string;
loadBalancerLimit?: string;
};

View file

@ -0,0 +1,27 @@
import { StorageClass } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { StorageQuotaItem } from './StorageQuotaItem';
interface Props {
storageClasses: StorageClass[];
}
export function StorageQuotaFormSection({ storageClasses }: Props) {
return (
<FormSection title="Storage">
<TextTip color="blue">
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.
</TextTip>
{storageClasses.map((storageClass) => (
<StorageQuotaItem key={storageClass.Name} storageClass={storageClass} />
))}
</FormSection>
);
}

View file

@ -0,0 +1,40 @@
import { Database } from 'lucide-react';
import { StorageClass } from '@/react/portainer/environments/types';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { Icon } from '@@/Icon';
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
import { SwitchField } from '@@/form-components/SwitchField';
type Props = {
storageClass: StorageClass;
};
export function StorageQuotaItem({ storageClass }: Props) {
return (
<div key={storageClass.Name}>
<FormSectionTitle>
<div className="vertical-center text-muted inline-flex gap-1 align-top">
<Icon icon={Database} className="!mt-0.5 flex-none" />
<span>{storageClass.Name}</span>
</div>
</FormSectionTitle>
<hr className="mt-2 mb-0 w-full" />
<div className="form-group">
<div className="col-sm-12">
<SwitchField
data-cy="k8sNamespaceEdit-storageClassQuota"
disabled={false}
label="Enable quota"
labelClass="col-sm-3 col-lg-2"
fieldClass="pt-2"
checked={false}
onChange={() => {}}
featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
/>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export { StorageQuotaFormSection } from './StorageQuotaFormSection';

View file

@ -0,0 +1,51 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
interface SelfSubjectAccessReviewResponse {
status: {
allowed: boolean;
};
spec: {
resourceAttributes: {
namespace: string;
};
};
}
/**
* getSelfSubjectAccessReview is used to retrieve the self subject access review for a given namespace.
* It's great to use this to determine if a user has access to a namespace.
* @returns the self subject access review for the given namespace
* */
export async function getSelfSubjectAccessReview(
environmentId: EnvironmentId,
namespaceName: string,
verb = 'list',
resource = 'deployments',
group = 'apps'
) {
try {
const { data: accessReview } =
await axios.post<SelfSubjectAccessReviewResponse>(
`endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`,
{
spec: {
resourceAttributes: {
group,
resource,
verb,
namespace: namespaceName,
},
},
apiVersion: 'authorization.k8s.io/v1',
kind: 'SelfSubjectAccessReview',
}
);
return accessReview;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve self subject access review'
);
}
}

View file

@ -0,0 +1,37 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { notifyError } from '@/portainer/services/notifications';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Namespaces } from '../types';
export function useNamespaceQuery(
environmentId: EnvironmentId,
namespace: string
) {
return useQuery(
['environments', environmentId, 'kubernetes', 'namespaces', namespace],
() => getNamespace(environmentId, namespace),
{
onError: (err) => {
notifyError('Failure', err as Error, 'Unable to get namespace.');
},
}
);
}
// getNamespace is used to retrieve a namespace using the Portainer backend
export async function getNamespace(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: ns } = await axios.get<Namespaces>(
`kubernetes/${environmentId}/namespaces/${namespace}`
);
return ns;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve namespace');
}
}

View file

@ -1,17 +1,13 @@
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { EnvironmentId } from '@/react/portainer/environments/types'; import { EnvironmentId } from '@/react/portainer/environments/types';
import { error as notifyError } from '@/portainer/services/notifications';
import { withError } from '@/react-tools/react-query'; import { withError } from '@/react-tools/react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { import { Namespaces } from '../types';
getNamespaces, import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview';
getNamespace,
getSelfSubjectAccessReview,
} from './service';
import { Namespaces } from './types';
export function useNamespaces( export function useNamespacesQuery(
environmentId: EnvironmentId, environmentId: EnvironmentId,
options?: { autoRefreshRate?: number } options?: { autoRefreshRate?: number }
) { ) {
@ -46,14 +42,14 @@ export function useNamespaces(
); );
} }
export function useNamespace(environmentId: EnvironmentId, namespace: string) { // getNamespaces is used to retrieve namespaces using the Portainer backend with caching
return useQuery( async function getNamespaces(environmentId: EnvironmentId) {
['environments', environmentId, 'kubernetes', 'namespaces', namespace], try {
() => getNamespace(environmentId, namespace), const { data: namespaces } = await axios.get<Namespaces>(
{ `kubernetes/${environmentId}/namespaces`
onError: (err) => { );
notifyError('Failure', err as Error, 'Unable to get namespace.'); return namespaces;
}, } catch (e) {
} throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
); }
} }

View file

@ -1,74 +0,0 @@
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Namespaces, SelfSubjectAccessReviewResponse } from './types';
// getNamespace is used to retrieve a namespace using the Portainer backend
export async function getNamespace(
environmentId: EnvironmentId,
namespace: string
) {
try {
const { data: ns } = await axios.get<Namespaces>(
buildUrl(environmentId, namespace)
);
return ns;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve namespace');
}
}
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching
export async function getNamespaces(environmentId: EnvironmentId) {
try {
const { data: namespaces } = await axios.get<Namespaces>(
buildUrl(environmentId)
);
return namespaces;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
}
}
export async function getSelfSubjectAccessReview(
environmentId: EnvironmentId,
namespaceName: string,
verb = 'list',
resource = 'deployments',
group = 'apps'
) {
try {
const { data: accessReview } =
await axios.post<SelfSubjectAccessReviewResponse>(
`endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`,
{
spec: {
resourceAttributes: {
group,
resource,
verb,
namespace: namespaceName,
},
},
apiVersion: 'authorization.k8s.io/v1',
kind: 'SelfSubjectAccessReview',
}
);
return accessReview;
} catch (e) {
throw parseAxiosError(
e as Error,
'Unable to retrieve self subject access review'
);
}
}
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
let url = `kubernetes/${environmentId}/namespaces`;
if (namespace) {
url += `/${namespace}`;
}
return url;
}

View file

@ -4,14 +4,3 @@ export interface Namespaces {
IsSystem: boolean; IsSystem: boolean;
}; };
} }
export interface SelfSubjectAccessReviewResponse {
status: {
allowed: boolean;
};
spec: {
resourceAttributes: {
namespace: string;
};
};
}

View file

@ -9,8 +9,8 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings'; import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils'; import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription'; import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
import { Datatable, Table, TableSettingsMenu } from '@@/datatables'; import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
@ -33,7 +33,8 @@ const settingsStore = createStore(storageKey);
export function ServicesDatatable() { export function ServicesDatatable() {
const tableState = useTableState(settingsStore, storageKey); const tableState = useTableState(settingsStore, storageKey);
const environmentId = useEnvironmentId(); const environmentId = useEnvironmentId();
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId); const { data: namespaces, ...namespacesQuery } =
useNamespacesQuery(environmentId);
const namespaceNames = (namespaces && Object.keys(namespaces)) || []; const namespaceNames = (namespaces && Object.keys(namespaces)) || [];
const { data: services, ...servicesQuery } = useServicesForCluster( const { data: services, ...servicesQuery } = useServicesForCluster(
environmentId, environmentId,

View file

@ -22,12 +22,12 @@ interface RegistryAccess {
} }
export async function updateEnvironmentRegistryAccess( export async function updateEnvironmentRegistryAccess(
id: EnvironmentId, environmentId: EnvironmentId,
registryId: RegistryId, registryId: RegistryId,
access: RegistryAccess access: Partial<RegistryAccess>
) { ) {
try { try {
await axios.put<void>(buildRegistryUrl(id, registryId), access); await axios.put<void>(buildRegistryUrl(environmentId, registryId), access);
} catch (e) { } catch (e) {
throw parseAxiosError(e as Error); throw parseAxiosError(e as Error);
} }

View file

@ -3,13 +3,16 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
import { buildUrl } from '../environment.service/utils'; import { buildUrl } from '../environment.service/utils';
import { EnvironmentId } from '../types'; import { EnvironmentId } from '../types';
import { Registry } from '../../registries/types/registry'; import { Registry } from '../../registries/types/registry';
import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries'; import {
GenericRegistriesQueryOptions,
useGenericRegistriesQuery,
} from '../../registries/queries/useRegistries';
import { environmentQueryKeys } from './query-keys'; import { environmentQueryKeys } from './query-keys';
export function useEnvironmentRegistries<T = Array<Registry>>( export function useEnvironmentRegistries<T = Array<Registry>>(
environmentId: EnvironmentId, environmentId: EnvironmentId,
queryOptions: { select?(data: Array<Registry>): T; enabled?: boolean } = {} queryOptions: GenericRegistriesQueryOptions<T> = {}
) { ) {
return useGenericRegistriesQuery( return useGenericRegistriesQuery(
environmentQueryKeys.registries(environmentId), environmentQueryKeys.registries(environmentId),

View file

@ -50,7 +50,7 @@ export type IngressClass = {
Type: string; Type: string;
}; };
interface StorageClass { export interface StorageClass {
Name: string; Name: string;
AccessModes: string[]; AccessModes: string[];
AllowVolumeExpansion: boolean; AllowVolumeExpansion: boolean;

View file

@ -22,6 +22,16 @@ export function useRegistries<T = Registry[]>(
); );
} }
/**
* @field hideDefault - is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this.
*/
export type GenericRegistriesQueryOptions<T> = {
enabled?: boolean;
select?: (registries: Registry[]) => T;
onSuccess?: (data: T) => void;
hideDefault?: boolean;
};
export function useGenericRegistriesQuery<T = Registry[]>( export function useGenericRegistriesQuery<T = Registry[]>(
queryKey: QueryKey, queryKey: QueryKey,
fetcher: () => Promise<Array<Registry>>, fetcher: () => Promise<Array<Registry>>,
@ -29,18 +39,16 @@ export function useGenericRegistriesQuery<T = Registry[]>(
enabled, enabled,
select, select,
onSuccess, onSuccess,
}: { hideDefault: hideDefaultOverride,
enabled?: boolean; }: GenericRegistriesQueryOptions<T> = {}
select?: (registries: Registry[]) => T;
onSuccess?: (data: T) => void;
} = {}
) { ) {
const hideDefaultRegistryQuery = usePublicSettings({ const hideDefaultRegistryQuery = usePublicSettings({
select: (settings) => settings.DefaultRegistry?.Hide, select: (settings) => settings.DefaultRegistry?.Hide,
enabled, // We don't need the hideDefaultRegistry info if we're overriding it to true
enabled: enabled && !hideDefaultOverride,
}); });
const hideDefault = !!hideDefaultRegistryQuery.data; const hideDefault = hideDefaultOverride || !!hideDefaultRegistryQuery.data;
return useQuery( return useQuery(
queryKey, queryKey,
@ -66,7 +74,8 @@ export function useGenericRegistriesQuery<T = Registry[]>(
{ {
select, select,
...withError('Unable to retrieve registries'), ...withError('Unable to retrieve registries'),
enabled: hideDefaultRegistryQuery.isSuccess && enabled, enabled:
(hideDefaultOverride || hideDefaultRegistryQuery.isSuccess) && enabled,
onSuccess, onSuccess,
} }
); );