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:
parent
31bcba96c6
commit
7218eb0892
83 changed files with 1869 additions and 358 deletions
|
@ -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>
|
15
app/react/kubernetes/annotations/types.ts
Normal file
15
app/react/kubernetes/annotations/types.ts
Normal 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;
|
68
app/react/kubernetes/annotations/validation.ts
Normal file
68
app/react/kubernetes/annotations/validation.ts
Normal 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.'),
|
||||
});
|
||||
}
|
|
@ -7,8 +7,9 @@ import { ButtonSelector } from '@@/form-components/ButtonSelector/ButtonSelector
|
|||
import { Button } from '@@/buttons';
|
||||
import { Card } from '@@/Card';
|
||||
import { Widget } from '@@/Widget';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { newPort } from '../utils';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
ServicePort,
|
||||
|
|
|
@ -8,8 +8,9 @@ import { Button } from '@@/buttons';
|
|||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { newPort } from '../utils';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
|
|
|
@ -8,8 +8,9 @@ import { Button } from '@@/buttons';
|
|||
import { Widget } from '@@/Widget';
|
||||
import { Card } from '@@/Card';
|
||||
import { InputGroup } from '@@/form-components/InputGroup';
|
||||
import { isErrorType } from '@@/form-components/formikUtils';
|
||||
|
||||
import { isErrorType, newPort } from '../utils';
|
||||
import { newPort } from '../utils';
|
||||
import { ContainerPortInput } from '../components/ContainerPortInput';
|
||||
import {
|
||||
ServiceFormValues,
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { Ingress } from '@/react/kubernetes/ingresses/types';
|
||||
|
||||
import { ServiceFormValues, ServicePort } from './types';
|
||||
|
||||
export function isErrorType<T>(
|
||||
error: string | FormikErrors<T> | undefined
|
||||
): error is FormikErrors<T> {
|
||||
return error !== undefined && typeof error !== 'string';
|
||||
}
|
||||
|
||||
export function newPort(serviceName?: string) {
|
||||
return {
|
||||
port: undefined,
|
||||
|
|
|
@ -21,11 +21,9 @@ import { ModalType } from '@@/modals';
|
|||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
|
||||
import { useIngressControllerClassMapQuery } from '../../ingressClass/useIngressControllerClassMap';
|
||||
import {
|
||||
IngressControllerClassMap,
|
||||
IngressControllerClassMapRowData,
|
||||
} from '../../ingressClass/types';
|
||||
import { IngressControllerClassMap } from '../../ingressClass/types';
|
||||
import { useIsRBACEnabledQuery } from '../../getIsRBACEnabled';
|
||||
import { getIngressClassesFormValues } from '../../ingressClass/IngressClassDatatable/utils';
|
||||
|
||||
import { useStorageClassesFormValues } from './useStorageClassesFormValues';
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
|
@ -176,15 +174,10 @@ function InnerForm({
|
|||
</FormSection>
|
||||
<FormSection title="Networking - Ingresses">
|
||||
<IngressClassDatatable
|
||||
onChangeControllers={onChangeControllers}
|
||||
onChange={onChangeControllers}
|
||||
description="Enabling ingress controllers in your cluster allows them to be available in the Portainer UI for users to publish applications over HTTP/HTTPS. A controller must have a class name for it to be included here."
|
||||
ingressControllers={
|
||||
values.ingressClasses as IngressControllerClassMapRowData[]
|
||||
}
|
||||
initialIngressControllers={
|
||||
initialValues.ingressClasses as IngressControllerClassMapRowData[]
|
||||
}
|
||||
allowNoneIngressClass={values.allowNoneIngressClass}
|
||||
values={values.ingressClasses}
|
||||
initialValues={initialValues.ingressClasses}
|
||||
isLoading={isIngressClassesLoading}
|
||||
noIngressControllerLabel="No supported ingress controllers found."
|
||||
view="cluster"
|
||||
|
@ -198,9 +191,19 @@ function InnerForm({
|
|||
tooltip='This allows users setting up ingresses to select "none" as the ingress class.'
|
||||
labelClass="col-sm-5 col-lg-4"
|
||||
checked={values.allowNoneIngressClass}
|
||||
onChange={(checked) =>
|
||||
setFieldValue('allowNoneIngressClass', checked)
|
||||
}
|
||||
onChange={(checked) => {
|
||||
setFieldValue('allowNoneIngressClass', checked);
|
||||
// add or remove the none ingress class from the ingress classes list
|
||||
if (checked) {
|
||||
setFieldValue(
|
||||
'ingressClasses',
|
||||
getIngressClassesFormValues(
|
||||
checked,
|
||||
initialValues.ingressClasses
|
||||
)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -376,12 +379,15 @@ function InnerForm({
|
|||
function useInitialValues(
|
||||
environment?: Environment | null,
|
||||
storageClassFormValues?: StorageClassFormValues[],
|
||||
ingressClasses?: IngressControllerClassMapRowData[]
|
||||
ingressClasses?: IngressControllerClassMap[]
|
||||
): ConfigureFormValues | undefined {
|
||||
return useMemo(() => {
|
||||
if (!environment) {
|
||||
return undefined;
|
||||
}
|
||||
const allowNoneIngressClass =
|
||||
!!environment.Kubernetes.Configuration.AllowNoneIngressClass;
|
||||
|
||||
return {
|
||||
storageClasses: storageClassFormValues || [],
|
||||
useLoadBalancer: !!environment.Kubernetes.Configuration.UseLoadBalancer,
|
||||
|
@ -396,9 +402,10 @@ function useInitialValues(
|
|||
!!environment.Kubernetes.Configuration.RestrictStandardUserIngressW,
|
||||
ingressAvailabilityPerNamespace:
|
||||
!!environment.Kubernetes.Configuration.IngressAvailabilityPerNamespace,
|
||||
allowNoneIngressClass:
|
||||
!!environment.Kubernetes.Configuration.AllowNoneIngressClass,
|
||||
ingressClasses: ingressClasses || [],
|
||||
allowNoneIngressClass,
|
||||
ingressClasses:
|
||||
getIngressClassesFormValues(allowNoneIngressClass, ingressClasses) ||
|
||||
[],
|
||||
};
|
||||
}, [environment, ingressClasses, storageClassFormValues]);
|
||||
}
|
||||
|
|
|
@ -8,8 +8,6 @@ import { UpdateEnvironmentPayload } from '@/react/portainer/environments/queries
|
|||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { TrackEventProps } from '@/angulartics.matomo/analytics-services';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
|
||||
|
||||
import { ConfigureFormValues, StorageClassFormValues } from './types';
|
||||
import { ConfigureClusterPayloads } from './useConfigureClusterMutation';
|
||||
|
||||
|
@ -64,10 +62,8 @@ export async function handleSubmitConfigureCluster(
|
|||
{
|
||||
id: environment.Id,
|
||||
updateEnvironmentPayload: updatedEnvironment,
|
||||
initialIngressControllers:
|
||||
initialValues?.ingressClasses as IngressControllerClassMapRowData[],
|
||||
ingressControllers:
|
||||
values.ingressClasses as IngressControllerClassMapRowData[],
|
||||
initialIngressControllers: initialValues?.ingressClasses ?? [],
|
||||
ingressControllers: values.ingressClasses,
|
||||
storageClassPatches,
|
||||
},
|
||||
{
|
||||
|
|
|
@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from 'react-query';
|
|||
import { Operation } from 'fast-json-patch';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { withError, withInvalidate } from '@/react-tools/react-query';
|
||||
import { environmentQueryKeys } from '@/react/portainer/environments/queries/query-keys';
|
||||
import {
|
||||
UpdateEnvironmentPayload,
|
||||
|
@ -12,13 +12,13 @@ import axios from '@/portainer/services/axios';
|
|||
import { parseKubernetesAxiosError } from '@/react/kubernetes/axiosError';
|
||||
|
||||
import { updateIngressControllerClassMap } from '../../ingressClass/useIngressControllerClassMap';
|
||||
import { IngressControllerClassMapRowData } from '../../ingressClass/types';
|
||||
import { IngressControllerClassMap } from '../../ingressClass/types';
|
||||
|
||||
export type ConfigureClusterPayloads = {
|
||||
id: number;
|
||||
updateEnvironmentPayload: Partial<UpdateEnvironmentPayload>;
|
||||
initialIngressControllers: IngressControllerClassMapRowData[];
|
||||
ingressControllers: IngressControllerClassMapRowData[];
|
||||
initialIngressControllers: IngressControllerClassMap[];
|
||||
ingressControllers: IngressControllerClassMap[];
|
||||
storageClassPatches: {
|
||||
name: string;
|
||||
patch: Operation[];
|
||||
|
@ -48,10 +48,9 @@ export function useConfigureClusterMutation() {
|
|||
}
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
// not returning the promise here because we don't want to wait for the invalidateQueries to complete (longer than the mutation itself)
|
||||
queryClient.invalidateQueries(environmentQueryKeys.base());
|
||||
},
|
||||
...withInvalidate(queryClient, [environmentQueryKeys.base()], {
|
||||
skipRefresh: true,
|
||||
}),
|
||||
...withError('Unable to apply configuration', 'Failure'),
|
||||
}
|
||||
);
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
@ -8,7 +9,19 @@ import { ConfigureForm } from './ConfigureForm';
|
|||
export function ConfigureView() {
|
||||
const { data: environment } = useCurrentEnvironment();
|
||||
|
||||
// get the initial values
|
||||
useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations: 'K8sClusterW',
|
||||
forceEnvironmentId: environment?.Id,
|
||||
adminOnlyCE: false,
|
||||
},
|
||||
{
|
||||
params: {
|
||||
id: environment?.Id,
|
||||
},
|
||||
to: 'kubernetes.dashboard',
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
|
@ -11,7 +9,7 @@ import { buildConfirmButton } from '@@/modals/utils';
|
|||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../types';
|
||||
import { IngressControllerClassMap } from '../types';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
||||
|
@ -19,82 +17,31 @@ const storageKey = 'ingressClasses';
|
|||
const settingsStore = createPersistedStore(storageKey, 'name');
|
||||
|
||||
interface Props {
|
||||
onChangeControllers: (
|
||||
controllerClassMap: IngressControllerClassMapRowData[]
|
||||
) => void; // angular function to save the ingress class list
|
||||
onChange: (controllerClassMap: IngressControllerClassMap[]) => void; // angular function to save the ingress class list
|
||||
description: string;
|
||||
ingressControllers: IngressControllerClassMapRowData[] | undefined;
|
||||
initialIngressControllers: IngressControllerClassMapRowData[] | undefined;
|
||||
allowNoneIngressClass: boolean;
|
||||
values: IngressControllerClassMap[] | undefined;
|
||||
initialValues: IngressControllerClassMap[] | undefined;
|
||||
isLoading: boolean;
|
||||
noIngressControllerLabel: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
export function IngressClassDatatable({
|
||||
onChangeControllers,
|
||||
onChange,
|
||||
description,
|
||||
initialIngressControllers,
|
||||
ingressControllers,
|
||||
allowNoneIngressClass,
|
||||
initialValues,
|
||||
values,
|
||||
isLoading,
|
||||
noIngressControllerLabel,
|
||||
view,
|
||||
}: Props) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
const [ingControllerFormValues, setIngControllerFormValues] = useState(
|
||||
ingressControllers || []
|
||||
);
|
||||
|
||||
// set the ingress controller form values when the ingress controller list changes
|
||||
// and the ingress controller form values are not set
|
||||
useEffect(() => {
|
||||
if (
|
||||
ingressControllers &&
|
||||
ingControllerFormValues.length !== ingressControllers.length
|
||||
) {
|
||||
setIngControllerFormValues(ingressControllers);
|
||||
}
|
||||
}, [ingressControllers, ingControllerFormValues]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowNoneIngressClass === undefined || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newIngFormValues: IngressControllerClassMapRowData[];
|
||||
const isCustomTypeExist = ingControllerFormValues.some(
|
||||
(ic) => ic.Type === 'custom'
|
||||
);
|
||||
if (allowNoneIngressClass) {
|
||||
newIngFormValues = [...ingControllerFormValues];
|
||||
// add the ingress controller type 'custom' with a 'none' ingress class name
|
||||
if (!isCustomTypeExist) {
|
||||
newIngFormValues.push({
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newIngFormValues = ingControllerFormValues.filter(
|
||||
(ingController) => ingController.ClassName !== 'none'
|
||||
);
|
||||
}
|
||||
setIngControllerFormValues(newIngFormValues);
|
||||
onChangeControllers(newIngFormValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowNoneIngressClass, onChangeControllers]);
|
||||
|
||||
return (
|
||||
<div className="-mx-[15px]">
|
||||
<Datatable
|
||||
settingsManager={tableState}
|
||||
dataset={ingControllerFormValues || []}
|
||||
dataset={values || []}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyContentLabel={noIngressControllerLabel}
|
||||
|
@ -107,9 +54,7 @@ export function IngressClassDatatable({
|
|||
</div>
|
||||
);
|
||||
|
||||
function renderTableActions(
|
||||
selectedRows: IngressControllerClassMapRowData[]
|
||||
) {
|
||||
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
<ButtonGroup>
|
||||
|
@ -121,11 +66,7 @@ export function IngressClassDatatable({
|
|||
color="dangerlight"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
false
|
||||
)
|
||||
updateIngressControllers(selectedRows, values || [], false)
|
||||
}
|
||||
>
|
||||
Disallow selected
|
||||
|
@ -138,11 +79,7 @@ export function IngressClassDatatable({
|
|||
color="default"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
true
|
||||
)
|
||||
updateIngressControllers(selectedRows, values || [], true)
|
||||
}
|
||||
>
|
||||
Allow selected
|
||||
|
@ -156,38 +93,34 @@ export function IngressClassDatatable({
|
|||
return (
|
||||
<div className="text-muted flex w-full flex-col !text-xs">
|
||||
<div className="mt-1">{description}</div>
|
||||
{initialIngressControllers &&
|
||||
ingControllerFormValues &&
|
||||
isUnsavedChanges(
|
||||
initialIngressControllers,
|
||||
ingControllerFormValues
|
||||
) && <TextTip>Unsaved changes.</TextTip>}
|
||||
{initialValues && values && isUnsavedChanges(initialValues, values) && (
|
||||
<TextTip>Unsaved changes.</TextTip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function updateIngressControllers(
|
||||
selectedRows: IngressControllerClassMapRowData[],
|
||||
ingControllerFormValues: IngressControllerClassMapRowData[],
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
values: IngressControllerClassMap[],
|
||||
availability: boolean
|
||||
) {
|
||||
const updatedIngressControllers = getUpdatedIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
values || [],
|
||||
availability
|
||||
);
|
||||
|
||||
if (ingressControllers && ingressControllers.length) {
|
||||
if (values && values.length) {
|
||||
const newAllowed = updatedIngressControllers.map(
|
||||
(ingController) => ingController.Availability
|
||||
);
|
||||
if (view === 'namespace') {
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeControllers(updatedIngressControllers);
|
||||
onChange(updatedIngressControllers);
|
||||
return;
|
||||
}
|
||||
|
||||
const usedControllersToDisallow = ingressControllers.filter(
|
||||
const usedControllersToDisallow = values.filter(
|
||||
(ingController, index) => {
|
||||
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
|
||||
if (
|
||||
|
@ -229,15 +162,14 @@ export function IngressClassDatatable({
|
|||
return;
|
||||
}
|
||||
}
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeControllers(updatedIngressControllers);
|
||||
onChange(updatedIngressControllers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedChanges(
|
||||
oldIngressControllers: IngressControllerClassMapRowData[],
|
||||
newIngressControllers: IngressControllerClassMapRowData[]
|
||||
oldIngressControllers: IngressControllerClassMap[],
|
||||
newIngressControllers: IngressControllerClassMap[]
|
||||
) {
|
||||
if (oldIngressControllers.length !== newIngressControllers.length) {
|
||||
return true;
|
||||
|
@ -254,8 +186,8 @@ function isUnsavedChanges(
|
|||
}
|
||||
|
||||
function getUpdatedIngressControllers(
|
||||
selectedRows: IngressControllerClassMapRowData[],
|
||||
allRows: IngressControllerClassMapRowData[],
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
allRows: IngressControllerClassMap[],
|
||||
allow: boolean
|
||||
) {
|
||||
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
|
||||
|
|
|
@ -0,0 +1,269 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
||||
import { confirm } from '@@/modals/confirm';
|
||||
import { ModalType } from '@@/modals';
|
||||
import { Datatable } from '@@/datatables';
|
||||
import { Button, ButtonGroup } from '@@/buttons';
|
||||
import { createPersistedStore } from '@@/datatables/types';
|
||||
import { buildConfirmButton } from '@@/modals/utils';
|
||||
import { useTableState } from '@@/datatables/useTableState';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { IngressControllerClassMap } from '../types';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
||||
const storageKey = 'ingressClasses';
|
||||
const settingsStore = createPersistedStore(storageKey, 'name');
|
||||
|
||||
interface Props {
|
||||
onChangeControllers: (
|
||||
controllerClassMap: IngressControllerClassMap[]
|
||||
) => void; // angular function to save the ingress class list
|
||||
description: string;
|
||||
ingressControllers: IngressControllerClassMap[] | undefined;
|
||||
initialIngressControllers: IngressControllerClassMap[] | undefined;
|
||||
allowNoneIngressClass: boolean;
|
||||
isLoading: boolean;
|
||||
noIngressControllerLabel: string;
|
||||
view: string;
|
||||
}
|
||||
|
||||
// This is a legacy component that has more state logic than the new one, for angular views
|
||||
// Delete this component when the namespace edit view is migrated to react
|
||||
export function IngressClassDatatableAngular({
|
||||
onChangeControllers,
|
||||
description,
|
||||
initialIngressControllers,
|
||||
ingressControllers,
|
||||
allowNoneIngressClass,
|
||||
isLoading,
|
||||
noIngressControllerLabel,
|
||||
view,
|
||||
}: Props) {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
|
||||
const [ingControllerFormValues, setIngControllerFormValues] = useState(
|
||||
ingressControllers || []
|
||||
);
|
||||
|
||||
// set the ingress controller form values when the ingress controller list changes
|
||||
// and the ingress controller form values are not set
|
||||
useEffect(() => {
|
||||
if (
|
||||
ingressControllers &&
|
||||
ingControllerFormValues.length !== ingressControllers.length
|
||||
) {
|
||||
setIngControllerFormValues(ingressControllers);
|
||||
}
|
||||
}, [ingressControllers, ingControllerFormValues]);
|
||||
|
||||
useEffect(() => {
|
||||
if (allowNoneIngressClass === undefined || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newIngFormValues: IngressControllerClassMap[];
|
||||
const isCustomTypeExist = ingControllerFormValues.some(
|
||||
(ic) => ic.Type === 'custom'
|
||||
);
|
||||
if (allowNoneIngressClass) {
|
||||
newIngFormValues = [...ingControllerFormValues];
|
||||
// add the ingress controller type 'custom' with a 'none' ingress class name
|
||||
if (!isCustomTypeExist) {
|
||||
newIngFormValues.push({
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
newIngFormValues = ingControllerFormValues.filter(
|
||||
(ingController) => ingController.ClassName !== 'none'
|
||||
);
|
||||
}
|
||||
setIngControllerFormValues(newIngFormValues);
|
||||
onChangeControllers(newIngFormValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allowNoneIngressClass, onChangeControllers]);
|
||||
|
||||
return (
|
||||
<div className="-mx-[15px]">
|
||||
<Datatable
|
||||
settingsManager={tableState}
|
||||
dataset={ingControllerFormValues || []}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
emptyContentLabel={noIngressControllerLabel}
|
||||
title="Ingress Controllers"
|
||||
titleIcon={Route}
|
||||
getRowId={(row) => `${row.Name}-${row.ClassName}-${row.Type}`}
|
||||
renderTableActions={(selectedRows) => renderTableActions(selectedRows)}
|
||||
description={renderIngressClassDescription()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
function renderTableActions(selectedRows: IngressControllerClassMap[]) {
|
||||
return (
|
||||
<div className="flex items-start">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
disabled={
|
||||
selectedRows.filter((row) => row.Availability === true).length ===
|
||||
0
|
||||
}
|
||||
color="dangerlight"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
false
|
||||
)
|
||||
}
|
||||
>
|
||||
Disallow selected
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
selectedRows.filter((row) => row.Availability === false)
|
||||
.length === 0
|
||||
}
|
||||
color="default"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
updateIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
true
|
||||
)
|
||||
}
|
||||
>
|
||||
Allow selected
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIngressClassDescription() {
|
||||
return (
|
||||
<div className="text-muted flex w-full flex-col !text-xs">
|
||||
<div className="mt-1">{description}</div>
|
||||
{initialIngressControllers &&
|
||||
ingControllerFormValues &&
|
||||
isUnsavedChanges(
|
||||
initialIngressControllers,
|
||||
ingControllerFormValues
|
||||
) && <TextTip>Unsaved changes.</TextTip>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function updateIngressControllers(
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
ingControllerFormValues: IngressControllerClassMap[],
|
||||
availability: boolean
|
||||
) {
|
||||
const updatedIngressControllers = getUpdatedIngressControllers(
|
||||
selectedRows,
|
||||
ingControllerFormValues || [],
|
||||
availability
|
||||
);
|
||||
|
||||
if (ingressControllers && ingressControllers.length) {
|
||||
const newAllowed = updatedIngressControllers.map(
|
||||
(ingController) => ingController.Availability
|
||||
);
|
||||
if (view === 'namespace') {
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeControllers(updatedIngressControllers);
|
||||
return;
|
||||
}
|
||||
|
||||
const usedControllersToDisallow = ingressControllers.filter(
|
||||
(ingController, index) => {
|
||||
// if any of the current controllers are allowed, and are used, then become disallowed, then add the controller to a new list
|
||||
if (
|
||||
ingController.Availability &&
|
||||
ingController.Used &&
|
||||
!newAllowed[index]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
if (usedControllersToDisallow.length > 0) {
|
||||
const confirmed = await confirm({
|
||||
title: 'Disallow in-use ingress controllers?',
|
||||
modalType: ModalType.Warn,
|
||||
message: (
|
||||
<div>
|
||||
<p>
|
||||
There are ingress controllers you want to disallow that are in
|
||||
use:
|
||||
</p>
|
||||
<ul className="ml-6">
|
||||
{usedControllersToDisallow.map((controller) => (
|
||||
<li key={controller.ClassName}>{controller.ClassName}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
No new ingress rules can be created for the disallowed
|
||||
controllers.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
confirmButton: buildConfirmButton('Disallow', 'warning'),
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIngControllerFormValues(updatedIngressControllers);
|
||||
onChangeControllers(updatedIngressControllers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isUnsavedChanges(
|
||||
oldIngressControllers: IngressControllerClassMap[],
|
||||
newIngressControllers: IngressControllerClassMap[]
|
||||
) {
|
||||
if (oldIngressControllers.length !== newIngressControllers.length) {
|
||||
return true;
|
||||
}
|
||||
for (let i = 0; i < newIngressControllers.length; i += 1) {
|
||||
if (
|
||||
oldIngressControllers[i]?.Availability !==
|
||||
newIngressControllers[i]?.Availability
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getUpdatedIngressControllers(
|
||||
selectedRows: IngressControllerClassMap[],
|
||||
allRows: IngressControllerClassMap[],
|
||||
allow: boolean
|
||||
) {
|
||||
const selectedRowClassNames = selectedRows.map((row) => row.ClassName);
|
||||
const updatedIngressControllers = allRows?.map((row) => {
|
||||
if (selectedRowClassNames.includes(row.ClassName)) {
|
||||
return { ...row, Availability: allow };
|
||||
}
|
||||
return row;
|
||||
});
|
||||
return updatedIngressControllers;
|
||||
}
|
|
@ -4,7 +4,7 @@ import { Check, X } from 'lucide-react';
|
|||
import { Badge } from '@@/Badge';
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import type { IngressControllerClassMapRowData } from '../../types';
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -16,9 +16,7 @@ export const availability = columnHelper.accessor('Availability', {
|
|||
sortingFn: 'basic',
|
||||
});
|
||||
|
||||
function Cell({
|
||||
getValue,
|
||||
}: CellContext<IngressControllerClassMapRowData, boolean>) {
|
||||
function Cell({ getValue }: CellContext<IngressControllerClassMap, boolean>) {
|
||||
const availability = getValue();
|
||||
|
||||
return (
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { IngressControllerClassMapRowData } from '../../types';
|
||||
import { IngressControllerClassMap } from '../../types';
|
||||
|
||||
export const columnHelper =
|
||||
createColumnHelper<IngressControllerClassMapRowData>();
|
||||
export const columnHelper = createColumnHelper<IngressControllerClassMap>();
|
||||
|
|
|
@ -2,7 +2,7 @@ import { CellContext } from '@tanstack/react-table';
|
|||
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import type { IngressControllerClassMapRowData } from '../../types';
|
||||
import type { IngressControllerClassMap } from '../../types';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
|
@ -15,7 +15,7 @@ export const name = columnHelper.accessor('ClassName', {
|
|||
function NameCell({
|
||||
row,
|
||||
getValue,
|
||||
}: CellContext<IngressControllerClassMapRowData, string>) {
|
||||
}: CellContext<IngressControllerClassMap, string>) {
|
||||
const className = getValue();
|
||||
|
||||
return (
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import { IngressControllerClassMap } from '../types';
|
||||
|
||||
export function getIngressClassesFormValues(
|
||||
allowNoneIngressClass: boolean,
|
||||
ingressClasses?: IngressControllerClassMap[]
|
||||
) {
|
||||
const ingressClassesFormValues = ingressClasses ? [...ingressClasses] : [];
|
||||
const noneIngressClassIndex = ingressClassesFormValues.findIndex(
|
||||
(ingressClass) =>
|
||||
ingressClass.Name === 'none' &&
|
||||
ingressClass.ClassName === 'none' &&
|
||||
ingressClass.Type === 'custom'
|
||||
);
|
||||
// add the none ingress class if it doesn't exist
|
||||
if (allowNoneIngressClass && noneIngressClassIndex === -1) {
|
||||
return [
|
||||
...ingressClassesFormValues,
|
||||
{
|
||||
Name: 'none',
|
||||
ClassName: 'none',
|
||||
Type: 'custom',
|
||||
Availability: true,
|
||||
New: false,
|
||||
Used: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
// remove the none ingress class if it exists
|
||||
if (!allowNoneIngressClass && noneIngressClassIndex > -1) {
|
||||
return [
|
||||
...ingressClassesFormValues.slice(0, noneIngressClassIndex),
|
||||
...ingressClassesFormValues.slice(noneIngressClassIndex + 1),
|
||||
];
|
||||
}
|
||||
// otherwise return the ingress classes as is
|
||||
return ingressClassesFormValues;
|
||||
}
|
|
@ -4,7 +4,6 @@ export type SupportedIngControllerTypes =
|
|||
| 'other'
|
||||
| 'custom';
|
||||
|
||||
// Not having 'extends Record<string, unknown>' fixes validation type errors from yup
|
||||
export interface IngressControllerClassMap {
|
||||
Name: string;
|
||||
ClassName: string;
|
||||
|
@ -13,8 +12,3 @@ export interface IngressControllerClassMap {
|
|||
New: boolean;
|
||||
Used: boolean; // if the controller is used by any ingress in the cluster
|
||||
}
|
||||
|
||||
// Record<string, unknown> fixes type errors when using the type with a react datatable
|
||||
export interface IngressControllerClassMapRowData
|
||||
extends Record<string, unknown>,
|
||||
IngressControllerClassMap {}
|
||||
|
|
|
@ -5,7 +5,7 @@ import PortainerError from '@/portainer/error';
|
|||
import axios from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
|
||||
import { IngressControllerClassMapRowData } from './types';
|
||||
import { IngressControllerClassMap } from './types';
|
||||
|
||||
export function useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
|
@ -54,7 +54,7 @@ export async function getIngressControllerClassMap({
|
|||
}) {
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.get<
|
||||
IngressControllerClassMapRowData[]
|
||||
IngressControllerClassMap[]
|
||||
>(
|
||||
buildUrl(environmentId, namespace),
|
||||
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
||||
|
@ -68,12 +68,12 @@ export async function getIngressControllerClassMap({
|
|||
// get all supported ingress classes and controllers for the cluster
|
||||
export async function updateIngressControllerClassMap(
|
||||
environmentId: EnvironmentId,
|
||||
ingressControllerClassMap: IngressControllerClassMapRowData[],
|
||||
ingressControllerClassMap: IngressControllerClassMap[],
|
||||
namespace?: string
|
||||
) {
|
||||
try {
|
||||
const { data: controllerMaps } = await axios.put<
|
||||
IngressControllerClassMapRowData[]
|
||||
IngressControllerClassMap[]
|
||||
>(buildUrl(environmentId, namespace), ingressControllerClassMap);
|
||||
return controllerMaps;
|
||||
} catch (e) {
|
||||
|
|
|
@ -4,7 +4,6 @@ import { ConfigMap } from 'kubernetes-types/core/v1';
|
|||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
|
@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR
|
|||
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -40,7 +40,7 @@ export function ConfigMapsDatatable() {
|
|||
);
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespacesQuery(
|
||||
environmentId,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
|
|
|
@ -4,7 +4,6 @@ import { Secret } from 'kubernetes-types/core/v1';
|
|||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { Authorized, useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { createStore } from '@/react/kubernetes/datatables/default-kube-datatable-store';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
|
@ -12,6 +11,7 @@ import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemR
|
|||
import { useApplicationsForCluster } from '@/react/kubernetes/applications/application.queries';
|
||||
import { Application } from '@/react/kubernetes/applications/types';
|
||||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { Datatable, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -40,7 +40,7 @@ export function SecretsDatatable() {
|
|||
);
|
||||
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespacesQuery(
|
||||
environmentId,
|
||||
{
|
||||
autoRefreshRate: tableState.autoRefreshRate * 1000,
|
||||
|
|
|
@ -8,20 +8,21 @@ import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
|
|||
import { DashboardItem } from '@@/DashboardItem/DashboardItem';
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { useNamespaces } from '../namespaces/queries';
|
||||
import { useApplicationsForCluster } from '../applications/application.queries';
|
||||
import { usePVCsForCluster } from '../volumes/queries';
|
||||
import { useServicesForCluster } from '../services/service';
|
||||
import { useIngresses } from '../ingresses/queries';
|
||||
import { useConfigMapsForCluster } from '../configs/configmap.service';
|
||||
import { useSecretsForCluster } from '../configs/secret.service';
|
||||
import { useNamespacesQuery } from '../namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { EnvironmentInfo } from './EnvironmentInfo';
|
||||
|
||||
export function DashboardView() {
|
||||
const queryClient = useQueryClient();
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
const namespaceNames = namespaces && Object.keys(namespaces);
|
||||
const { data: applications, ...applicationsQuery } =
|
||||
useApplicationsForCluster(environmentId, namespaceNames);
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
export interface Annotation {
|
||||
Key: string;
|
||||
Value: string;
|
||||
ID: string;
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import { useState, useEffect, useMemo, ReactNode, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback, ReactNode } from 'react';
|
||||
import { useCurrentStateAndParams, useRouter } from '@uirouter/react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { debounce } from 'lodash';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useConfigurations } from '@/react/kubernetes/configs/queries';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { useNamespaceServices } from '@/react/kubernetes/networks/services/queries';
|
||||
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
|
@ -15,6 +14,7 @@ import { PageHeader } from '@@/PageHeader';
|
|||
import { Option } from '@@/form-components/Input/Select';
|
||||
import { Button } from '@@/buttons';
|
||||
|
||||
import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery';
|
||||
import { Ingress, IngressController } from '../types';
|
||||
import {
|
||||
useCreateIngress,
|
||||
|
@ -22,8 +22,15 @@ import {
|
|||
useUpdateIngress,
|
||||
useIngressControllers,
|
||||
} from '../queries';
|
||||
import { Annotation } from '../../annotations/types';
|
||||
|
||||
import { Rule, Path, Host, GroupedServiceOptions } from './types';
|
||||
import {
|
||||
Rule,
|
||||
Path,
|
||||
Host,
|
||||
GroupedServiceOptions,
|
||||
IngressErrors,
|
||||
} from './types';
|
||||
import { IngressForm } from './IngressForm';
|
||||
import {
|
||||
prepareTLS,
|
||||
|
@ -32,7 +39,6 @@ import {
|
|||
prepareRuleFromIngress,
|
||||
checkIfPathExistsWithHost,
|
||||
} from './utils';
|
||||
import { Annotation } from './Annotations/types';
|
||||
|
||||
export function CreateIngressView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
@ -56,11 +62,10 @@ export function CreateIngressView() {
|
|||
// isEditClassNameSet is used to prevent premature validation of the classname in the edit view
|
||||
const [isEditClassNameSet, setIsEditClassNameSet] = useState<boolean>(false);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, ReactNode>>(
|
||||
{} as Record<string, string>
|
||||
);
|
||||
const [errors, setErrors] = useState<IngressErrors>({});
|
||||
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
|
||||
const { data: allServices } = useNamespaceServices(environmentId, namespace);
|
||||
const configResults = useConfigurations(environmentId, namespace);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { ChangeEvent, ReactNode, useEffect } from 'react';
|
||||
import { ChangeEvent, useEffect } from 'react';
|
||||
import { Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
|
@ -16,8 +16,14 @@ import { InputGroup } from '@@/form-components/InputGroup';
|
|||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
import { Annotations } from './Annotations';
|
||||
import { GroupedServiceOptions, Rule, ServicePorts } from './types';
|
||||
import { AnnotationsForm } from '../../annotations/AnnotationsForm';
|
||||
|
||||
import {
|
||||
GroupedServiceOptions,
|
||||
IngressErrors,
|
||||
Rule,
|
||||
ServicePorts,
|
||||
} from './types';
|
||||
|
||||
import '../style.css';
|
||||
|
||||
|
@ -36,7 +42,7 @@ interface Props {
|
|||
environmentID: number;
|
||||
rule: Rule;
|
||||
|
||||
errors: Record<string, ReactNode>;
|
||||
errors: IngressErrors;
|
||||
isEdit: boolean;
|
||||
namespace: string;
|
||||
|
||||
|
@ -298,12 +304,12 @@ export function IngressForm({
|
|||
</div>
|
||||
|
||||
{rule?.Annotations && (
|
||||
<Annotations
|
||||
<AnnotationsForm
|
||||
placeholder={placeholderAnnotation}
|
||||
annotations={rule.Annotations}
|
||||
handleAnnotationChange={handleAnnotationChange}
|
||||
removeAnnotation={removeAnnotation}
|
||||
errors={errors}
|
||||
errors={errors.annotations}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { ReactNode } from 'react';
|
||||
|
||||
import { Option } from '@@/form-components/Input/Select';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
import { Annotation, AnnotationErrors } from '../../annotations/types';
|
||||
|
||||
export interface Path {
|
||||
Key: string;
|
||||
|
@ -40,3 +42,7 @@ export type GroupedServiceOptions = {
|
|||
label: string;
|
||||
options: ServiceOption[];
|
||||
}[];
|
||||
|
||||
export type IngressErrors = Record<string, ReactNode> & {
|
||||
annotations?: AnnotationErrors;
|
||||
};
|
||||
|
|
|
@ -3,8 +3,8 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import { SupportedIngControllerTypes } from '@/react/kubernetes/cluster/ingressClass/types';
|
||||
|
||||
import { TLS, Ingress } from '../types';
|
||||
import { Annotation } from '../../annotations/types';
|
||||
|
||||
import { Annotation } from './Annotations/types';
|
||||
import { Host, Rule } from './types';
|
||||
|
||||
const ignoreAnnotationsForEdit = [
|
||||
|
|
|
@ -3,7 +3,6 @@ import { useRouter } from '@uirouter/react';
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
|
||||
import Route from '@/assets/ico/route.svg?c';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
|
@ -19,6 +18,7 @@ import { useTableState } from '@@/datatables/useTableState';
|
|||
|
||||
import { DeleteIngressesRequest, Ingress } from '../types';
|
||||
import { useDeleteIngresses, useIngresses } from '../queries';
|
||||
import { useNamespacesQuery } from '../../namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { columns } from './columns';
|
||||
|
||||
|
@ -39,7 +39,8 @@ export function IngressDatatable() {
|
|||
const canAccessSystemResources = useAuthorizations(
|
||||
'K8sAccessSystemNamespaces'
|
||||
);
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
const { data: ingresses, ...ingressesQuery } = useIngresses(
|
||||
environmentId,
|
||||
Object.keys(namespaces || {}),
|
||||
|
|
|
@ -181,7 +181,8 @@ export function useDeleteIngresses() {
|
|||
*/
|
||||
export function useIngressControllers(
|
||||
environmentId: EnvironmentId,
|
||||
namespace?: string
|
||||
namespace?: string,
|
||||
allowedOnly?: boolean
|
||||
) {
|
||||
return useQuery(
|
||||
[
|
||||
|
@ -193,7 +194,9 @@ export function useIngressControllers(
|
|||
'ingresscontrollers',
|
||||
],
|
||||
async () =>
|
||||
namespace ? getIngressControllers(environmentId, namespace) : [],
|
||||
namespace
|
||||
? getIngressControllers(environmentId, namespace, allowedOnly)
|
||||
: [],
|
||||
{
|
||||
enabled: !!namespace,
|
||||
...withError('Unable to get ingress controllers'),
|
||||
|
|
|
@ -34,11 +34,13 @@ export async function getIngresses(
|
|||
|
||||
export async function getIngressControllers(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
namespace: string,
|
||||
allowedOnly?: boolean
|
||||
) {
|
||||
try {
|
||||
const { data: ingresscontrollers } = await axios.get<IngressController[]>(
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`
|
||||
`kubernetes/${environmentId}/namespaces/${namespace}/ingresscontrollers`,
|
||||
allowedOnly ? { params: { allowedOnly: true } } : undefined
|
||||
);
|
||||
return ingresscontrollers;
|
||||
} catch (e) {
|
||||
|
|
|
@ -0,0 +1,119 @@
|
|||
import { Formik } from 'formik';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { NamespaceInnerForm } from '../components/NamespaceInnerForm';
|
||||
import { useNamespacesQuery } from '../queries/useNamespacesQuery';
|
||||
|
||||
import {
|
||||
CreateNamespaceFormValues,
|
||||
CreateNamespacePayload,
|
||||
UpdateRegistryPayload,
|
||||
} from './types';
|
||||
import { useClusterResourceLimitsQuery } from './queries/useResourceLimitsQuery';
|
||||
import { getNamespaceValidationSchema } from './CreateNamespaceForm.validation';
|
||||
import { transformFormValuesToNamespacePayload } from './utils';
|
||||
import { useCreateNamespaceMutation } from './queries/useCreateNamespaceMutation';
|
||||
|
||||
export function CreateNamespaceForm() {
|
||||
const router = useRouter();
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: environment, ...environmentQuery } = useCurrentEnvironment();
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const { data: registries } = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
// for namespace create, show ingress classes that are allowed in the current environment.
|
||||
// the ingressClasses show the none option, so we don't need to add it here.
|
||||
const { data: ingressClasses } = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
allowedOnly: true,
|
||||
});
|
||||
|
||||
const { data: namespaces } = useNamespacesQuery(environmentId);
|
||||
const namespaceNames = Object.keys(namespaces || {});
|
||||
|
||||
const createNamespaceMutation = useCreateNamespaceMutation(environmentId);
|
||||
|
||||
if (resourceLimitsQuery.isLoading || environmentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
const initialValues: CreateNamespaceFormValues = {
|
||||
name: '',
|
||||
ingressClasses: ingressClasses ?? [],
|
||||
resourceQuota: {
|
||||
enabled: false,
|
||||
memory: '0',
|
||||
cpu: '0',
|
||||
},
|
||||
registries: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<Widget>
|
||||
<WidgetBody>
|
||||
<Formik
|
||||
enableReinitialize
|
||||
initialValues={initialValues}
|
||||
onSubmit={handleSubmit}
|
||||
validateOnMount
|
||||
validationSchema={getNamespaceValidationSchema(
|
||||
memoryLimit,
|
||||
namespaceNames
|
||||
)}
|
||||
>
|
||||
{NamespaceInnerForm}
|
||||
</Formik>
|
||||
</WidgetBody>
|
||||
</Widget>
|
||||
);
|
||||
|
||||
function handleSubmit(values: CreateNamespaceFormValues) {
|
||||
const createNamespacePayload: CreateNamespacePayload =
|
||||
transformFormValuesToNamespacePayload(values);
|
||||
const updateRegistriesPayload: UpdateRegistryPayload[] =
|
||||
values.registries.flatMap((registryFormValues) => {
|
||||
// find the matching registry from the cluster registries
|
||||
const selectedRegistry = registries?.find(
|
||||
(registry) => registryFormValues.Id === registry.Id
|
||||
);
|
||||
if (!selectedRegistry) {
|
||||
return [];
|
||||
}
|
||||
const envNamespacesWithAccess =
|
||||
selectedRegistry.RegistryAccesses[`${environmentId}`]?.Namespaces ||
|
||||
[];
|
||||
return {
|
||||
Id: selectedRegistry.Id,
|
||||
Namespaces: [...envNamespacesWithAccess, values.name],
|
||||
};
|
||||
});
|
||||
|
||||
createNamespaceMutation.mutate(
|
||||
{
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload: values.ingressClasses,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess(
|
||||
'Success',
|
||||
`Namespace '${values.name}' created successfully`
|
||||
);
|
||||
router.stateService.go('kubernetes.resourcePools');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
import { string, object, array, SchemaOf } from 'yup';
|
||||
|
||||
import { registriesValidationSchema } from '../components/RegistriesFormSection/registriesValidationSchema';
|
||||
import { getResourceQuotaValidationSchema } from '../components/ResourceQuotaFormSection/getResourceQuotaValidationSchema';
|
||||
|
||||
import { CreateNamespaceFormValues } from './types';
|
||||
|
||||
export function getNamespaceValidationSchema(
|
||||
memoryLimit: number,
|
||||
namespaceNames: string[]
|
||||
): SchemaOf<CreateNamespaceFormValues> {
|
||||
return object({
|
||||
name: string()
|
||||
.matches(
|
||||
/^[a-z0-9](?:[-a-z0-9]{0,251}[a-z0-9])?$/,
|
||||
"This field must consist of lower case alphanumeric characters or '-', and contain at most 63 characters, and must start and end with an alphanumeric character."
|
||||
)
|
||||
.max(63, 'Name must be at most 63 characters.')
|
||||
// must not have the same name as an existing namespace
|
||||
.notOneOf(namespaceNames, 'Name must be unique.')
|
||||
.required('Name is required.'),
|
||||
resourceQuota: getResourceQuotaValidationSchema(memoryLimit),
|
||||
// ingress classes table is constrained already, and doesn't need validation
|
||||
ingressClasses: array(),
|
||||
registries: registriesValidationSchema,
|
||||
});
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||
import { useUnauthorizedRedirect } from '@/react/hooks/useUnauthorizedRedirect';
|
||||
|
||||
import { PageHeader } from '@@/PageHeader';
|
||||
|
||||
import { CreateNamespaceForm } from './CreateNamespaceForm';
|
||||
|
||||
export function CreateNamespaceView() {
|
||||
const environmentId = useEnvironmentId();
|
||||
|
||||
useUnauthorizedRedirect(
|
||||
{
|
||||
authorizations: 'K8sResourcePoolsW',
|
||||
forceEnvironmentId: environmentId,
|
||||
adminOnlyCE: !isBE,
|
||||
},
|
||||
{
|
||||
to: 'kubernetes.resourcePools',
|
||||
params: {
|
||||
id: environmentId,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<PageHeader
|
||||
title="Create a namespace"
|
||||
breadcrumbs="Create a namespace"
|
||||
reload
|
||||
/>
|
||||
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<CreateNamespaceForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
1
app/react/kubernetes/namespaces/CreateView/index.tsx
Normal file
1
app/react/kubernetes/namespaces/CreateView/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { CreateNamespaceView } from './CreateNamespaceView';
|
|
@ -0,0 +1,83 @@
|
|||
import { useMutation } from 'react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { updateEnvironmentRegistryAccess } from '@/react/portainer/environments/environment.service/registries';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { IngressControllerClassMap } from '../../../cluster/ingressClass/types';
|
||||
import { updateIngressControllerClassMap } from '../../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { Namespaces } from '../../types';
|
||||
import { CreateNamespacePayload, UpdateRegistryPayload } from '../types';
|
||||
|
||||
export function useCreateNamespaceMutation(environmentId: EnvironmentId) {
|
||||
return useMutation(
|
||||
async ({
|
||||
createNamespacePayload,
|
||||
updateRegistriesPayload,
|
||||
namespaceIngressControllerPayload,
|
||||
}: {
|
||||
createNamespacePayload: CreateNamespacePayload;
|
||||
updateRegistriesPayload: UpdateRegistryPayload[];
|
||||
namespaceIngressControllerPayload: IngressControllerClassMap[];
|
||||
}) => {
|
||||
try {
|
||||
// create the namespace first, so that it exists before referencing it in the registry access request
|
||||
await createNamespace(environmentId, createNamespacePayload);
|
||||
} catch (e) {
|
||||
throw new Error(e as string);
|
||||
}
|
||||
|
||||
// collect promises
|
||||
const updateRegistriesPromises = updateRegistriesPayload.map(
|
||||
({ Id, Namespaces }) =>
|
||||
updateEnvironmentRegistryAccess(environmentId, Id, {
|
||||
Namespaces,
|
||||
})
|
||||
);
|
||||
const updateIngressControllerPromise =
|
||||
namespaceIngressControllerPayload.length > 0
|
||||
? updateIngressControllerClassMap(
|
||||
environmentId,
|
||||
namespaceIngressControllerPayload,
|
||||
createNamespacePayload.Name
|
||||
)
|
||||
: Promise.resolve();
|
||||
|
||||
// return combined promises
|
||||
return Promise.allSettled([
|
||||
updateIngressControllerPromise,
|
||||
...updateRegistriesPromises,
|
||||
]);
|
||||
},
|
||||
{
|
||||
...withError('Unable to create namespace'),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// createNamespace is used to create a namespace using the Portainer backend
|
||||
async function createNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
payload: CreateNamespacePayload
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.post<Namespaces>(
|
||||
buildUrl(environmentId),
|
||||
payload
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create namespace');
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||
let url = `kubernetes/${environmentId}/namespaces`;
|
||||
|
||||
if (namespace) {
|
||||
url += `/${namespace}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { notifyError } from '@/portainer/services/notifications';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
type K8sNodeLimits = {
|
||||
CPU: number;
|
||||
Memory: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* useClusterResourceLimitsQuery is used to retrieve the total resource limits for a cluster, minus the allocated resources taken by existing namespaces
|
||||
* @returns the available resource limits for the cluster
|
||||
* */
|
||||
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'max_resource_limits'],
|
||||
() => getResourceLimits(environmentId),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get resource limits');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function getResourceLimits(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: limits } = await axios.get<K8sNodeLimits>(
|
||||
`/kubernetes/${environmentId}/max_resource_limits`
|
||||
);
|
||||
return limits;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e, 'Unable to retrieve resource limits');
|
||||
}
|
||||
}
|
24
app/react/kubernetes/namespaces/CreateView/types.ts
Normal file
24
app/react/kubernetes/namespaces/CreateView/types.ts
Normal 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[];
|
||||
};
|
16
app/react/kubernetes/namespaces/CreateView/utils.ts
Normal file
16
app/react/kubernetes/namespaces/CreateView/utils.ts
Normal 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}`,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
export function LoadBalancerFormSection() {
|
||||
return (
|
||||
<FormSection title="Load balancers">
|
||||
<TextTip color="blue">
|
||||
You can set a quota on the number of external load balancers that can be
|
||||
created inside this namespace. Set this quota to 0 to effectively
|
||||
disable the use of load balancers in this namespace.
|
||||
</TextTip>
|
||||
<SwitchField
|
||||
dataCy="k8sNamespaceCreate-loadBalancerQuotaToggle"
|
||||
label="Load balancer quota"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={false}
|
||||
featureId={FeatureId.K8S_RESOURCE_POOL_LB_QUOTA}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
|
@ -0,0 +1,119 @@
|
|||
import { Field, Form, FormikProps } from 'formik';
|
||||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { Input } from '@@/form-components/Input';
|
||||
import { FormActions } from '@@/form-components/FormActions';
|
||||
|
||||
import { IngressClassDatatable } from '../../cluster/ingressClass/IngressClassDatatable';
|
||||
import { useIngressControllerClassMapQuery } from '../../cluster/ingressClass/useIngressControllerClassMap';
|
||||
import { CreateNamespaceFormValues } from '../CreateView/types';
|
||||
import { AnnotationsBeTeaser } from '../../annotations/AnnotationsBeTeaser';
|
||||
|
||||
import { LoadBalancerFormSection } from './LoadBalancerFormSection';
|
||||
import { NamespaceSummary } from './NamespaceSummary';
|
||||
import { StorageQuotaFormSection } from './StorageQuotaFormSection/StorageQuotaFormSection';
|
||||
import { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
||||
import { RegistriesFormSection } from './RegistriesFormSection';
|
||||
import { ResourceQuotaFormValues } from './ResourceQuotaFormSection/types';
|
||||
|
||||
export function NamespaceInnerForm({
|
||||
errors,
|
||||
isValid,
|
||||
setFieldValue,
|
||||
values,
|
||||
isSubmitting,
|
||||
initialValues,
|
||||
}: FormikProps<CreateNamespaceFormValues>) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const environmentQuery = useCurrentEnvironment();
|
||||
const ingressClassesQuery = useIngressControllerClassMapQuery({
|
||||
environmentId,
|
||||
allowedOnly: true,
|
||||
});
|
||||
|
||||
if (environmentQuery.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const useLoadBalancer =
|
||||
environmentQuery.data?.Kubernetes.Configuration.UseLoadBalancer;
|
||||
const enableResourceOverCommit =
|
||||
environmentQuery.data?.Kubernetes.Configuration.EnableResourceOverCommit;
|
||||
const enableIngressControllersPerNamespace =
|
||||
environmentQuery.data?.Kubernetes.Configuration
|
||||
.IngressAvailabilityPerNamespace;
|
||||
const storageClasses =
|
||||
environmentQuery.data?.Kubernetes.Configuration.StorageClasses ?? [];
|
||||
|
||||
return (
|
||||
<Form>
|
||||
<FormControl
|
||||
inputId="namespace"
|
||||
label="Name"
|
||||
required
|
||||
errors={errors.name}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="namespace"
|
||||
name="name"
|
||||
placeholder="e.g. my-namespace"
|
||||
data-cy="k8sNamespaceCreate-namespaceNameInput"
|
||||
/>
|
||||
</FormControl>
|
||||
<AnnotationsBeTeaser />
|
||||
<ResourceQuotaFormSection
|
||||
enableResourceOverCommit={enableResourceOverCommit}
|
||||
values={values.resourceQuota}
|
||||
onChange={(resourceQuota: ResourceQuotaFormValues) =>
|
||||
setFieldValue('resourceQuota', resourceQuota)
|
||||
}
|
||||
errors={errors.resourceQuota}
|
||||
/>
|
||||
{useLoadBalancer && <LoadBalancerFormSection />}
|
||||
{enableIngressControllersPerNamespace && (
|
||||
<FormSection title="Networking">
|
||||
<IngressClassDatatable
|
||||
onChange={(classes) => setFieldValue('ingressClasses', classes)}
|
||||
values={values.ingressClasses}
|
||||
description="Enable the ingress controllers that users can select when publishing applications in this namespace."
|
||||
noIngressControllerLabel="No ingress controllers available in the cluster. Go to the cluster setup view to configure and allow the use of ingress controllers in the cluster."
|
||||
view="namespace"
|
||||
isLoading={ingressClassesQuery.isLoading}
|
||||
initialValues={initialValues.ingressClasses}
|
||||
/>
|
||||
</FormSection>
|
||||
)}
|
||||
<RegistriesFormSection
|
||||
values={values.registries}
|
||||
onChange={(registries: MultiValue<Registry>) =>
|
||||
setFieldValue('registries', registries)
|
||||
}
|
||||
errors={errors.registries}
|
||||
/>
|
||||
{storageClasses.length > 0 && (
|
||||
<StorageQuotaFormSection storageClasses={storageClasses} />
|
||||
)}
|
||||
<NamespaceSummary
|
||||
initialValues={initialValues}
|
||||
values={values}
|
||||
isValid={isValid}
|
||||
/>
|
||||
<FormSection title="Actions">
|
||||
<FormActions
|
||||
submitLabel="Create namespace"
|
||||
loadingText="Creating namespace"
|
||||
isLoading={isSubmitting}
|
||||
isValid={isValid}
|
||||
data-cy="k8sNamespaceCreate-submitButton"
|
||||
/>
|
||||
</FormSection>
|
||||
</Form>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { CreateNamespaceFormValues } from '../CreateView/types';
|
||||
|
||||
interface Props {
|
||||
initialValues: CreateNamespaceFormValues;
|
||||
values: CreateNamespaceFormValues;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export function NamespaceSummary({ initialValues, values, isValid }: Props) {
|
||||
const hasChanges = !_.isEqual(values, initialValues);
|
||||
|
||||
if (!hasChanges || !isValid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormSection title="Summary" isFoldable defaultFolded={false}>
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
Portainer will execute the following Kubernetes actions.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-12 small text-muted pt-1">
|
||||
<ul>
|
||||
<li>
|
||||
Create a <span className="bold">Namespace</span> named{' '}
|
||||
<code>{values.name}</code>
|
||||
</li>
|
||||
{values.resourceQuota.enabled && (
|
||||
<li>
|
||||
Create a <span className="bold">ResourceQuota</span> named{' '}
|
||||
<code>portainer-rq-{values.name}</code>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { InlineLoader } from '@@/InlineLoader';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
|
||||
import { RegistriesSelector } from './RegistriesSelector';
|
||||
|
||||
type Props = {
|
||||
values: MultiValue<Registry>;
|
||||
onChange: (value: MultiValue<Registry>) => void;
|
||||
errors?: string | string[] | FormikErrors<Registry>[];
|
||||
};
|
||||
|
||||
export function RegistriesFormSection({ values, onChange, errors }: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const registriesQuery = useEnvironmentRegistries(environmentId, {
|
||||
hideDefault: true,
|
||||
});
|
||||
return (
|
||||
<FormSection title="Registries">
|
||||
<FormControl
|
||||
inputId="registries"
|
||||
label="Select registries"
|
||||
required
|
||||
errors={errors}
|
||||
>
|
||||
{registriesQuery.isLoading && (
|
||||
<InlineLoader>Loading registries...</InlineLoader>
|
||||
)}
|
||||
{registriesQuery.data && (
|
||||
<RegistriesSelector
|
||||
value={values}
|
||||
onChange={(registries) => onChange(registries)}
|
||||
options={registriesQuery.data}
|
||||
inputId="registries"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
import { MultiValue } from 'react-select';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { Select } from '@@/form-components/ReactSelect';
|
||||
|
||||
interface Props {
|
||||
value: Registry[];
|
||||
onChange(value: readonly Registry[]): void;
|
||||
value: MultiValue<Registry>;
|
||||
onChange(value: MultiValue<Registry>): void;
|
||||
options: Registry[];
|
||||
inputId?: string;
|
||||
}
|
||||
|
||||
export function CreateNamespaceRegistriesSelector({
|
||||
export function RegistriesSelector({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
|
@ -26,7 +28,7 @@ export function CreateNamespaceRegistriesSelector({
|
|||
onChange={onChange}
|
||||
inputId={inputId}
|
||||
data-cy="namespaceCreate-registrySelect"
|
||||
placeholder="Select one or more registry"
|
||||
placeholder="Select one or more registries"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { RegistriesFormSection } from './RegistriesFormSection';
|
|
@ -0,0 +1,10 @@
|
|||
import { SchemaOf, array, object, number, string } from 'yup';
|
||||
|
||||
import { Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
export const registriesValidationSchema: SchemaOf<Registry[]> = array(
|
||||
object({
|
||||
Id: number().required('Registry ID is required.'),
|
||||
Name: string().required('Registry name is required.'),
|
||||
})
|
||||
);
|
|
@ -0,0 +1,121 @@
|
|||
import { FormikErrors } from 'formik';
|
||||
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { FormError } from '@@/form-components/FormError';
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { Slider } from '@@/form-components/Slider';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
||||
|
||||
import { useClusterResourceLimitsQuery } from '../../CreateView/queries/useResourceLimitsQuery';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
interface Props {
|
||||
values: ResourceQuotaFormValues;
|
||||
onChange: (value: ResourceQuotaFormValues) => void;
|
||||
enableResourceOverCommit?: boolean;
|
||||
errors?: FormikErrors<ResourceQuotaFormValues>;
|
||||
}
|
||||
|
||||
export function ResourceQuotaFormSection({
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
enableResourceOverCommit,
|
||||
}: Props) {
|
||||
const environmentId = useEnvironmentId();
|
||||
const resourceLimitsQuery = useClusterResourceLimitsQuery(environmentId);
|
||||
const cpuLimit = resourceLimitsQuery.data?.CPU ?? 0;
|
||||
const memoryLimit = resourceLimitsQuery.data?.Memory ?? 0;
|
||||
|
||||
return (
|
||||
<FormSection title="Resource Quota">
|
||||
{values.enabled ? (
|
||||
<TextTip color="blue">
|
||||
A namespace is a logical abstraction of a Kubernetes cluster, to
|
||||
provide for more flexible management of resources. Best practice is to
|
||||
set a quota assignment as this ensures greatest security/stability;
|
||||
alternatively, you can disable assigning a quota for unrestricted
|
||||
access (not recommended).
|
||||
</TextTip>
|
||||
) : (
|
||||
<TextTip color="blue">
|
||||
A namespace is a logical abstraction of a Kubernetes cluster, to
|
||||
provide for more flexible management of resources. Resource
|
||||
over-commit is disabled, please assign a capped limit of resources to
|
||||
this namespace.
|
||||
</TextTip>
|
||||
)}
|
||||
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceCreate-resourceAssignmentToggle"
|
||||
disabled={enableResourceOverCommit}
|
||||
label="Resource assignment"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={values.enabled || !!enableResourceOverCommit}
|
||||
onChange={(enabled) => onChange({ ...values, enabled })}
|
||||
/>
|
||||
|
||||
{(values.enabled || !!enableResourceOverCommit) && (
|
||||
<div className="pt-5">
|
||||
<div className="flex flex-row">
|
||||
<FormSectionTitle>Resource Limits</FormSectionTitle>
|
||||
</div>
|
||||
{/* keep the FormError component present, but invisible to avoid layout shift */}
|
||||
<FormError
|
||||
className={typeof errors === 'string' ? 'visible' : 'invisible'}
|
||||
>
|
||||
{/* 'error' keeps the formerror the exact same height while hidden so there is no layout shift */}
|
||||
{errors || 'error'}
|
||||
</FormError>
|
||||
<FormControl
|
||||
className="flex flex-row"
|
||||
label="Memory limit (MB)"
|
||||
inputId="memory-limit"
|
||||
>
|
||||
<div className="col-xs-8">
|
||||
<SliderWithInput
|
||||
value={Number(values.memory) ?? 0}
|
||||
onChange={(value) =>
|
||||
onChange({ ...values, memory: `${value}` })
|
||||
}
|
||||
max={memoryLimit}
|
||||
step={128}
|
||||
dataCy="k8sNamespaceCreate-memoryLimit"
|
||||
visibleTooltip
|
||||
/>
|
||||
{errors?.memory && (
|
||||
<FormError className="pt-1">{errors.memory}</FormError>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormControl className="flex flex-row" label="CPU limit">
|
||||
<div className="col-xs-8">
|
||||
<Slider
|
||||
min={0}
|
||||
max={cpuLimit / 1000}
|
||||
step={0.1}
|
||||
value={Number(values.cpu) ?? 0}
|
||||
onChange={(cpu) => {
|
||||
if (Array.isArray(cpu)) {
|
||||
return;
|
||||
}
|
||||
onChange({ ...values, cpu: cpu.toString() });
|
||||
}}
|
||||
dataCy="k8sNamespaceCreate-cpuLimitSlider"
|
||||
visibleTooltip
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
)}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { boolean, string, object, SchemaOf, TestContext } from 'yup';
|
||||
|
||||
import { ResourceQuotaFormValues } from './types';
|
||||
|
||||
export function getResourceQuotaValidationSchema(
|
||||
memoryLimit: number
|
||||
): SchemaOf<ResourceQuotaFormValues> {
|
||||
return object({
|
||||
enabled: boolean().required('Resource quota enabled status is required.'),
|
||||
memory: string().test(
|
||||
'memory-validation',
|
||||
`Value must be between 0 and ${memoryLimit}.`,
|
||||
memoryValidation
|
||||
),
|
||||
cpu: string().test(
|
||||
'cpu-validation',
|
||||
'CPU limit value is required.',
|
||||
cpuValidation
|
||||
),
|
||||
}).test(
|
||||
'resource-quota-validation',
|
||||
'At least a single limit must be set.',
|
||||
oneLimitSet
|
||||
);
|
||||
|
||||
function oneLimitSet({
|
||||
enabled,
|
||||
memory,
|
||||
cpu,
|
||||
}: Partial<ResourceQuotaFormValues>) {
|
||||
return !enabled || (Number(memory) ?? 0) > 0 || (Number(cpu) ?? 0) > 0;
|
||||
}
|
||||
|
||||
function memoryValidation(this: TestContext, memoryValue?: string) {
|
||||
const memory = Number(memoryValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || (memory >= 0 && memory <= memoryLimit);
|
||||
}
|
||||
|
||||
function cpuValidation(this: TestContext, cpuValue?: string) {
|
||||
const cpu = Number(cpuValue) ?? 0;
|
||||
const { enabled } = this.parent;
|
||||
return !enabled || cpu >= 0;
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { ResourceQuotaFormSection } from './ResourceQuotaFormSection';
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* @property enabled - Whether resource quota is enabled
|
||||
* @property memory - Memory limit in bytes
|
||||
* @property cpu - CPU limit in cores
|
||||
* @property loadBalancer - Load balancer limit in number of load balancers
|
||||
*/
|
||||
export type ResourceQuotaFormValues = {
|
||||
enabled: boolean;
|
||||
memory?: string;
|
||||
cpu?: string;
|
||||
};
|
||||
|
||||
export type ResourceQuotaPayload = {
|
||||
enabled: boolean;
|
||||
memory?: string;
|
||||
cpu?: string;
|
||||
loadBalancerLimit?: string;
|
||||
};
|
|
@ -0,0 +1,27 @@
|
|||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
|
||||
import { FormSection } from '@@/form-components/FormSection';
|
||||
import { TextTip } from '@@/Tip/TextTip';
|
||||
|
||||
import { StorageQuotaItem } from './StorageQuotaItem';
|
||||
|
||||
interface Props {
|
||||
storageClasses: StorageClass[];
|
||||
}
|
||||
|
||||
export function StorageQuotaFormSection({ storageClasses }: Props) {
|
||||
return (
|
||||
<FormSection title="Storage">
|
||||
<TextTip color="blue">
|
||||
Quotas can be set on each storage option to prevent users from exceeding
|
||||
a specific threshold when deploying applications. You can set a quota to
|
||||
0 to effectively prevent the usage of a specific storage option inside
|
||||
this namespace.
|
||||
</TextTip>
|
||||
|
||||
{storageClasses.map((storageClass) => (
|
||||
<StorageQuotaItem key={storageClass.Name} storageClass={storageClass} />
|
||||
))}
|
||||
</FormSection>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { Database } from 'lucide-react';
|
||||
|
||||
import { StorageClass } from '@/react/portainer/environments/types';
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
|
||||
type Props = {
|
||||
storageClass: StorageClass;
|
||||
};
|
||||
|
||||
export function StorageQuotaItem({ storageClass }: Props) {
|
||||
return (
|
||||
<div key={storageClass.Name}>
|
||||
<FormSectionTitle>
|
||||
<div className="vertical-center text-muted inline-flex gap-1 align-top">
|
||||
<Icon icon={Database} className="!mt-0.5 flex-none" />
|
||||
<span>{storageClass.Name}</span>
|
||||
</div>
|
||||
</FormSectionTitle>
|
||||
<hr className="mt-2 mb-0 w-full" />
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<SwitchField
|
||||
data-cy="k8sNamespaceEdit-storageClassQuota"
|
||||
disabled={false}
|
||||
label="Enable quota"
|
||||
labelClass="col-sm-3 col-lg-2"
|
||||
fieldClass="pt-2"
|
||||
checked={false}
|
||||
onChange={() => {}}
|
||||
featureId={FeatureId.K8S_RESOURCE_POOL_STORAGE_QUOTA}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { StorageQuotaFormSection } from './StorageQuotaFormSection';
|
|
@ -0,0 +1,51 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
interface SelfSubjectAccessReviewResponse {
|
||||
status: {
|
||||
allowed: boolean;
|
||||
};
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
namespace: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* getSelfSubjectAccessReview is used to retrieve the self subject access review for a given namespace.
|
||||
* It's great to use this to determine if a user has access to a namespace.
|
||||
* @returns the self subject access review for the given namespace
|
||||
* */
|
||||
export async function getSelfSubjectAccessReview(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
verb = 'list',
|
||||
resource = 'deployments',
|
||||
group = 'apps'
|
||||
) {
|
||||
try {
|
||||
const { data: accessReview } =
|
||||
await axios.post<SelfSubjectAccessReviewResponse>(
|
||||
`endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`,
|
||||
{
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
group,
|
||||
resource,
|
||||
verb,
|
||||
namespace: namespaceName,
|
||||
},
|
||||
},
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
}
|
||||
);
|
||||
return accessReview;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve self subject access review'
|
||||
);
|
||||
}
|
||||
}
|
37
app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
Normal file
37
app/react/kubernetes/namespaces/queries/useNamespaceQuery.ts
Normal 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');
|
||||
}
|
||||
}
|
|
@ -1,17 +1,13 @@
|
|||
import { useQuery } from 'react-query';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { error as notifyError } from '@/portainer/services/notifications';
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
|
||||
import {
|
||||
getNamespaces,
|
||||
getNamespace,
|
||||
getSelfSubjectAccessReview,
|
||||
} from './service';
|
||||
import { Namespaces } from './types';
|
||||
import { Namespaces } from '../types';
|
||||
import { getSelfSubjectAccessReview } from '../getSelfSubjectAccessReview';
|
||||
|
||||
export function useNamespaces(
|
||||
export function useNamespacesQuery(
|
||||
environmentId: EnvironmentId,
|
||||
options?: { autoRefreshRate?: number }
|
||||
) {
|
||||
|
@ -46,14 +42,14 @@ export function useNamespaces(
|
|||
);
|
||||
}
|
||||
|
||||
export function useNamespace(environmentId: EnvironmentId, namespace: string) {
|
||||
return useQuery(
|
||||
['environments', environmentId, 'kubernetes', 'namespaces', namespace],
|
||||
() => getNamespace(environmentId, namespace),
|
||||
{
|
||||
onError: (err) => {
|
||||
notifyError('Failure', err as Error, 'Unable to get namespace.');
|
||||
},
|
||||
}
|
||||
);
|
||||
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching
|
||||
async function getNamespaces(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: namespaces } = await axios.get<Namespaces>(
|
||||
`kubernetes/${environmentId}/namespaces`
|
||||
);
|
||||
return namespaces;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
|
||||
}
|
||||
}
|
|
@ -1,74 +0,0 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { Namespaces, SelfSubjectAccessReviewResponse } from './types';
|
||||
|
||||
// getNamespace is used to retrieve a namespace using the Portainer backend
|
||||
export async function getNamespace(
|
||||
environmentId: EnvironmentId,
|
||||
namespace: string
|
||||
) {
|
||||
try {
|
||||
const { data: ns } = await axios.get<Namespaces>(
|
||||
buildUrl(environmentId, namespace)
|
||||
);
|
||||
return ns;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespace');
|
||||
}
|
||||
}
|
||||
|
||||
// getNamespaces is used to retrieve namespaces using the Portainer backend with caching
|
||||
export async function getNamespaces(environmentId: EnvironmentId) {
|
||||
try {
|
||||
const { data: namespaces } = await axios.get<Namespaces>(
|
||||
buildUrl(environmentId)
|
||||
);
|
||||
return namespaces;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to retrieve namespaces');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSelfSubjectAccessReview(
|
||||
environmentId: EnvironmentId,
|
||||
namespaceName: string,
|
||||
verb = 'list',
|
||||
resource = 'deployments',
|
||||
group = 'apps'
|
||||
) {
|
||||
try {
|
||||
const { data: accessReview } =
|
||||
await axios.post<SelfSubjectAccessReviewResponse>(
|
||||
`endpoints/${environmentId}/kubernetes/apis/authorization.k8s.io/v1/selfsubjectaccessreviews`,
|
||||
{
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
group,
|
||||
resource,
|
||||
verb,
|
||||
namespace: namespaceName,
|
||||
},
|
||||
},
|
||||
apiVersion: 'authorization.k8s.io/v1',
|
||||
kind: 'SelfSubjectAccessReview',
|
||||
}
|
||||
);
|
||||
return accessReview;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(
|
||||
e as Error,
|
||||
'Unable to retrieve self subject access review'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildUrl(environmentId: EnvironmentId, namespace?: string) {
|
||||
let url = `kubernetes/${environmentId}/namespaces`;
|
||||
|
||||
if (namespace) {
|
||||
url += `/${namespace}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
|
@ -4,14 +4,3 @@ export interface Namespaces {
|
|||
IsSystem: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfSubjectAccessReviewResponse {
|
||||
status: {
|
||||
allowed: boolean;
|
||||
};
|
||||
spec: {
|
||||
resourceAttributes: {
|
||||
namespace: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,8 +9,8 @@ import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
|||
import { pluralize } from '@/portainer/helpers/strings';
|
||||
import { DefaultDatatableSettings } from '@/react/kubernetes/datatables/DefaultDatatableSettings';
|
||||
import { isSystemNamespace } from '@/react/kubernetes/namespaces/utils';
|
||||
import { useNamespaces } from '@/react/kubernetes/namespaces/queries';
|
||||
import { SystemResourceDescription } from '@/react/kubernetes/datatables/SystemResourceDescription';
|
||||
import { useNamespacesQuery } from '@/react/kubernetes/namespaces/queries/useNamespacesQuery';
|
||||
|
||||
import { Datatable, Table, TableSettingsMenu } from '@@/datatables';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
@ -33,7 +33,8 @@ const settingsStore = createStore(storageKey);
|
|||
export function ServicesDatatable() {
|
||||
const tableState = useTableState(settingsStore, storageKey);
|
||||
const environmentId = useEnvironmentId();
|
||||
const { data: namespaces, ...namespacesQuery } = useNamespaces(environmentId);
|
||||
const { data: namespaces, ...namespacesQuery } =
|
||||
useNamespacesQuery(environmentId);
|
||||
const namespaceNames = (namespaces && Object.keys(namespaces)) || [];
|
||||
const { data: services, ...servicesQuery } = useServicesForCluster(
|
||||
environmentId,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue