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 (
"net/http"
"sort"
"strconv"
portainer "github.com/portainer/portainer/api"
@ -30,7 +29,7 @@ const (
// @produce json
// @param start query int false "Start searching from"
// @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 search query string false "Search query"
// @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)
}
sortEndpointsByField(filteredEndpoints, endpointGroups, sortField, sortOrder == "desc")
sortEnvironmentsByField(filteredEndpoints, endpointGroups, getSortKey(sortField), sortOrder == "desc")
filteredEndpointCount := len(filteredEndpoints)
@ -147,46 +146,6 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta
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 {
var endpointGroup portainer.EndpointGroup
for _, group := range groups {

View File

@ -1,46 +1,94 @@
package endpoints
import (
"strings"
"slices"
"github.com/fvbommel/sortorder"
portainer "github.com/portainer/portainer/api"
)
type EndpointsByName []portainer.Endpoint
type comp[T any] func(a, b T) int
func (e EndpointsByName) Len() int {
return len(e)
func stringComp(a, b string) int {
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) {
e[i], e[j] = e[j], e[i]
}
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
func sortEnvironmentsByField(environments []portainer.Endpoint, environmentGroups []portainer.EndpointGroup, sortField sortKey, isSortDesc bool) {
if sortField == "" {
return
}
groupA := e.endpointGroupNames[e.endpoints[i].GroupID]
groupB := e.endpointGroupNames[e.endpoints[j].GroupID]
var less comp[portainer.Endpoint]
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({
pageLimit: tableState.pageSize,
search: tableState.search,
sortBy: tableState.sortBy,
});
return (

View File

@ -8,27 +8,32 @@ const columnHelper = createColumnHelper<WaitingRoomEnvironment>();
export const columns = [
columnHelper.accessor('Name', {
header: 'Name',
id: 'name',
id: 'Name',
}),
columnHelper.accessor('EdgeID', {
header: 'Edge ID',
id: 'edge-id',
id: 'EdgeID',
}),
columnHelper.accessor((row) => row.EdgeGroups.join(', ') || '-', {
columnHelper.accessor((row) => row.EdgeGroups.join(', '), {
header: 'Edge Groups',
id: 'edge-groups',
enableSorting: false,
cell: ({ getValue }) => getValue() || '-',
}),
columnHelper.accessor((row) => row.Group || '-', {
columnHelper.accessor((row) => row.Group, {
header: 'Group',
id: 'group',
id: 'Group',
cell: ({ getValue }) => getValue() || '-',
}),
columnHelper.accessor((row) => row.Tags.join(', ') || '-', {
columnHelper.accessor((row) => row.Tags.join(', '), {
header: 'Tags',
id: 'tags',
enableSorting: false,
cell: ({ getValue }) => getValue() || '-',
}),
columnHelper.accessor((row) => row.LastCheckInDate, {
header: 'Last Check-in',
id: 'last-check-in',
id: 'LastCheckIn',
cell: ({ getValue }) => {
const value = getValue();
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 { useEnvironmentList } from '@/react/portainer/environments/queries';
import { EdgeTypes } from '@/react/portainer/environments/types';
import {
Query,
getSortType,
} from '@/react/portainer/environments/queries/useEnvironmentList';
import { WaitingRoomEnvironment } from '../types';
@ -14,9 +18,11 @@ import { useFilterStore } from './filter-store';
export function useEnvironments({
pageLimit = 10,
search,
sortBy,
}: {
pageLimit: number;
search: string;
sortBy: { id: string; desc: boolean } | undefined;
}) {
const [page, setPage] = useState(0);
const filterStore = useFilterStore();
@ -35,7 +41,7 @@ export function useEnvironments({
[edgeGroupsQuery.data, filterStore.edgeGroups]
);
const query = useMemo(
const query: Partial<Query> = useMemo(
() => ({
pageLimit,
edgeDeviceUntrusted: true,
@ -46,6 +52,8 @@ export function useEnvironments({
endpointIds: filterByEnvironmentsIds,
edgeCheckInPassedSeconds: filterStore.checkIn,
search,
sort: getSortType(sortBy?.id),
order: sortBy?.desc ? 'desc' : 'asc',
}),
[
filterByEnvironmentsIds,
@ -54,6 +62,7 @@ export function useEnvironments({
filterStore.tags,
pageLimit,
search,
sortBy,
]
);

View File

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

View File

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

View File

@ -1,24 +1,34 @@
import clsx from 'clsx';
import { Option, PortainerSelect } from '@@/form-components/PortainerSelect';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { TableHeaderSortIcons } from '@@/datatables/TableHeaderSortIcons';
import { SortType } from '../../environments/queries/useEnvironmentList';
import {
SortOptions,
SortType,
} from '../../environments/queries/useEnvironmentList';
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 {
filterOptions: Option<SortType>[];
onChange: (value: SortType) => void;
onChange: (value: ListSortType) => void;
onDescending: () => void;
placeHolder: string;
sortByDescending: boolean;
sortByButton: boolean;
value?: SortType;
value?: ListSortType;
}
export function SortbySelector({
filterOptions,
onChange,
onDescending,
placeHolder,
@ -31,8 +41,8 @@ export function SortbySelector({
<div className="flex items-center justify-end gap-1">
<PortainerSelect
placeholder={placeHolder}
options={filterOptions}
onChange={(option: SortType) => onChange(option || '')}
options={sortByOptions}
onChange={(option: ListSortType) => onChange(option)}
isClearable
value={value}
/>

View File

@ -12,12 +12,22 @@ import { environmentQueryKeys } from './query-keys';
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 function isSortType(value?: string): value is SortType {
return SortOptions.includes(value as SortType);
}
export function getSortType(value?: string): SortType | undefined {
return isSortType(value) ? value : undefined;
}
export type Query = EnvironmentsQueryParams & {
page?: number;
pageLimit?: number;