mirror of
https://github.com/portainer/portainer.git
synced 2025-07-18 21:09:40 +02:00
feat(app/edge-stacks): summarize the edge stack statuses in the backend (#818)
This commit is contained in:
parent
363a62d885
commit
e1c480d3c3
21 changed files with 645 additions and 312 deletions
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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, ""
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
28
api/slicesx/filter.go
Normal file
28
api/slicesx/filter.go
Normal 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]
|
||||
}
|
96
api/slicesx/filter_test.go
Normal file
96
api/slicesx/filter_test.go
Normal 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
7
api/slicesx/flatten.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package slicesx
|
||||
|
||||
import "slices"
|
||||
|
||||
func Flatten[T any](input [][]T) []T {
|
||||
return slices.Concat(input...)
|
||||
}
|
19
api/slicesx/flatten_test.go
Normal file
19
api/slicesx/flatten_test.go
Normal 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
17
api/slicesx/includes.go
Normal 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) })
|
||||
}
|
76
api/slicesx/includes_test.go
Normal file
76
api/slicesx/includes_test.go
Normal 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
15
api/slicesx/map.go
Normal 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
43
api/slicesx/map_test.go
Normal 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}
|
||||
},
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
29
api/slicesx/slicesx_test.go
Normal file
29
api/slicesx/slicesx_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -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
|
46
api/slicesx/unique_test.go
Normal file
46
api/slicesx/unique_test.go
Normal 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)
|
||||
})
|
||||
}
|
|
@ -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<Array<DecoratedEdgeStack>>({
|
||||
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<Record<StatusType, number>> = {};
|
||||
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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<div className="mx-auto inline-flex items-center gap-2">
|
||||
|
@ -45,106 +31,68 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
|
|||
);
|
||||
}
|
||||
|
||||
function getStatus(
|
||||
numDeployments: number,
|
||||
envStatus: Array<DeploymentStatus>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<DecoratedEdgeStack>();
|
||||
|
||||
export const columns = _.compact([
|
||||
buildNameColumn<DecoratedEdgeStack>(
|
||||
'Name',
|
||||
'edge.stacks.edit',
|
||||
'edge-stacks-name',
|
||||
'stackId'
|
||||
),
|
||||
buildNameColumnFromObject<DecoratedEdgeStack>({
|
||||
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 }) => (
|
||||
<div className="w-full text-center">
|
||||
<EdgeStackStatus edgeStack={row.original} />
|
||||
|
@ -167,3 +172,27 @@ export const columns = _.compact([
|
|||
}
|
||||
),
|
||||
]);
|
||||
|
||||
function StatusHeader() {
|
||||
return (
|
||||
<>
|
||||
Status
|
||||
<Tooltip
|
||||
position="top"
|
||||
message={
|
||||
<>
|
||||
<div>
|
||||
The status feature for the Edge stack is only available for Edge
|
||||
Agent versions 2.19.0 and above.
|
||||
</div>
|
||||
<div>
|
||||
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.
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,21 @@
|
|||
import { EdgeStack, StatusType } from '../../types';
|
||||
|
||||
export type DecoratedEdgeStack = EdgeStack & {
|
||||
aggregatedStatus: Partial<Record<StatusType, number>>;
|
||||
export enum SummarizedStatus {
|
||||
Unavailable = 'Unavailable',
|
||||
Deploying = 'Deploying',
|
||||
Failed = 'Failed',
|
||||
Paused = 'Paused',
|
||||
PartiallyRunning = 'PartiallyRunning',
|
||||
Completed = 'Completed',
|
||||
Running = 'Running',
|
||||
}
|
||||
|
||||
export type StatusSummary = {
|
||||
AggregatedStatus?: Partial<Record<StatusType, number>>;
|
||||
Status: SummarizedStatus;
|
||||
Reason: string;
|
||||
};
|
||||
|
||||
export type DecoratedEdgeStack = EdgeStack & {
|
||||
StatusSummary?: StatusSummary;
|
||||
};
|
||||
|
|
|
@ -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<T = Array<EdgeStack>>({
|
||||
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<T extends EdgeStack[] = EdgeStack[]>({
|
||||
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<T>(params),
|
||||
refetchInterval,
|
||||
...withGlobalError('Failed loading Edge stack'),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getEdgeStacks() {
|
||||
async function getEdgeStacks<T extends EdgeStack[] = EdgeStack[]>(
|
||||
params: QueryParams = {}
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<EdgeStack[]>(buildUrl());
|
||||
const { data } = await axios.get<T>(buildUrl(), { params });
|
||||
return data;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error);
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue