diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go index 581b219ae..c96d61523 100644 --- a/api/http/handler/customtemplates/customtemplate_list.go +++ b/api/http/handler/customtemplates/customtemplate_list.go @@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques customTemplates = filterByType(customTemplates, templateTypes) if edge != nil { - customTemplates = slicesx.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool { + customTemplates = slicesx.FilterInPlace(customTemplates, func(customTemplate portainer.CustomTemplate) bool { return customTemplate.EdgeTemplate == *edge }) } diff --git a/api/http/handler/edgestacks/edgestack_list.go b/api/http/handler/edgestacks/edgestack_list.go index b0df238c3..1ea991c4b 100644 --- a/api/http/handler/edgestacks/edgestack_list.go +++ b/api/http/handler/edgestacks/edgestack_list.go @@ -3,10 +3,39 @@ package edgestacks import ( "net/http" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/slicesx" httperror "github.com/portainer/portainer/pkg/libhttp/error" + "github.com/portainer/portainer/pkg/libhttp/request" "github.com/portainer/portainer/pkg/libhttp/response" ) +type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int + +type SummarizedStatus string + +const ( + sumStatusUnavailable SummarizedStatus = "Unavailable" + sumStatusDeploying SummarizedStatus = "Deploying" + sumStatusFailed SummarizedStatus = "Failed" + sumStatusPaused SummarizedStatus = "Paused" + sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning" + sumStatusCompleted SummarizedStatus = "Completed" + sumStatusRunning SummarizedStatus = "Running" +) + +type edgeStackStatusSummary struct { + AggregatedStatus aggregatedStatusesMap + Status SummarizedStatus + Reason string +} + +type edgeStackListResponseItem struct { + portainer.EdgeStack + StatusSummary edgeStackStatusSummary +} + // @id EdgeStackList // @summary Fetches the list of EdgeStacks // @description **Access policy**: administrator @@ -14,22 +43,122 @@ import ( // @security ApiKeyAuth // @security jwt // @produce json +// @param summarizeStatuses query boolean false "will summarize the statuses" // @success 200 {array} portainer.EdgeStack // @failure 500 // @failure 400 // @failure 503 "Edge compute features are disabled" // @router /edge_stacks [get] func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true) + edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() if err != nil { return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) } + res := make([]edgeStackListResponseItem, len(edgeStacks)) + for i := range edgeStacks { - if err := fillEdgeStackStatus(handler.DataStore, &edgeStacks[i]); err != nil { + res[i].EdgeStack = edgeStacks[i] + + if summarizeStatuses { + if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil { + return handlerDBErr(err, "Unable to retrieve edge stack status from the database") + } + } else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil { return handlerDBErr(err, "Unable to retrieve edge stack status from the database") } } - return response.JSON(w, edgeStacks) + return response.JSON(w, res) +} + +func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error { + statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID) + if err != nil { + return err + } + + aggregated := make(aggregatedStatusesMap) + + for _, envStatus := range statuses { + for _, status := range envStatus.Status { + aggregated[status.Type]++ + } + } + + status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments) + + edgeStack.StatusSummary = edgeStackStatusSummary{ + AggregatedStatus: aggregated, + Status: status, + Reason: reason, + } + + edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} + + return nil +} + +func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) { + if numDeployments == 0 { + return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group" + } + + allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus { + return x.Status + }) + + lastStatuses := slicesx.Map( + slicesx.Filter( + statuses, + func(s portainer.EdgeStackStatusForEnv) bool { + return len(s.Status) > 0 + }, + ), + func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus { + return x.Status[len(x.Status)-1] + }, + ) + + if len(lastStatuses) == 0 { + return sumStatusDeploying, "" + } + + if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusError + }); allFailed { + return sumStatusFailed, "" + } + + if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusPausedDeploying + }); hasPaused { + return sumStatusPaused, "" + } + + if len(lastStatuses) < numDeployments { + return sumStatusDeploying, "" + } + + hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying }) + hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning }) + hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError }) + + if hasRunning && hasFailed && !hasDeploying { + return sumStatusPartiallyRunning, "" + } + + if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted { + return sumStatusCompleted, "" + } + + if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { + return s.Type == portainer.EdgeStackStatusRunning + }); allRunning { + return sumStatusRunning, "" + } + + return sumStatusDeploying, "" } diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index e945d38da..ac25a7b7a 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -35,7 +35,7 @@ type ( func getUniqueElements(items string) []string { xs := strings.Split(items, ",") xs = slicesx.Map(xs, strings.TrimSpace) - xs = slicesx.Filter(xs, func(x string) bool { return len(x) > 0 }) + xs = slicesx.FilterInPlace(xs, func(x string) bool { return len(x) > 0 }) return slicesx.Unique(xs) } diff --git a/api/slicesx/filter.go b/api/slicesx/filter.go new file mode 100644 index 000000000..13dc12105 --- /dev/null +++ b/api/slicesx/filter.go @@ -0,0 +1,28 @@ +package slicesx + +// Iterates over elements of collection, returning an array of all elements predicate returns truthy for. +// +// Note: Unlike `FilterInPlace`, this method returns a new array. +func Filter[T any](input []T, predicate func(T) bool) []T { + result := make([]T, 0) + for i := range input { + if predicate(input[i]) { + result = append(result, input[i]) + } + } + return result +} + +// Filter in place all elements from input that predicate returns truthy for and returns an array of the removed elements. +// +// Note: Unlike `Filter`, this method mutates input. +func FilterInPlace[T any](input []T, predicate func(T) bool) []T { + n := 0 + for _, v := range input { + if predicate(v) { + input[n] = v + n++ + } + } + return input[:n] +} diff --git a/api/slicesx/filter_test.go b/api/slicesx/filter_test.go new file mode 100644 index 000000000..36f97fa10 --- /dev/null +++ b/api/slicesx/filter_test.go @@ -0,0 +1,96 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Filter(t *testing.T) { + test(t, slicesx.Filter, "Filter even numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{2, 4, 6, 8}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, slicesx.Filter, "Filter odd numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{1, 3, 5, 7, 9}, + func(x int) bool { return x%2 == 1 }, + ) + test(t, slicesx.Filter, "Filter strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Apple", "Avocado", "Apricot"}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Filter, "Filter strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Banana", "Avocado", "Grapes", "Apricot"}, + func(s string) bool { return len(s) > 5 }, + ) +} + +func Test_Retain(t *testing.T) { + test(t, slicesx.FilterInPlace, "Filter even numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{2, 4, 6, 8}, + func(x int) bool { return x%2 == 0 }, + ) + test(t, slicesx.FilterInPlace, "Filter odd numbers", + []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, + []int{1, 3, 5, 7, 9}, + func(x int) bool { return x%2 == 1 }, + ) + test(t, slicesx.FilterInPlace, "Filter strings starting with 'A'", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Apple", "Avocado", "Apricot"}, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.FilterInPlace, "Filter strings longer than 5 chars", + []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, + []string{"Banana", "Avocado", "Grapes", "Apricot"}, + func(s string) bool { return len(s) > 5 }, + ) +} + +func Benchmark_Filter(b *testing.B) { + n := 100000 + + source := make([]int, n) + for i := range source { + source[i] = i + } + + b.ResetTimer() + for range b.N { + e := slicesx.Filter(source, func(x int) bool { return x%2 == 0 }) + if len(e) != n/2 { + b.FailNow() + } + } +} + +func Benchmark_FilterInPlace(b *testing.B) { + n := 100000 + + source := make([]int, n) + for i := range source { + source[i] = i + } + + // Preallocate all copies before timing + // because FilterInPlace mutates the original slice + copies := make([][]int, b.N) + for i := range b.N { + buf := make([]int, len(source)) + copy(buf, source) + copies[i] = buf + } + + b.ResetTimer() + for i := range b.N { + e := slicesx.FilterInPlace(copies[i], func(x int) bool { return x%2 == 0 }) + if len(e) != n/2 { + b.FailNow() + } + } +} diff --git a/api/slicesx/flatten.go b/api/slicesx/flatten.go new file mode 100644 index 000000000..56a77f3e9 --- /dev/null +++ b/api/slicesx/flatten.go @@ -0,0 +1,7 @@ +package slicesx + +import "slices" + +func Flatten[T any](input [][]T) []T { + return slices.Concat(input...) +} diff --git a/api/slicesx/flatten_test.go b/api/slicesx/flatten_test.go new file mode 100644 index 000000000..6875c4e6b --- /dev/null +++ b/api/slicesx/flatten_test.go @@ -0,0 +1,19 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + "github.com/stretchr/testify/assert" +) + +func Test_Flatten(t *testing.T) { + t.Run("Flatten an array of arrays", func(t *testing.T) { + is := assert.New(t) + + source := [][]int{{1, 2, 3}, {4, 5, 6}, {7, 8, 9}} + expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} + is.ElementsMatch(slicesx.Flatten(source), expected) + + }) +} diff --git a/api/slicesx/includes.go b/api/slicesx/includes.go new file mode 100644 index 000000000..377a54215 --- /dev/null +++ b/api/slicesx/includes.go @@ -0,0 +1,17 @@ +package slicesx + +import "slices" + +// Checks if predicate returns truthy for any element of input. Iteration is stopped once predicate returns truthy. +func Some[T any](input []T, predicate func(T) bool) bool { + return slices.ContainsFunc(input, predicate) +} + +// Checks if predicate returns truthy for all elements of input. Iteration is stopped once predicate returns falsey. +// +// Note: This method returns true for empty collections because everything is true of elements of empty collections. +// https://en.wikipedia.org/wiki/Vacuous_truth +func Every[T any](input []T, predicate func(T) bool) bool { + // if the slice doesn't contain an inverted predicate then all items follow the predicate + return !slices.ContainsFunc(input, func(t T) bool { return !predicate(t) }) +} diff --git a/api/slicesx/includes_test.go b/api/slicesx/includes_test.go new file mode 100644 index 000000000..a3f074c1c --- /dev/null +++ b/api/slicesx/includes_test.go @@ -0,0 +1,76 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Every(t *testing.T) { + test(t, slicesx.Every, "All start with an A (ok)", + []string{"Apple", "Avocado", "Apricot"}, + true, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Every, "All start with an A (ko = some don't start with A)", + []string{"Apple", "Avocado", "Banana"}, + false, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Every, "All are under 5 (ok)", + []int{1, 2, 3}, + true, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Every, "All are under 5 (ko = some above 10)", + []int{1, 2, 10}, + false, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Every, "All are true (ok)", + []struct{ x bool }{{x: true}, {x: true}, {x: true}}, + true, + func(s struct{ x bool }) bool { return s.x }) + test(t, slicesx.Every, "All are true (ko = some are false)", + []struct{ x bool }{{x: true}, {x: true}, {x: false}}, + false, + func(s struct{ x bool }) bool { return s.x }) + test(t, slicesx.Every, "Must be true on empty slice", + []int{}, + true, + func(i int) bool { return i%2 == 0 }, + ) +} + +func Test_Some(t *testing.T) { + test(t, slicesx.Some, "Some start with an A (ok)", + []string{"Apple", "Avocado", "Banana"}, + true, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Some, "Some start with an A (ko = all don't start with A)", + []string{"Banana", "Cherry", "Peach"}, + false, + func(s string) bool { return s[0] == 'A' }, + ) + test(t, slicesx.Some, "Some are under 5 (ok)", + []int{1, 2, 30}, + true, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Some, "Some are under 5 (ko = all above 5)", + []int{10, 11, 12}, + false, + func(i int) bool { return i < 5 }, + ) + test(t, slicesx.Some, "Some are true (ok)", + []struct{ x bool }{{x: true}, {x: true}, {x: false}}, + true, + func(s struct{ x bool }) bool { return s.x }, + ) + test(t, slicesx.Some, "Some are true (ko = all are false)", + []struct{ x bool }{{x: false}, {x: false}, {x: false}}, + false, + func(s struct{ x bool }) bool { return s.x }, + ) +} diff --git a/api/slicesx/map.go b/api/slicesx/map.go new file mode 100644 index 000000000..7e24bdd0d --- /dev/null +++ b/api/slicesx/map.go @@ -0,0 +1,15 @@ +package slicesx + +// Map applies the given function to each element of the slice and returns a new slice with the results +func Map[T, U any](s []T, f func(T) U) []U { + result := make([]U, len(s)) + for i, v := range s { + result[i] = f(v) + } + return result +} + +// FlatMap applies the given function to each element of the slice and returns a new slice with the flattened results +func FlatMap[T, U any](s []T, f func(T) []U) []U { + return Flatten(Map(s, f)) +} diff --git a/api/slicesx/map_test.go b/api/slicesx/map_test.go new file mode 100644 index 000000000..a2cd2256d --- /dev/null +++ b/api/slicesx/map_test.go @@ -0,0 +1,43 @@ +package slicesx_test + +import ( + "strconv" + "testing" + + "github.com/portainer/portainer/api/slicesx" +) + +func Test_Map(t *testing.T) { + test(t, slicesx.Map, "Map integers to strings", + []int{1, 2, 3, 4, 5}, + []string{"1", "2", "3", "4", "5"}, + strconv.Itoa, + ) + test(t, slicesx.Map, "Map strings to integers", + []string{"1", "2", "3", "4", "5"}, + []int{1, 2, 3, 4, 5}, + func(s string) int { + n, _ := strconv.Atoi(s) + return n + }, + ) +} + +func Test_FlatMap(t *testing.T) { + test(t, slicesx.FlatMap, "Map integers to strings and flatten", + []int{1, 2, 3, 4, 5}, + []string{"1", "1", "2", "2", "3", "3", "4", "4", "5", "5"}, + func(i int) []string { + x := strconv.Itoa(i) + return []string{x, x} + }, + ) + test(t, slicesx.FlatMap, "Map strings to integers and flatten", + []string{"1", "2", "3", "4", "5"}, + []int{1, 1, 2, 2, 3, 3, 4, 4, 5, 5}, + func(s string) []int { + n, _ := strconv.Atoi(s) + return []int{n, n} + }, + ) +} diff --git a/api/slicesx/slices_test.go b/api/slicesx/slices_test.go deleted file mode 100644 index d75f9b559..000000000 --- a/api/slicesx/slices_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package slicesx - -import ( - "strconv" - "testing" - - "github.com/stretchr/testify/assert" -) - -type filterTestCase[T any] struct { - name string - input []T - expected []T - predicate func(T) bool -} - -func TestFilter(t *testing.T) { - intTestCases := []filterTestCase[int]{ - { - name: "Filter even numbers", - input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - expected: []int{2, 4, 6, 8}, - - predicate: func(n int) bool { - return n%2 == 0 - }, - }, - { - name: "Filter odd numbers", - input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9}, - expected: []int{1, 3, 5, 7, 9}, - - predicate: func(n int) bool { - return n%2 != 0 - }, - }, - } - - runTestCases(t, intTestCases) - - stringTestCases := []filterTestCase[string]{ - { - name: "Filter strings starting with 'A'", - input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - expected: []string{"Apple", "Avocado", "Apricot"}, - predicate: func(s string) bool { - return s[0] == 'A' - }, - }, - { - name: "Filter strings longer than 5 characters", - input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"}, - expected: []string{"Banana", "Avocado", "Grapes", "Apricot"}, - predicate: func(s string) bool { - return len(s) > 5 - }, - }, - } - - runTestCases(t, stringTestCases) -} - -func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) { - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - is := assert.New(t) - result := Filter(testCase.input, testCase.predicate) - - is.Equal(len(testCase.expected), len(result)) - is.ElementsMatch(testCase.expected, result) - }) - } -} - -func TestMap(t *testing.T) { - intTestCases := []struct { - name string - input []int - expected []string - mapper func(int) string - }{ - { - name: "Map integers to strings", - input: []int{1, 2, 3, 4, 5}, - expected: []string{"1", "2", "3", "4", "5"}, - mapper: strconv.Itoa, - }, - } - - runMapTestCases(t, intTestCases) - - stringTestCases := []struct { - name string - input []string - expected []int - mapper func(string) int - }{ - { - name: "Map strings to integers", - input: []string{"1", "2", "3", "4", "5"}, - expected: []int{1, 2, 3, 4, 5}, - mapper: func(s string) int { - n, _ := strconv.Atoi(s) - return n - }, - }, - } - - runMapTestCases(t, stringTestCases) -} - -func runMapTestCases[T, U any](t *testing.T, testCases []struct { - name string - input []T - expected []U - mapper func(T) U -}) { - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - is := assert.New(t) - result := Map(testCase.input, testCase.mapper) - - is.Equal(len(testCase.expected), len(result)) - is.ElementsMatch(testCase.expected, result) - }) - } -} diff --git a/api/slicesx/slicesx_test.go b/api/slicesx/slicesx_test.go new file mode 100644 index 000000000..1bb8a76fe --- /dev/null +++ b/api/slicesx/slicesx_test.go @@ -0,0 +1,29 @@ +package slicesx_test + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +type libFunc[T, U, V any] func([]T, func(T) U) V +type predicateFunc[T, U any] func(T) U + +func test[T, U, V any](t *testing.T, libFn libFunc[T, U, V], name string, input []T, expected V, predicate predicateFunc[T, U]) { + t.Helper() + + t.Run(name, func(t *testing.T) { + is := assert.New(t) + + result := libFn(input, predicate) + + switch reflect.TypeOf(result).Kind() { + case reflect.Slice, reflect.Array: + is.Equal(expected, result) + is.ElementsMatch(expected, result) + default: + is.Equal(expected, result) + } + }) +} diff --git a/api/slicesx/slices.go b/api/slicesx/unique.go similarity index 51% rename from api/slicesx/slices.go rename to api/slicesx/unique.go index b7e0aa0ef..8659b0778 100644 --- a/api/slicesx/slices.go +++ b/api/slicesx/unique.go @@ -1,27 +1,5 @@ package slicesx -// Map applies the given function to each element of the slice and returns a new slice with the results -func Map[T, U any](s []T, f func(T) U) []U { - result := make([]U, len(s)) - for i, v := range s { - result[i] = f(v) - } - return result -} - -// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true -func Filter[T any](s []T, predicate func(T) bool) []T { - n := 0 - for _, v := range s { - if predicate(v) { - s[n] = v - n++ - } - } - - return s[:n] -} - func Unique[T comparable](items []T) []T { return UniqueBy(items, func(item T) T { return item diff --git a/api/slicesx/unique_test.go b/api/slicesx/unique_test.go new file mode 100644 index 000000000..8ff967ca6 --- /dev/null +++ b/api/slicesx/unique_test.go @@ -0,0 +1,46 @@ +package slicesx_test + +import ( + "testing" + + "github.com/portainer/portainer/api/slicesx" + "github.com/stretchr/testify/assert" +) + +func Test_Unique(t *testing.T) { + is := assert.New(t) + t.Run("Should extract unique numbers", func(t *testing.T) { + + source := []int{1, 1, 2, 3, 4, 4, 5, 4, 6, 7, 8, 9, 1} + result := slicesx.Unique(source) + expected := []int{1, 2, 3, 4, 5, 6, 7, 8, 9} + + is.ElementsMatch(result, expected) + }) + + t.Run("Should return empty array", func(t *testing.T) { + source := []int{} + result := slicesx.Unique(source) + expected := []int{} + is.ElementsMatch(result, expected) + }) +} + +func Test_UniqueBy(t *testing.T) { + is := assert.New(t) + t.Run("Should extract unique numbers by property", func(t *testing.T) { + + source := []struct{ int }{{1}, {1}, {2}, {3}, {4}, {4}, {5}, {4}, {6}, {7}, {8}, {9}, {1}} + result := slicesx.UniqueBy(source, func(item struct{ int }) int { return item.int }) + expected := []struct{ int }{{1}, {2}, {3}, {4}, {5}, {6}, {7}, {8}, {9}} + + is.ElementsMatch(result, expected) + }) + + t.Run("Should return empty array", func(t *testing.T) { + source := []int{} + result := slicesx.UniqueBy(source, func(x int) int { return x }) + expected := []int{} + is.ElementsMatch(result, expected) + }) +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx index 7dd8e35af..be1f664e0 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksDatatable.tsx @@ -5,7 +5,6 @@ import { useTableState } from '@@/datatables/useTableState'; import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu'; import { useEdgeStacks } from '../../queries/useEdgeStacks'; -import { EdgeStack, StatusType } from '../../types'; import { createStore } from './store'; import { columns } from './columns'; @@ -20,11 +19,7 @@ const settingsStore = createStore(tableKey); export function EdgeStacksDatatable() { const tableState = useTableState(settingsStore, tableKey); const edgeStacksQuery = useEdgeStacks>({ - select: (edgeStacks) => - edgeStacks.map((edgeStack) => ({ - ...edgeStack, - aggregatedStatus: aggregateStackStatus(edgeStack.Status), - })), + params: { summarizeStatuses: true }, refetchInterval: tableState.autoRefreshRate * 1000, }); @@ -50,16 +45,3 @@ export function EdgeStacksDatatable() { /> ); } - -function aggregateStackStatus(stackStatus: EdgeStack['Status']) { - const aggregateStatus: Partial> = {}; - return Object.values(stackStatus).reduce( - (acc, envStatus) => - envStatus.Status.reduce((acc, status) => { - const { Type } = status; - acc[Type] = (acc[Type] || 0) + 1; - return acc; - }, acc), - aggregateStatus - ); -} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx index 565eac41e..8c6443396 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/EdgeStacksStatus.tsx @@ -1,4 +1,3 @@ -import _ from 'lodash'; import { AlertTriangle, CheckCircle, @@ -6,35 +5,22 @@ import { Loader2, XCircle, MinusCircle, + PauseCircle, } from 'lucide-react'; -import { useEnvironmentList } from '@/react/portainer/environments/queries'; -import { isVersionSmaller } from '@/react/common/semver-utils'; - import { Icon, IconMode } from '@@/Icon'; import { Tooltip } from '@@/Tip/Tooltip'; -import { DeploymentStatus, EdgeStack, StatusType } from '../../types'; +import { DecoratedEdgeStack, StatusSummary, SummarizedStatus } from './types'; -export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) { - const status = Object.values(edgeStack.Status); - const lastStatus = _.compact(status.map((s) => _.last(s.Status))); +export function EdgeStackStatus({ + edgeStack, +}: { + edgeStack: DecoratedEdgeStack; +}) { + const { StatusSummary } = edgeStack; - const environmentsQuery = useEnvironmentList({ edgeStackId: edgeStack.Id }); - - if (environmentsQuery.isLoading) { - return null; - } - - const hasOldVersion = environmentsQuery.environments.some((env) => - isVersionSmaller(env.Agent.Version, '2.19.0') - ); - - const { icon, label, mode, spin, tooltip } = getStatus( - edgeStack.NumDeployments, - lastStatus, - hasOldVersion - ); + const { icon, label, mode, spin, tooltip } = getStatus(StatusSummary); return (
@@ -45,106 +31,68 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) { ); } -function getStatus( - numDeployments: number, - envStatus: Array, - hasOldVersion: boolean -): { +function getStatus(summary?: StatusSummary): { label: string; icon?: LucideIcon; spin?: boolean; mode?: IconMode; tooltip?: string; } { - if (!numDeployments || hasOldVersion) { + if (!summary) { return { label: 'Unavailable', icon: MinusCircle, mode: 'secondary', - tooltip: getUnavailableTooltip(), + tooltip: 'Status summary is unavailable', }; } + const { Status, Reason } = summary; - if (!envStatus.length) { - return { - label: 'Deploying', - icon: Loader2, - spin: true, - mode: 'primary', - }; - } - - const allFailed = envStatus.every((s) => s.Type === StatusType.Error); - - if (allFailed) { - return { - label: 'Failed', - icon: XCircle, - mode: 'danger', - }; - } - - if (envStatus.length < numDeployments) { - return { - label: 'Deploying', - icon: Loader2, - spin: true, - mode: 'primary', - }; - } - - const allCompleted = envStatus.every((s) => s.Type === StatusType.Completed); - - if (allCompleted) { - return { - label: 'Completed', - icon: CheckCircle, - mode: 'success', - }; - } - - const allRunning = envStatus.every( - (s) => - s.Type === StatusType.Running || - (s.Type === StatusType.DeploymentReceived && hasOldVersion) - ); - - if (allRunning) { - return { - label: 'Running', - icon: CheckCircle, - mode: 'success', - }; - } - - const hasDeploying = envStatus.some((s) => s.Type === StatusType.Deploying); - const hasRunning = envStatus.some((s) => s.Type === StatusType.Running); - const hasFailed = envStatus.some((s) => s.Type === StatusType.Error); - - if (hasRunning && hasFailed && !hasDeploying) { - return { - label: 'Partially Running', - icon: AlertTriangle, - mode: 'warning', - }; - } - - return { - label: 'Deploying', - icon: Loader2, - spin: true, - mode: 'primary', - }; - - function getUnavailableTooltip() { - if (!numDeployments) { - return 'Your edge stack is currently unavailable due to the absence of an available environment in your edge group'; - } - - if (hasOldVersion) { - return 'Please note that the new status feature for the Edge stack is only available for Edge Agent versions 2.19.0 and above. To access the status of your edge stack, it is essential to upgrade your Edge Agent to a corresponding version that is compatible with your Portainer server.'; - } - - return ''; + switch (Status) { + case SummarizedStatus.Deploying: + return { + label: 'Deploying', + icon: Loader2, + spin: true, + mode: 'primary', + }; + case SummarizedStatus.Failed: + return { + label: 'Failed', + icon: XCircle, + mode: 'danger', + }; + case SummarizedStatus.Paused: + return { + label: 'Paused', + icon: PauseCircle, + mode: 'warning', + }; + case SummarizedStatus.PartiallyRunning: + return { + label: 'Partially Running', + icon: AlertTriangle, + mode: 'warning', + }; + case SummarizedStatus.Completed: + return { + label: 'Completed', + icon: CheckCircle, + mode: 'success', + }; + case SummarizedStatus.Running: + return { + label: 'Running', + icon: CheckCircle, + mode: 'success', + }; + case SummarizedStatus.Unavailable: + default: + return { + label: 'Unavailable', + icon: MinusCircle, + mode: 'secondary', + tooltip: Reason, + }; } } diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx index 2b1a4472b..8df209598 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/columns.tsx @@ -5,8 +5,9 @@ import { isoDateFromTimestamp } from '@/portainer/filters/filters'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { GitCommitLink } from '@/react/portainer/gitops/GitCommitLink'; -import { buildNameColumn } from '@@/datatables/buildNameColumn'; +import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn'; import { Link } from '@@/Link'; +import { Tooltip } from '@@/Tip/Tooltip'; import { StatusType } from '../../types'; @@ -17,14 +18,15 @@ import { DeploymentCounter } from './DeploymentCounter'; const columnHelper = createColumnHelper(); export const columns = _.compact([ - buildNameColumn( - 'Name', - 'edge.stacks.edit', - 'edge-stacks-name', - 'stackId' - ), + buildNameColumnFromObject({ + nameKey: 'Name', + path: 'edge.stacks.edit', + dataCy: 'edge-stacks-name', + idParam: 'stackId', + }), columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.Acknowledged] || 0, + (item) => + item.StatusSummary?.AggregatedStatus?.[StatusType.Acknowledged] || 0, { header: 'Acknowledged', enableSorting: false, @@ -43,7 +45,8 @@ export const columns = _.compact([ ), isBE && columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0, + (item) => + item.StatusSummary?.AggregatedStatus?.[StatusType.ImagesPulled] || 0, { header: 'Images pre-pulled', cell: ({ getValue, row: { original: item } }) => { @@ -67,7 +70,9 @@ export const columns = _.compact([ } ), columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.DeploymentReceived] || 0, + (item) => + item.StatusSummary?.AggregatedStatus?.[StatusType.DeploymentReceived] || + 0, { header: 'Deployments received', cell: ({ getValue, row }) => ( @@ -85,7 +90,7 @@ export const columns = _.compact([ } ), columnHelper.accessor( - (item) => item.aggregatedStatus[StatusType.Error] || 0, + (item) => item.StatusSummary?.AggregatedStatus?.[StatusType.Error] || 0, { header: 'Deployments failed', cell: ({ getValue, row }) => { @@ -123,7 +128,7 @@ export const columns = _.compact([ } ), columnHelper.accessor('Status', { - header: 'Status', + header: StatusHeader, cell: ({ row }) => (
@@ -167,3 +172,27 @@ export const columns = _.compact([ } ), ]); + +function StatusHeader() { + return ( + <> + Status + +
+ The status feature for the Edge stack is only available for Edge + Agent versions 2.19.0 and above. +
+
+ To access the status of your edge stack, it is essential to + upgrade your Edge Agent to a corresponding version that is + compatible with your Portainer server. +
+ + } + /> + + ); +} diff --git a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts index 89b360342..64f37b83f 100644 --- a/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts +++ b/app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/types.ts @@ -1,5 +1,21 @@ import { EdgeStack, StatusType } from '../../types'; -export type DecoratedEdgeStack = EdgeStack & { - aggregatedStatus: Partial>; +export enum SummarizedStatus { + Unavailable = 'Unavailable', + Deploying = 'Deploying', + Failed = 'Failed', + Paused = 'Paused', + PartiallyRunning = 'PartiallyRunning', + Completed = 'Completed', + Running = 'Running', +} + +export type StatusSummary = { + AggregatedStatus?: Partial>; + Status: SummarizedStatus; + Reason: string; +}; + +export type DecoratedEdgeStack = EdgeStack & { + StatusSummary?: StatusSummary; }; diff --git a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts index 6ab5a0cf1..d03e143f8 100644 --- a/app/react/edge/edge-stacks/queries/useEdgeStacks.ts +++ b/app/react/edge/edge-stacks/queries/useEdgeStacks.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { withError } from '@/react-tools/react-query'; +import { withGlobalError } from '@/react-tools/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios'; import { EdgeStack } from '../types'; @@ -8,28 +8,30 @@ import { EdgeStack } from '../types'; import { buildUrl } from './buildUrl'; import { queryKeys } from './query-keys'; -export function useEdgeStacks>({ - select, - /** - * If set to a number, the query will continuously refetch at this frequency in milliseconds. - * If set to a function, the function will be executed with the latest data and query to compute a frequency - * Defaults to `false`. - */ +type QueryParams = { + summarizeStatuses?: boolean; +}; + +export function useEdgeStacks({ + params, refetchInterval, }: { - select?: (stacks: EdgeStack[]) => T; + params?: QueryParams; refetchInterval?: number | false | ((data?: T) => false | number); } = {}) { - return useQuery(queryKeys.base(), () => getEdgeStacks(), { - ...withError('Failed loading Edge stack'), - select, + return useQuery({ + queryKey: queryKeys.base(), + queryFn: () => getEdgeStacks(params), refetchInterval, + ...withGlobalError('Failed loading Edge stack'), }); } -export async function getEdgeStacks() { +async function getEdgeStacks( + params: QueryParams = {} +) { try { - const { data } = await axios.get(buildUrl()); + const { data } = await axios.get(buildUrl(), { params }); return data; } catch (e) { throw parseAxiosError(e as Error); diff --git a/package.json b/package.json index d9c180698..3bcac5b09 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dev": "webpack-dev-server", "start": "webpack -w", "build": "webpack", - "format": "prettier --log-level warn --write \"**/*.{js,css,html,jsx,tsx,ts,json}\"", + "format": "prettier --log-level warn --write \"**/*.{js,css,html,jsx,tsx,ts}\"", "lint": "eslint --cache --fix './**/*.{js,jsx,ts,tsx}'", "test": "vitest run", "sb": "yarn storybook",