1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

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

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

View file

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