mirror of https://github.com/portainer/portainer
feat(namespace): migrate create ns to react [EE-2226] (#10377)
parent
31bcba96c6
commit
7218eb0892
|
@ -53,6 +53,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
|
|||
endpointRouter.Use(h.kubeClient)
|
||||
|
||||
endpointRouter.PathPrefix("/nodes_limits").Handler(httperror.LoggerHandler(h.getKubernetesNodesLimits)).Methods(http.MethodGet)
|
||||
endpointRouter.PathPrefix("/max_resource_limits").Handler(httperror.LoggerHandler(h.getKubernetesMaxResourceLimits)).Methods(http.MethodGet)
|
||||
endpointRouter.Path("/metrics/nodes").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllNodes)).Methods(http.MethodGet)
|
||||
endpointRouter.Path("/metrics/nodes/{name}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForNode)).Methods(http.MethodGet)
|
||||
endpointRouter.Path("/metrics/pods/namespace/{namespace}").Handler(httperror.LoggerHandler(h.getKubernetesMetricsForAllPods)).Methods(http.MethodGet)
|
||||
|
|
|
@ -121,7 +121,7 @@ func (handler *Handler) getKubernetesNamespace(w http.ResponseWriter, r *http.Re
|
|||
// @success 200 {string} string "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @router /kubernetes/{id}/namespaces/{namespace} [post]
|
||||
// @router /kubernetes/{id}/namespaces [post]
|
||||
func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
|
@ -157,6 +157,7 @@ func (handler *Handler) createKubernetesNamespace(w http.ResponseWriter, r *http
|
|||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
@ -51,3 +51,38 @@ func (handler *Handler) getKubernetesNodesLimits(w http.ResponseWriter, r *http.
|
|||
|
||||
return response.JSON(w, nodesLimits)
|
||||
}
|
||||
|
||||
func (handler *Handler) getKubernetesMaxResourceLimits(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest(
|
||||
"Invalid environment identifier route variable",
|
||||
err,
|
||||
)
|
||||
}
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an environment with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an environment with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError(
|
||||
"Failed to lookup KubeClient",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
overCommit := endpoint.Kubernetes.Configuration.EnableResourceOverCommit
|
||||
overCommitPercent := endpoint.Kubernetes.Configuration.ResourceOverCommitPercentage
|
||||
|
||||
// name is set to "" so all namespaces resources are considered when calculating max resource limits
|
||||
resourceLimit, err := cli.GetMaxResourceLimits("", overCommit, overCommitPercent)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve max resource limit", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, resourceLimit)
|
||||
}
|
||||
|
|
|
@ -1,12 +1,36 @@
|
|||
package kubernetes
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
)
|
||||
|
||||
type K8sNamespaceDetails struct {
|
||||
Name string `json:"Name"`
|
||||
Annotations map[string]string `json:"Annotations"`
|
||||
Name string `json:"Name"`
|
||||
Annotations map[string]string `json:"Annotations"`
|
||||
ResourceQuota *K8sResourceQuota `json:"ResourceQuota"`
|
||||
}
|
||||
|
||||
type K8sResourceQuota struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Memory string `json:"memory"`
|
||||
CPU string `json:"cpu"`
|
||||
}
|
||||
|
||||
func (r *K8sNamespaceDetails) Validate(request *http.Request) error {
|
||||
if r.ResourceQuota != nil && r.ResourceQuota.Enabled {
|
||||
_, err := resource.ParseQuantity(r.ResourceQuota.Memory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing memory quota value: %w", err)
|
||||
}
|
||||
|
||||
_, err = resource.ParseQuantity(r.ResourceQuota.CPU)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing cpu quota value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -8,7 +8,9 @@ import (
|
|||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
models "github.com/portainer/portainer/api/http/models/kubernetes"
|
||||
"github.com/rs/zerolog/log"
|
||||
v1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/resource"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
|
@ -61,14 +63,59 @@ func (kcl *KubeClient) GetNamespace(name string) (portainer.K8sNamespaceInfo, er
|
|||
|
||||
// CreateNamespace creates a new ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
|
||||
client := kcl.cli.CoreV1().Namespaces()
|
||||
|
||||
var ns v1.Namespace
|
||||
ns.Name = info.Name
|
||||
ns.Annotations = info.Annotations
|
||||
|
||||
_, err := client.Create(context.Background(), &ns, metav1.CreateOptions{})
|
||||
return err
|
||||
resourceQuota := &v1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
},
|
||||
Spec: v1.ResourceQuotaSpec{
|
||||
Hard: v1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("Namespace", info.Name).
|
||||
Interface("ResourceQuota", resourceQuota).
|
||||
Msg("Failed to create the namespace due to a resource quota issue.")
|
||||
return err
|
||||
}
|
||||
|
||||
if info.ResourceQuota != nil {
|
||||
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
|
||||
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
|
||||
|
||||
if info.ResourceQuota.Enabled {
|
||||
memory := resource.MustParse(info.ResourceQuota.Memory)
|
||||
cpu := resource.MustParse(info.ResourceQuota.CPU)
|
||||
if memory.Value() > 0 {
|
||||
memQuota := memory
|
||||
resourceQuota.Spec.Hard[v1.ResourceLimitsMemory] = memQuota
|
||||
resourceQuota.Spec.Hard[v1.ResourceRequestsMemory] = memQuota
|
||||
}
|
||||
|
||||
if cpu.Value() > 0 {
|
||||
cpuQuota := cpu
|
||||
resourceQuota.Spec.Hard[v1.ResourceLimitsCPU] = cpuQuota
|
||||
resourceQuota.Spec.Hard[v1.ResourceRequestsCPU] = cpuQuota
|
||||
}
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().ResourceQuotas(info.Name).Create(context.Background(), resourceQuota, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
log.Error().Msgf("Failed to create resource quota for namespace %s: %s", info.Name, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSystemNamespace(namespace v1.Namespace) bool {
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/rs/zerolog/log"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
|
@ -42,3 +43,62 @@ func (kcl *KubeClient) GetNodesLimits() (portainer.K8sNodesLimits, error) {
|
|||
|
||||
return nodesLimits, nil
|
||||
}
|
||||
|
||||
// GetMaxResourceLimits gets the maximum CPU and Memory limits(unused resources) of all nodes in the current k8s environment(endpoint) connection, minus the accumulated resourcequotas for all namespaces except the one we're editing (skipNamespace)
|
||||
// if skipNamespace is set to "" then all namespaces are considered
|
||||
func (client *KubeClient) GetMaxResourceLimits(skipNamespace string, overCommitEnabled bool, resourceOverCommitPercent int) (portainer.K8sNodeLimits, error) {
|
||||
limits := portainer.K8sNodeLimits{}
|
||||
nodes, err := client.cli.CoreV1().Nodes().List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return limits, err
|
||||
}
|
||||
|
||||
// accumulated node limits
|
||||
memory := int64(0)
|
||||
for _, node := range nodes.Items {
|
||||
limits.CPU += node.Status.Allocatable.Cpu().MilliValue()
|
||||
memory += node.Status.Allocatable.Memory().Value()
|
||||
}
|
||||
limits.Memory = memory / 1000000 // B to MB
|
||||
|
||||
if !overCommitEnabled {
|
||||
namespaces, err := client.cli.CoreV1().Namespaces().List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
return limits, err
|
||||
}
|
||||
|
||||
reservedPercent := float64(resourceOverCommitPercent) / 100.0
|
||||
|
||||
reserved := portainer.K8sNodeLimits{}
|
||||
for _, namespace := range namespaces.Items {
|
||||
// skip the namespace we're editing
|
||||
if namespace.Name == skipNamespace {
|
||||
continue
|
||||
}
|
||||
|
||||
// minus accumulated resourcequotas for all namespaces except the one we're editing
|
||||
resourceQuota, err := client.cli.CoreV1().ResourceQuotas(namespace.Name).List(context.TODO(), metav1.ListOptions{})
|
||||
if err != nil {
|
||||
log.Debug().Msgf("error getting resourcequota for namespace %s: %s", namespace.Name, err)
|
||||
continue // skip it
|
||||
}
|
||||
|
||||
for _, rq := range resourceQuota.Items {
|
||||
hardLimits := rq.Status.Hard
|
||||
for resourceType, limit := range hardLimits {
|
||||
switch resourceType {
|
||||
case "limits.cpu":
|
||||
reserved.CPU += limit.MilliValue()
|
||||
case "limits.memory":
|
||||
reserved.Memory += limit.ScaledValue(6) // MB
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
limits.CPU = limits.CPU - int64(float64(limits.CPU)*reservedPercent) - reserved.CPU
|
||||
limits.Memory = limits.Memory - int64(float64(limits.Memory)*reservedPercent) - reserved.Memory
|
||||
}
|
||||
|
||||
return limits, nil
|
||||
}
|
||||
|
|
|
@ -1495,6 +1495,7 @@ type (
|
|||
GetServices(namespace string, lookupApplications bool) ([]models.K8sServiceInfo, error)
|
||||
DeleteServices(reqs models.K8sServiceDeleteRequests) error
|
||||
GetNodesLimits() (K8sNodesLimits, error)
|
||||
GetMaxResourceLimits(name string, overCommitEnabled bool, resourceOverCommitPercent int) (K8sNodeLimits, error)
|
||||
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
|
||||
UpdateNamespaceAccessPolicies(accessPolicies map[string]K8sNamespaceAccessPolicy) error
|
||||
DeleteRegistrySecret(registry *Registry, namespace string) error
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EnvironmentStatus } from '@/react/portainer/environments/types';
|
||||
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/service';
|
||||
import { getSelfSubjectAccessReview } from '@/react/kubernetes/namespaces/getSelfSubjectAccessReview';
|
||||
|
||||
import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
|
||||
|
||||
|
@ -375,12 +375,12 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
|||
},
|
||||
};
|
||||
|
||||
const resourcePoolCreation = {
|
||||
const namespaceCreation = {
|
||||
name: 'kubernetes.resourcePools.new',
|
||||
url: '/new',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesCreateResourcePoolView',
|
||||
component: 'kubernetesCreateNamespaceView',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -504,7 +504,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
|||
$stateRegistryProvider.register(node);
|
||||
$stateRegistryProvider.register(nodeStats);
|
||||
$stateRegistryProvider.register(resourcePools);
|
||||
$stateRegistryProvider.register(resourcePoolCreation);
|
||||
$stateRegistryProvider.register(namespaceCreation);
|
||||
$stateRegistryProvider.register(resourcePool);
|
||||
$stateRegistryProvider.register(resourcePoolAccess);
|
||||
$stateRegistryProvider.register(volumes);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import angular from 'angular';
|
||||
|
||||
import { r2a } from '@/react-tools/react2angular';
|
||||
import { IngressClassDatatable } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable';
|
||||
import { IngressClassDatatableAngular } from '@/react/kubernetes/cluster/ingressClass/IngressClassDatatable/IngressClassDatatableAngular';
|
||||
import { NamespacesSelector } from '@/react/kubernetes/cluster/RegistryAccessView/NamespacesSelector';
|
||||
import { StorageAccessModeSelector } from '@/react/kubernetes/cluster/ConfigureView/ConfigureForm/StorageAccessModeSelector';
|
||||
import { NamespaceAccessUsersSelector } from '@/react/kubernetes/namespaces/AccessView/NamespaceAccessUsersSelector';
|
||||
import { CreateNamespaceRegistriesSelector } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceRegistriesSelector';
|
||||
import { RegistriesSelector } from '@/react/kubernetes/namespaces/components/RegistriesFormSection/RegistriesSelector';
|
||||
import { KubeApplicationAccessPolicySelector } from '@/react/kubernetes/applications/CreateView/KubeApplicationAccessPolicySelector';
|
||||
import { KubeServicesForm } from '@/react/kubernetes/applications/CreateView/application-services/KubeServicesForm';
|
||||
import { kubeServicesValidation } from '@/react/kubernetes/applications/CreateView/application-services/kubeServicesValidation';
|
||||
|
@ -27,7 +27,7 @@ export const ngModule = angular
|
|||
.module('portainer.kubernetes.react.components', [])
|
||||
.component(
|
||||
'ingressClassDatatable',
|
||||
r2a(IngressClassDatatable, [
|
||||
r2a(IngressClassDatatableAngular, [
|
||||
'onChangeControllers',
|
||||
'description',
|
||||
'ingressControllers',
|
||||
|
@ -74,12 +74,7 @@ export const ngModule = angular
|
|||
)
|
||||
.component(
|
||||
'createNamespaceRegistriesSelector',
|
||||
r2a(CreateNamespaceRegistriesSelector, [
|
||||
'inputId',
|
||||
'onChange',
|
||||
'options',
|
||||
'value',
|
||||
])
|
||||
r2a(RegistriesSelector, ['inputId', 'onChange', 'options', 'value'])
|
||||
)
|
||||
.component(
|
||||
'kubeApplicationAccessPolicySelector',
|
||||
|
|
|
@ -10,11 +10,16 @@ import { DashboardView } from '@/react/kubernetes/dashboard/DashboardView';
|
|||
import { ServicesView } from '@/react/kubernetes/services/ServicesView';
|
||||
import { ConsoleView } from '@/react/kubernetes/applications/ConsoleView';
|
||||
import { ConfigmapsAndSecretsView } from '@/react/kubernetes/configs/ListView/ConfigmapsAndSecretsView';
|
||||
import { CreateNamespaceView } from '@/react/kubernetes/namespaces/CreateView/CreateNamespaceView';
|
||||
import { ApplicationDetailsView } from '@/react/kubernetes/applications/DetailsView/ApplicationDetailsView';
|
||||
import { ConfigureView } from '@/react/kubernetes/cluster/ConfigureView';
|
||||
|
||||
export const viewsModule = angular
|
||||
.module('portainer.kubernetes.react.views', [])
|
||||
.component(
|
||||
'kubernetesCreateNamespaceView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(CreateNamespaceView))), [])
|
||||
)
|
||||
.component(
|
||||
'kubernetesServicesView',
|
||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ServicesView))), [])
|
||||
|
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -339,7 +339,7 @@
|
|||
refresh-callback="ctrl.getIngresses"
|
||||
loading="ctrl.state.ingressesLoading"
|
||||
title-text="Ingress routes and applications"
|
||||
title-icon="svg-route"
|
||||
title-icon="database"
|
||||
>
|
||||
</kubernetes-resource-pool-ingresses-datatable>
|
||||
</div>
|
||||
|
|
|
@ -29,13 +29,19 @@ type OptionalReadonly<T> = T | Readonly<T>;
|
|||
|
||||
export function withInvalidate(
|
||||
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 {
|
||||
onSuccess() {
|
||||
return Promise.all(
|
||||
const promise = Promise.all(
|
||||
queryKeysToInvalidate.map((keys) => queryClient.invalidateQueries(keys))
|
||||
);
|
||||
return skipRefresh
|
||||
? undefined // don't wait for queries to refresh before setting state to success
|
||||
: promise; // stay loading until all queries are refreshed
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -36,7 +36,7 @@ export function Slider({
|
|||
return (
|
||||
<div className={styles.root}>
|
||||
<RcSlider
|
||||
handleRender={sliderTooltip}
|
||||
handleRender={visible ? sliderTooltip : undefined}
|
||||
min={min}
|
||||
max={max}
|
||||
marks={marks}
|
||||
|
|
|
@ -6,10 +6,16 @@ export function SliderWithInput({
|
|||
value,
|
||||
onChange,
|
||||
max,
|
||||
step = 1,
|
||||
dataCy,
|
||||
visibleTooltip = false,
|
||||
}: {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
max: number;
|
||||
dataCy: string;
|
||||
step?: number;
|
||||
visibleTooltip?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4">
|
||||
|
@ -22,7 +28,9 @@ export function SliderWithInput({
|
|||
value={value}
|
||||
min={0}
|
||||
max={max}
|
||||
step={256}
|
||||
step={step}
|
||||
dataCy={`${dataCy}Slider`}
|
||||
visibleTooltip={visibleTooltip}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -33,6 +41,7 @@ export function SliderWithInput({
|
|||
value={value}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
className="w-32"
|
||||
data-cy={`${dataCy}Input`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -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';
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -38,6 +38,8 @@ export function ResourceFieldset({
|
|||
value={values.reservation}
|
||||
onChange={(value) => onChange({ ...values, reservation: value })}
|
||||
max={maxMemory}
|
||||
step={256}
|
||||
dataCy="k8sNamespaceCreate-resourceReservationMemory"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
@ -46,6 +48,8 @@ export function ResourceFieldset({
|
|||
value={values.limit}
|
||||
onChange={(value) => onChange({ ...values, limit: value })}
|
||||
max={maxMemory}
|
||||
step={256}
|
||||
dataCy="k8sNamespaceCreate-resourceLimitMemory"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
|
|
@ -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]);
|
||||
}
|
|
@ -1,10 +1,11 @@
|
|||
import { ChangeEvent, ReactNode } from 'react';
|
||||
import { ChangeEvent } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { Button } from '@@/buttons';
|
||||
import { isArrayErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { Annotation } from './types';
|
||||
import { Annotation, AnnotationErrors } from './types';
|
||||
|
||||
interface Props {
|
||||
annotations: Annotation[];
|
||||
|
@ -14,17 +15,21 @@ interface Props {
|
|||
val: string
|
||||
) => void;
|
||||
removeAnnotation: (index: number) => void;
|
||||
errors: Record<string, ReactNode>;
|
||||
errors: AnnotationErrors;
|
||||
placeholder: string[];
|
||||
}
|
||||
|
||||
export function Annotations({
|
||||
export function AnnotationsForm({
|
||||
annotations,
|
||||
handleAnnotationChange,
|
||||
removeAnnotation,
|
||||
errors,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
const annotationErrors = isArrayErrorType<Annotation>(errors)
|
||||
? errors
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{annotations.map((annotation, i) => (
|
||||
|
@ -43,9 +48,9 @@ export function Annotations({
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{errors[`annotations.key[${i}]`] && (
|
||||
<FormError className="!mb-0 mt-1">
|
||||
{errors[`annotations.key[${i}]`]}
|
||||
{annotationErrors?.[i]?.Key && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{annotationErrors[i]?.Key}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
@ -63,9 +68,9 @@ export function Annotations({
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
{errors[`annotations.value[${i}]`] && (
|
||||
<FormError className="!mb-0 mt-1">
|
||||
{errors[`annotations.value[${i}]`]}
|
||||
{annotationErrors?.[i]?.Value && (
|
||||
<FormError className="mt-1 !mb-0">
|
||||
{annotationErrors[i]?.Value}
|
||||
</FormError>
|
||||
)}
|
||||
</div>
|
|
@ -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;
|
|
@ -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.'),
|
||||
});
|
||||
}
|
|
@ -7,8 +7,9 @@ import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector
|
|||
import { Button } from '@@/buttons';
|
||||
import { Card } from '@@/Card';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { newPort } from '../utils';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
ServicePort,
|
||||
|
|
|
@ -8,8 +8,9 @@ import { Button } from '@@/buttons';
|
|||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { newPort } from '../utils';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
|
|
|
@ -8,8 +8,9 @@ import { Button } from '@@/buttons';
|
|||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { newPort } from '../utils';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { Ingress } from '@/react/kubernetes/ingresses/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) {
|
||||
return {
|
||||
port: undefined,
|
||||
|
|
|
@ -21,11 +21,9 @@ import { ModalType } from '@@/modals';
|
|||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
|
||||
import {
|
||||
IngressControllerClassMap,
|
||||
IngressControllerClassMapRowData,
|
||||
} from '../../ingressClass/types';
|
||||
import { IngressControllerClassMap } from '../../ingressClass/types';
|
||||
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
|
||||
import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils';
|
||||
|
||||
import { useStorageClassesFormValues } from './useStorageClassesFormValues';
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
|
@ -176,15 +174,10 @@ function InnerForm({
|
|||
</FormSection>
|
||||
<FormSection title="Networking - Ingresses">
|
||||
<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."
|
||||
ingressControllers={
|
||||
values.ingressClasses as IngressControllerClassMapRowData[]
|
||||
}
|
||||
initialIngressControllers={
|
||||
initialValues.ingressClasses as IngressControllerClassMapRowData[]
|
||||
}
|
||||
allowNoneIngressClass={values.allowNoneIngressClass}
|
||||
values={values.ingressClasses}
|
||||
initialValues={initialValues.ingressClasses}
|
||||
isLoading={isIngressClassesLoading}
|
||||
noIngressControllerLabel="No supported ingress controllers found."
|
||||
view="cluster"
|
||||
|
@ -198,9 +191,19 @@ function InnerForm({
|
|||
tooltip='This allows users setting up ingresses to select "none" as the ingress class.'
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.allowNoneIngressClass}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('allowNoneIngressClass', checked)
|
||||
}
|
||||
onChange={(checked) => {
|
||||
setFieldValue('allowNoneIngressClass', checked);
|
||||
// add or remove the none ingress class from the ingress classes list
|
||||
if (checked) {
|
||||
setFieldValue(
|
||||
'ingressClasses',
|
||||
getIngressClassesFormValues(
|
||||
checked,
|
||||
initialValues.ingressClasses
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -376,12 +379,15 @@ function InnerForm({
|
|||
function useInitialValues(
|
||||
environment?: Environment | null,
|
||||
storageClassFormValues?: StorageClassFormValues[],
|
||||
ingressClasses?: IngressControllerClassMapRowData[]
|
||||
ingressClasses?: IngressControllerClassMap[]
|
||||
): ConfigureFormValues | undefined {
|
||||
return useMemo(() => {
|
||||
if (!environment) {
|
||||
return undefined;
|
||||
}
|
||||
const allowNoneIngressClass =
|
||||
!!environment.Kubernetes.Configuration.AllowNoneIngressClass;
|
||||
|
||||
return {
|
||||
storageClasses: storageClassFormValues || [],
|
||||
useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer,
|
||||
|
@ -396,9 +402,10 @@ function useInitialValues(
|
|||
!!environment.Kubernetes.Configuration.RestrictStandardUserIngressW,
|
||||
ingressAvailabilityPerNamespace:
|
||||
!!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
allowNoneIngressClass:
|
||||
!!environment.Kubernetes.Configuration.AllowNoneIngressClass,
|
||||
ingressClasses: ingressClasses || [],
|
||||
allowNoneIngressClass,
|
||||
ingressClasses:
|
||||
getIngressClassesFormValues(allowNoneIngressClass, ingressClasses) ||
|
||||
[],
|
||||
};
|
||||
}, [environment, ingressClasses, storageClassFormValues]);
|
||||
}
|
||||
|
|
|
@ -8,8 +8,6 @@ import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { TrackEventProps } from '@/angulartics.matomo/analytics-services';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
|
||||
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
import { ConfigureClusterPayloads } from './useConfigureClusterMutation';
|
||||
|
||||
|
@ -64,10 +62,8 @@ export async function handleSubmitConfigureCluster(
|
|||
{
|
||||
id: environment.Id,
|
||||
updateEnvironmentPayload: updatedEnvironment,
|
||||
initialIngressControllers:
|
||||
initialValues?.ingressClasses as IngressControllerClassMapRowData[],
|
||||
ingressControllers:
|
||||
values.ingressClasses as IngressControllerClassMapRowData[],
|
||||
initialIngressControllers: initialValues?.ingressClasses ?? [],
|
||||
ingressControllers: values.ingressClasses,
|
||||
storageClassPatches,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
|||
import { Operation } from 'fast-json-patch';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
|
||||
import {
|
||||
UpdateEnvironmentPayload,
|
||||
|
@ -12,13 +12,13 @@ import axios from '@/portainer/services/axios';
|
|||
import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError';
|
||||
|
||||
import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap';
|
||||
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
|
||||
import { IngressControllerClassMap } from '../../ingressClass/types';
|
||||
|
||||
export type ConfigureClusterPayloads = {
|
||||
id: number;
|
||||
updateEnvironmentPayload: Partial<UpdateEnvironmentPayload>;
|
||||
initialIngressControllers: IngressControllerClassMapRowData[];
|
||||
ingressControllers: IngressControllerClassMapRowData[];
|
||||
initialIngressControllers: IngressControllerClassMap[];
|
||||
ingressControllers: IngressControllerClassMap[];
|
||||
storageClassPatches: {
|
||||
name: string;
|
||||
patch: Operation[];
|
||||
|
@ -48,10 +48,9 @@ export function useConfigureClusterMutation() {
|
|||
}
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// not returning the promise here because we don't want to wait for the invalidateQueries to complete (longer than the mutation itself)
|
||||
queryClient.invalidateQueries(environmentQueryKeys.base());
|
||||
},
|
||||
...withInvalidate(queryClient, [environmentQueryKeys.base()], {
|
||||
skipRefresh: true,
|
||||
}),
|
||||
...withError('Unable to apply configuration', 'Failure'),
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
@ -8,7 +9,19 @@ import { ConfigureForm } from './ConfigureForm';
|
|||
export function ConfigureView() {
|
||||
const { data: environment } = useCurrentEnvironment();
|
||||
|
||||
// get the initial values
|
||||
useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations: 'K8sClusterW',
|
||||
forceEnvironmentId: environment?.Id,
|
||||
adminOnlyCE: false,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
id: environment?.Id,
|
||||
},
|
||||
to: 'kubernetes.dashboard',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
|
@ -11,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils';
|
|||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../types';
|
||||
import { IngressControllerClassMap } from '../types';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
||||
|
@ -19,82 +17,31 @@ const storageKey = 'ingressClasses';
|
|||
const settingsStore = createPersistedStore(storageKey, 'name');
|
||||
|
||||
interface Props {
|
||||
onChangeControllers: (
|
||||
controllerClassMap: IngressControllerClassMapRowData[]
|
||||
) => void; // angular function to save the ingress class list
|
||||
onChange: (controllerClassMap: IngressControllerClassMap[]) => void; // angular function to save the ingress class list
|
||||
description: string;
|
||||
ingressControllers: IngressControllerClassMapRowData[] | undefined;
|
||||
initialIngressControllers: IngressControllerClassMapRowData[] | undefined;
|
||||
allowNoneIngressClass: boolean;
|
||||
values: IngressControllerClassMap[] | undefined;
|
||||
initialValues: IngressControllerClassMap[] | undefined;
|
||||
isLoading: boolean;
|
||||
noIngressControllerLabel: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
export function IngressClassDatatable({
|
||||
onChangeControllers,
|
||||
onChange,
|
||||
description,
|
||||
initialIngressControllers,
|
||||
ingressControllers,
|
||||
allowNoneIngressClass,
|
||||
initialValues,
|
||||
values,
|
||||
isLoading,
|
||||
noIngressControllerLabel,
|
||||
view,
|
||||
}: Props) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
const [ingControllerFormValues, setIngControllerFormValues] = useState(
|
||||
ingressControllers || []
|
||||
);
|
||||
|
||||
// set the ingress controller form values when the ingress controller list changes
|
||||
// and the ingress controller form values are not set
|
||||
useEffect(() => {
|
||||
if (
|
||||
ingressControllers &&
|
||||
ingControllerFormValues.length !== ingressControllers.length
|
||||
) {
|
||||
setIngControllerFormValues(ingressControllers);
|
||||
}
|
||||
}, [ingressControllers, ingControllerFormValues]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowNoneIngressClass === undefined || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newIngFormValues: IngressControllerClassMapRowData[];
|
||||
const isCustomTypeExist = ingControllerFormValues.some(
|
||||
(ic) => ic.Type === 'custom'
|
||||
);
|
||||
if (allowNoneIngressClass) {
|
||||
newIngFormValues = [...ingControllerFormValues];
|
||||
// add the ingress controller type 'custom' with a 'none' ingress class name
|
||||
if (!isCustomTypeExist) {
|
||||
newIngFormValues.push({
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newIngFormValues = ingControllerFormValues.filter(
|
||||
(ingController) => ingController.ClassName !== 'none'
|
||||
);
|
||||
}
|
||||
setIngControllerFormValues(newIngFormValues);
|
||||
onChangeControllers(newIngFormValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowNoneIngressClass, onChangeControllers]);
|
||||
|
||||
return (
|
||||
<div className="-mx-[15px]">
|
||||
<Datatable
|
||||
settingsManager={tableState}
|
||||
dataset={ingControllerFormValues || []}
|
||||
dataset={values || []}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyContentLabel={noIngressControllerLabel}
|
||||
|
@ -107,9 +54,7 @@ export function IngressClassDatatable({
|
|||
</div>
|
||||
);
|
||||
|
||||
function renderTableActions(
|
||||
selectedRows: IngressControllerClassMapRowData[]
|
||||
) {
|
||||
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
<ButtonGroup>
|
||||
|
@ -121,11 +66,7 @@ export function IngressClassDatatable({
|
|||
color="dangerlight"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
false
|
||||
)
|
||||
updateIngressControllers(selectedRows, values || [], false)
|
||||
}
|
||||
>
|
||||
Disallow selected
|
||||
|
@ -138,11 +79,7 @@ export function IngressClassDatatable({
|
|||
color="default"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
true
|
||||
)
|
||||
updateIngressControllers(selectedRows, values || [], true)
|
||||
}
|
||||
>
|
||||
Allow selected
|
||||
|
@ -156,38 +93,34 @@ export function IngressClassDatatable({
|
|||
return (
|
||||
<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>}
|
||||
{initialValues && values && isUnsavedChanges(initialValues, values) && (
|
||||
<TextTip>Unsaved changes.</TextTip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function updateIngressControllers(
|
||||
selectedRows: IngressControllerClassMapRowData[],
|
||||
ingControllerFormValues: IngressControllerClassMapRowData[],
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
values: IngressControllerClassMap[],
|
||||
availability: boolean
|
||||
) {
|
||||
const updatedIngressControllers = getUpdatedIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
values || [],
|
||||
availability
|
||||
);
|
||||
|
||||
if (ingressControllers && ingressControllers.length) {
|
||||
if (values && values.length) {
|
||||
const newAllowed = updatedIngressControllers.map(
|
||||
(ingController) => ingController.Availability
|
||||
);
|
||||
if (view === 'namespace') {
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeControllers(updatedIngressControllers);
|
||||
onChange(updatedIngressControllers);
|
||||
return;
|
||||
}
|
||||
|
||||
const usedControllersToDisallow = ingressControllers.filter(
|
||||
const usedControllersToDisallow = values.filter(
|
||||
(ingController, index) => {
|
||||
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
|
||||
if (
|
||||
|
@ -229,15 +162,14 @@ export function IngressClassDatatable({
|
|||
return;
|
||||
}
|
||||
}
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeControllers(updatedIngressControllers);
|
||||
onChange(updatedIngressControllers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedChanges(
|
||||
oldIngressControllers: IngressControllerClassMapRowData[],
|
||||
newIngressControllers: IngressControllerClassMapRowData[]
|
||||
oldIngressControllers: IngressControllerClassMap[],
|
||||
newIngressControllers: IngressControllerClassMap[]
|
||||
) {
|
||||
if (oldIngressControllers.length !== newIngressControllers.length) {
|
||||
return true;
|
||||
|
@ -254,8 +186,8 @@ function isUnsavedChanges(
|
|||
}
|
||||
|
||||
function getUpdatedIngressControllers(
|
||||
selectedRows: IngressControllerClassMapRowData[],
|
||||
allRows: IngressControllerClassMapRowData[],
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
allRows: IngressControllerClassMap[],
|
||||
allow: boolean
|
||||
) {
|
||||
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
|
|||
import { Badge } from '@@/Badge';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import type { IngressControllerClassMapRowData } from '../../types';
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -16,9 +16,7 @@ export const availability = columnHelper.accessor('Availability', {
|
|||
sortingFn: 'basic',
|
||||
});
|
||||
|
||||
function Cell({
|
||||
getValue,
|
||||
}: CellContext<IngressControllerClassMapRowData, boolean>) {
|
||||
function Cell({ getValue }: CellContext<IngressControllerClassMap, boolean>) {
|
||||
const availability = getValue();
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../../types';
|
||||
import { IngressControllerClassMap } from '../../types';
|
||||
|
||||
export const columnHelper =
|
||||
createColumnHelper<IngressControllerClassMapRowData>();
|
||||
export const columnHelper = createColumnHelper<IngressControllerClassMap>();
|
||||
|
|
|
@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table';
|
|||
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import type { IngressControllerClassMapRowData } from '../../types';
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', {
|
|||
function NameCell({
|
||||
row,
|
||||
getValue,
|
||||
}: CellContext<IngressControllerClassMapRowData, string>) {
|
||||
}: CellContext<IngressControllerClassMap, string>) {
|
||||
const className = getValue();
|
||||
|
||||
return (
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -4,7 +4,6 @@ export type SupportedIngControllerTypes =
|
|||
| 'other'
|
||||
| 'custom';
|
||||
|
||||
// Not having 'extends Record<string, unknown>' fixes validation type errors from yup
|
||||
export interface IngressControllerClassMap {
|
||||
Name: string;
|
||||
ClassName: string;
|
||||
|
@ -13,8 +12,3 @@ export interface IngressControllerClassMap {
|
|||
New: boolean;
|
||||
Used: boolean; // if the controller is used by any ingress in the cluster
|
||||
}
|
||||
|
||||
// Record<string, unknown> fixes type errors when using the type with a react datatable
|
||||
export interface IngressControllerClassMapRowData
|
||||
extends Record<string, unknown>,
|
||||
IngressControllerClassMap {}
|
||||
|
|
|
@ -5,7 +5,7 @@ import PortainerError from '@/portainer/error';
|
|||
import axios from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { IngressControllerClassMapRowData } from './types';
|
||||
import { IngressControllerClassMap } from './types';
|
||||
|
||||
export function useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
|
@ -54,7 +54,7 @@ export async function getIngressControllerClassMap({
|
|||
}) {
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.get<
|
||||
IngressControllerClassMapRowData[]
|
||||
IngressControllerClassMap[]
|
||||
>(
|
||||
buildUrl(environmentId, namespace),
|
||||
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
||||
|
@ -68,12 +68,12 @@ export async function getIngressControllerClassMap({
|
|||
// get all supported ingress classes and controllers for the cluster
|
||||
export async function updateIngressControllerClassMap(
|
||||
environmentId: EnvironmentId,
|
||||
ingressControllerClassMap: IngressControllerClassMapRowData[],
|
||||
ingressControllerClassMap: IngressControllerClassMap[],
|
||||
namespace?: string
|
||||
) {
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.put<
|
||||
IngressControllerClassMapRowData[]
|
||||
IngressControllerClassMap[]
|
||||
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
|
||||
return controllerMaps;
|
||||
} catch (e) {
|
||||
|
|
|
@ -4,7 +4,6 @@ import { ConfigMap } from 'kubernetes-types/core/v1';
|
|||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
|
@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR
|
|||
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -40,7 +40,7 @@ export function ConfigMapsDatatable() {
|
|||
);
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespacesQuery(
|
||||
environmentId,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
|
|
|
@ -4,7 +4,6 @@ import { Secret } from 'kubernetes-types/core/v1';
|
|||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
|
@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR
|
|||
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -40,7 +40,7 @@ export function SecretsDatatable() {
|
|||
);
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespacesQuery(
|
||||
environmentId,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
|
|
|
@ -8,20 +8,21 @@ import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
|
|||
import { DashboardItem } from '@@/DashboardItem/DashboardItem';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { useNamespaces } from '../namespaces/queries';
|
||||
import { useApplicationsForCluster } from '../applications/application.queries';
|
||||
import { usePVCsForCluster } from '../volumes/queries';
|
||||
import { useServicesForCluster } from '../services/service';
|
||||
import { useIngresses } from '../ingresses/queries';
|
||||
import { useConfigMapsForCluster } from '../configs/configmap.service';
|
||||
import { useSecretsForCluster } from '../configs/secret.service';
|
||||
import { useNamespacesQuery } from '../namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { EnvironmentInfo } from './EnvironmentInfo';
|
||||
|
||||
export function DashboardView() {
|
||||
const queryClient = useQueryClient();
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
const namespaceNames = namespaces && Object.keys(namespaces);
|
||||
const { data: applications, ...applicationsQuery } =
|
||||
useApplicationsForCluster(environmentId, namespaceNames);
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export interface Annotation {
|
||||
Key: string;
|
||||
Value: string;
|
||||
ID: string;
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import { useState, useEffect, useMemo, ReactNode, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, ReactNode } from 'react';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useConfigurations } from '@/react/kubernetes/configs/queries';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
@ -15,6 +14,7 @@ import { PageHeader } from '@@/PageHeader';
|
|||
import { Option } from '@@/form-components/Input/Select';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery';
|
||||
import { Ingress, IngressController } from '../types';
|
||||
import {
|
||||
useCreateIngress,
|
||||
|
@ -22,8 +22,15 @@ import {
|
|||
useUpdateIngress,
|
||||
useIngressControllers,
|
||||
} from '../queries';
|
||||
import { Annotation } from '../../annotations/types';
|
||||
|
||||
import { Rule, Path, Host, GroupedServiceOptions } from './types';
|
||||
import {
|
||||
Rule,
|
||||
Path,
|
||||
Host,
|
||||
GroupedServiceOptions,
|
||||
IngressErrors,
|
||||
} from './types';
|
||||
import { IngressForm } from './IngressForm';
|
||||
import {
|
||||
prepareTLS,
|
||||
|
@ -32,7 +39,6 @@ import {
|
|||
prepareRuleFromIngress,
|
||||
checkIfPathExistsWithHost,
|
||||
} from './utils';
|
||||
import { Annotation } from './Annotations/types';
|
||||
|
||||
export function CreateIngressView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
@ -56,11 +62,10 @@ export function CreateIngressView() {
|
|||
// isEditClassNameSet is used to prevent premature validation of the classname in the edit view
|
||||
const [isEditClassNameSet, setIsEditClassNameSet] = useState<boolean>(false);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, ReactNode>>(
|
||||
{} as Record<string, string>
|
||||
);
|
||||
const [errors, setErrors] = useState<IngressErrors>({});
|
||||
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
|
||||
const { data: allServices } = useNamespaceServices(environmentId, namespace);
|
||||
const configResults = useConfigurations(environmentId, namespace);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeEvent, ReactNode, useEffect } from 'react';
|
||||
import { ChangeEvent, useEffect } from 'react';
|
||||
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
@ -16,8 +16,14 @@ import { InputGroup } from '@@/form-components/InputGroup';
|
|||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
import { Annotations } from './Annotations';
|
||||
import { GroupedServiceOptions, Rule, ServicePorts } from './types';
|
||||
import { AnnotationsForm } from '../../annotations/AnnotationsForm';
|
||||
|
||||
import {
|
||||
GroupedServiceOptions,
|
||||
IngressErrors,
|
||||
Rule,
|
||||
ServicePorts,
|
||||
} from './types';
|
||||
|
||||
import '../style.css';
|
||||
|
||||
|
@ -36,7 +42,7 @@ interface Props {
|
|||
environmentID: number;
|
||||
rule: Rule;
|
||||
|
||||
errors: Record<string, ReactNode>;
|
||||
errors: IngressErrors;
|
||||
isEdit: boolean;
|
||||
namespace: string;
|
||||
|
||||
|
@ -298,12 +304,12 @@ export function IngressForm({
|
|||
</div>
|
||||
|
||||
{rule?.Annotations && (
|
||||
<Annotations
|
||||
<AnnotationsForm
|
||||
placeholder={placeholderAnnotation}
|
||||
annotations={rule.Annotations}
|
||||
handleAnnotationChange={handleAnnotationChange}
|
||||
removeAnnotation={removeAnnotation}
|
||||
errors={errors}
|
||||
errors={errors.annotations}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import { Option } from '@@/form-components/Input/Select';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
import { Annotation, AnnotationErrors } from '../../annotations/types';
|
||||
|
||||
export interface Path {
|
||||
Key: string;
|
||||
|
@ -40,3 +42,7 @@ export type GroupedServiceOptions = {
|
|||
label: string;
|
||||
options: ServiceOption[];
|
||||
}[];
|
||||
|
||||
export type IngressErrors = Record<string, ReactNode> & {
|
||||
annotations?: AnnotationErrors;
|
||||
};
|
||||
|
|
|
@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
|
||||
|
||||
import { TLS, Ingress } from '../types';
|
||||
import { Annotation } from '../../annotations/types';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
import { Host, Rule } from './types';
|
||||
|
||||
const ignoreAnnotationsForEdit = [
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
|
@ -19,6 +18,7 @@ import { useTableState } from '@@/datatables/useTableState';
|
|||
|
||||
import { DeleteIngressesRequest, Ingress } from '../types';
|
||||
import { useDeleteIngresses, useIngresses } from '../queries';
|
||||
import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
||||
|
@ -39,7 +39,8 @@ export function IngressDatatable() {
|
|||
const canAccessSystemResources = useAuthorizations(
|
||||
'K8sAccessSystemNamespaces'
|
||||
);
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
const { data: ingresses, ...ingressesQuery } = useIngresses(
|
||||
environmentId,
|
||||
Object.keys(namespaces || {}),
|
||||
|
|
|
@ -181,7 +181,8 @@ export function useDeleteIngresses() {
|
|||
*/
|
||||
export function useIngressControllers(
|
||||
environmentId: EnvironmentId,
|
||||
namespace?: string
|
||||
namespace?: string,
|
||||
allowedOnly?: boolean
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
|
@ -193,7 +194,9 @@ export function useIngressControllers(
|
|||
'ingresscontrollers',
|
||||
],
|
||||
async () =>
|
||||
namespace ? getIngressControllers(environmentId, namespace) : [],
|
||||
namespace
|
||||
? getIngressControllers(environmentId, namespace, allowedOnly)
|
||||
: [],
|
||||
{
|
||||
enabled: !!namespace,
|
||||
...withError('Unable to get ingress controllers'),
|
||||
|
|
|
@ -34,11 +34,13 @@ export async function getIngresses(
|
|||
|
||||
export async function getIngressControllers(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
namespace: string,
|
||||
allowedOnly?: boolean
|
||||
) {
|
||||
try {
|
||||
const { data: ingresscontrollers } = await axios.get<IngressController[]>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`,
|
||||
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
||||
);
|
||||
return ingresscontrollers;
|
||||
} catch (e) {
|
||||
|
|
|
@ -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');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateNamespaceView } from './CreateNamespaceView';
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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[];
|
||||
};
|
|
@ -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}`,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
interface Props {
|
||||
value: Registry[];
|
||||
onChange(value: readonly Registry[]): void;
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options: Registry[];
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function CreateNamespaceRegistriesSelector({
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
|
@ -26,7 +28,7 @@ export function CreateNamespaceRegistriesSelector({
|
|||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registry"
|
||||
placeholder="Select one or more registries"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { RegistriesFormSection } from './RegistriesFormSection';
|
|
@ -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.'),
|
||||
})
|
||||
);
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StorageQuotaFormSection } from './StorageQuotaFormSection';
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -1,17 +1,13 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import {
|
||||
getNamespaces,
|
||||
getNamespace,
|
||||
getSelfSubjectAccessReview,
|
||||
} from './service';
|
||||
import { Namespaces } from './types';
|
||||
import { Namespaces } from '../types';
|
||||
import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview';
|
||||
|
||||
export function useNamespaces(
|
||||
export function useNamespacesQuery(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
|
@ -46,14 +42,14 @@ export function useNamespaces(
|
|||
);
|
||||
}
|
||||
|
||||
export function useNamespace(environmentId: EnvironmentId, namespace: string) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'namespaces', namespace],
|
||||
() => getNamespace(environmentId, namespace),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get namespace.');
|
||||
},
|
||||
}
|
||||
);
|
||||
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching
|
||||
async function getNamespaces(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: namespaces } = await axios.get<Namespaces>(
|
||||
`kubernetes/${environmentId}/namespaces`
|
||||
);
|
||||
return namespaces;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -4,14 +4,3 @@ export interface Namespaces {
|
|||
IsSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfSubjectAccessReviewResponse {
|
||||
status: {
|
||||
allowed: boolean;
|
||||
};
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
namespace: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
|||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
|
||||
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -33,7 +33,8 @@ const settingsStore = createStore(storageKey);
|
|||
export function ServicesDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
const namespaceNames = (namespaces && Object.keys(namespaces)) || [];
|
||||
const { data: services, ...servicesQuery } = useServicesForCluster(
|
||||
environmentId,
|
||||
|
|
|
@ -22,12 +22,12 @@ interface RegistryAccess {
|
|||
}
|
||||
|
||||
export async function updateEnvironmentRegistryAccess(
|
||||
id: EnvironmentId,
|
||||
environmentId: EnvironmentId,
|
||||
registryId: RegistryId,
|
||||
access: RegistryAccess
|
||||
access: Partial<RegistryAccess>
|
||||
) {
|
||||
try {
|
||||
await axios.put<void>(buildRegistryUrl(id, registryId), access);
|
||||
await axios.put<void>(buildRegistryUrl(environmentId, registryId), access);
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
}
|
||||
|
|
|
@ -3,13 +3,16 @@ import axios, { parseAxiosError } from '@/portainer/services/axios';
|
|||
import { buildUrl } from '../environment.service/utils';
|
||||
import { EnvironmentId } from '../types';
|
||||
import { Registry } from '../../registries/types/registry';
|
||||
import { useGenericRegistriesQuery } from '../../registries/queries/useRegistries';
|
||||
import {
|
||||
GenericRegistriesQueryOptions,
|
||||
useGenericRegistriesQuery,
|
||||
} from '../../registries/queries/useRegistries';
|
||||
|
||||
import { environmentQueryKeys } from './query-keys';
|
||||
|
||||
export function useEnvironmentRegistries<T = Array<Registry>>(
|
||||
environmentId: EnvironmentId,
|
||||
queryOptions: { select?(data: Array<Registry>): T; enabled?: boolean } = {}
|
||||
queryOptions: GenericRegistriesQueryOptions<T> = {}
|
||||
) {
|
||||
return useGenericRegistriesQuery(
|
||||
environmentQueryKeys.registries(environmentId),
|
||||
|
|
|
@ -50,7 +50,7 @@ export type IngressClass = {
|
|||
Type: string;
|
||||
};
|
||||
|
||||
interface StorageClass {
|
||||
export interface StorageClass {
|
||||
Name: string;
|
||||
AccessModes: string[];
|
||||
AllowVolumeExpansion: boolean;
|
||||
|
|
|
@ -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[]>(
|
||||
queryKey: QueryKey,
|
||||
fetcher: () => Promise<Array<Registry>>,
|
||||
|
@ -29,18 +39,16 @@ export function useGenericRegistriesQuery<T = Registry[]>(
|
|||
enabled,
|
||||
select,
|
||||
onSuccess,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
select?: (registries: Registry[]) => T;
|
||||
onSuccess?: (data: T) => void;
|
||||
} = {}
|
||||
hideDefault: hideDefaultOverride,
|
||||
}: GenericRegistriesQueryOptions<T> = {}
|
||||
) {
|
||||
const hideDefaultRegistryQuery = usePublicSettings({
|
||||
select: (settings) => settings.DefaultRegistry?.Hide,
|
||||
enabled,
|
||||
// We don't need the hideDefaultRegistry info if we're overriding it to true
|
||||
enabled: enabled && !hideDefaultOverride,
|
||||
});
|
||||
|
||||
const hideDefault = !!hideDefaultRegistryQuery.data;
|
||||
const hideDefault = hideDefaultOverride || !!hideDefaultRegistryQuery.data;
|
||||
|
||||
return useQuery(
|
||||
queryKey,
|
||||
|
@ -66,7 +74,8 @@ export function useGenericRegistriesQuery<T = Registry[]>(
|
|||
{
|
||||
select,
|
||||
...withError('Unable to retrieve registries'),
|
||||
enabled: hideDefaultRegistryQuery.isSuccess && enabled,
|
||||
enabled:
|
||||
(hideDefaultOverride || hideDefaultRegistryQuery.isSuccess) && enabled,
|
||||
onSuccess,
|
||||
}
|
||||
);
|
||||
|
|
Loading…
Reference in New Issue