diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 3f75b3aff..dc95e15b0 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -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 { diff --git a/api/http/handler/endpoints/sort.go b/api/http/handler/endpoints/sort.go index f82d5e210..3c4705b60 100644 --- a/api/http/handler/endpoints/sort.go +++ b/api/http/handler/endpoints/sort.go @@ -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 "" } diff --git a/api/http/handler/endpoints/sort_test.go b/api/http/handler/endpoints/sort_test.go new file mode 100644 index 000000000..aad83f751 --- /dev/null +++ b/api/http/handler/endpoints/sort_test.go @@ -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 + }) +} diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx index 4c5c408a7..7b0611146 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/Datatable.tsx @@ -22,6 +22,7 @@ export function Datatable() { } = useEnvironments({ pageLimit: tableState.pageSize, search: tableState.search, + sortBy: tableState.sortBy, }); return ( diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts index 953e9248c..f9266c044 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/columns.ts @@ -8,27 +8,32 @@ const columnHelper = createColumnHelper(); 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() : '-'; diff --git a/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts index 8e02be406..f6b14c6c4 100644 --- a/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts +++ b/app/react/edge/edge-devices/WaitingRoomView/Datatable/useEnvironments.ts @@ -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 = 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, ] ); diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx index ec471aef1..d25673b2a 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentList.tsx @@ -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', diff --git a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx index 40a3bcf53..0a64c7396 100644 --- a/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx +++ b/app/react/portainer/HomeView/EnvironmentList/EnvironmentListFilters.tsx @@ -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({
; + +const sortByOptions = SortOptions.filter( + (v): v is ListSortType => !['LastCheckIn', 'EdgeID'].includes(v) +).map((v) => ({ + value: v, + label: v, +})); + interface Props { - filterOptions: Option[]; - 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({
onChange(option || '')} + options={sortByOptions} + onChange={(option: ListSortType) => onChange(option)} isClearable value={value} /> diff --git a/app/react/portainer/environments/queries/useEnvironmentList.ts b/app/react/portainer/environments/queries/useEnvironmentList.ts index 978aba855..ef183ef1b 100644 --- a/app/react/portainer/environments/queries/useEnvironmentList.ts +++ b/app/react/portainer/environments/queries/useEnvironmentList.ts @@ -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;