1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

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
This commit is contained in:
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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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