1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 15:59:41 +02:00

feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818)

This commit is contained in:
LP B 2025-07-01 15:04:10 +02:00 committed by GitHub
parent 363a62d885
commit e1c480d3c3
21 changed files with 645 additions and 312 deletions

28
api/slicesx/filter.go Normal file
View file

@ -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]
}

View file

@ -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()
}
}
}

7
api/slicesx/flatten.go Normal file
View file

@ -0,0 +1,7 @@
package slicesx
import "slices"
func Flatten[T any](input [][]T) []T {
return slices.Concat(input...)
}

View file

@ -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)
})
}

17
api/slicesx/includes.go Normal file
View file

@ -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) })
}

View file

@ -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 },
)
}

15
api/slicesx/map.go Normal file
View file

@ -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))
}

43
api/slicesx/map_test.go Normal file
View file

@ -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}
},
)
}

View file

@ -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)
})
}
}

View file

@ -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)
}
})
}

View file

@ -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

View file

@ -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)
})
}