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:
parent
7c01f84a5c
commit
1d12011eb5
14 changed files with 373 additions and 46 deletions
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
|
|
30
app/react/edge/components/associationTableColumnHelper.ts
Normal file
30
app/react/edge/components/associationTableColumnHelper.ts
Normal 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 }),
|
||||
}),
|
||||
];
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -21,6 +21,7 @@ export function useValidation({
|
|||
is: true,
|
||||
then: (schema) => schema.min(1, 'Tags are required'),
|
||||
}),
|
||||
edgeGroupId: number().default(0).notRequired(),
|
||||
}),
|
||||
[nameValidation]
|
||||
);
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -50,6 +50,7 @@ export interface BaseEnvironmentsQueryParams {
|
|||
edgeCheckInPassedSeconds?: number;
|
||||
platformTypes?: PlatformType[];
|
||||
edgeGroupIds?: EdgeGroupId[];
|
||||
excludeEdgeGroupIds?: EdgeGroupId[];
|
||||
}
|
||||
|
||||
export type EnvironmentsQueryParams = BaseEnvironmentsQueryParams &
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue