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"> <form class="form-horizontal">
<div class="col-sm-12 form-section-title"> Edge Groups </div> <edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
<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-stack-deployment-type-selector <edge-stack-deployment-type-selector
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose" 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"> <div class="form-group">
<label for="group_name" class="col-sm-3 col-lg-2 control-label required text-left"> Name </label> <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"> <div class="col-sm-9 col-lg-10">

View File

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

View File

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

View File

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

View File

@ -39,19 +39,7 @@
</div> </div>
<!-- !name-input --> <!-- !name-input -->
<div class="col-sm-12 form-section-title"> Edge Groups </div> <edge-groups-selector ng-if="!$ctrl.noGroups" value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
<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-stack-deployment-type-selector <edge-stack-deployment-type-selector
value="$ctrl.formValues.DeploymentType" value="$ctrl.formValues.DeploymentType"

View File

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

View File

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

View File

@ -22,17 +22,17 @@ interface Option {
export function TagSelector({ value, allowCreate = false, onChange }: Props) { 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) // 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) => const tagsQuery = useTags({
tags.map((opt) => ({ label: opt.Name, value: opt.ID })) select: (tags) => tags?.map((opt) => ({ label: opt.Name, value: opt.ID })),
); });
const createTagMutation = useCreateTagMutation(); const createTagMutation = useCreateTagMutation();
if (!tagsQuery.tags) { if (!tagsQuery.data) {
return null; return null;
} }
const { tags } = tagsQuery; const { data: tags } = tagsQuery;
const selectedTags = _.compact( const selectedTags = _.compact(
value.map((id) => tags.find((tag) => tag.value === id)) 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 _ from 'lodash';
import { AutomationTestingProps } from '@/types'; import { AutomationTestingProps } from '@/types';
@ -10,9 +14,10 @@ export interface Option<TValue> {
label: string; label: string;
} }
type Group<TValue> = { label: string; options: Option<TValue>[] }; type Options<TValue> = OptionsOrGroups<
Option<TValue>,
type Options<TValue> = OptionsOrGroups<Option<TValue>, Group<TValue>>; GroupBase<Option<TValue>>
>;
interface SharedProps extends AutomationTestingProps { interface SharedProps extends AutomationTestingProps {
name?: string; name?: string;
@ -28,6 +33,11 @@ interface MultiProps<TValue> extends SharedProps {
onChange(value: readonly TValue[]): void; onChange(value: readonly TValue[]): void;
options: Options<TValue>; options: Options<TValue>;
isMulti: true; isMulti: true;
components?: SelectComponentsConfig<
Option<TValue>,
true,
GroupBase<Option<TValue>>
>;
} }
interface SingleProps<TValue> extends SharedProps { interface SingleProps<TValue> extends SharedProps {
@ -35,6 +45,11 @@ interface SingleProps<TValue> extends SharedProps {
onChange(value: TValue | null): void; onChange(value: TValue | null): void;
options: Options<TValue>; options: Options<TValue>;
isMulti?: never; isMulti?: never;
components?: SelectComponentsConfig<
Option<TValue>,
false,
GroupBase<Option<TValue>>
>;
} }
type Props<TValue> = MultiProps<TValue> | SingleProps<TValue>; type Props<TValue> = MultiProps<TValue> | SingleProps<TValue>;
@ -66,6 +81,7 @@ export function SingleSelect<TValue = string>({
placeholder, placeholder,
isClearable, isClearable,
bindToBody, bindToBody,
components,
}: SingleProps<TValue>) { }: SingleProps<TValue>) {
const selectedValue = const selectedValue =
value || (typeof value === 'number' && value === 0) value || (typeof value === 'number' && value === 0)
@ -86,6 +102,7 @@ export function SingleSelect<TValue = string>({
placeholder={placeholder} placeholder={placeholder}
isDisabled={disabled} isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined} menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
/> />
); );
} }
@ -124,6 +141,7 @@ export function MultiSelect<TValue = string>({
disabled, disabled,
isClearable, isClearable,
bindToBody, bindToBody,
components,
}: Omit<MultiProps<TValue>, 'isMulti'>) { }: Omit<MultiProps<TValue>, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value); const selectedOptions = findSelectedOptions(options, value);
return ( return (
@ -142,12 +160,13 @@ export function MultiSelect<TValue = string>({
placeholder={placeholder} placeholder={placeholder}
isDisabled={disabled} isDisabled={disabled}
menuPortalTarget={bindToBody ? document.body : undefined} menuPortalTarget={bindToBody ? document.body : undefined}
components={components}
/> />
); );
} }
function isGroup<TValue>( function isGroup<TValue>(
option: Option<TValue> | Group<TValue> option: Option<TValue> | GroupBase<Option<TValue>>
): option is Group<TValue> { ): option is GroupBase<Option<TValue>> {
return 'options' in option; return 'options' in option;
} }

View File

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

View File

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

View File

@ -52,14 +52,6 @@ export const commandsTabs: Record<string, CommandTab> = {
}, },
} as const; } 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( export function buildLinuxStandaloneCommand(
agentVersion: string, agentVersion: string,
edgeKey: string, edgeKey: string,
@ -70,16 +62,16 @@ export function buildLinuxStandaloneCommand(
) { ) {
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars( const env = buildDockerEnvVars(envVars, [
envVars, ...buildDefaultDockerEnvVars(
buildDefaultEnvVars(
edgeKey, edgeKey,
allowSelfSignedCertificates, allowSelfSignedCertificates,
!edgeIdGenerator ? edgeId : undefined, !edgeIdGenerator ? edgeId : undefined,
agentSecret, agentSecret,
useAsyncMode useAsyncMode
) ),
); ...metaEnvVars(properties),
]);
return `${ return `${
edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : '' edgeIdGenerator ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` : ''
@ -106,16 +98,16 @@ export function buildWindowsStandaloneCommand(
) { ) {
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars( const env = buildDockerEnvVars(envVars, [
envVars, ...buildDefaultDockerEnvVars(
buildDefaultEnvVars(
edgeKey, edgeKey,
allowSelfSignedCertificates, allowSelfSignedCertificates,
edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId, edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId,
agentSecret, agentSecret,
useAsyncMode useAsyncMode
) ),
); ...metaEnvVars(properties),
]);
return `${ return `${
edgeIdGenerator edgeIdGenerator
@ -144,7 +136,7 @@ export function buildLinuxSwarmCommand(
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(envVars, [ const env = buildDockerEnvVars(envVars, [
...buildDefaultEnvVars( ...buildDefaultDockerEnvVars(
edgeKey, edgeKey,
allowSelfSignedCertificates, allowSelfSignedCertificates,
!edgeIdGenerator ? edgeId : undefined, !edgeIdGenerator ? edgeId : undefined,
@ -152,6 +144,7 @@ export function buildLinuxSwarmCommand(
useAsyncMode useAsyncMode
), ),
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent', 'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
...metaEnvVars(properties),
]); ]);
return `${ return `${
@ -186,7 +179,7 @@ export function buildWindowsSwarmCommand(
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const env = buildDockerEnvVars(envVars, [ const env = buildDockerEnvVars(envVars, [
...buildDefaultEnvVars( ...buildDefaultDockerEnvVars(
edgeKey, edgeKey,
allowSelfSignedCertificates, allowSelfSignedCertificates,
edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId, edgeIdGenerator ? '$Env:PORTAINER_EDGE_ID' : edgeId,
@ -194,6 +187,7 @@ export function buildWindowsSwarmCommand(
useAsyncMode useAsyncMode
), ),
'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent', 'AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent',
...metaEnvVars(properties),
]); ]);
return `${ return `${
@ -229,17 +223,18 @@ export function buildLinuxKubernetesCommand(
const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties; const { allowSelfSignedCertificates, edgeIdGenerator, envVars } = properties;
const agentShortVersion = getAgentShortVersion(agentVersion); const agentShortVersion = getAgentShortVersion(agentVersion);
let envVarsTrimmed = envVars.trim(); const allEnvVars = buildEnvVars(
if (useAsyncMode) { envVars,
envVarsTrimmed += `EDGE_ASYNC=1`; _.compact([useAsyncMode && 'EDGE_ASYNC=1', ...metaEnvVars(properties)])
} );
const idEnvVar = edgeIdGenerator const idEnvVar = edgeIdGenerator
? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n` ? `PORTAINER_EDGE_ID=$(${edgeIdGenerator}) \n\n`
: ''; : '';
const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID'; const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID';
const selfSigned = allowSelfSignedCertificates ? '1' : '0'; 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( export function buildLinuxNomadCommand(
@ -259,10 +254,11 @@ export function buildLinuxNomadCommand(
} = properties; } = properties;
const agentShortVersion = getAgentShortVersion(agentVersion); const agentShortVersion = getAgentShortVersion(agentVersion);
let envVarsTrimmed = envVars.trim();
if (useAsyncMode) { const allEnvVars = buildEnvVars(
envVarsTrimmed += `EDGE_ASYNC=1`; envVars,
} _.compact([useAsyncMode && 'EDGE_ASYNC=1', ...metaEnvVars(properties)])
);
const selfSigned = allowSelfSignedCertificates ? '1' : '0'; const selfSigned = allowSelfSignedCertificates ? '1' : '0';
const idEnvVar = edgeIdGenerator const idEnvVar = edgeIdGenerator
@ -270,10 +266,16 @@ export function buildLinuxNomadCommand(
: ''; : '';
const edgeIdVar = !edgeIdGenerator && edgeId ? edgeId : '$PORTAINER_EDGE_ID'; 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, edgeKey: string,
allowSelfSignedCerts: boolean, allowSelfSignedCerts: boolean,
edgeId = '$PORTAINER_EDGE_ID', edgeId = '$PORTAINER_EDGE_ID',
@ -289,3 +291,22 @@ function buildDefaultEnvVars(
useAsyncMode ? 'EDGE_ASYNC=1' : '', 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 Platform = 'standalone' | 'swarm' | 'k8s' | 'nomad';
export type OS = 'win' | 'linux'; export type OS = 'win' | 'linux';
@ -13,6 +18,10 @@ export interface ScriptFormValues {
platform: Platform; platform: Platform;
edgeIdGenerator?: string; edgeIdGenerator?: string;
group: EnvironmentGroupId;
edgeGroupsIds: Array<EdgeGroup['Id']>;
tagsIds: Array<TagId>;
} }
export interface EdgeInfo { export interface EdgeInfo {

View File

@ -12,27 +12,24 @@ import { useSearchBarState } from '@@/datatables/SearchBar';
import { useAssociateDeviceMutation, useLicenseOverused } from '../queries'; import { useAssociateDeviceMutation, useLicenseOverused } from '../queries';
import { columns } from './columns'; import { columns } from './columns';
import { Filter } from './Filter';
import { useEnvironments } from './useEnvironments';
const storageKey = 'edge-devices-waiting-room'; const storageKey = 'edge-devices-waiting-room';
const settingsStore = createPersistedStore(storageKey, 'Name'); const settingsStore = createPersistedStore(storageKey, 'Name');
interface Props { export function Datatable() {
devices: Environment[];
isLoading: boolean;
totalCount: number;
}
export function Datatable({ devices, isLoading, totalCount }: Props) {
const associateMutation = useAssociateDeviceMutation(); const associateMutation = useAssociateDeviceMutation();
const licenseOverused = useLicenseOverused(); const licenseOverused = useLicenseOverused();
const settings = useStore(settingsStore); const settings = useStore(settingsStore);
const [search, setSearch] = useSearchBarState(storageKey); const [search, setSearch] = useSearchBarState(storageKey);
const { data: environments, totalCount, isLoading } = useEnvironments();
return ( return (
<GenericDatatable <GenericDatatable
columns={columns} columns={columns}
dataset={devices} dataset={environments}
initialPageSize={settings.pageSize} initialPageSize={settings.pageSize}
onPageSizeChange={settings.setPageSize} onPageSizeChange={settings.setPageSize}
initialSortBy={settings.sortBy} initialSortBy={settings.sortBy}
@ -62,6 +59,7 @@ export function Datatable({ devices, isLoading, totalCount }: Props) {
)} )}
isLoading={isLoading} isLoading={isLoading}
totalCount={totalCount} 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', Header: 'Name',
accessor: (row) => row.Name, accessor: (row) => row.Name,
@ -21,4 +21,35 @@ export const columns: readonly Column<Environment>[] = [
canHide: false, canHide: false,
sortType: 'string', 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; ] 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 { withLimitToBE } from '@/react/hooks/useLimitToBE';
import { InformationPanel } from '@@/InformationPanel'; import { InformationPanel } from '@@/InformationPanel';
@ -11,12 +9,6 @@ import { Datatable } from './Datatable';
export default withLimitToBE(WaitingRoomView); export default withLimitToBE(WaitingRoomView);
function WaitingRoomView() { function WaitingRoomView() {
const { environments, isLoading, totalCount } = useEnvironmentList({
edgeDeviceUntrusted: true,
excludeSnapshots: true,
types: EdgeTypes,
});
return ( return (
<> <>
<PageHeader <PageHeader
@ -35,11 +27,7 @@ function WaitingRoomView() {
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
<Datatable <Datatable />
devices={environments}
totalCount={totalCount}
isLoading={isLoading}
/>
</> </>
); );
} }

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

View File

@ -3,21 +3,78 @@ import _ from 'lodash';
import { EdgeGroup } from '@/react/edge/edge-groups/types'; import { EdgeGroup } from '@/react/edge/edge-groups/types';
import { Select } from '@@/form-components/ReactSelect'; 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']; type SingleValue = EdgeGroup['Id'];
interface Props { interface Props {
items: EdgeGroup[];
value: SingleValue[]; value: SingleValue[];
onChange: (value: SingleValue[]) => void; 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( const valueGroups = _.compact(
value.map((id) => items.find((item) => item.Id === id)) value.map((id) => items.find((item) => item.Id === id))
); );
return ( return items.length ? (
<Select <Select
aria-label="Edge groups" aria-label="Edge groups"
options={items} options={items}
@ -31,5 +88,10 @@ export function EdgeGroupsSelector({ items, value, onChange }: Props) {
placeholder="Select one or multiple group(s)" placeholder="Select one or multiple group(s)"
closeMenuOnSelect={false} 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[]) { function useEnvironmentTagNames(tagIds?: TagId[]) {
const { tags, isLoading } = useTags((tags) => { const tagsQuery = useTags({
if (!tagIds) { select: (tags) => {
return []; if (!tagIds) {
} return [];
return _.compact( }
tagIds return _.compact(
.map((id) => tags.find((tag) => tag.ID === id)) tagIds
.map((tag) => tag?.Name) .map((id) => tags.find((tag) => tag.ID === id))
); .map((tag) => tag?.Name)
);
},
}); });
const { data: tags, isLoading } = tagsQuery;
if (tags && tags.length > 0) { if (tags && tags.length > 0) {
return tags.join(', '); return tags.join(', ');
} }

View File

@ -29,7 +29,7 @@ import { PaginationControls } from '@@/PaginationControls';
import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar'; import { SearchBar, useSearchBarState } from '@@/datatables/SearchBar';
import { useHomePageFilter } from './HomepageFilter'; import { useHomePageFilter } from './HomepageFilter';
import { ConnectionType, Filter } from './types'; import { ConnectionType } from './types';
import { EnvironmentItem } from './EnvironmentItem'; import { EnvironmentItem } from './EnvironmentItem';
import { KubeconfigButton } from './KubeconfigButton'; import { KubeconfigButton } from './KubeconfigButton';
import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel'; import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
@ -48,15 +48,16 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const { isAdmin } = useUser(); const { isAdmin } = useUser();
const currentEnvStore = useStore(environmentStore); const currentEnvStore = useStore(environmentStore);
const [platformTypes, setPlatformTypes] = useHomePageFilter< const [platformTypes, setPlatformTypes] = useHomePageFilter<PlatformType[]>(
Filter<PlatformType>[] 'platformType',
>('platformType', []); []
);
const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey); const [searchBarValue, setSearchBarValue] = useSearchBarState(storageKey);
const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey); const [pageLimit, setPageLimit] = usePaginationLimitState(storageKey);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [connectionTypes, setConnectionTypes] = useHomePageFilter< const [connectionTypes, setConnectionTypes] = useHomePageFilter<
Filter<ConnectionType>[] ConnectionType[]
>('connectionTypes', []); >('connectionTypes', []);
const [statusFilter, setStatusFilter] = useHomePageFilter< const [statusFilter, setStatusFilter] = useHomePageFilter<
@ -77,20 +78,17 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
false false
); );
const [statusState, setStatusState] = useHomePageFilter<Filter[]>( const [statusState, setStatusState] = useHomePageFilter<number[]>(
'status_state', 'status_state',
[] []
); );
const [tagState, setTagState] = useHomePageFilter<Filter[]>('tag_state', []); const [tagState, setTagState] = useHomePageFilter<number[]>('tag_state', []);
const [groupState, setGroupState] = useHomePageFilter<Filter[]>( const [groupState, setGroupState] = useHomePageFilter<number[]>(
'group_state', 'group_state',
[] []
); );
const [sortByState, setSortByState] = useHomePageFilter<Filter | undefined>(
'sort_by_state', const [agentVersions, setAgentVersions] = useHomePageFilter<string[]>(
undefined
);
const [agentVersions, setAgentVersions] = useHomePageFilter<Filter<string>[]>(
'agentVersions', 'agentVersions',
[] []
); );
@ -98,17 +96,14 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
const groupsQuery = useGroups(); const groupsQuery = useGroups();
const environmentsQueryParams: EnvironmentsQueryParams = { const environmentsQueryParams: EnvironmentsQueryParams = {
types: getTypes( types: getTypes(platformTypes, connectionTypes),
platformTypes.map((p) => p.value),
connectionTypes.map((p) => p.value)
),
search: searchBarValue, search: searchBarValue,
status: statusFilter, status: statusFilter,
tagIds: tagFilter?.length ? tagFilter : undefined, tagIds: tagFilter?.length ? tagFilter : undefined,
groupIds: groupFilter, groupIds: groupFilter,
provisioned: true, provisioned: true,
tagsPartialMatch: true, tagsPartialMatch: true,
agentVersions: agentVersions.map((a) => a.value), agentVersions,
updateInformation: isBE, updateInformation: isBE,
edgeAsync: getEdgeAsyncValue(connectionTypes), edgeAsync: getEdgeAsyncValue(connectionTypes),
}; };
@ -202,11 +197,11 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
setAgentVersions={setAgentVersions} setAgentVersions={setAgentVersions}
agentVersions={agentVersions} agentVersions={agentVersions}
clearFilter={clearFilter} clearFilter={clearFilter}
sortOnchange={sortOnchange} sortOnChange={sortOnchange}
sortOnDescending={sortOnDescending} sortOnDescending={sortOnDescending}
sortByDescending={sortByDescending} sortByDescending={sortByDescending}
sortByButton={sortByButton} sortByButton={sortByButton}
sortByState={sortByState} sortByState={sortByFilter}
/> />
</div> </div>
<div <div
@ -305,50 +300,32 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
return _.intersection(selectedTypesByConnection, selectedTypesByPlatform); return _.intersection(selectedTypesByConnection, selectedTypesByPlatform);
} }
function statusOnChange(filterOptions: Filter[]) { function statusOnChange(value: number[]) {
setStatusState(filterOptions); setStatusState(value);
if (filterOptions.length === 0) { if (value.length === 0) {
setStatusFilter([]); setStatusFilter([]);
} else { } else {
const filteredStatus = [ const filteredStatus = [...new Set(value)];
...new Set(
filterOptions.map(
(filterOptions: { value: number }) => filterOptions.value
)
),
];
setStatusFilter(filteredStatus); setStatusFilter(filteredStatus);
} }
} }
function groupOnChange(filterOptions: Filter[]) { function groupOnChange(value: number[]) {
setGroupState(filterOptions); setGroupState(value);
if (filterOptions.length === 0) { if (value.length === 0) {
setGroupFilter([]); setGroupFilter([]);
} else { } else {
const filteredGroups = [ const filteredGroups = [...new Set(value)];
...new Set(
filterOptions.map(
(filterOptions: { value: number }) => filterOptions.value
)
),
];
setGroupFilter(filteredGroups); setGroupFilter(filteredGroups);
} }
} }
function tagOnChange(filterOptions: Filter[]) { function tagOnChange(value: number[]) {
setTagState(filterOptions); setTagState(value);
if (filterOptions.length === 0) { if (value.length === 0) {
setTagFilter([]); setTagFilter([]);
} else { } else {
const filteredTags = [ const filteredTags = [...new Set(value)];
...new Set(
filterOptions.map(
(filterOptions: { value: number }) => filterOptions.value
)
),
];
setTagFilter(filteredTags); setTagFilter(filteredTags);
} }
} }
@ -365,16 +342,9 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
setConnectionTypes([]); setConnectionTypes([]);
} }
function sortOnchange(filterOptions: Filter) { function sortOnchange(value: string) {
if (filterOptions !== null) { setSortByFilter(value);
setSortByFilter(filterOptions.label); setSortByButton(!!value);
setSortByButton(true);
setSortByState(filterOptions);
} else {
setSortByFilter('');
setSortByButton(true);
setSortByState(undefined);
}
} }
function sortOnDescending() { function sortOnDescending() {
@ -407,14 +377,13 @@ function renderItems(
return items; return items;
} }
function getEdgeAsyncValue(connectionTypes: Filter<ConnectionType>[]) { function getEdgeAsyncValue(connectionTypes: ConnectionType[]) {
const hasEdgeAsync = connectionTypes.some( const hasEdgeAsync = connectionTypes.some(
(connectionType) => connectionType.value === ConnectionType.EdgeAgentAsync (connectionType) => connectionType === ConnectionType.EdgeAgentAsync
); );
const hasEdgeStandard = connectionTypes.some( const hasEdgeStandard = connectionTypes.some(
(connectionType) => (connectionType) => connectionType === ConnectionType.EdgeAgentStandard
connectionType.value === ConnectionType.EdgeAgentStandard
); );
// If both are selected, we don't want to filter on either, and same for if both are not selected // 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 { HomepageFilter } from './HomepageFilter';
import { SortbySelector } from './SortbySelector'; import { SortbySelector } from './SortbySelector';
import { ConnectionType, Filter } from './types'; import { ConnectionType } from './types';
import styles from './EnvironmentList.module.css'; import styles from './EnvironmentList.module.css';
const status = [ const status = [
@ -17,11 +17,10 @@ const status = [
{ value: EnvironmentStatus.Down, label: 'Down' }, { value: EnvironmentStatus.Down, label: 'Down' },
]; ];
const sortByOptions = [ const sortByOptions = ['Name', 'Group', 'Status'].map((v) => ({
{ value: 1, label: 'Name' }, value: v,
{ value: 2, label: 'Group' }, label: v,
{ value: 3, label: 'Status' }, }));
];
export function EnvironmentListFilters({ export function EnvironmentListFilters({
agentVersions, agentVersions,
@ -37,32 +36,32 @@ export function EnvironmentListFilters({
sortByDescending, sortByDescending,
sortByState, sortByState,
sortOnDescending, sortOnDescending,
sortOnchange, sortOnChange,
statusOnChange, statusOnChange,
statusState, statusState,
tagOnChange, tagOnChange,
tagState, tagState,
}: { }: {
platformTypes: Filter<PlatformType>[]; platformTypes: PlatformType[];
setPlatformTypes: (value: Filter<PlatformType>[]) => void; setPlatformTypes: (value: PlatformType[]) => void;
connectionTypes: Filter<ConnectionType>[]; connectionTypes: ConnectionType[];
setConnectionTypes: (value: Filter<ConnectionType>[]) => void; setConnectionTypes: (value: ConnectionType[]) => void;
statusState: Filter<number>[]; statusState: number[];
statusOnChange: (filterOptions: Filter[]) => void; statusOnChange: (value: number[]) => void;
tagOnChange: (filterOptions: Filter[]) => void; tagOnChange: (value: number[]) => void;
tagState: Filter<number>[]; tagState: number[];
groupOnChange: (filterOptions: Filter[]) => void; groupOnChange: (value: number[]) => void;
groupState: Filter<number>[]; groupState: number[];
setAgentVersions: (value: Filter<string>[]) => void; setAgentVersions: (value: string[]) => void;
agentVersions: Filter<string>[]; agentVersions: string[];
sortByState: Filter<number> | undefined; sortByState: string;
sortOnchange: (filterOptions: Filter) => void; sortOnChange: (value: string) => void;
sortOnDescending: () => void; sortOnDescending: () => void;
sortByDescending: boolean; sortByDescending: boolean;
@ -85,7 +84,7 @@ export function EnvironmentListFilters({
})); }));
const tagsQuery = useTags(); const tagsQuery = useTags();
const tagOptions = [...(tagsQuery.tags || [])]; const tagOptions = [...(tagsQuery.data || [])];
const uniqueTag = [ const uniqueTag = [
...new Map(tagOptions.map((item) => [item.ID, item])).values(), ...new Map(tagOptions.map((item) => [item.ID, item])).values(),
].map(({ ID: value, Name: label }) => ({ ].map(({ ID: value, Name: label }) => ({
@ -136,7 +135,7 @@ export function EnvironmentListFilters({
/> />
</div> </div>
<div className={styles.filterLeft}> <div className={styles.filterLeft}>
<HomepageFilter<string> <HomepageFilter
filterOptions={ filterOptions={
agentVersionsQuery.data?.map((v) => ({ agentVersionsQuery.data?.map((v) => ({
label: v, label: v,
@ -159,7 +158,7 @@ export function EnvironmentListFilters({
<div className={styles.filterRight}> <div className={styles.filterRight}>
<SortbySelector <SortbySelector
filterOptions={sortByOptions} filterOptions={sortByOptions}
onChange={sortOnchange} onChange={sortOnChange}
onDescending={sortOnDescending} onDescending={sortOnDescending}
placeHolder="Sort By" placeHolder="Sort By"
sortByDescending={sortByDescending} sortByDescending={sortByDescending}
@ -171,7 +170,7 @@ export function EnvironmentListFilters({
); );
} }
function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) { function getConnectionTypeOptions(platformTypes: PlatformType[]) {
const platformTypeConnectionType = { const platformTypeConnectionType = {
[PlatformType.Docker]: [ [PlatformType.Docker]: [
ConnectionType.API, ConnectionType.API,
@ -204,12 +203,12 @@ function getConnectionTypeOptions(platformTypes: Filter<PlatformType>[]) {
return _.compact( return _.compact(
_.intersection( _.intersection(
...platformTypes.map((p) => platformTypeConnectionType[p.value]) ...platformTypes.map((p) => platformTypeConnectionType[p])
).map((c) => connectionTypesDefaultOptions.find((o) => o.value === c)) ).map((c) => connectionTypesDefaultOptions.find((o) => o.value === c))
); );
} }
function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) { function getPlatformTypeOptions(connectionTypes: ConnectionType[]) {
const platformDefaultOptions = [ const platformDefaultOptions = [
{ value: PlatformType.Docker, label: 'Docker' }, { value: PlatformType.Docker, label: 'Docker' },
{ value: PlatformType.Azure, label: 'Azure' }, { value: PlatformType.Azure, label: 'Azure' },
@ -244,7 +243,7 @@ function getPlatformTypeOptions(connectionTypes: Filter<ConnectionType>[]) {
return _.compact( return _.compact(
_.intersection( _.intersection(
...connectionTypes.map((p) => connectionTypePlatformType[p.value]) ...connectionTypes.map((p) => connectionTypePlatformType[p])
).map((c) => platformDefaultOptions.find((o) => o.value === c)) ).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 { useLocalStorage } from '@/react/hooks/useLocalStorage';
import { Select } from '@@/form-components/ReactSelect'; import {
type Option as OptionType,
import { Filter } from './types'; PortainerSelect,
} from '@@/form-components/PortainerSelect';
interface Props<TValue = number> { interface Props<TValue = number> {
filterOptions?: Filter<TValue>[]; filterOptions?: OptionType<TValue>[];
onChange: (filterOptions: Filter<TValue>[]) => void; onChange: (value: TValue[]) => void;
placeHolder: string; 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; const { isSelected, label } = props;
return ( return (
<div> <div>
@ -21,8 +22,10 @@ function Option<TValue = number>(props: OptionProps<Filter<TValue>, true>) {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
> >
<input type="checkbox" checked={isSelected} onChange={() => null} />{' '} <div className="flex items-center gap-2">
<label>{label}</label> <input type="checkbox" checked={isSelected} onChange={() => null} />
<label className="whitespace-nowrap">{label}</label>
</div>
</components.Option> </components.Option>
</div> </div>
); );
@ -35,14 +38,14 @@ export function HomepageFilter<TValue = number>({
value, value,
}: Props<TValue>) { }: Props<TValue>) {
return ( return (
<Select <PortainerSelect<TValue>
closeMenuOnSelect={false}
placeholder={placeHolder} placeholder={placeHolder}
options={filterOptions} options={filterOptions}
value={value} value={value}
isMulti isMulti
components={{ Option }} components={{ Option }}
onChange={(option) => onChange([...option])} onChange={(option) => onChange([...option])}
bindToBody
/> />
); );
} }

View File

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

View File

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

View File

@ -5,8 +5,12 @@ import { error as notifyError } from '@/portainer/services/notifications';
import { EnvironmentGroup, EnvironmentGroupId } from './types'; import { EnvironmentGroup, EnvironmentGroupId } from './types';
import { getGroup, getGroups } from './environment-groups.service'; import { getGroup, getGroups } from './environment-groups.service';
export function useGroups() { export function useGroups<T = EnvironmentGroup[]>({
return useQuery<EnvironmentGroup[]>(['environment-groups'], getGroups); select,
}: { select?: (group: EnvironmentGroup[]) => T } = {}) {
return useQuery(['environment-groups'], getGroups, {
select,
});
} }
export function useGroup<T = EnvironmentGroup>( 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 { FormControl } from '@@/form-components/FormControl';
import { Select } from '@@/form-components/Input'; import { Select } from '@@/form-components/Input';
export function GroupField() { export function GroupField({ name = 'meta.groupId' }: { name?: string }) {
const [fieldProps, metaProps, helpers] = const [fieldProps, metaProps, helpers] = useField<EnvironmentGroupId>(name);
useField<EnvironmentGroupId>('meta.groupId');
const groupsQuery = useGroups(); const groupsQuery = useGroups();
if (!groupsQuery.data) { if (!groupsQuery.data) {
@ -23,7 +22,7 @@ export function GroupField() {
return ( return (
<FormControl label="Group" errors={metaProps.error}> <FormControl label="Group" errors={metaProps.error}>
<Select <Select
name="meta.groupId" name={name}
options={options} options={options}
value={fieldProps.value} value={fieldProps.value}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}