From 70710cfeb7599cbfcfb6dbcd8e51f5742ded3e80 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Mon, 6 Mar 2023 22:25:04 +0200 Subject: [PATCH] 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 --- .../editEdgeStackForm.html | 16 +-- app/edge/components/group-form/groupForm.html | 2 +- .../group-form/groupFormController.js | 5 + app/edge/react/components/index.ts | 9 +- .../createEdgeGroupViewController.js | 29 +++--- .../editEdgeGroupViewController.js | 29 +++--- .../create-edge-stack-view.html | 14 +-- app/portainer/react/components/index.ts | 1 + app/portainer/tags/queries.ts | 8 +- .../components/TagSelector/TagSelector.tsx | 10 +- .../form-components/PortainerSelect.tsx | 31 ++++-- .../EdgeScriptForm/EdgeScriptForm.tsx | 6 ++ .../EdgeScriptSettingsFieldset.tsx | 25 +++++ .../edge/components/EdgeScriptForm/scripts.ts | 83 ++++++++++------ .../edge/components/EdgeScriptForm/types.ts | 9 ++ .../WaitingRoomView/Datatable/Datatable.tsx | 14 ++- .../WaitingRoomView/Datatable/Filter.tsx | 50 ++++++++++ .../WaitingRoomView/Datatable/columns.ts | 37 ++++++- .../WaitingRoomView/Datatable/filter-store.ts | 35 +++++++ .../Datatable/useEnvironments.ts | 74 ++++++++++++++ .../WaitingRoomView/WaitingRoomView.tsx | 14 +-- .../edge-devices/WaitingRoomView/types.ts | 7 ++ .../edge/edge-groups/queries/useEdgeGroups.ts | 13 ++- .../components/EdgeGroupsSelector.tsx | 68 ++++++++++++- .../EnvironmentItem/EnvironmentItem.tsx | 22 +++-- .../EnvironmentList/EnvironmentList.tsx | 97 +++++++------------ .../EnvironmentListFilters.tsx | 55 ++++++----- .../EnvironmentList/HomepageFilter.tsx | 25 ++--- .../EnvironmentList/SortbySelector.tsx | 13 ++- .../AutomaticEdgeEnvCreation.tsx | 1 + .../environment-groups/queries.ts | 8 +- .../shared/MetadataFieldset/GroupsField.tsx | 7 +- 32 files changed, 554 insertions(+), 263 deletions(-) create mode 100644 app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx create mode 100644 app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts create mode 100644 app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts create mode 100644 app/react/edge/edge-devices/WaitingRoomView/types.ts diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html index 942babdce..436f393c7 100644 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html @@ -1,19 +1,5 @@
-
Edge Groups
-
-
- -
- -

- 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. -

-

- 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. -

-
+ +
diff --git a/app/edge/components/group-form/groupFormController.js b/app/edge/components/group-form/groupFormController.js index 253c7ea66..7086570a7 100644 --- a/app/edge/components/group-form/groupFormController.js +++ b/app/edge/components/group-form/groupFormController.js @@ -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(); } diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 3eee3f259..fb877f251 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -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( diff --git a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js index a1172b836..c82a63f1d 100644 --- a/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/createEdgeGroupView/createEdgeGroupViewController.js @@ -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; + } + }); } } diff --git a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js index feb06b2c2..3838206ca 100644 --- a/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js +++ b/app/edge/views/edge-groups/editEdgeGroupView/editEdgeGroupViewController.js @@ -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; + } + }); } } diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html index ecfa4dc84..7f1b730bb 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html @@ -39,19 +39,7 @@
-
Edge Groups
-
-
- -
-
- No Edge groups are available. Head over to the Edge groups view to create one. -
-

- 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. -

-
+ [...tagKeys.all, id] as const, }; -export function useTags(select?: (tags: Tag[]) => T[]) { - const { data, isLoading } = useQuery(tagKeys.all, () => getTags(), { +export function useTags({ + 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() { diff --git a/app/react/components/TagSelector/TagSelector.tsx b/app/react/components/TagSelector/TagSelector.tsx index 79ee52fca..2c48e2908 100644 --- a/app/react/components/TagSelector/TagSelector.tsx +++ b/app/react/components/TagSelector/TagSelector.tsx @@ -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)) diff --git a/app/react/components/form-components/PortainerSelect.tsx b/app/react/components/form-components/PortainerSelect.tsx index fc02a700c..fa4e1128c 100644 --- a/app/react/components/form-components/PortainerSelect.tsx +++ b/app/react/components/form-components/PortainerSelect.tsx @@ -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 { label: string; } -type Group = { label: string; options: Option[] }; - -type Options = OptionsOrGroups, Group>; +type Options = OptionsOrGroups< + Option, + GroupBase> +>; interface SharedProps extends AutomationTestingProps { name?: string; @@ -28,6 +33,11 @@ interface MultiProps extends SharedProps { onChange(value: readonly TValue[]): void; options: Options; isMulti: true; + components?: SelectComponentsConfig< + Option, + true, + GroupBase> + >; } interface SingleProps extends SharedProps { @@ -35,6 +45,11 @@ interface SingleProps extends SharedProps { onChange(value: TValue | null): void; options: Options; isMulti?: never; + components?: SelectComponentsConfig< + Option, + false, + GroupBase> + >; } type Props = MultiProps | SingleProps; @@ -66,6 +81,7 @@ export function SingleSelect({ placeholder, isClearable, bindToBody, + components, }: SingleProps) { const selectedValue = value || (typeof value === 'number' && value === 0) @@ -86,6 +102,7 @@ export function SingleSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} + components={components} /> ); } @@ -124,6 +141,7 @@ export function MultiSelect({ disabled, isClearable, bindToBody, + components, }: Omit, 'isMulti'>) { const selectedOptions = findSelectedOptions(options, value); return ( @@ -142,12 +160,13 @@ export function MultiSelect({ placeholder={placeholder} isDisabled={disabled} menuPortalTarget={bindToBody ? document.body : undefined} + components={components} /> ); } function isGroup( - option: Option | Group -): option is Group { + option: Option | GroupBase> +): option is GroupBase> { return 'options' in option; } diff --git a/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx b/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx index d143951e7..cc8e2bc77 100644 --- a/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx +++ b/app/react/edge/components/EdgeScriptForm/EdgeScriptForm.tsx @@ -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>; isNomadTokenVisible?: boolean; asyncMode?: boolean; + showMetaFields?: boolean; } export function EdgeScriptForm({ @@ -30,6 +34,7 @@ export function EdgeScriptForm({ commands, isNomadTokenVisible, asyncMode, + showMetaFields, children, }: PropsWithChildren) { const showOsSelector = !(commands instanceof Array); @@ -50,6 +55,7 @@ export function EdgeScriptForm({ isNomadTokenVisible && values.platform === 'nomad' } hideIdGetter={edgeInfo.id !== undefined} + showMetaFields={showMetaFields} />
{showOsSelector && ( diff --git a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx index ceee5c746..1b1c3adbe 100644 --- a/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx +++ b/app/react/edge/components/EdgeScriptForm/EdgeScriptSettingsFieldset.tsx @@ -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(); return ( <> + {showMetaFields && ( + <> + + + setFieldValue('edgeGroupsIds', value)} + isGroupVisible={(group) => !group.Dynamic} + horizontal + /> + + setFieldValue('tagsIds', value)} + /> + + )} + {!hideIdGetter && ( <> = { }, } 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) { + return _.compact([ + edgeGroupsIds.length && + `EDGE_GROUPS=${edgeGroupsIds.join(VAR_LIST_SEPARATOR)}`, + group && `PORTAINER_GROUP=${group}`, + tagsIds.length && `PORTAINER_TAGS=${tagsIds.join(VAR_LIST_SEPARATOR)}`, + ]); +} diff --git a/app/react/edge/components/EdgeScriptForm/types.ts b/app/react/edge/components/EdgeScriptForm/types.ts index 2d6817922..f7c8f86a2 100644 --- a/app/react/edge/components/EdgeScriptForm/types.ts +++ b/app/react/edge/components/EdgeScriptForm/types.ts @@ -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; + tagsIds: Array; } export interface EdgeInfo { diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx index db5daa52e..d622de079 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx @@ -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 ( } /> ); diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx new file mode 100644 index 000000000..1ba91a723 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Filter.tsx @@ -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 ( +
+ filterStore.setEdgeGroups(f)} + placeHolder="Edge groups" + value={filterStore.edgeGroups} + filterOptions={edgeGroupsQuery.data.map((g) => ({ + label: g.Name, + value: g.Id, + }))} + /> + filterStore.setGroups(f)} + placeHolder="Group" + value={filterStore.groups} + filterOptions={groupsQuery.data.map((g) => ({ + label: g.Name, + value: g.Id, + }))} + /> + filterStore.setTags(f)} + placeHolder="Tags" + value={filterStore.tags} + filterOptions={tagsQuery.data.map((g) => ({ + label: g.Name, + value: g.ID, + }))} + /> +
+ ); +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts index 8a5078247..0c2722761 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts @@ -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[] = [ +export const columns: readonly Column[] = [ { Header: 'Name', accessor: (row) => row.Name, @@ -21,4 +21,35 @@ export const columns: readonly Column[] = [ canHide: false, sortType: 'string', }, + { + Header: 'Edge Groups', + accessor: (row) => row.EdgeGroups || [], + Cell: ({ value }: CellProps) => + 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) => + value.join(', ') || '-', + id: 'tags', + disableFilters: true, + Filter: () => null, + canHide: false, + sortType: 'string', + }, ] as const; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts new file mode 100644 index 000000000..feeeae6d7 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/filter-store.ts @@ -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()( + 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'), + } + ) +); diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts new file mode 100644 index 000000000..6d899fc2b --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts @@ -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 = + 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, + }; +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx index cce80f494..396fb0a3a 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx @@ -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 ( <> - + ); } diff --git a/app/react/edge/edge-devices/WaitingRoomView/types.ts b/app/react/edge/edge-devices/WaitingRoomView/types.ts new file mode 100644 index 000000000..484313732 --- /dev/null +++ b/app/react/edge/edge-devices/WaitingRoomView/types.ts @@ -0,0 +1,7 @@ +import { Environment } from '@/react/portainer/environments/types'; + +export type WaitingRoomEnvironment = Environment & { + EdgeGroups: string[]; + Tags: string[]; + Group: string; +}; diff --git a/app/react/edge/edge-groups/queries/useEdgeGroups.ts b/app/react/edge/edge-groups/queries/useEdgeGroups.ts index 8bc1548c6..d2a256309 100644 --- a/app/react/edge/edge-groups/queries/useEdgeGroups.ts +++ b/app/react/edge/edge-groups/queries/useEdgeGroups.ts @@ -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; +} + async function getEdgeGroups() { try { - const { data } = await axios.get('/edge_groups'); + const { data } = await axios.get( + '/edge_groups' + ); return data; } catch (err) { throw parseAxiosError(err as Error, 'Failed fetching edge groups'); } } -export function useEdgeGroups({ +export function useEdgeGroups({ select, }: { - select?: (groups: EdgeGroup[]) => T; + select?: (groups: EdgeGroupListItemResponse[]) => T; } = {}) { return useQuery(['edge', 'groups'], getEdgeGroups, { select }); } diff --git a/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx b/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx index 5f1147822..5d32f1b28 100644 --- a/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx +++ b/app/react/edge/edge-stacks/components/EdgeGroupsSelector.tsx @@ -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 = ( + + ); + + return horizontal ? ( + + {selector} + + ) : ( + +
+
{selector}
+ {error && ( +
+ {error} +
+ )} +
+
+ ); +} + +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 ? ( null} />{' '} - +
+ null} /> + +
); @@ -35,14 +38,14 @@ export function HomepageFilter({ value, }: Props) { return ( - onChange(option as Filter)} + onChange={(option) => onChange(option || '')} isClearable value={value} /> diff --git a/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx b/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx index 5d5f91d81..8880986d5 100644 --- a/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx +++ b/app/react/portainer/environments/EdgeAutoCreateScriptView/AutomaticEdgeEnvCreation/AutomaticEdgeEnvCreation.tsx @@ -158,6 +158,7 @@ function EdgeKeyInfo({ commands={commands} isNomadTokenVisible asyncMode={asyncMode} + showMetaFields > diff --git a/app/react/portainer/environments/environment-groups/queries.ts b/app/react/portainer/environments/environment-groups/queries.ts index d480c95c4..9b2f937e9 100644 --- a/app/react/portainer/environments/environment-groups/queries.ts +++ b/app/react/portainer/environments/environment-groups/queries.ts @@ -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(['environment-groups'], getGroups); +export function useGroups({ + select, +}: { select?: (group: EnvironmentGroup[]) => T } = {}) { + return useQuery(['environment-groups'], getGroups, { + select, + }); } export function useGroup( diff --git a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx index 6dff2a13b..1f6b403f4 100644 --- a/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx +++ b/app/react/portainer/environments/wizard/EnvironmentsCreationView/shared/MetadataFieldset/GroupsField.tsx @@ -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('meta.groupId'); +export function GroupField({ name = 'meta.groupId' }: { name?: string }) { + const [fieldProps, metaProps, helpers] = useField(name); const groupsQuery = useGroups(); if (!groupsQuery.data) { @@ -23,7 +22,7 @@ export function GroupField() { return (