From bebee78152a447d8fea85198306084881d9d3aaf Mon Sep 17 00:00:00 2001 From: Prabhat Khera <91852476+prabhat-org@users.noreply.github.com> Date: Tue, 26 Apr 2022 12:17:36 +1200 Subject: [PATCH] fix(home): fix home page filters EE-2972 (#6789) --- api/http/handler/endpoints/endpoint_list.go | 92 +++++- api/http/handler/endpoints/sort.go | 43 +++ api/internal/natsort/natsort.go | 126 ++++++++ api/internal/utils/utils.go | 11 + app/assets/css/theme.css | 6 + .../components/FilterSearchBar.module.css | 5 + .../datatables/components/FilterSearchBar.tsx | 2 +- .../components/SortbySelector.module.css | 10 +- .../datatables/components/SortbySelector.tsx | 19 +- .../environments/environment.service/index.ts | 25 +- .../EnvironmentList.module.css | 31 +- .../home/EnvironmentList/EnvironmentList.tsx | 280 +++++++++--------- app/portainer/home/HomepageFilter.tsx | 31 ++ 13 files changed, 515 insertions(+), 166 deletions(-) create mode 100644 api/http/handler/endpoints/sort.go create mode 100644 api/internal/natsort/natsort.go create mode 100644 api/internal/utils/utils.go diff --git a/api/http/handler/endpoints/endpoint_list.go b/api/http/handler/endpoints/endpoint_list.go index 0411af6ba..4bd51ab6a 100644 --- a/api/http/handler/endpoints/endpoint_list.go +++ b/api/http/handler/endpoints/endpoint_list.go @@ -2,6 +2,7 @@ package endpoints import ( "net/http" + "sort" "strconv" "strings" "time" @@ -12,6 +13,8 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/internal/utils" ) const ( @@ -20,6 +23,8 @@ const ( EdgeDeviceFilterUntrusted = "untrusted" ) +var endpointGroupNames map[portainer.EndpointGroupID]string + // @id EndpointList // @summary List environments(endpoints) // @description List all environments(endpoints) based on the current user authorizations. Will @@ -55,6 +60,8 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true) limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true) + sortField, _ := request.RetrieveQueryParameter(r, "sort", true) + sortOrder, _ := request.RetrieveQueryParameter(r, "order", true) var endpointTypes []int request.RetrieveJSONQueryParameter(r, "types", &endpointTypes, true) @@ -67,11 +74,23 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht var endpointIDs []portainer.EndpointID request.RetrieveJSONQueryParameter(r, "endpointIds", &endpointIDs, true) + var statuses []int + request.RetrieveJSONQueryParameter(r, "status", &statuses, true) + + var groupIDs []int + request.RetrieveJSONQueryParameter(r, "groupIds", &groupIDs, true) + endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from the database", err} } + // create endpoint groups as a map for more convenient access + endpointGroupNames = make(map[portainer.EndpointGroupID]string, 0) + for _, group := range endpointGroups { + endpointGroupNames[group.ID] = group.Name + } + endpoints, err := handler.DataStore.Endpoint().Endpoints() if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from the database", err} @@ -90,12 +109,16 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) totalAvailableEndpoints := len(filteredEndpoints) + if groupID != 0 { + filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, []int{groupID}) + } + if endpointIDs != nil { filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, endpointIDs) } - if groupID != 0 { - filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID)) + if len(groupIDs) > 0 { + filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, groupIDs) } edgeDeviceFilter, _ := request.RetrieveQueryParameter(r, "edgeDeviceFilter", false) @@ -103,6 +126,10 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht filteredEndpoints = filterEndpointsByEdgeDevice(filteredEndpoints, edgeDeviceFilter) } + if len(statuses) > 0 { + filteredEndpoints = filterEndpointsByStatuses(filteredEndpoints, statuses) + } + if search != "" { tags, err := handler.DataStore.Tag().Tags() if err != nil { @@ -123,6 +150,9 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht filteredEndpoints = filteredEndpointsByTags(filteredEndpoints, tagIDs, endpointGroups, tagsPartialMatch) } + // Sort endpoints by field + sortEndpointsByField(filteredEndpoints, sortField, sortOrder == "desc") + filteredEndpointCount := len(filteredEndpoints) paginatedEndpoints := paginateEndpoints(filteredEndpoints, start, limit) @@ -160,11 +190,11 @@ func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []porta return endpoints[start:end] } -func filterEndpointsByGroupID(endpoints []portainer.Endpoint, endpointGroupID portainer.EndpointGroupID) []portainer.Endpoint { +func filterEndpointsByGroupIDs(endpoints []portainer.Endpoint, endpointGroupIDs []int) []portainer.Endpoint { filteredEndpoints := make([]portainer.Endpoint, 0) for _, endpoint := range endpoints { - if endpoint.GroupID == endpointGroupID { + if utils.Contains(endpointGroupIDs, int(endpoint.GroupID)) { filteredEndpoints = append(filteredEndpoints, endpoint) } } @@ -190,6 +220,60 @@ func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGro return filteredEndpoints } +func filterEndpointsByStatuses(endpoints []portainer.Endpoint, statuses []int) []portainer.Endpoint { + filteredEndpoints := make([]portainer.Endpoint, 0) + + for _, endpoint := range endpoints { + status := endpoint.Status + if endpointutils.IsEdgeEndpoint(&endpoint) { + isCheckValid := false + if endpoint.EdgeCheckinInterval != 0 && endpoint.LastCheckInDate != 0 { + isCheckValid = time.Now().Unix()-endpoint.LastCheckInDate <= int64(endpoint.EdgeCheckinInterval*2+20) + } + status = portainer.EndpointStatusDown // Offline + if isCheckValid { + status = portainer.EndpointStatusUp // Online + } + } + + if utils.Contains(statuses, int(status)) { + filteredEndpoints = append(filteredEndpoints, endpoint) + } + } + + return filteredEndpoints +} + +func sortEndpointsByField(endpoints []portainer.Endpoint, sortField string, isSortDesc bool) { + + switch sortField { + case "Name": + if isSortDesc { + sort.Stable(sort.Reverse(EndpointsByName(endpoints))) + } else { + sort.Stable(EndpointsByName(endpoints)) + } + + case "Group": + if isSortDesc { + sort.Stable(sort.Reverse(EndpointsByGroup(endpoints))) + } else { + sort.Stable(EndpointsByGroup(endpoints)) + } + + 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 endpointMatchSearchCriteria(endpoint *portainer.Endpoint, tags []string, searchCriteria string) bool { if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) { return true diff --git a/api/http/handler/endpoints/sort.go b/api/http/handler/endpoints/sort.go new file mode 100644 index 000000000..70d4f9d30 --- /dev/null +++ b/api/http/handler/endpoints/sort.go @@ -0,0 +1,43 @@ +package endpoints + +import ( + "strings" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/internal/natsort" +) + +type EndpointsByName []portainer.Endpoint + +func (e EndpointsByName) Len() int { + return len(e) +} + +func (e EndpointsByName) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func (e EndpointsByName) Less(i, j int) bool { + return natsort.Compare(strings.ToLower(e[i].Name), strings.ToLower(e[j].Name)) +} + +type EndpointsByGroup []portainer.Endpoint + +func (e EndpointsByGroup) Len() int { + return len(e) +} + +func (e EndpointsByGroup) Swap(i, j int) { + e[i], e[j] = e[j], e[i] +} + +func (e EndpointsByGroup) Less(i, j int) bool { + if e[i].GroupID == e[j].GroupID { + return false + } + + groupA := endpointGroupNames[e[i].GroupID] + groupB := endpointGroupNames[e[j].GroupID] + + return natsort.Compare(strings.ToLower(groupA), strings.ToLower(groupB)) +} diff --git a/api/internal/natsort/natsort.go b/api/internal/natsort/natsort.go new file mode 100644 index 000000000..503191924 --- /dev/null +++ b/api/internal/natsort/natsort.go @@ -0,0 +1,126 @@ +// Package natsort implements natural strings sorting + +// An extension of the following package found here: +// https://github.com/facette/natsort +// Our extension adds ReverseSort +// +// Original 3-Clause BSD License below: +// Copyright (c) 2015, Vincent Batoufflet and Marc Falzon +// All rights reserved. + +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions +// are met: + +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. + +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. + +// * Neither the name of the authors nor the names of its contributors +// may be used to endorse or promote products derived from this software +// without specific prior written permission. + +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +// POSSIBILITY OF SUCH DAMAGE. + +package natsort + +import ( + "regexp" + "sort" + "strconv" +) + +type natsort []string + +func (s natsort) Len() int { + return len(s) +} + +func (s natsort) Less(a, b int) bool { + return Compare(s[a], s[b]) +} + +func (s natsort) Swap(a, b int) { + s[a], s[b] = s[b], s[a] +} + +var chunkifyRegexp = regexp.MustCompile(`(\d+|\D+)`) + +func chunkify(s string) []string { + return chunkifyRegexp.FindAllString(s, -1) +} + +// Sort sorts a list of strings in a natural order +func Sort(l []string) { + sort.Sort(natsort(l)) +} + +// ReverseSort sorts a list of strings in a natural decending order +func ReverseSort(l []string) { + sort.Sort(sort.Reverse(natsort(l))) +} + +// compare returns true if the first string < second (natsort order) e.g. 1.1.1 < 1.11 +func Compare(a, b string) bool { + chunksA := chunkify(a) + chunksB := chunkify(b) + + nChunksA := len(chunksA) + nChunksB := len(chunksB) + + for i := range chunksA { + if i >= nChunksB { + return false + } + + aInt, aErr := strconv.Atoi(chunksA[i]) + bInt, bErr := strconv.Atoi(chunksB[i]) + + // If both chunks are numeric, compare them as integers + if aErr == nil && bErr == nil { + if aInt == bInt { + if i == nChunksA-1 { + // We reached the last chunk of A, thus B is greater than A + return true + } else if i == nChunksB-1 { + // We reached the last chunk of B, thus A is greater than B + return false + } + + continue + } + + return aInt < bInt + } + + // So far both strings are equal, continue to next chunk + if chunksA[i] == chunksB[i] { + if i == nChunksA-1 { + // We reached the last chunk of A, thus B is greater than A + return true + } else if i == nChunksB-1 { + // We reached the last chunk of B, thus A is greater than B + return false + } + + continue + } + + return chunksA[i] < chunksB[i] + } + + return false +} diff --git a/api/internal/utils/utils.go b/api/internal/utils/utils.go new file mode 100644 index 000000000..55a70b637 --- /dev/null +++ b/api/internal/utils/utils.go @@ -0,0 +1,11 @@ +package utils + +// Contains returns true if the given int is contained in the given slice of int. +func Contains(s []int, e int) bool { + for _, a := range s { + if a == e { + return true + } + } + return false +} diff --git a/app/assets/css/theme.css b/app/assets/css/theme.css index 5e7b5a352..7ddcc596c 100644 --- a/app/assets/css/theme.css +++ b/app/assets/css/theme.css @@ -161,6 +161,7 @@ html { --bg-app-datatable-tbody: var(--grey-24); --bg-stepper-item-active: var(--white-color); --bg-stepper-item-counter: var(--grey-61); + --bg-sortbutton-color: var(--white-color); --text-main-color: var(--grey-7); --text-body-color: var(--grey-6); @@ -242,6 +243,7 @@ html { --border-daterangepicker-after: var(--white-color); --border-tooltip-color: var(--grey-47); --border-modal: 0px; + --border-sortbutton: var(--grey-8); --hover-sidebar-color: var(--grey-37); --shadow-box-color: 0 3px 10px -2px var(--grey-50); @@ -334,6 +336,7 @@ html { --bg-app-datatable-tbody: var(--grey-1); --bg-stepper-item-active: var(--grey-1); --bg-stepper-item-counter: var(--grey-7); + --bg-sortbutton-color: var(--grey-1); --text-main-color: var(--white-color); --text-body-color: var(--white-color); @@ -416,6 +419,7 @@ html { --border-daterangepicker-after: var(--grey-3); --border-tooltip-color: var(--grey-3); --border-modal: 0px; + --border-sortbutton: var(--grey-3); --hover-sidebar-color: var(--grey-3); --blue-color: var(--blue-2); @@ -507,6 +511,7 @@ html { --bg-app-datatable-tbody: var(--black-color); --bg-stepper-item-active: var(--black-color); --bg-stepper-item-counter: var(--grey-3); + --bg-sortbutton-color: var(--grey-1); --text-main-color: var(--white-color); --text-body-color: var(--white-color); @@ -578,6 +583,7 @@ html { --border-codemirror-cursor-color: var(--white-color); --border-modal: 1px solid var(--white-color); --border-blocklist-color: var(--white-color); + --border-sortbutton: var(--black-color); --hover-sidebar-color: var(--blue-9); --hover-sidebar-color: var(--black-color); diff --git a/app/portainer/components/datatables/components/FilterSearchBar.module.css b/app/portainer/components/datatables/components/FilterSearchBar.module.css index c7ba2815a..9d1cf7cf8 100644 --- a/app/portainer/components/datatables/components/FilterSearchBar.module.css +++ b/app/portainer/components/datatables/components/FilterSearchBar.module.css @@ -8,3 +8,8 @@ display: inline-block; margin-right: 5px; } + +.searchBar .textSpan { + display: inline-block; + width: 90%; +} diff --git a/app/portainer/components/datatables/components/FilterSearchBar.tsx b/app/portainer/components/datatables/components/FilterSearchBar.tsx index 44f0b1903..ebcbcfb19 100644 --- a/app/portainer/components/datatables/components/FilterSearchBar.tsx +++ b/app/portainer/components/datatables/components/FilterSearchBar.tsx @@ -18,7 +18,7 @@ export function FilterSearchBar({