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

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

View File

@ -53,6 +53,7 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
endpointRouter.Use(h.kubeClient)
endpointRouter.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)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -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);

View File

@ -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',

View File

@ -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))), [])

View File

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

View File

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

View File

@ -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
},
};
}

View File

@ -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}

View File

@ -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>
);

View File

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

View File

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

View File

@ -38,6 +38,8 @@ export function ResourceFieldset({
value={values.reservation}
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>

View File

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

View File

@ -1,10 +1,11 @@
import { ChangeEvent, ReactNode } from 'react';
import { ChangeEvent } from 'react';
import { Trash2 } from 'lucide-react';
import { 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>

View File

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

View File

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

View File

@ -7,8 +7,9 @@ import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector
import { Button } from '@@/buttons';
import { 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,

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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]);
}

View File

@ -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,
},
{

View File

@ -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'),
}
);

View File

@ -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 (
<>

View File

@ -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);

View File

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

View File

@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
import { Badge } from '@@/Badge';
import { 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 (

View File

@ -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>();

View File

@ -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 (

View File

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

View File

@ -4,7 +4,6 @@ export type SupportedIngControllerTypes =
| 'other'
| '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 {}

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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);

View File

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

View File

@ -1,11 +1,10 @@
import { useState, useEffect, useMemo, ReactNode, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, ReactNode } from 'react';
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
import { 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);

View File

@ -1,4 +1,4 @@
import { ChangeEvent, ReactNode, useEffect } from 'react';
import { ChangeEvent, useEffect } from 'react';
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
import 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}
/>
)}

View File

@ -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;
};

View File

@ -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 = [

View File

@ -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 || {}),

View File

@ -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'),

View File

@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,17 @@
import { MultiValue } from 'react-select';
import { Registry } from '@/react/portainer/registries/types';
import { 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"
/>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,27 @@
import { StorageClass } from '@/react/portainer/environments/types';
import { FormSection } from '@@/form-components/FormSection';
import { TextTip } from '@@/Tip/TextTip';
import { StorageQuotaItem } from './StorageQuotaItem';
interface Props {
storageClasses: StorageClass[];
}
export function StorageQuotaFormSection({ storageClasses }: Props) {
return (
<FormSection title="Storage">
<TextTip color="blue">
Quotas can be set on each storage option to prevent users from exceeding
a specific threshold when deploying applications. You can set a quota to
0 to effectively prevent the usage of a specific storage option inside
this namespace.
</TextTip>
{storageClasses.map((storageClass) => (
<StorageQuotaItem key={storageClass.Name} storageClass={storageClass} />
))}
</FormSection>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,13 @@
import { useQuery } from 'react-query';
import { 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');
}
}

View File

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

View File

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

View File

@ -9,8 +9,8 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { pluralize } from '@/portainer/helpers/strings';
import { 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,

View File

@ -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);
}

View File

@ -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),

View File

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

View File

@ -22,6 +22,16 @@ export function useRegistries<T = Registry[]>(
);
}
/**
* @field hideDefault - is used to hide the default registry from the list of registries, regardless of the user's settings. Kubernetes views use this.
*/
export type GenericRegistriesQueryOptions<T> = {
enabled?: boolean;
select?: (registries: Registry[]) => T;
onSuccess?: (data: T) => void;
hideDefault?: boolean;
};
export function useGenericRegistriesQuery<T = Registry[]>(
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,
}
);