1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +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

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