1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-19 05:19:39 +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

View file

@ -71,7 +71,7 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
customTemplates = filterByType(customTemplates, templateTypes) customTemplates = filterByType(customTemplates, templateTypes)
if edge != nil { 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 return customTemplate.EdgeTemplate == *edge
}) })
} }

View file

@ -3,10 +3,39 @@ package edgestacks
import ( import (
"net/http" "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" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response" "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 // @id EdgeStackList
// @summary Fetches the list of EdgeStacks // @summary Fetches the list of EdgeStacks
// @description **Access policy**: administrator // @description **Access policy**: administrator
@ -14,22 +43,122 @@ import (
// @security ApiKeyAuth // @security ApiKeyAuth
// @security jwt // @security jwt
// @produce json // @produce json
// @param summarizeStatuses query boolean false "will summarize the statuses"
// @success 200 {array} portainer.EdgeStack // @success 200 {array} portainer.EdgeStack
// @failure 500 // @failure 500
// @failure 400 // @failure 400
// @failure 503 "Edge compute features are disabled" // @failure 503 "Edge compute features are disabled"
// @router /edge_stacks [get] // @router /edge_stacks [get]
func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true)
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks() edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
if err != nil { if err != nil {
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err) return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
} }
res := make([]edgeStackListResponseItem, len(edgeStacks))
for i := range 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 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, ""
} }

View file

@ -35,7 +35,7 @@ type (
func getUniqueElements(items string) []string { func getUniqueElements(items string) []string {
xs := strings.Split(items, ",") xs := strings.Split(items, ",")
xs = slicesx.Map(xs, strings.TrimSpace) 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) return slicesx.Unique(xs)
} }

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 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 { func Unique[T comparable](items []T) []T {
return UniqueBy(items, func(item T) T { return UniqueBy(items, func(item T) T {
return item 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)
})
}

View file

@ -5,7 +5,6 @@ import { useTableState } from '@@/datatables/useTableState';
import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu'; import { getColumnVisibilityState } from '@@/datatables/ColumnVisibilityMenu';
import { useEdgeStacks } from '../../queries/useEdgeStacks'; import { useEdgeStacks } from '../../queries/useEdgeStacks';
import { EdgeStack, StatusType } from '../../types';
import { createStore } from './store'; import { createStore } from './store';
import { columns } from './columns'; import { columns } from './columns';
@ -20,11 +19,7 @@ const settingsStore = createStore(tableKey);
export function EdgeStacksDatatable() { export function EdgeStacksDatatable() {
const tableState = useTableState(settingsStore, tableKey); const tableState = useTableState(settingsStore, tableKey);
const edgeStacksQuery = useEdgeStacks<Array<DecoratedEdgeStack>>({ const edgeStacksQuery = useEdgeStacks<Array<DecoratedEdgeStack>>({
select: (edgeStacks) => params: { summarizeStatuses: true },
edgeStacks.map((edgeStack) => ({
...edgeStack,
aggregatedStatus: aggregateStackStatus(edgeStack.Status),
})),
refetchInterval: tableState.autoRefreshRate * 1000, 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
);
}

View file

@ -1,4 +1,3 @@
import _ from 'lodash';
import { import {
AlertTriangle, AlertTriangle,
CheckCircle, CheckCircle,
@ -6,35 +5,22 @@ import {
Loader2, Loader2,
XCircle, XCircle,
MinusCircle, MinusCircle,
PauseCircle,
} from 'lucide-react'; } from 'lucide-react';
import { useEnvironmentList } from '@/react/portainer/environments/queries';
import { isVersionSmaller } from '@/react/common/semver-utils';
import { Icon, IconMode } from '@@/Icon'; import { Icon, IconMode } from '@@/Icon';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { DeploymentStatus, EdgeStack, StatusType } from '../../types'; import { DecoratedEdgeStack, StatusSummary, SummarizedStatus } from './types';
export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) { export function EdgeStackStatus({
const status = Object.values(edgeStack.Status); edgeStack,
const lastStatus = _.compact(status.map((s) => _.last(s.Status))); }: {
edgeStack: DecoratedEdgeStack;
}) {
const { StatusSummary } = edgeStack;
const environmentsQuery = useEnvironmentList({ edgeStackId: edgeStack.Id }); const { icon, label, mode, spin, tooltip } = getStatus(StatusSummary);
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
);
return ( return (
<div className="mx-auto inline-flex items-center gap-2"> <div className="mx-auto inline-flex items-center gap-2">
@ -45,106 +31,68 @@ export function EdgeStackStatus({ edgeStack }: { edgeStack: EdgeStack }) {
); );
} }
function getStatus( function getStatus(summary?: StatusSummary): {
numDeployments: number,
envStatus: Array<DeploymentStatus>,
hasOldVersion: boolean
): {
label: string; label: string;
icon?: LucideIcon; icon?: LucideIcon;
spin?: boolean; spin?: boolean;
mode?: IconMode; mode?: IconMode;
tooltip?: string; tooltip?: string;
} { } {
if (!numDeployments || hasOldVersion) { if (!summary) {
return { return {
label: 'Unavailable', label: 'Unavailable',
icon: MinusCircle, icon: MinusCircle,
mode: 'secondary', mode: 'secondary',
tooltip: getUnavailableTooltip(), tooltip: 'Status summary is unavailable',
}; };
} }
const { Status, Reason } = summary;
if (!envStatus.length) { switch (Status) {
case SummarizedStatus.Deploying:
return { return {
label: 'Deploying', label: 'Deploying',
icon: Loader2, icon: Loader2,
spin: true, spin: true,
mode: 'primary', mode: 'primary',
}; };
} case SummarizedStatus.Failed:
const allFailed = envStatus.every((s) => s.Type === StatusType.Error);
if (allFailed) {
return { return {
label: 'Failed', label: 'Failed',
icon: XCircle, icon: XCircle,
mode: 'danger', mode: 'danger',
}; };
} case SummarizedStatus.Paused:
if (envStatus.length < numDeployments) {
return { return {
label: 'Deploying', label: 'Paused',
icon: Loader2, icon: PauseCircle,
spin: true, mode: 'warning',
mode: 'primary',
}; };
} case SummarizedStatus.PartiallyRunning:
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 { return {
label: 'Partially Running', label: 'Partially Running',
icon: AlertTriangle, icon: AlertTriangle,
mode: 'warning', mode: 'warning',
}; };
} case SummarizedStatus.Completed:
return { return {
label: 'Deploying', label: 'Completed',
icon: Loader2, icon: CheckCircle,
spin: true, mode: 'success',
mode: 'primary', };
case SummarizedStatus.Running:
return {
label: 'Running',
icon: CheckCircle,
mode: 'success',
};
case SummarizedStatus.Unavailable:
default:
return {
label: 'Unavailable',
icon: MinusCircle,
mode: 'secondary',
tooltip: Reason,
}; };
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 '';
} }
} }

View file

@ -5,8 +5,9 @@ import { isoDateFromTimestamp } from '@/portainer/filters/filters';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { GitCommitLink } from '@/react/portainer/gitops/GitCommitLink'; import { GitCommitLink } from '@/react/portainer/gitops/GitCommitLink';
import { buildNameColumn } from '@@/datatables/buildNameColumn'; import { buildNameColumnFromObject } from '@@/datatables/buildNameColumn';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
import { Tooltip } from '@@/Tip/Tooltip';
import { StatusType } from '../../types'; import { StatusType } from '../../types';
@ -17,14 +18,15 @@ import { DeploymentCounter } from './DeploymentCounter';
const columnHelper = createColumnHelper<DecoratedEdgeStack>(); const columnHelper = createColumnHelper<DecoratedEdgeStack>();
export const columns = _.compact([ export const columns = _.compact([
buildNameColumn<DecoratedEdgeStack>( buildNameColumnFromObject<DecoratedEdgeStack>({
'Name', nameKey: 'Name',
'edge.stacks.edit', path: 'edge.stacks.edit',
'edge-stacks-name', dataCy: 'edge-stacks-name',
'stackId' idParam: 'stackId',
), }),
columnHelper.accessor( columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.Acknowledged] || 0, (item) =>
item.StatusSummary?.AggregatedStatus?.[StatusType.Acknowledged] || 0,
{ {
header: 'Acknowledged', header: 'Acknowledged',
enableSorting: false, enableSorting: false,
@ -43,7 +45,8 @@ export const columns = _.compact([
), ),
isBE && isBE &&
columnHelper.accessor( columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.ImagesPulled] || 0, (item) =>
item.StatusSummary?.AggregatedStatus?.[StatusType.ImagesPulled] || 0,
{ {
header: 'Images pre-pulled', header: 'Images pre-pulled',
cell: ({ getValue, row: { original: item } }) => { cell: ({ getValue, row: { original: item } }) => {
@ -67,7 +70,9 @@ export const columns = _.compact([
} }
), ),
columnHelper.accessor( columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.DeploymentReceived] || 0, (item) =>
item.StatusSummary?.AggregatedStatus?.[StatusType.DeploymentReceived] ||
0,
{ {
header: 'Deployments received', header: 'Deployments received',
cell: ({ getValue, row }) => ( cell: ({ getValue, row }) => (
@ -85,7 +90,7 @@ export const columns = _.compact([
} }
), ),
columnHelper.accessor( columnHelper.accessor(
(item) => item.aggregatedStatus[StatusType.Error] || 0, (item) => item.StatusSummary?.AggregatedStatus?.[StatusType.Error] || 0,
{ {
header: 'Deployments failed', header: 'Deployments failed',
cell: ({ getValue, row }) => { cell: ({ getValue, row }) => {
@ -123,7 +128,7 @@ export const columns = _.compact([
} }
), ),
columnHelper.accessor('Status', { columnHelper.accessor('Status', {
header: 'Status', header: StatusHeader,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="w-full text-center"> <div className="w-full text-center">
<EdgeStackStatus edgeStack={row.original} /> <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>
</>
}
/>
</>
);
}

View file

@ -1,5 +1,21 @@
import { EdgeStack, StatusType } from '../../types'; import { EdgeStack, StatusType } from '../../types';
export type DecoratedEdgeStack = EdgeStack & { export enum SummarizedStatus {
aggregatedStatus: Partial<Record<StatusType, number>>; 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;
}; };

View file

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; 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 axios, { parseAxiosError } from '@/portainer/services/axios';
import { EdgeStack } from '../types'; import { EdgeStack } from '../types';
@ -8,28 +8,30 @@ import { EdgeStack } from '../types';
import { buildUrl } from './buildUrl'; import { buildUrl } from './buildUrl';
import { queryKeys } from './query-keys'; import { queryKeys } from './query-keys';
export function useEdgeStacks<T = Array<EdgeStack>>({ type QueryParams = {
select, summarizeStatuses?: boolean;
/** };
* 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 export function useEdgeStacks<T extends EdgeStack[] = EdgeStack[]>({
* Defaults to `false`. params,
*/
refetchInterval, refetchInterval,
}: { }: {
select?: (stacks: EdgeStack[]) => T; params?: QueryParams;
refetchInterval?: number | false | ((data?: T) => false | number); refetchInterval?: number | false | ((data?: T) => false | number);
} = {}) { } = {}) {
return useQuery(queryKeys.base(), () => getEdgeStacks(), { return useQuery({
...withError('Failed loading Edge stack'), queryKey: queryKeys.base(),
select, queryFn: () => getEdgeStacks<T>(params),
refetchInterval, refetchInterval,
...withGlobalError('Failed loading Edge stack'),
}); });
} }
export async function getEdgeStacks() { async function getEdgeStacks<T extends EdgeStack[] = EdgeStack[]>(
params: QueryParams = {}
) {
try { try {
const { data } = await axios.get<EdgeStack[]>(buildUrl()); const { data } = await axios.get<T>(buildUrl(), { params });
return data; return data;
} catch (e) { } catch (e) {
throw parseAxiosError(e as Error); throw parseAxiosError(e as Error);

View file

@ -20,7 +20,7 @@
"dev": "webpack-dev-server", "dev": "webpack-dev-server",
"start": "webpack -w", "start": "webpack -w",
"build": "webpack", "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}'", "lint": "eslint --cache --fix './**/*.{js,jsx,ts,tsx}'",
"test": "vitest run", "test": "vitest run",
"sb": "yarn storybook", "sb": "yarn storybook",