1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

fix(edge groups): make large edge groups editable [BE-11720] (#558)

This commit is contained in:
Viktor Pettersson 2025-03-28 15:16:05 +01:00 committed by GitHub
parent 7c01f84a5c
commit 1d12011eb5
14 changed files with 373 additions and 46 deletions

View file

@ -1,10 +1,9 @@
import { EdgeTypes, EnvironmentId } from '@/react/portainer/environments/types';
import { EdgeEnvironmentsAssociationTable } from '@/react/edge/components/EdgeEnvironmentsAssociationTable';
import { FormError } from '@@/form-components/FormError';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
export function AssociatedEdgeEnvironmentsSelector({
onChange,
value,
@ -20,9 +19,9 @@ export function AssociatedEdgeEnvironmentsSelector({
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
You can also select environments individually by moving them to the
associated environments table. Simply click on any environment entry to
move it from one table to the other.
</div>
{error && (
@ -36,7 +35,7 @@ export function AssociatedEdgeEnvironmentsSelector({
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<EdgeGroupAssociationTable
<EdgeEnvironmentsAssociationTable
title="Available environments"
query={{
types: EdgeTypes,
@ -51,7 +50,7 @@ export function AssociatedEdgeEnvironmentsSelector({
/>
</div>
<div className="w-1/2">
<EdgeGroupAssociationTable
<EdgeEnvironmentsAssociationTable
title="Associated environments"
query={{
types: EdgeTypes,

View file

@ -0,0 +1,116 @@
import { useState } from 'react';
import {
EdgeGroupId,
Environment,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { FormError } from '@@/form-components/FormError';
import { ArrayError } from '@@/form-components/InputList/InputList';
import { EdgeGroupAssociationTable } from './EdgeGroupAssociationTable';
export function AssociatedEdgeGroupEnvironmentsSelector({
onChange,
value,
error,
edgeGroupId,
}: {
onChange: (
value: EnvironmentId[],
meta: { type: 'add' | 'remove'; value: EnvironmentId }
) => void;
value: EnvironmentId[];
error?: ArrayError<Array<EnvironmentId>>;
edgeGroupId?: EdgeGroupId;
}) {
const [associatedEnvironments, setAssociatedEnvironments] = useState<
Environment[]
>([]);
const [dissociatedEnvironments, setDissociatedEnvironments] = useState<
Environment[]
>([]);
function updateEditedEnvironments(env: Environment) {
// If the env is associated, this update is a dissociation
const isAssociated = value.includes(env.Id);
setAssociatedEnvironments((prev) =>
isAssociated
? prev.filter((prevEnv) => prevEnv.Id !== env.Id)
: [...prev, env]
);
setDissociatedEnvironments((prev) =>
isAssociated
? [...prev, env]
: prev.filter((prevEnv) => prevEnv.Id !== env.Id)
);
const updatedValue = isAssociated
? value.filter((id) => id !== env.Id)
: [...value, env.Id];
onChange(updatedValue, {
type: isAssociated ? 'remove' : 'add',
value: env.Id,
});
}
return (
<>
<div className="col-sm-12 small text-muted">
You can select which environment should be part of this group by moving
them to the associated environments table. Simply click on any
environment entry to move it from one table to the other.
</div>
{error && (
<div className="col-sm-12">
<FormError>
{typeof error === 'string' ? error : error.join(', ')}
</FormError>
</div>
)}
<div className="col-sm-12 mt-4">
<div className="flex">
<div className="w-1/2">
<EdgeGroupAssociationTable
title="Available environments"
query={{
excludeEdgeGroupIds: edgeGroupId ? [edgeGroupId] : [],
}}
addEnvironments={dissociatedEnvironments}
excludeEnvironments={associatedEnvironments}
onClickRow={(env) => {
if (!value.includes(env.Id)) {
updateEditedEnvironments(env);
}
}}
data-cy="edgeGroupCreate-availableEndpoints"
/>
</div>
<div className="w-1/2">
<EdgeGroupAssociationTable
title="Associated environments"
query={{
edgeGroupIds: edgeGroupId ? [edgeGroupId] : [],
endpointIds: edgeGroupId ? undefined : [], // workaround to avoid showing all environments for new edge group
}}
addEnvironments={associatedEnvironments}
excludeEnvironments={dissociatedEnvironments}
onClickRow={(env) => {
if (value.includes(env.Id)) {
updateEditedEnvironments(env);
}
}}
data-cy="edgeGroupCreate-associatedEndpointsTable"
/>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,77 @@
import { useMemo, useState } from 'react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useTags } from '@/portainer/tags/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { AutomationTestingProps } from '@/types';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
import { Datatable, TableRow } from '@@/datatables';
import { columns, DecoratedEnvironment } from './associationTableColumnHelper';
export function EdgeEnvironmentsAssociationTable({
title,
query,
onClickRow = () => {},
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
onClickRow?: (env: Environment) => void;
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(0);
const environmentsQuery = useEnvironmentList({
pageLimit: tableState.pageSize,
page: page + 1,
search: tableState.search,
sort: tableState.sortBy?.id as 'Group' | 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
types: EdgeTypes,
...query,
});
const groupsQuery = useGroups({
enabled: environmentsQuery.environments.length > 0,
});
const tagsQuery = useTags({
enabled: environmentsQuery.environments.length > 0,
});
const memoizedEnvironments: Array<DecoratedEnvironment> = useMemo(
() =>
environmentsQuery.environments.map((env) => ({
...env,
Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
Tags: env.TagIds.map(
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
),
})),
[environmentsQuery.environments, groupsQuery.data, tagsQuery.data]
);
const { totalCount } = environmentsQuery;
return (
<Datatable<DecoratedEnvironment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={memoizedEnvironments}
isServerSidePagination
page={page}
onPageChange={setPage}
totalCount={totalCount}
renderRow={(row) => (
<TableRow<DecoratedEnvironment>
cells={row.getVisibleCells()}
onClick={() => onClickRow(row.original)}
/>
)}
data-cy={dataCy}
disableSelect
/>
);
}

View file

@ -1,52 +1,32 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { useMemo, useState } from 'react';
import { useTags } from '@/portainer/tags/queries';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { Environment } from '@/react/portainer/environments/types';
import { EdgeTypes, Environment } from '@/react/portainer/environments/types';
import { AutomationTestingProps } from '@/types';
import {
columns,
DecoratedEnvironment,
} from '@/react/edge/components/associationTableColumnHelper';
import { Datatable, TableRow } from '@@/datatables';
import { useTableStateWithoutStorage } from '@@/datatables/useTableState';
type DecoratedEnvironment = Environment & {
Tags: string[];
Group: string;
};
const columHelper = createColumnHelper<DecoratedEnvironment>();
const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor('Group', {
header: 'Group',
id: 'Group',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor((row) => row.Tags.join(','), {
header: 'Tags',
id: 'tags',
enableSorting: false,
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
];
export function EdgeGroupAssociationTable({
title,
query,
onClickRow = () => {},
addEnvironments = [],
excludeEnvironments = [],
'data-cy': dataCy,
}: {
title: string;
query: EnvironmentsQueryParams;
onClickRow?: (env: Environment) => void;
addEnvironments?: Environment[];
excludeEnvironments?: Environment[];
} & AutomationTestingProps) {
const tableState = useTableStateWithoutStorage('Name');
const [page, setPage] = useState(0);
@ -56,8 +36,11 @@ export function EdgeGroupAssociationTable({
search: tableState.search,
sort: tableState.sortBy?.id as 'Group' | 'Name',
order: tableState.sortBy?.desc ? 'desc' : 'asc',
types: EdgeTypes,
excludeIds: excludeEnvironments?.map((env) => env.Id),
...query,
});
const groupsQuery = useGroups({
enabled: environmentsQuery.environments.length > 0,
});
@ -65,7 +48,7 @@ export function EdgeGroupAssociationTable({
enabled: environmentsQuery.environments.length > 0,
});
const environments: Array<DecoratedEnvironment> = useMemo(
const memoizedEnvironments: Array<DecoratedEnvironment> = useMemo(
() =>
environmentsQuery.environments.map((env) => ({
...env,
@ -79,12 +62,29 @@ export function EdgeGroupAssociationTable({
const { totalCount } = environmentsQuery;
const memoizedAddEnvironments: Array<DecoratedEnvironment> = useMemo(
() =>
addEnvironments.map((env) => ({
...env,
Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
Tags: env.TagIds.map(
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
),
})),
[addEnvironments, groupsQuery.data, tagsQuery.data]
);
// Filter out environments that are already in the table, this is to prevent duplicates, which can happen when an environment is associated and then disassociated
const filteredAddEnvironments = memoizedAddEnvironments.filter(
(env) => !memoizedEnvironments.some((e) => e.Id === env.Id)
);
return (
<Datatable<DecoratedEnvironment>
title={title}
columns={columns}
settingsManager={tableState}
dataset={environments}
dataset={memoizedEnvironments.concat(filteredAddEnvironments)}
isServerSidePagination
page={page}
onPageChange={setPage}

View file

@ -0,0 +1,30 @@
import { createColumnHelper } from '@tanstack/react-table';
import { truncate } from 'lodash';
import { Environment } from '@/react/portainer/environments/types';
export type DecoratedEnvironment = Environment & {
Tags: string[];
Group: string;
};
const columHelper = createColumnHelper<DecoratedEnvironment>();
export const columns = [
columHelper.accessor('Name', {
header: 'Name',
id: 'Name',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor('Group', {
header: 'Group',
id: 'Group',
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
columHelper.accessor((row) => row.Tags.join(','), {
header: 'Tags',
id: 'tags',
enableSorting: false,
cell: ({ getValue }) => truncate(getValue(), { length: 64 }),
}),
];

View file

@ -35,6 +35,7 @@ export function EdgeGroupForm({
name: group.Name,
partialMatch: group.PartialMatch,
tagIds: group.TagIds,
edgeGroupId: group.Id,
}
: {
name: '',
@ -42,6 +43,7 @@ export function EdgeGroupForm({
environmentIds: [],
partialMatch: false,
tagIds: [],
edgeGroupId: 0,
}
}
onSubmit={onSubmit}

View file

@ -1,6 +1,6 @@
import { useFormikContext } from 'formik';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { AssociatedEdgeGroupEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeGroupEnvironmentsSelector';
import { FormSection } from '@@/form-components/FormSection';
import { confirmDestructive } from '@@/modals/confirm';
@ -14,7 +14,7 @@ export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
return (
<FormSection title="Associated environments">
<div className="form-group">
<AssociatedEdgeEnvironmentsSelector
<AssociatedEdgeGroupEnvironmentsSelector
value={values.environmentIds}
error={errors.environmentIds}
onChange={async (environmentIds, meta) => {
@ -33,6 +33,7 @@ export function StaticGroupFieldset({ isEdit }: { isEdit?: boolean }) {
setFieldValue('environmentIds', environmentIds);
}}
edgeGroupId={values.edgeGroupId}
/>
</div>
</FormSection>

View file

@ -1,7 +1,11 @@
import { EnvironmentId } from '@/react/portainer/environments/types';
import {
EdgeGroupId,
EnvironmentId,
} from '@/react/portainer/environments/types';
import { TagId } from '@/portainer/tags/types';
export interface FormValues {
edgeGroupId: EdgeGroupId;
name: string;
dynamic: boolean;
environmentIds: EnvironmentId[];

View file

@ -21,6 +21,7 @@ export function useValidation({
is: true,
then: (schema) => schema.min(1, 'Tags are required'),
}),
edgeGroupId: number().default(0).notRequired(),
}),
[nameValidation]
);

View file

@ -9,13 +9,10 @@ export function getInternalNodeIpAddress(node?: Node) {
const controlPlaneLabels = [
'node-role.kubernetes.io/control-plane',
'node-role.kubernetes.io/master',
'node.kubernetes.io/microk8s-controlplane'
'node.kubernetes.io/microk8s-controlplane',
];
const roleLabels = [
'kubernetes.io/role',
'node.kubernetes.io/role'
];
const roleLabels = ['kubernetes.io/role', 'node.kubernetes.io/role'];
export function getRole(node: Node): 'Control plane' | 'Worker' {
const hasControlPlaneLabel = controlPlaneLabels.some(

View file

@ -50,6 +50,7 @@ export interface BaseEnvironmentsQueryParams {
edgeCheckInPassedSeconds?: number;
platformTypes?: PlatformType[];
edgeGroupIds?: EdgeGroupId[];
excludeEdgeGroupIds?: EdgeGroupId[];
}
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &