feat(edge): associate edge env to meta fields [EE-3209] (#8551)

* refactor(edge/groups): load edge groups in selector

fix(edge/stacks): remove double groups title

* feat(edge): supply meta fields to edge script [EE-5043]

* feat(edge): auto assign aeec envs to groups and tags [EE-5043]

fix [EE-5043]

fix(envs): fix global key test

* fix(edge/groups): save group type

* refactor(edge/devices): move loading of devices to table

* refactor(tags): select paramter for query

* feat(edge/devices): show meta fields

* refactor(home): simplify filter

* feat(edge/devices): filter by meta fields

* refactor(edge/devices): break filter and loading hook
pull/8619/head
Chaim Lev-Ari 2023-03-06 22:25:04 +02:00 committed by GitHub
parent 03712966e4
commit 70710cfeb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 554 additions and 263 deletions

View File

@ -1,19 +1,5 @@
<form class="form-horizontal">
<div class="col-sm-12 form-section-title"> Edge Groups </div>
<div class="form-group">
<div class="col-sm-12">
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
</div>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge group
selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
</p>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Compose && $ctrl.hasKubeEndpoint()">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Edge groups with kubernetes environments no longer support compose deployment types in Portainer. Please select
edge groups that only have docker environments when using compose deployment types.
</p>
</div>
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
<edge-stack-deployment-type-selector
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"

View File

@ -1,4 +1,4 @@
<form class="form-horizontal" name="EdgeGroupForm" ng-submit="$ctrl.formAction()">
<form class="form-horizontal" name="EdgeGroupForm" ng-submit="$ctrl.handleSubmit()">
<div class="form-group">
<label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left"> Name </label>
<div class="col-sm-9 col-lg-10">

View File

@ -37,6 +37,7 @@ export class EdgeGroupFormController {
this.onChangeDynamic = this.onChangeDynamic.bind(this);
this.onChangeModel = this.onChangeModel.bind(this);
this.onChangePartialMatch = this.onChangePartialMatch.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
$scope.$watch(
() => this.model,
@ -118,6 +119,10 @@ export class EdgeGroupFormController {
});
}
handleSubmit() {
this.formAction(this.model);
}
$onInit() {
this.getTags();
}

View File

@ -12,7 +12,13 @@ export const componentsModule = angular
.module('portainer.edge.react.components', [])
.component(
'edgeGroupsSelector',
r2a(EdgeGroupsSelector, ['items', 'onChange', 'value'])
r2a(withReactQuery(EdgeGroupsSelector), [
'onChange',
'value',
'error',
'horizontal',
'isGroupVisible',
])
)
.component(
'edgeScriptForm',
@ -21,6 +27,7 @@ export const componentsModule = angular
'commands',
'isNomadTokenVisible',
'asyncMode',
'showMetaFields',
])
)
.component(

View File

@ -21,7 +21,6 @@ export class CreateEdgeGroupController {
};
this.createGroup = this.createGroup.bind(this);
this.createGroupAsync = this.createGroupAsync.bind(this);
}
async $onInit() {
@ -31,20 +30,18 @@ export class CreateEdgeGroupController {
this.state.loaded = true;
}
createGroup() {
return this.$async(this.createGroupAsync);
}
async createGroupAsync() {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.create(this.model);
this.Notifications.success('Success', 'Edge group successfully created');
this.$state.go('edge.groups');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create edge group');
} finally {
this.state.actionInProgress = false;
}
async createGroup(model) {
return this.$async(async () => {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.create(model);
this.Notifications.success('Success', 'Edge group successfully created');
this.$state.go('edge.groups');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to create edge group');
} finally {
this.state.actionInProgress = false;
}
});
}
}

View File

@ -13,7 +13,6 @@ export class EditEdgeGroupController {
};
this.updateGroup = this.updateGroup.bind(this);
this.updateGroupAsync = this.updateGroupAsync.bind(this);
}
async $onInit() {
@ -28,20 +27,18 @@ export class EditEdgeGroupController {
this.state.loaded = true;
}
updateGroup() {
return this.$async(this.updateGroupAsync);
}
async updateGroupAsync() {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.update(this.model);
this.Notifications.success('Success', 'Edge group successfully updated');
this.$state.go('edge.groups');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to update edge group');
} finally {
this.state.actionInProgress = false;
}
updateGroup(group) {
return this.$async(async () => {
this.state.actionInProgress = true;
try {
await this.EdgeGroupService.update(group);
this.Notifications.success('Success', 'Edge group successfully updated');
this.$state.go('edge.groups');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to update edge group');
} finally {
this.state.actionInProgress = false;
}
});
}
}

View File

@ -39,19 +39,7 @@
</div>
<!-- !name-input -->
<div class="col-sm-12 form-section-title"> Edge Groups </div>
<div class="form-group" ng-if="$ctrl.edgeGroups">
<div class="col-sm-12">
<edge-groups-selector ng-if="!$ctrl.noGroups" value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
</div>
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
</div>
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.formValues.DeploymentType === undefined">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge
group selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
</p>
</div>
<edge-groups-selector ng-if="!$ctrl.noGroups" value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
<edge-stack-deployment-type-selector
value="$ctrl.formValues.DeploymentType"

View File

@ -161,6 +161,7 @@ export const componentsModule = angular
'options',
'isMulti',
'isClearable',
'components',
])
)
.component(

View File

@ -14,14 +14,14 @@ const tagKeys = {
tag: (id: TagId) => [...tagKeys.all, id] as const,
};
export function useTags<T = Tag>(select?: (tags: Tag[]) => T[]) {
const { data, isLoading } = useQuery(tagKeys.all, () => getTags(), {
export function useTags<T = Tag[]>({
select,
}: { select?: (tags: Tag[]) => T } = {}) {
return useQuery(tagKeys.all, () => getTags(), {
staleTime: 50,
select,
...withError('Failed to retrieve tags'),
});
return { tags: data, isLoading };
}
export function useCreateTagMutation() {

View File

@ -22,17 +22,17 @@ interface Option {
export function TagSelector({ value, allowCreate = false, onChange }: Props) {
// change the struct because react-select has a bug with Creatable (https://github.com/JedWatson/react-select/issues/3417#issuecomment-461868989)
const tagsQuery = useTags((tags) =>
tags.map((opt) => ({ label: opt.Name, value: opt.ID }))
);
const tagsQuery = useTags({
select: (tags) => tags?.map((opt) => ({ label: opt.Name, value: opt.ID })),
});
const createTagMutation = useCreateTagMutation();
if (!tagsQuery.tags) {
if (!tagsQuery.data) {
return null;
}
const { tags } = tagsQuery;
const { data: tags } = tagsQuery;
const selectedTags = _.compact(
value.map((id) => tags.find((tag) => tag.value === id))

View File

@ -1,4 +1,8 @@
import { OptionsOrGroups } from 'react-select';
import {
GroupBase,
OptionsOrGroups,
SelectComponentsConfig,
} from 'react-select';
import _ from 'lodash';
import { AutomationTestingProps } from '@/types';
@ -10,9 +14,10 @@ export interface Option<TValue> {
label: string;
}
type Group<TValue> = { label: string; options: Option<TValue>[] };
type Options<TValue> = OptionsOrGroups<Option<TValue>, Group<TValue>>;
type Options<TValue> = OptionsOrGroups<
Option<TValue>,
GroupBase<Option<TValue>>
>;
interface SharedProps extends AutomationTestingProps {
name?: string;
@ -28,6 +33,11 @@ interface MultiProps<TValue> extends SharedProps {
onChange(value: readonly TValue[]): void;
options: Options<TValue>;
isMulti: true;
components?: SelectComponentsConfig<
Option<TValue>,
true,
GroupBase<Option<TValue>>
>;
}
interface SingleProps<TValue> extends SharedProps {
@ -35,6 +45,11 @@ interface SingleProps<TValue> extends SharedProps {
onChange(value: TValue | null): void;
options: Options<TValue>;
isMulti?: never;
components?: SelectComponentsConfig<
Option<TValue>,
false,
GroupBase<Option<TValue>>
>;
}
type Props<TValue> = MultiProps<TValue> | SingleProps<TValue>;
@ -66,6 +81,7 @@ export function SingleSelect<TValue = string>({
placeholder,
isClearable,
bindToBody,
components,
}: SingleProps<TValue>) {
const selectedValue =
value || (typeof value === 'number' && value === 0)
@ -86,6 +102,7 @@ export function SingleSelect<TValue = string>({
placeholder={placeholder}
isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
/>
);
}
@ -124,6 +141,7 @@ export function MultiSelect<TValue = string>({
disabled,
isClearable,
bindToBody,
components,
}: Omit<MultiProps<TValue>, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value);
return (
@ -142,12 +160,13 @@ export function MultiSelect<TValue = string>({
placeholder={placeholder}
isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
/>
);
}
function isGroup<TValue>(
option: Option<TValue> | Group<TValue>
): option is Group<TValue> {
option: Option<TValue> | GroupBase<Option<TValue>>
): option is GroupBase<Option<TValue>> {
return 'options' in option;
}

View File

@ -16,6 +16,9 @@ const edgePropertiesFormInitialValues: ScriptFormValues = {
nomadToken: '',
authEnabled: true,
tlsEnabled: false,
edgeGroupsIds: [],
group: 0,
tagsIds: [],
};
interface Props {
@ -23,6 +26,7 @@ interface Props {
commands: CommandTab[] | Partial<Record<OS, CommandTab[]>>;
isNomadTokenVisible?: boolean;
asyncMode?: boolean;
showMetaFields?: boolean;
}
export function EdgeScriptForm({
@ -30,6 +34,7 @@ export function EdgeScriptForm({
commands,
isNomadTokenVisible,
asyncMode,
showMetaFields,
children,
}: PropsWithChildren<Props>) {
const showOsSelector = !(commands instanceof Array);
@ -50,6 +55,7 @@ export function EdgeScriptForm({
isNomadTokenVisible && values.platform === 'nomad'
}
hideIdGetter={edgeInfo.id !== undefined}
showMetaFields={showMetaFields}
/>
<div className="mt-8">
{showOsSelector && (

View File

@ -1,9 +1,14 @@
import { useFormikContext, Field } from 'formik';
import { GroupField } from '@/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { SwitchField } from '@@/form-components/SwitchField';
import { TextTip } from '@@/Tip/TextTip';
import { TagSelector } from '@@/TagSelector';
import { EdgeGroupsSelector } from '../../edge-stacks/components/EdgeGroupsSelector';
import { NomadTokenField } from './NomadTokenField';
import { ScriptFormValues } from './types';
@ -11,16 +16,36 @@ import { ScriptFormValues } from './types';
interface Props {
isNomadTokenVisible?: boolean;
hideIdGetter?: boolean;
showMetaFields?: boolean;
}
export function EdgeScriptSettingsFieldset({
isNomadTokenVisible,
hideIdGetter,
showMetaFields,
}: Props) {
const { values, setFieldValue } = useFormikContext<ScriptFormValues>();
return (
<>
{showMetaFields && (
<>
<GroupField name="group" />
<EdgeGroupsSelector
value={values.edgeGroupsIds}
onChange={(value) => setFieldValue('edgeGroupsIds', value)}
isGroupVisible={(group) => !group.Dynamic}
horizontal
/>
<TagSelector
value={values.tagsIds}
onChange={(value) => setFieldValue('tagsIds', value)}
/>
</>
)}
{!hideIdGetter && (
<>
<FormControl

View File

@ -52,14 +52,6 @@ export const commandsTabs: Record<string, CommandTab> = {
},
} as const;
function buildDockerEnvVars(envVars: string, defaultVars: string[]) {
const vars = defaultVars.concat(
envVars.split(',').filter((s) => s.length > 0)
);
return vars.map((s) => `-e ${s}`).join(' \\\n ');
}
export function buildLinuxStandaloneCommand(
agentVersion: string,
edgeKey: string,
@ -70,16 +62,16 @@ export function buildLinuxStandaloneCommand(
) {
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(
envVars,
buildDefaultEnvVars(
const env = buildDockerEnvVars(envVars, [
...buildDefaultDockerEnvVars(
edgeKey,
allowSelfSignedCertificates,
!edgeIdGenerator ? edgeId : undefined,
agentSecret,
useAsyncMode
)
);
),
...metaEnvVars(properties),
]);
return `${
edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : ''
@ -106,16 +98,16 @@ export function buildWindowsStandaloneCommand(
) {
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(
envVars,
buildDefaultEnvVars(
const env = buildDockerEnvVars(envVars, [
...buildDefaultDockerEnvVars(
edgeKey,
allowSelfSignedCertificates,
edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId,
agentSecret,
useAsyncMode
)
);
),
...metaEnvVars(properties),
]);
return `${
edgeIdGenerator
@ -144,7 +136,7 @@ export function buildLinuxSwarmCommand(
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(envVars, [
...buildDefaultEnvVars(
...buildDefaultDockerEnvVars(
edgeKey,
allowSelfSignedCertificates,
!edgeIdGenerator ? edgeId : undefined,
@ -152,6 +144,7 @@ export function buildLinuxSwarmCommand(
useAsyncMode
),
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
...metaEnvVars(properties),
]);
return `${
@ -186,7 +179,7 @@ export function buildWindowsSwarmCommand(
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(envVars, [
...buildDefaultEnvVars(
...buildDefaultDockerEnvVars(
edgeKey,
allowSelfSignedCertificates,
edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId,
@ -194,6 +187,7 @@ export function buildWindowsSwarmCommand(
useAsyncMode
),
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
...metaEnvVars(properties),
]);
return `${
@ -229,17 +223,18 @@ export function buildLinuxKubernetesCommand(
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const agentShortVersion = getAgentShortVersion(agentVersion);
let envVarsTrimmed = envVars.trim();
if (useAsyncMode) {
envVarsTrimmed += `EDGE_ASYNC=1`;
}
const allEnvVars = buildEnvVars(
envVars,
_.compact([useAsyncMode && 'EDGE_ASYNC=1', ...metaEnvVars(properties)])
);
const idEnvVar = edgeIdGenerator
? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n`
: '';
const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
const selfSigned = allowSelfSignedCertificates ? '1' : '0';
return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${envVarsTrimmed}"`;
return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-setup.sh | bash -s -- "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${agentSecret}" "${allEnvVars}"`;
}
export function buildLinuxNomadCommand(
@ -259,10 +254,11 @@ export function buildLinuxNomadCommand(
} = properties;
const agentShortVersion = getAgentShortVersion(agentVersion);
let envVarsTrimmed = envVars.trim();
if (useAsyncMode) {
envVarsTrimmed += `EDGE_ASYNC=1`;
}
const allEnvVars = buildEnvVars(
envVars,
_.compact([useAsyncMode && 'EDGE_ASYNC=1', ...metaEnvVars(properties)])
);
const selfSigned = allowSelfSignedCertificates ? '1' : '0';
const idEnvVar = edgeIdGenerator
@ -270,10 +266,16 @@ export function buildLinuxNomadCommand(
: '';
const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-nomad-setup.sh | bash -s -- "${nomadToken}" "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${envVarsTrimmed}" "${agentSecret}" "${tlsEnabled}"`;
return `${idEnvVar}curl https://downloads.portainer.io/ee${agentShortVersion}/portainer-edge-agent-nomad-setup.sh | bash -s -- "${nomadToken}" "${edgeIdVar}" "${edgeKey}" "${selfSigned}" "${allEnvVars}" "${agentSecret}" "${tlsEnabled}"`;
}
function buildDefaultEnvVars(
function buildDockerEnvVars(envVars: string, moreVars: string[]) {
const vars = moreVars.concat(envVars.split(',').filter((s) => s.length > 0));
return vars.map((s) => `-e ${s}`).join(' \\\n ');
}
function buildDefaultDockerEnvVars(
edgeKey: string,
allowSelfSignedCerts: boolean,
edgeId = '$PORTAINER_EDGE_ID',
@ -289,3 +291,22 @@ function buildDefaultEnvVars(
useAsyncMode ? 'EDGE_ASYNC=1' : '',
]);
}
const ENV_VAR_SEPARATOR = ',';
const VAR_LIST_SEPARATOR = ':';
function buildEnvVars(envVars: string, moreVars: string[]) {
return _.compact([envVars.trim(), ...moreVars]).join(ENV_VAR_SEPARATOR);
}
function metaEnvVars({
edgeGroupsIds,
group,
tagsIds,
}: Pick<ScriptFormValues, 'edgeGroupsIds' | 'tagsIds' | 'group'>) {
return _.compact([
edgeGroupsIds.length &&
`EDGE_GROUPS=${edgeGroupsIds.join(VAR_LIST_SEPARATOR)}`,
group && `PORTAINER_GROUP=${group}`,
tagsIds.length && `PORTAINER_TAGS=${tagsIds.join(VAR_LIST_SEPARATOR)}`,
]);
}

View File

@ -1,3 +1,8 @@
import { TagId } from '@/portainer/tags/types';
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { EdgeGroup } from '../../edge-groups/types';
export type Platform = 'standalone' | 'swarm' | 'k8s' | 'nomad';
export type OS = 'win' | 'linux';
@ -13,6 +18,10 @@ export interface ScriptFormValues {
platform: Platform;
edgeIdGenerator?: string;
group: EnvironmentGroupId;
edgeGroupsIds: Array<EdgeGroup['Id']>;
tagsIds: Array<TagId>;
}
export interface EdgeInfo {

View File

@ -12,27 +12,24 @@ import { useSearchBarState } from '@@/datatables/SearchBar';
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
import { columns } from './columns';
import { Filter } from './Filter';
import { useEnvironments } from './useEnvironments';
const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey, 'Name');
interface Props {
devices: Environment[];
isLoading: boolean;
totalCount: number;
}
export function Datatable({ devices, isLoading, totalCount }: Props) {
export function Datatable() {
const associateMutation = useAssociateDeviceMutation();
const licenseOverused = useLicenseOverused();
const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey);
const { data: environments, totalCount, isLoading } = useEnvironments();
return (
<GenericDatatable
columns={columns}
dataset={devices}
dataset={environments}
initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy}
@ -62,6 +59,7 @@ export function Datatable({ devices, isLoading, totalCount }: Props) {
)}
isLoading={isLoading}
totalCount={totalCount}
description={<Filter />}
/>
);

View File

@ -0,0 +1,50 @@
import { HomepageFilter } from '@/react/portainer/HomeView/EnvironmentList/HomepageFilter';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { useTags } from '@/portainer/tags/queries';
import { useFilterStore } from './filter-store';
export function Filter() {
const edgeGroupsQuery = useEdgeGroups();
const groupsQuery = useGroups();
const tagsQuery = useTags();
const filterStore = useFilterStore();
if (!edgeGroupsQuery.data || !groupsQuery.data || !tagsQuery.data) {
return null;
}
return (
<div className="flex w-full gap-5 [&>*]:w-1/5">
<HomepageFilter
onChange={(f) => filterStore.setEdgeGroups(f)}
placeHolder="Edge groups"
value={filterStore.edgeGroups}
filterOptions={edgeGroupsQuery.data.map((g) => ({
label: g.Name,
value: g.Id,
}))}
/>
<HomepageFilter
onChange={(f) => filterStore.setGroups(f)}
placeHolder="Group"
value={filterStore.groups}
filterOptions={groupsQuery.data.map((g) => ({
label: g.Name,
value: g.Id,
}))}
/>
<HomepageFilter
onChange={(f) => filterStore.setTags(f)}
placeHolder="Tags"
value={filterStore.tags}
filterOptions={tagsQuery.data.map((g) => ({
label: g.Name,
value: g.ID,
}))}
/>
</div>
);
}

View File

@ -1,8 +1,8 @@
import { Column } from 'react-table';
import { CellProps, Column } from 'react-table';
import { Environment } from '@/react/portainer/environments/types';
import { WaitingRoomEnvironment } from '../types';
export const columns: readonly Column<Environment>[] = [
export const columns: readonly Column<WaitingRoomEnvironment>[] = [
{
Header: 'Name',
accessor: (row) => row.Name,
@ -21,4 +21,35 @@ export const columns: readonly Column<Environment>[] = [
canHide: false,
sortType: 'string',
},
{
Header: 'Edge Groups',
accessor: (row) => row.EdgeGroups || [],
Cell: ({ value }: CellProps<WaitingRoomEnvironment, string[]>) =>
value.join(', ') || '-',
id: 'edge-groups',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
{
Header: 'Group',
accessor: (row) => row.Group || '-',
id: 'group',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
{
Header: 'Tags',
accessor: (row) => row.Tags || [],
Cell: ({ value }: CellProps<WaitingRoomEnvironment, string[]>) =>
value.join(', ') || '-',
id: 'tags',
disableFilters: true,
Filter: () => null,
canHide: false,
sortType: 'string',
},
] as const;

View File

@ -0,0 +1,35 @@
import createStore from 'zustand';
import { persist } from 'zustand/middleware';
import { keyBuilder } from '@/react/hooks/useLocalStorage';
interface TableFiltersStore {
groups: number[];
setGroups(value: number[]): void;
edgeGroups: number[];
setEdgeGroups(value: number[]): void;
tags: number[];
setTags(value: number[]): void;
}
export const useFilterStore = createStore<TableFiltersStore>()(
persist(
(set) => ({
edgeGroups: [],
setEdgeGroups(edgeGroups: number[]) {
set({ edgeGroups });
},
groups: [],
setGroups(groups: number[]) {
set({ groups });
},
tags: [],
setTags(tags: number[]) {
set({ tags });
},
}),
{
name: keyBuilder('edge-devices-meta-filters'),
}
)
);

View File

@ -0,0 +1,74 @@
import _ from 'lodash';
import { useTags } from '@/portainer/tags/queries';
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EdgeTypes } from '@/react/portainer/environments/types';
import { WaitingRoomEnvironment } from '../types';
import { useFilterStore } from './filter-store';
export function useEnvironments() {
const filterStore = useFilterStore();
const edgeGroupsQuery = useEdgeGroups();
const filterByEnvironmentsIds = filterStore.edgeGroups.length
? _.compact(
filterStore.edgeGroups.flatMap(
(groupId) =>
edgeGroupsQuery.data?.find((g) => g.Id === groupId)?.Endpoints
)
)
: undefined;
const environmentsQuery = useEnvironmentList({
edgeDeviceUntrusted: true,
excludeSnapshots: true,
types: EdgeTypes,
tagIds: filterStore.tags.length ? filterStore.tags : undefined,
groupIds: filterStore.groups.length ? filterStore.groups : undefined,
endpointIds: filterByEnvironmentsIds,
});
const groupsQuery = useGroups({
select: (groups) =>
Object.fromEntries(groups.map((g) => [g.Id, g.Name] as const)),
});
const environmentEdgeGroupsQuery = useEdgeGroups({
select: (groups) =>
_.groupBy(
groups.flatMap((group) => {
const envs = group.Endpoints;
return envs.map((id) => ({ id, group: group.Name }));
}),
(env) => env.id
),
});
const tagsQuery = useTags({
select: (tags) =>
Object.fromEntries(tags.map((tag) => [tag.ID, tag.Name] as const)),
});
const envs: Array<WaitingRoomEnvironment> =
environmentsQuery.environments.map((env) => ({
...env,
Group: groupsQuery.data?.[env.GroupId] || '',
EdgeGroups:
environmentEdgeGroupsQuery.data?.[env.Id]?.map((env) => env.group) ||
[],
Tags:
_.compact(env.TagIds?.map((tagId) => tagsQuery.data?.[tagId])) || [],
}));
return {
data: envs,
isLoading:
environmentsQuery.isLoading ||
groupsQuery.isLoading ||
environmentEdgeGroupsQuery.isLoading ||
tagsQuery.isLoading,
totalCount: environmentsQuery.totalCount,
};
}

View File

@ -1,5 +1,3 @@
import { useEnvironmentList } from '@/react/portainer/environments/queries/useEnvironmentList';
import { EdgeTypes } from '@/react/portainer/environments/types';
import { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { InformationPanel } from '@@/InformationPanel';
@ -11,12 +9,6 @@ import { Datatable } from './Datatable';
export default withLimitToBE(WaitingRoomView);
function WaitingRoomView() {
const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDeviceUntrusted: true,
excludeSnapshots: true,
types: EdgeTypes,
});
return (
<>
<PageHeader
@ -35,11 +27,7 @@ function WaitingRoomView() {
</TextTip>
</InformationPanel>
<Datatable
devices={environments}
totalCount={totalCount}
isLoading={isLoading}
/>
<Datatable />
</>
);
}

View File

@ -0,0 +1,7 @@
import { Environment } from '@/react/portainer/environments/types';
export type WaitingRoomEnvironment = Environment & {
EdgeGroups: string[];
Tags: string[];
Group: string;
};

View File

@ -1,22 +1,29 @@
import { useQuery } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { EnvironmentType } from '@/react/portainer/environments/types';
import { EdgeGroup } from '../types';
interface EdgeGroupListItemResponse extends EdgeGroup {
EndpointTypes: Array<EnvironmentType>;
}
async function getEdgeGroups() {
try {
const { data } = await axios.get<EdgeGroup[]>('/edge_groups');
const { data } = await axios.get<EdgeGroupListItemResponse[]>(
'/edge_groups'
);
return data;
} catch (err) {
throw parseAxiosError(err as Error, 'Failed fetching edge groups');
}
}
export function useEdgeGroups<T = EdgeGroup[]>({
export function useEdgeGroups<T = EdgeGroupListItemResponse[]>({
select,
}: {
select?: (groups: EdgeGroup[]) => T;
select?: (groups: EdgeGroupListItemResponse[]) => T;
} = {}) {
return useQuery(['edge', 'groups'], getEdgeGroups, { select });
}

View File

@ -3,21 +3,78 @@ import _ from 'lodash';
import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { Select } from '@@/form-components/ReactSelect';
import { FormSection } from '@@/form-components/FormSection';
import { FormError } from '@@/form-components/FormError';
import { Link } from '@@/Link';
import { FormControl } from '@@/form-components/FormControl';
import { useEdgeGroups } from '../../edge-groups/queries/useEdgeGroups';
type SingleValue = EdgeGroup['Id'];
interface Props {
items: EdgeGroup[];
value: SingleValue[];
onChange: (value: SingleValue[]) => void;
error?: string | string[];
horizontal?: boolean;
isGroupVisible?(group: EdgeGroup): boolean;
}
export function EdgeGroupsSelector({ items, value, onChange }: Props) {
export function EdgeGroupsSelector({
value,
onChange,
error,
horizontal,
isGroupVisible = () => true,
}: Props) {
const selector = (
<InnerSelector
value={value}
onChange={onChange}
isGroupVisible={isGroupVisible}
/>
);
return horizontal ? (
<FormControl errors={error} label="Edge Groups">
{selector}
</FormControl>
) : (
<FormSection title="Edge Groups">
<div className="form-group">
<div className="col-sm-12">{selector} </div>
{error && (
<div className="col-sm-12">
<FormError>{error}</FormError>
</div>
)}
</div>
</FormSection>
);
}
function InnerSelector({
value,
onChange,
isGroupVisible,
}: {
isGroupVisible(group: EdgeGroup): boolean;
value: SingleValue[];
onChange: (value: SingleValue[]) => void;
}) {
const edgeGroupsQuery = useEdgeGroups();
if (!edgeGroupsQuery.data) {
return null;
}
const items = edgeGroupsQuery.data.filter(isGroupVisible);
const valueGroups = _.compact(
value.map((id) => items.find((item) => item.Id === id))
);
return (
return items.length ? (
<Select
aria-label="Edge groups"
options={items}
@ -31,5 +88,10 @@ export function EdgeGroupsSelector({ items, value, onChange }: Props) {
placeholder="Select one or multiple group(s)"
closeMenuOnSelect={false}
/>
) : (
<div className="small text-muted">
No Edge groups are available. Head over to the{' '}
<Link to="edge.groups">Edge groups view</Link> to create one.
</div>
);
}

View File

@ -130,17 +130,21 @@ export function EnvironmentItem({
}
function useEnvironmentTagNames(tagIds?: TagId[]) {
const { tags, isLoading } = useTags((tags) => {
if (!tagIds) {
return [];
}
return _.compact(
tagIds
.map((id) => tags.find((tag) => tag.ID === id))
.map((tag) => tag?.Name)
);
const tagsQuery = useTags({
select: (tags) => {
if (!tagIds) {
return [];
}
return _.compact(
tagIds
.map((id) => tags.find((tag) => tag.ID === id))
.map((tag) => tag?.Name)
);
},
});
const { data: tags, isLoading } = tagsQuery;
if (tags && tags.length > 0) {
return tags.join(', ');
}

View File

@ -29,7 +29,7 @@ import { PaginationControls } from '@@/PaginationControls';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useHomePageFilter } from './HomepageFilter';
import { ConnectionType, Filter } from './types';
import { ConnectionType } from './types';
import { EnvironmentItem } from './EnvironmentItem';
import { KubeconfigButton } from './KubeconfigButton';
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
@ -48,15 +48,16 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const { isAdmin } = useUser();
const currentEnvStore = useStore(environmentStore);
const [platformTypes, setPlatformTypes] = useHomePageFilter<
Filter<PlatformType>[]
>('platformType', []);
const [platformTypes, setPlatformTypes] = useHomePageFilter<PlatformType[]>(
'platformType',
[]
);
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
const [page, setPage] = useState(1);
const [connectionTypes, setConnectionTypes] = useHomePageFilter<
Filter<ConnectionType>[]
ConnectionType[]
>('connectionTypes', []);
const [statusFilter, setStatusFilter] = useHomePageFilter<
@ -77,20 +78,17 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
false
);
const [statusState, setStatusState] = useHomePageFilter<Filter[]>(
const [statusState, setStatusState] = useHomePageFilter<number[]>(
'status_state',
[]
);
const [tagState, setTagState] = useHomePageFilter<Filter[]>('tag_state', []);
const [groupState, setGroupState] = useHomePageFilter<Filter[]>(
const [tagState, setTagState] = useHomePageFilter<number[]>('tag_state', []);
const [groupState, setGroupState] = useHomePageFilter<number[]>(
'group_state',
[]
);
const [sortByState, setSortByState] = useHomePageFilter<Filter | undefined>(
'sort_by_state',
undefined
);
const [agentVersions, setAgentVersions] = useHomePageFilter<Filter<string>[]>(
const [agentVersions, setAgentVersions] = useHomePageFilter<string[]>(
'agentVersions',
[]
);
@ -98,17 +96,14 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const groupsQuery = useGroups();
const environmentsQueryParams: EnvironmentsQueryParams = {
types: getTypes(
platformTypes.map((p) => p.value),
connectionTypes.map((p) => p.value)
),
types: getTypes(platformTypes, connectionTypes),
search: searchBarValue,
status: statusFilter,
tagIds: tagFilter?.length ? tagFilter : undefined,
groupIds: groupFilter,
provisioned: true,
tagsPartialMatch: true,
agentVersions: agentVersions.map((a) => a.value),
agentVersions,
updateInformation: isBE,
edgeAsync: getEdgeAsyncValue(connectionTypes),
};
@ -202,11 +197,11 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
setAgentVersions={setAgentVersions}
agentVersions={agentVersions}
clearFilter={clearFilter}
sortOnchange={sortOnchange}
sortOnChange={sortOnchange}
sortOnDescending={sortOnDescending}
sortByDescending={sortByDescending}
sortByButton={sortByButton}
sortByState={sortByState}
sortByState={sortByFilter}
/>
</div>
<div
@ -305,50 +300,32 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
return _.intersection(selectedTypesByConnection, selectedTypesByPlatform);
}
function statusOnChange(filterOptions: Filter[]) {
setStatusState(filterOptions);
if (filterOptions.length === 0) {
function statusOnChange(value: number[]) {
setStatusState(value);
if (value.length === 0) {
setStatusFilter([]);
} else {
const filteredStatus = [
...new Set(
filterOptions.map(
(filterOptions: { value: number }) => filterOptions.value
)
),
];
const filteredStatus = [...new Set(value)];
setStatusFilter(filteredStatus);
}
}
function groupOnChange(filterOptions: Filter[]) {
setGroupState(filterOptions);
if (filterOptions.length === 0) {
function groupOnChange(value: number[]) {
setGroupState(value);
if (value.length === 0) {
setGroupFilter([]);
} else {
const filteredGroups = [
...new Set(
filterOptions.map(
(filterOptions: { value: number }) => filterOptions.value
)
),
];
const filteredGroups = [...new Set(value)];
setGroupFilter(filteredGroups);
}
}
function tagOnChange(filterOptions: Filter[]) {
setTagState(filterOptions);
if (filterOptions.length === 0) {
function tagOnChange(value: number[]) {
setTagState(value);
if (value.length === 0) {
setTagFilter([]);
} else {
const filteredTags = [
...new Set(
filterOptions.map(
(filterOptions: { value: number }) => filterOptions.value
)
),
];
const filteredTags = [...new Set(value)];
setTagFilter(filteredTags);
}
}
@ -365,16 +342,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
setConnectionTypes([]);
}
function sortOnchange(filterOptions: Filter) {
if (filterOptions !== null) {
setSortByFilter(filterOptions.label);
setSortByButton(true);
setSortByState(filterOptions);
} else {
setSortByFilter('');
setSortByButton(true);
setSortByState(undefined);
}
function sortOnchange(value: string) {
setSortByFilter(value);
setSortByButton(!!value);
}
function sortOnDescending() {
@ -407,14 +377,13 @@ function renderItems(
return items;
}
function getEdgeAsyncValue(connectionTypes: Filter<ConnectionType>[]) {
function getEdgeAsyncValue(connectionTypes: ConnectionType[]) {
const hasEdgeAsync = connectionTypes.some(
(connectionType) => connectionType.value === ConnectionType.EdgeAgentAsync
(connectionType) => connectionType === ConnectionType.EdgeAgentAsync
);
const hasEdgeStandard = connectionTypes.some(
(connectionType) =>
connectionType.value === ConnectionType.EdgeAgentStandard
(connectionType) => connectionType === ConnectionType.EdgeAgentStandard
);
// If both are selected, we don't want to filter on either, and same for if both are not selected

View File

@ -9,7 +9,7 @@ import { useGroups } from '../../environments/environment-groups/queries';
import { HomepageFilter } from './HomepageFilter';
import { SortbySelector } from './SortbySelector';
import { ConnectionType, Filter } from './types';
import { ConnectionType } from './types';
import styles from './EnvironmentList.module.css';
const status = [
@ -17,11 +17,10 @@ const status = [
{ value: EnvironmentStatus.Down, label: 'Down' },
];
const sortByOptions = [
{ value: 1, label: 'Name' },
{ value: 2, label: 'Group' },
{ value: 3, label: 'Status' },
];
const sortByOptions = ['Name', 'Group', 'Status'].map((v) => ({
value: v,
label: v,
}));
export function EnvironmentListFilters({
agentVersions,
@ -37,32 +36,32 @@ export function EnvironmentListFilters({
sortByDescending,
sortByState,
sortOnDescending,
sortOnchange,
sortOnChange,
statusOnChange,
statusState,
tagOnChange,
tagState,
}: {
platformTypes: Filter<PlatformType>[];
setPlatformTypes: (value: Filter<PlatformType>[]) => void;
platformTypes: PlatformType[];
setPlatformTypes: (value: PlatformType[]) => void;
connectionTypes: Filter<ConnectionType>[];
setConnectionTypes: (value: Filter<ConnectionType>[]) => void;
connectionTypes: ConnectionType[];
setConnectionTypes: (value: ConnectionType[]) => void;
statusState: Filter<number>[];
statusOnChange: (filterOptions: Filter[]) => void;
statusState: number[];
statusOnChange: (value: number[]) => void;
tagOnChange: (filterOptions: Filter[]) => void;
tagState: Filter<number>[];
tagOnChange: (value: number[]) => void;
tagState: number[];
groupOnChange: (filterOptions: Filter[]) => void;
groupState: Filter<number>[];
groupOnChange: (value: number[]) => void;
groupState: number[];
setAgentVersions: (value: Filter<string>[]) => void;
agentVersions: Filter<string>[];
setAgentVersions: (value: string[]) => void;
agentVersions: string[];
sortByState: Filter<number> | undefined;
sortOnchange: (filterOptions: Filter) => void;
sortByState: string;
sortOnChange: (value: string) => void;
sortOnDescending: () => void;
sortByDescending: boolean;
@ -85,7 +84,7 @@ export function EnvironmentListFilters({
}));
const tagsQuery = useTags();
const tagOptions = [...(tagsQuery.tags || [])];
const tagOptions = [...(tagsQuery.data || [])];
const uniqueTag = [
...new Map(tagOptions.map((item) => [item.ID, item])).values(),
].map(({ ID: value, Name: label }) => ({
@ -136,7 +135,7 @@ export function EnvironmentListFilters({
/>
</div>
<div className={styles.filterLeft}>
<HomepageFilter<string>
<HomepageFilter
filterOptions={
agentVersionsQuery.data?.map((v) => ({
label: v,
@ -159,7 +158,7 @@ export function EnvironmentListFilters({
<div className={styles.filterRight}>
<SortbySelector
filterOptions={sortByOptions}
onChange={sortOnchange}
onChange={sortOnChange}
onDescending={sortOnDescending}
placeHolder="Sort By"
sortByDescending={sortByDescending}
@ -171,7 +170,7 @@ export function EnvironmentListFilters({
);
}
function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) {
function getConnectionTypeOptions(platformTypes: PlatformType[]) {
const platformTypeConnectionType = {
[PlatformType.Docker]: [
ConnectionType.API,
@ -204,12 +203,12 @@ function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) {
return _.compact(
_.intersection(
...platformTypes.map((p) => platformTypeConnectionType[p.value])
...platformTypes.map((p) => platformTypeConnectionType[p])
).map((c) => connectionTypesDefaultOptions.find((o) => o.value === c))
);
}
function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
function getPlatformTypeOptions(connectionTypes: ConnectionType[]) {
const platformDefaultOptions = [
{ value: PlatformType.Docker, label: 'Docker' },
{ value: PlatformType.Azure, label: 'Azure' },
@ -244,7 +243,7 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
return _.compact(
_.intersection(
...connectionTypes.map((p) => connectionTypePlatformType[p.value])
...connectionTypes.map((p) => connectionTypePlatformType[p])
).map((c) => platformDefaultOptions.find((o) => o.value === c))
);
}

View File

@ -2,18 +2,19 @@ import { components, OptionProps } from 'react-select';
import { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { Select } from '@@/form-components/ReactSelect';
import { Filter } from './types';
import {
type Option as OptionType,
PortainerSelect,
} from '@@/form-components/PortainerSelect';
interface Props<TValue = number> {
filterOptions?: Filter<TValue>[];
onChange: (filterOptions: Filter<TValue>[]) => void;
filterOptions?: OptionType<TValue>[];
onChange: (value: TValue[]) => void;
placeHolder: string;
value: Filter<TValue>[];
value: TValue[];
}
function Option<TValue = number>(props: OptionProps<Filter<TValue>, true>) {
function Option<TValue = number>(props: OptionProps<OptionType<TValue>, true>) {
const { isSelected, label } = props;
return (
<div>
@ -21,8 +22,10 @@ function Option<TValue = number>(props: OptionProps<Filter<TValue>, true>) {
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>
<input type="checkbox" checked={isSelected} onChange={() => null} />{' '}
<label>{label}</label>
<div className="flex items-center gap-2">
<input type="checkbox" checked={isSelected} onChange={() => null} />
<label className="whitespace-nowrap">{label}</label>
</div>
</components.Option>
</div>
);
@ -35,14 +38,14 @@ export function HomepageFilter<TValue = number>({
value,
}: Props<TValue>) {
return (
<Select
closeMenuOnSelect={false}
<PortainerSelect<TValue>
placeholder={placeHolder}
options={filterOptions}
value={value}
isMulti
components={{ Option }}
onChange={(option) => onChange([...option])}
bindToBody
/>
);
}

View File

@ -1,19 +1,18 @@
import clsx from 'clsx';
import { Select } from '@@/form-components/ReactSelect';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
import { Filter } from './types';
import styles from './SortbySelector.module.css';
interface Props {
filterOptions: Filter[];
onChange: (filterOptions: Filter) => void;
filterOptions: Option<string>[];
onChange: (value: string) => void;
onDescending: () => void;
placeHolder: string;
sortByDescending: boolean;
sortByButton: boolean;
value?: Filter;
value: string;
}
export function SortbySelector({
@ -28,10 +27,10 @@ export function SortbySelector({
const sorted = sortByButton && !!value;
return (
<div className="flex items-center justify-end gap-1">
<Select
<PortainerSelect
placeholder={placeHolder}
options={filterOptions}
onChange={(option) => onChange(option as Filter)}
onChange={(option) => onChange(option || '')}
isClearable
value={value}
/>

View File

@ -158,6 +158,7 @@ function EdgeKeyInfo({
commands={commands}
isNomadTokenVisible
asyncMode={asyncMode}
showMetaFields
>
<FormControl label="Portainer API server URL">
<Input value={url} readOnly />

View File

@ -5,8 +5,12 @@ import { error as notifyError } from '@/portainer/services/notifications';
import { EnvironmentGroup, EnvironmentGroupId } from './types';
import { getGroup, getGroups } from './environment-groups.service';
export function useGroups() {
return useQuery<EnvironmentGroup[]>(['environment-groups'], getGroups);
export function useGroups<T = EnvironmentGroup[]>({
select,
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
return useQuery(['environment-groups'], getGroups, {
select,
});
}
export function useGroup<T = EnvironmentGroup>(

View File

@ -6,9 +6,8 @@ import { EnvironmentGroupId } from '@/react/portainer/environments/environment-g
import { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/Input';
export function GroupField() {
const [fieldProps, metaProps, helpers] =
useField<EnvironmentGroupId>('meta.groupId');
export function GroupField({ name = 'meta.groupId' }: { name?: string }) {
const [fieldProps, metaProps, helpers] = useField<EnvironmentGroupId>(name);
const groupsQuery = useGroups();
if (!groupsQuery.data) {
@ -23,7 +22,7 @@ export function GroupField() {
return (
<FormControl label="Group" errors={metaProps.error}>
<Select
name="meta.groupId"
name={name}
options={options}
value={fieldProps.value}
onChange={(e) => handleChange(e.target.value)}