feat(edge): sort waiting room table [EE-6259] (#10577)

pull/10844/head
Chaim Lev-Ari 2023-12-13 11:10:29 +02:00 committed by GitHub
parent 32d8dc311b
commit 25741e8c4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 306 additions and 106 deletions

View File

@ -2,7 +2,6 @@ package endpoints
import ( import (
"net/http" "net/http"
"sort"
"strconv" "strconv"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
@ -30,7 +29,7 @@ const (
// @produce json // @produce json
// @param start query int false "Start searching from" // @param start query int false "Start searching from"
// @param limit query int false "Limit results to this value" // @param limit query int false "Limit results to this value"
// @param sort query int false "Sort results by this value" // @param sort query sortKey false "Sort results by this value" Enum("Name", "Group", "Status", "LastCheckIn", "EdgeID")
// @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc") // @param order query int false "Order sorted results by desc/asc" Enum("asc", "desc")
// @param search query string false "Search query" // @param search query string false "Search query"
// @param groupIds query []int false "List environments(endpoints) of these groups" // @param groupIds query []int false "List environments(endpoints) of these groups"
@ -98,7 +97,7 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
return httperror.InternalServerError("Unable to filter endpoints", err) return httperror.InternalServerError("Unable to filter endpoints", err)
} }
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc") sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
filteredEndpointCount := len(filteredEndpoints) filteredEndpointCount := len(filteredEndpoints)
@ -147,46 +146,6 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
return endpoints[start:end] return endpoints[start:end]
} }
func sortEndpointsByField(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, sortField string, isSortDesc bool) {
switch sortField {
case "Name":
if isSortDesc {
sort.Stable(sort.Reverse(EndpointsByName(endpoints)))
} else {
sort.Stable(EndpointsByName(endpoints))
}
case "Group":
endpointGroupNames := make(map[portainer.EndpointGroupID]string, 0)
for _, group := range endpointGroups {
endpointGroupNames[group.ID] = group.Name
}
endpointsByGroup := EndpointsByGroup{
endpointGroupNames: endpointGroupNames,
endpoints: endpoints,
}
if isSortDesc {
sort.Stable(sort.Reverse(endpointsByGroup))
} else {
sort.Stable(endpointsByGroup)
}
case "Status":
if isSortDesc {
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i].Status > endpoints[j].Status
})
} else {
sort.Slice(endpoints, func(i, j int) bool {
return endpoints[i].Status < endpoints[j].Status
})
}
}
}
func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup { func getEndpointGroup(groupID portainer.EndpointGroupID, groups []portainer.EndpointGroup) portainer.EndpointGroup {
var endpointGroup portainer.EndpointGroup var endpointGroup portainer.EndpointGroup
for _, group := range groups { for _, group := range groups {

View File

@ -1,46 +1,94 @@
package endpoints package endpoints
import ( import (
"strings" "slices"
"github.com/fvbommel/sortorder" "github.com/fvbommel/sortorder"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
type EndpointsByName []portainer.Endpoint type comp[T any] func(a, b T) int
func (e EndpointsByName) Len() int { func stringComp(a, b string) int {
return len(e) if sortorder.NaturalLess(a, b) {
return -1
} else if sortorder.NaturalLess(b, a) {
return 1
} else {
return 0
}
} }
func (e EndpointsByName) Swap(i, j int) { func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool) {
e[i], e[j] = e[j], e[i] if sortField == "" {
} return
func (e EndpointsByName) Less(i, j int) bool {
return sortorder.NaturalLess(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name))
}
type EndpointsByGroup struct {
endpointGroupNames map[portainer.EndpointGroupID]string
endpoints []portainer.Endpoint
}
func (e EndpointsByGroup) Len() int {
return len(e.endpoints)
}
func (e EndpointsByGroup) Swap(i, j int) {
e.endpoints[i], e.endpoints[j] = e.endpoints[j], e.endpoints[i]
}
func (e EndpointsByGroup) Less(i, j int) bool {
if e.endpoints[i].GroupID == e.endpoints[j].GroupID {
return false
} }
groupA := e.endpointGroupNames[e.endpoints[i].GroupID] var less comp[portainer.Endpoint]
groupB := e.endpointGroupNames[e.endpoints[j].GroupID] switch sortField {
case sortKeyName:
less = func(a, b portainer.Endpoint) int {
return stringComp(a.Name, b.Name)
}
case sortKeyGroup:
environmentGroupNames := make(map[portainer.EndpointGroupID]string, 0)
for _, group := range environmentGroups {
environmentGroupNames[group.ID] = group.Name
}
// set the "unassigned" group name to be empty string
environmentGroupNames[1] = ""
less = func(a, b portainer.Endpoint) int {
aGroup := environmentGroupNames[a.GroupID]
bGroup := environmentGroupNames[b.GroupID]
return stringComp(aGroup, bGroup)
}
case sortKeyStatus:
less = func(a, b portainer.Endpoint) int {
return int(a.Status - b.Status)
}
case sortKeyLastCheckInDate:
less = func(a, b portainer.Endpoint) int {
return int(a.LastCheckInDate - b.LastCheckInDate)
}
case sortKeyEdgeID:
less = func(a, b portainer.Endpoint) int {
return stringComp(a.EdgeID, b.EdgeID)
}
}
slices.SortStableFunc(environments, func(a, b portainer.Endpoint) int {
mul := 1
if isSortDesc {
mul = -1
}
return less(a, b) * mul
})
return sortorder.NaturalLess(strings.ToLower(groupA), strings.ToLower(groupB)) }
type sortKey string
const (
sortKeyName sortKey = "Name"
sortKeyGroup sortKey = "Group"
sortKeyStatus sortKey = "Status"
sortKeyLastCheckInDate sortKey = "LastCheckIn"
sortKeyEdgeID sortKey = "EdgeID"
)
func getSortKey(sortField string) sortKey {
fieldAsSortKey := sortKey(sortField)
if slices.Contains([]sortKey{sortKeyName, sortKeyGroup, sortKeyStatus, sortKeyLastCheckInDate, sortKeyEdgeID}, fieldAsSortKey) {
return fieldAsSortKey
}
return ""
} }

View File

@ -0,0 +1,168 @@
package endpoints
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/internal/slices"
"github.com/stretchr/testify/assert"
)
func TestSortEndpointsByField(t *testing.T) {
environments := []portainer.Endpoint{
{ID: 0, Name: "Environment 1", GroupID: 1, Status: 1, LastCheckInDate: 3, EdgeID: "edge32"},
{ID: 1, Name: "Environment 2", GroupID: 2, Status: 2, LastCheckInDate: 6, EdgeID: "edge57"},
{ID: 2, Name: "Environment 3", GroupID: 1, Status: 3, LastCheckInDate: 2, EdgeID: "test87"},
{ID: 3, Name: "Environment 4", GroupID: 2, Status: 4, LastCheckInDate: 1, EdgeID: "abc123"},
}
environmentGroups := []portainer.EndpointGroup{
{ID: 1, Name: "Group 1"},
{ID: 2, Name: "Group 2"},
}
tests := []struct {
name string
sortField sortKey
isSortDesc bool
expected []portainer.EndpointID
}{
{
name: "sort without value",
sortField: "",
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by name ascending",
sortField: "Name",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by name descending",
sortField: "Name",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[3].ID,
environments[2].ID,
environments[1].ID,
environments[0].ID,
},
},
{
name: "sort by group name ascending",
sortField: "Group",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[0].ID,
environments[2].ID,
environments[1].ID,
environments[3].ID,
},
},
{
name: "sort by group name descending",
sortField: "Group",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[1].ID,
environments[3].ID,
environments[0].ID,
environments[2].ID,
},
},
{
name: "sort by status ascending",
sortField: "Status",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[0].ID,
environments[1].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by status descending",
sortField: "Status",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[3].ID,
environments[2].ID,
environments[1].ID,
environments[0].ID,
},
},
{
name: "sort by last check-in ascending",
sortField: "LastCheckIn",
isSortDesc: false,
expected: []portainer.EndpointID{
environments[3].ID,
environments[2].ID,
environments[0].ID,
environments[1].ID,
},
},
{
name: "sort by last check-in descending",
sortField: "LastCheckIn",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[1].ID,
environments[0].ID,
environments[2].ID,
environments[3].ID,
},
},
{
name: "sort by edge ID ascending",
sortField: "EdgeID",
expected: []portainer.EndpointID{
environments[3].ID,
environments[0].ID,
environments[1].ID,
environments[2].ID,
},
},
{
name: "sort by edge ID descending",
sortField: "EdgeID",
isSortDesc: true,
expected: []portainer.EndpointID{
environments[2].ID,
environments[1].ID,
environments[0].ID,
environments[3].ID,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
is := assert.New(t)
sortEnvironmentsByField(environments, environmentGroups, "Name", false) // reset to default sort order
sortEnvironmentsByField(environments, environmentGroups, tt.sortField, tt.isSortDesc)
is.Equal(tt.expected, getEndpointIDs(environments))
})
}
}
func getEndpointIDs(environments []portainer.Endpoint) []portainer.EndpointID {
return slices.Map(environments, func(environment portainer.Endpoint) portainer.EndpointID {
return environment.ID
})
}

View File

@ -22,6 +22,7 @@ export function Datatable() {
} = useEnvironments({ } = useEnvironments({
pageLimit: tableState.pageSize, pageLimit: tableState.pageSize,
search: tableState.search, search: tableState.search,
sortBy: tableState.sortBy,
}); });
return ( return (

View File

@ -8,27 +8,32 @@ const columnHelper = createColumnHelper<WaitingRoomEnvironment>();
export const columns = [ export const columns = [
columnHelper.accessor('Name', { columnHelper.accessor('Name', {
header: 'Name', header: 'Name',
id: 'name', id: 'Name',
}), }),
columnHelper.accessor('EdgeID', { columnHelper.accessor('EdgeID', {
header: 'Edge ID', header: 'Edge ID',
id: 'edge-id', id: 'EdgeID',
}), }),
columnHelper.accessor((row) => row.EdgeGroups.join(', ') || '-', { columnHelper.accessor((row) => row.EdgeGroups.join(', '), {
header: 'Edge Groups', header: 'Edge Groups',
id: 'edge-groups', id: 'edge-groups',
enableSorting: false,
cell: ({ getValue }) => getValue() || '-',
}), }),
columnHelper.accessor((row) => row.Group || '-', { columnHelper.accessor((row) => row.Group, {
header: 'Group', header: 'Group',
id: 'group', id: 'Group',
cell: ({ getValue }) => getValue() || '-',
}), }),
columnHelper.accessor((row) => row.Tags.join(', ') || '-', { columnHelper.accessor((row) => row.Tags.join(', '), {
header: 'Tags', header: 'Tags',
id: 'tags', id: 'tags',
enableSorting: false,
cell: ({ getValue }) => getValue() || '-',
}), }),
columnHelper.accessor((row) => row.LastCheckInDate, { columnHelper.accessor((row) => row.LastCheckInDate, {
header: 'Last Check-in', header: 'Last Check-in',
id: 'last-check-in', id: 'LastCheckIn',
cell: ({ getValue }) => { cell: ({ getValue }) => {
const value = getValue(); const value = getValue();
return value ? moment(value * 1000).fromNow() : '-'; return value ? moment(value * 1000).fromNow() : '-';

View File

@ -6,6 +6,10 @@ import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EdgeTypes } from '@/react/portainer/environments/types'; import { EdgeTypes } from '@/react/portainer/environments/types';
import {
Query,
getSortType,
} from '@/react/portainer/environments/queries/useEnvironmentList';
import { WaitingRoomEnvironment } from '../types'; import { WaitingRoomEnvironment } from '../types';
@ -14,9 +18,11 @@ import { useFilterStore } from './filter-store';
export function useEnvironments({ export function useEnvironments({
pageLimit = 10, pageLimit = 10,
search, search,
sortBy,
}: { }: {
pageLimit: number; pageLimit: number;
search: string; search: string;
sortBy: { id: string; desc: boolean } | undefined;
}) { }) {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const filterStore = useFilterStore(); const filterStore = useFilterStore();
@ -35,7 +41,7 @@ export function useEnvironments({
[edgeGroupsQuery.data, filterStore.edgeGroups] [edgeGroupsQuery.data, filterStore.edgeGroups]
); );
const query = useMemo( const query: Partial<Query> = useMemo(
() => ({ () => ({
pageLimit, pageLimit,
edgeDeviceUntrusted: true, edgeDeviceUntrusted: true,
@ -46,6 +52,8 @@ export function useEnvironments({
endpointIds: filterByEnvironmentsIds, endpointIds: filterByEnvironmentsIds,
edgeCheckInPassedSeconds: filterStore.checkIn, edgeCheckInPassedSeconds: filterStore.checkIn,
search, search,
sort: getSortType(sortBy?.id),
order: sortBy?.desc ? 'desc' : 'asc',
}), }),
[ [
filterByEnvironmentsIds, filterByEnvironmentsIds,
@ -54,6 +62,7 @@ export function useEnvironments({
filterStore.tags, filterStore.tags,
pageLimit, pageLimit,
search, search,
sortBy,
] ]
); );

View File

@ -14,7 +14,6 @@ import {
import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types'; import { EnvironmentGroupId } from '@/react/portainer/environments/environment-groups/types';
import { import {
refetchIfAnyOffline, refetchIfAnyOffline,
SortType,
useEnvironmentList, useEnvironmentList,
} from '@/react/portainer/environments/queries/useEnvironmentList'; } from '@/react/portainer/environments/queries/useEnvironmentList';
import { useGroups } from '@/react/portainer/environments/environment-groups/queries'; import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
@ -37,6 +36,7 @@ import { NoEnvironmentsInfoPanel } from './NoEnvironmentsInfoPanel';
import { UpdateBadge } from './UpdateBadge'; import { UpdateBadge } from './UpdateBadge';
import { EnvironmentListFilters } from './EnvironmentListFilters'; import { EnvironmentListFilters } from './EnvironmentListFilters';
import { AMTButton } from './AMTButton/AMTButton'; import { AMTButton } from './AMTButton/AMTButton';
import { ListSortType } from './SortbySelector';
interface Props { interface Props {
onClickBrowse(environment: Environment): void; onClickBrowse(environment: Environment): void;
@ -70,7 +70,7 @@ export function EnvironmentList({ onClickBrowse, onRefresh }: Props) {
[] []
); );
const [sortByFilter, setSortByFilter] = useHomePageFilter< const [sortByFilter, setSortByFilter] = useHomePageFilter<
SortType | undefined ListSortType | undefined
>('sortBy', undefined); >('sortBy', undefined);
const [sortByDescending, setSortByDescending] = useHomePageFilter( const [sortByDescending, setSortByDescending] = useHomePageFilter(
'sortOrder', 'sortOrder',

View File

@ -5,13 +5,9 @@ import { useTags } from '@/portainer/tags/queries';
import { useAgentVersionsList } from '../../environments/queries/useAgentVersionsList'; import { useAgentVersionsList } from '../../environments/queries/useAgentVersionsList';
import { EnvironmentStatus, PlatformType } from '../../environments/types'; import { EnvironmentStatus, PlatformType } from '../../environments/types';
import { useGroups } from '../../environments/environment-groups/queries'; import { useGroups } from '../../environments/environment-groups/queries';
import {
SortOptions,
SortType,
} from '../../environments/queries/useEnvironmentList';
import { HomepageFilter } from './HomepageFilter'; import { HomepageFilter } from './HomepageFilter';
import { SortbySelector } from './SortbySelector'; import { ListSortType, SortbySelector } from './SortbySelector';
import { ConnectionType } from './types'; import { ConnectionType } from './types';
import styles from './EnvironmentList.module.css'; import styles from './EnvironmentList.module.css';
@ -20,11 +16,6 @@ const status = [
{ value: EnvironmentStatus.Down, label: 'Down' }, { value: EnvironmentStatus.Down, label: 'Down' },
]; ];
const sortByOptions = SortOptions.map((v) => ({
value: v,
label: v,
}));
export function EnvironmentListFilters({ export function EnvironmentListFilters({
agentVersions, agentVersions,
clearFilter, clearFilter,
@ -63,8 +54,8 @@ export function EnvironmentListFilters({
setAgentVersions: (value: string[]) => void; setAgentVersions: (value: string[]) => void;
agentVersions: string[]; agentVersions: string[];
sortByState?: SortType; sortByState?: ListSortType;
sortOnChange: (value: SortType) => void; sortOnChange: (value: ListSortType) => void;
sortOnDescending: () => void; sortOnDescending: () => void;
sortByDescending: boolean; sortByDescending: boolean;
@ -160,7 +151,6 @@ export function EnvironmentListFilters({
<div className={styles.filterRight}> <div className={styles.filterRight}>
<SortbySelector <SortbySelector
filterOptions={sortByOptions}
onChange={sortOnChange} onChange={sortOnChange}
onDescending={sortOnDescending} onDescending={sortOnDescending}
placeHolder="Sort By" placeHolder="Sort By"

View File

@ -1,24 +1,34 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect'; import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons'; import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
import { SortType } from '../../environments/queries/useEnvironmentList'; import {
SortOptions,
SortType,
} from '../../environments/queries/useEnvironmentList';
import styles from './SortbySelector.module.css'; import styles from './SortbySelector.module.css';
export type ListSortType = Exclude<SortType, 'LastCheckIn' | 'EdgeID'>;
const sortByOptions = SortOptions.filter(
(v): v is ListSortType => !['LastCheckIn', 'EdgeID'].includes(v)
).map((v) => ({
value: v,
label: v,
}));
interface Props { interface Props {
filterOptions: Option<SortType>[]; onChange: (value: ListSortType) => void;
onChange: (value: SortType) => void;
onDescending: () => void; onDescending: () => void;
placeHolder: string; placeHolder: string;
sortByDescending: boolean; sortByDescending: boolean;
sortByButton: boolean; sortByButton: boolean;
value?: SortType; value?: ListSortType;
} }
export function SortbySelector({ export function SortbySelector({
filterOptions,
onChange, onChange,
onDescending, onDescending,
placeHolder, placeHolder,
@ -31,8 +41,8 @@ export function SortbySelector({
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<PortainerSelect <PortainerSelect
placeholder={placeHolder} placeholder={placeHolder}
options={filterOptions} options={sortByOptions}
onChange={(option: SortType) => onChange(option || '')} onChange={(option: ListSortType) => onChange(option)}
isClearable isClearable
value={value} value={value}
/> />

View File

@ -12,12 +12,22 @@ import { environmentQueryKeys } from './query-keys';
export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms export const ENVIRONMENTS_POLLING_INTERVAL = 30000; // in ms
export const SortOptions = ['Name', 'Group', 'Status'] as const; export const SortOptions = [
'Name',
'Group',
'Status',
'LastCheckIn',
'EdgeID',
] as const;
export type SortType = (typeof SortOptions)[number]; export type SortType = (typeof SortOptions)[number];
export function isSortType(value?: string): value is SortType { export function isSortType(value?: string): value is SortType {
return SortOptions.includes(value as SortType); return SortOptions.includes(value as SortType);
} }
export function getSortType(value?: string): SortType | undefined {
return isSortType(value) ? value : undefined;
}
export type Query = EnvironmentsQueryParams & { export type Query = EnvironmentsQueryParams & {
page?: number; page?: number;
pageLimit?: number; pageLimit?: number;