diff --git a/api/datastore/services_tx.go b/api/datastore/services_tx.go
index 0968d18cc..c20d632ba 100644
--- a/api/datastore/services_tx.go
+++ b/api/datastore/services_tx.go
@@ -70,7 +70,10 @@ func (tx *StoreTx) Snapshot() dataservices.SnapshotService {
}
func (tx *StoreTx) SSLSettings() dataservices.SSLSettingsService { return nil }
-func (tx *StoreTx) Stack() dataservices.StackService { return nil }
+
+func (tx *StoreTx) Stack() dataservices.StackService {
+ return tx.store.StackService.Tx(tx.tx)
+}
func (tx *StoreTx) Tag() dataservices.TagService {
return tx.store.TagService.Tx(tx.tx)
@@ -80,7 +83,8 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
return tx.store.TeamMembershipService.Tx(tx.tx)
}
-func (tx *StoreTx) Team() dataservices.TeamService { return nil }
+func (tx *StoreTx) Team() dataservices.TeamService { return nil }
+
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
func (tx *StoreTx) User() dataservices.UserService {
diff --git a/api/docker/consts/labels.go b/api/docker/consts/labels.go
index 718e85e93..748dff8e2 100644
--- a/api/docker/consts/labels.go
+++ b/api/docker/consts/labels.go
@@ -5,4 +5,5 @@ const (
SwarmStackNameLabel = "com.docker.stack.namespace"
SwarmServiceIdLabel = "com.docker.swarm.service.id"
SwarmNodeIdLabel = "com.docker.swarm.node.id"
+ HideStackLabel = "io.portainer.hideStack"
)
diff --git a/api/docker/container_stats.go b/api/docker/container_stats.go
new file mode 100644
index 000000000..321d2e22c
--- /dev/null
+++ b/api/docker/container_stats.go
@@ -0,0 +1,37 @@
+package docker
+
+import "github.com/docker/docker/api/types"
+
+type ContainerStats struct {
+ Running int `json:"running"`
+ Stopped int `json:"stopped"`
+ Healthy int `json:"healthy"`
+ Unhealthy int `json:"unhealthy"`
+ Total int `json:"total"`
+}
+
+func CalculateContainerStats(containers []types.Container) ContainerStats {
+ var running, stopped, healthy, unhealthy int
+ for _, container := range containers {
+ switch container.State {
+ case "running":
+ running++
+ case "healthy":
+ running++
+ healthy++
+ case "unhealthy":
+ running++
+ unhealthy++
+ case "exited", "stopped":
+ stopped++
+ }
+ }
+
+ return ContainerStats{
+ Running: running,
+ Stopped: stopped,
+ Healthy: healthy,
+ Unhealthy: unhealthy,
+ Total: len(containers),
+ }
+}
diff --git a/api/docker/container_stats_test.go b/api/docker/container_stats_test.go
new file mode 100644
index 000000000..5422a276c
--- /dev/null
+++ b/api/docker/container_stats_test.go
@@ -0,0 +1,27 @@
+package docker
+
+import (
+ "testing"
+
+ "github.com/docker/docker/api/types"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestCalculateContainerStats(t *testing.T) {
+ containers := []types.Container{
+ {State: "running"},
+ {State: "running"},
+ {State: "exited"},
+ {State: "stopped"},
+ {State: "healthy"},
+ {State: "unhealthy"},
+ }
+
+ stats := CalculateContainerStats(containers)
+
+ assert.Equal(t, 4, stats.Running)
+ assert.Equal(t, 2, stats.Stopped)
+ assert.Equal(t, 1, stats.Healthy)
+ assert.Equal(t, 1, stats.Unhealthy)
+ assert.Equal(t, 6, stats.Total)
+}
diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go
index ec77bb417..540f8a807 100644
--- a/api/docker/snapshot.go
+++ b/api/docker/snapshot.go
@@ -153,19 +153,11 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
return err
}
- runningContainers := 0
- stoppedContainers := 0
- healthyContainers := 0
- unhealthyContainers := 0
stacks := make(map[string]struct{})
gpuUseSet := make(map[string]struct{})
gpuUseAll := false
for _, container := range containers {
- if container.State == "exited" || container.State == "stopped" {
- stoppedContainers++
- } else if container.State == "running" {
- runningContainers++
-
+ if container.State == "running" {
// snapshot GPUs
response, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil {
@@ -202,15 +194,6 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
}
}
- if container.State == "healthy" {
- runningContainers++
- healthyContainers++
- }
-
- if container.State == "unhealthy" {
- unhealthyContainers++
- }
-
for k, v := range container.Labels {
if k == consts.ComposeStackNameLabel {
stacks[v] = struct{}{}
@@ -226,11 +209,13 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
snapshot.GpuUseAll = gpuUseAll
snapshot.GpuUseList = gpuUseList
- snapshot.ContainerCount = len(containers)
- snapshot.RunningContainerCount = runningContainers
- snapshot.StoppedContainerCount = stoppedContainers
- snapshot.HealthyContainerCount = healthyContainers
- snapshot.UnhealthyContainerCount = unhealthyContainers
+ stats := CalculateContainerStats(containers)
+
+ snapshot.ContainerCount = stats.Total
+ snapshot.RunningContainerCount = stats.Running
+ snapshot.StoppedContainerCount = stats.Stopped
+ snapshot.HealthyContainerCount = stats.Healthy
+ snapshot.UnhealthyContainerCount = stats.Unhealthy
snapshot.StackCount += len(stacks)
for _, container := range containers {
snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container})
diff --git a/api/http/handler/docker/dashboard.go b/api/http/handler/docker/dashboard.go
new file mode 100644
index 000000000..ad0399569
--- /dev/null
+++ b/api/http/handler/docker/dashboard.go
@@ -0,0 +1,164 @@
+package docker
+
+import (
+ "net/http"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/container"
+ "github.com/docker/docker/api/types/image"
+ "github.com/docker/docker/api/types/swarm"
+ "github.com/docker/docker/api/types/volume"
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ "github.com/portainer/portainer/api/docker"
+ "github.com/portainer/portainer/api/http/errors"
+ "github.com/portainer/portainer/api/http/handler/docker/utils"
+ "github.com/portainer/portainer/api/http/middlewares"
+ "github.com/portainer/portainer/api/http/security"
+ httperror "github.com/portainer/portainer/pkg/libhttp/error"
+ "github.com/portainer/portainer/pkg/libhttp/response"
+)
+
+type imagesCounters struct {
+ Total int `json:"total"`
+ Size int64 `json:"size"`
+}
+
+type dashboardResponse struct {
+ Containers docker.ContainerStats `json:"containers"`
+ Services int `json:"services"`
+ Images imagesCounters `json:"images"`
+ Volumes int `json:"volumes"`
+ Networks int `json:"networks"`
+ Stacks int `json:"stacks"`
+}
+
+// @id dockerDashboard
+// @summary Get counters for the dashboard
+// @description **Access policy**: restricted
+// @tags docker
+// @security jwt
+// @param environmentId path int true "Environment identifier"
+// @accept json
+// @produce json
+// @success 200 {object} dashboardResponse "Success"
+// @failure 400 "Bad request"
+// @failure 500 "Internal server error"
+// @router /docker/{environmentId}/dashboard [post]
+func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ var resp dashboardResponse
+ err := h.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
+ cli, httpErr := utils.GetClient(r, h.dockerClientFactory)
+ if httpErr != nil {
+ return httpErr
+ }
+
+ context, err := security.RetrieveRestrictedRequestContext(r)
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve user details from request context", err)
+ }
+
+ containers, err := cli.ContainerList(r.Context(), container.ListOptions{All: true})
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve Docker containers", err)
+ }
+
+ containers, err = utils.FilterByResourceControl(tx, containers, portainer.ContainerResourceControl, context, func(c types.Container) string {
+ return c.ID
+ })
+ if err != nil {
+ return err
+ }
+
+ images, err := cli.ImageList(r.Context(), image.ListOptions{})
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve Docker images", err)
+ }
+
+ var totalSize int64
+ for _, image := range images {
+ totalSize += image.Size
+ }
+
+ info, err := cli.Info(r.Context())
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve Docker info", err)
+ }
+
+ isSwarmManager := info.Swarm.ControlAvailable && info.Swarm.NodeID != ""
+
+ var services []swarm.Service
+ if isSwarmManager {
+ servicesRes, err := cli.ServiceList(r.Context(), types.ServiceListOptions{})
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve Docker services", err)
+ }
+
+ filteredServices, err := utils.FilterByResourceControl(tx, servicesRes, portainer.ServiceResourceControl, context, func(c swarm.Service) string {
+ return c.ID
+ })
+ if err != nil {
+ return err
+ }
+
+ services = filteredServices
+ }
+
+ volumesRes, err := cli.VolumeList(r.Context(), volume.ListOptions{})
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve Docker volumes", err)
+ }
+
+ volumes, err := utils.FilterByResourceControl(tx, volumesRes.Volumes, portainer.NetworkResourceControl, context, func(c *volume.Volume) string {
+ return c.Name
+ })
+ if err != nil {
+ return err
+ }
+
+ networks, err := cli.NetworkList(r.Context(), types.NetworkListOptions{})
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve Docker networks", err)
+ }
+
+ networks, err = utils.FilterByResourceControl(tx, networks, portainer.NetworkResourceControl, context, func(c types.NetworkResource) string {
+ return c.Name
+ })
+ if err != nil {
+ return err
+ }
+
+ environment, err := middlewares.FetchEndpoint(r)
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve environment", err)
+ }
+
+ stackCount := 0
+ if environment.SecuritySettings.AllowStackManagementForRegularUsers || context.IsAdmin {
+ stacks, err := utils.GetDockerStacks(tx, context, environment.ID, containers, services)
+ if err != nil {
+ return httperror.InternalServerError("Unable to retrieve stacks", err)
+ }
+
+ stackCount = len(stacks)
+ }
+
+ resp = dashboardResponse{
+ Images: imagesCounters{
+ Total: len(images),
+ Size: totalSize,
+ },
+ Services: len(services),
+ Containers: docker.CalculateContainerStats(containers),
+ Networks: len(networks),
+ Volumes: len(volumes),
+ Stacks: stackCount,
+ }
+
+ return nil
+ })
+
+ return errors.TxResponse(err, func() *httperror.HandlerError {
+ return response.JSON(w, resp)
+ })
+}
diff --git a/api/http/handler/docker/handler.go b/api/http/handler/docker/handler.go
index 290a57975..3c9638bd8 100644
--- a/api/http/handler/docker/handler.go
+++ b/api/http/handler/docker/handler.go
@@ -41,8 +41,10 @@ func NewHandler(bouncer security.BouncerService, authorizationService *authoriza
// endpoints
endpointRouter := h.PathPrefix("/docker/{id}").Subrouter()
- endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
- endpointRouter.Use(dockerOnlyMiddleware)
+ endpointRouter.Use(bouncer.AuthenticatedAccess)
+ endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"), dockerOnlyMiddleware)
+
+ endpointRouter.Handle("/dashboard", httperror.LoggerHandler(h.dashboard)).Methods(http.MethodGet)
containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler)
diff --git a/api/http/handler/docker/utils/filter_by_uac.go b/api/http/handler/docker/utils/filter_by_uac.go
new file mode 100644
index 000000000..bb8b41529
--- /dev/null
+++ b/api/http/handler/docker/utils/filter_by_uac.go
@@ -0,0 +1,36 @@
+package utils
+
+import (
+ "fmt"
+
+ portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ "github.com/portainer/portainer/api/http/security"
+ "github.com/portainer/portainer/api/internal/authorization"
+ "github.com/portainer/portainer/api/internal/slices"
+)
+
+// filterByResourceControl filters a list of items based on the user's role and the resource control associated to the item.
+func FilterByResourceControl[T any](tx dataservices.DataStoreTx, items []T, rcType portainer.ResourceControlType, securityContext *security.RestrictedRequestContext, idGetter func(T) string) ([]T, error) {
+ if securityContext.IsAdmin {
+ return items, nil
+ }
+
+ userTeamIDs := slices.Map(securityContext.UserMemberships, func(membership portainer.TeamMembership) portainer.TeamID {
+ return membership.TeamID
+ })
+
+ filteredItems := make([]T, 0)
+ for _, item := range items {
+ resourceControl, err := tx.ResourceControl().ResourceControlByResourceIDAndType(idGetter(item), portainer.ContainerResourceControl)
+ if err != nil {
+ return nil, fmt.Errorf("Unable to retrieve resource control: %w", err)
+ }
+
+ if resourceControl == nil || authorization.UserCanAccessResource(securityContext.UserID, userTeamIDs, resourceControl) {
+ filteredItems = append(filteredItems, item)
+ }
+
+ }
+ return filteredItems, nil
+}
diff --git a/api/http/handler/docker/utils/get_stacks.go b/api/http/handler/docker/utils/get_stacks.go
new file mode 100644
index 000000000..1a3f86e37
--- /dev/null
+++ b/api/http/handler/docker/utils/get_stacks.go
@@ -0,0 +1,83 @@
+package utils
+
+import (
+ "fmt"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/swarm"
+ portainer "github.com/portainer/portainer/api"
+ portaineree "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
+ dockerconsts "github.com/portainer/portainer/api/docker/consts"
+ "github.com/portainer/portainer/api/http/security"
+)
+
+type StackViewModel struct {
+ InternalStack *portaineree.Stack
+
+ ID portainer.StackID
+ Name string
+ IsExternal bool
+ Type portainer.StackType
+}
+
+// GetDockerStacks retrieves all the stacks associated to a specific environment filtered by the user's access.
+func GetDockerStacks(tx dataservices.DataStoreTx, securityContext *security.RestrictedRequestContext, environmentID portainer.EndpointID, containers []types.Container, services []swarm.Service) ([]StackViewModel, error) {
+
+ stacks, err := tx.Stack().ReadAll()
+ if err != nil {
+ return nil, fmt.Errorf("Unable to retrieve stacks: %w", err)
+ }
+
+ stacksNameSet := map[string]*StackViewModel{}
+
+ for i := range stacks {
+ stack := stacks[i]
+ if stack.EndpointID == environmentID {
+ stacksNameSet[stack.Name] = &StackViewModel{
+ InternalStack: &stack,
+ ID: stack.ID,
+ Name: stack.Name,
+ IsExternal: false,
+ Type: stack.Type,
+ }
+ }
+ }
+
+ for _, container := range containers {
+ name := container.Labels[dockerconsts.ComposeStackNameLabel]
+
+ if name != "" && stacksNameSet[name] == nil && !isHiddenStack(container.Labels) {
+ stacksNameSet[name] = &StackViewModel{
+ Name: name,
+ IsExternal: true,
+ Type: portainer.DockerComposeStack,
+ }
+ }
+ }
+
+ for _, service := range services {
+ name := service.Spec.Labels[dockerconsts.SwarmStackNameLabel]
+
+ if name != "" && stacksNameSet[name] == nil && !isHiddenStack(service.Spec.Labels) {
+ stacksNameSet[name] = &StackViewModel{
+ Name: name,
+ IsExternal: true,
+ Type: portainer.DockerSwarmStack,
+ }
+ }
+ }
+
+ stacksList := make([]StackViewModel, 0)
+ for _, stack := range stacksNameSet {
+ stacksList = append(stacksList, *stack)
+ }
+
+ return FilterByResourceControl(tx, stacksList, portainer.StackResourceControl, securityContext, func(c StackViewModel) string {
+ return c.Name
+ })
+}
+
+func isHiddenStack(labels map[string]string) bool {
+ return labels[dockerconsts.HideStackLabel] != ""
+}
diff --git a/api/http/handler/docker/utils/get_stacks_test.go b/api/http/handler/docker/utils/get_stacks_test.go
new file mode 100644
index 000000000..6880c8abc
--- /dev/null
+++ b/api/http/handler/docker/utils/get_stacks_test.go
@@ -0,0 +1,96 @@
+package utils
+
+import (
+ "testing"
+
+ "github.com/docker/docker/api/types"
+ "github.com/docker/docker/api/types/swarm"
+ portainer "github.com/portainer/portainer/api"
+ portaineree "github.com/portainer/portainer/api"
+ dockerconsts "github.com/portainer/portainer/api/docker/consts"
+ "github.com/portainer/portainer/api/http/security"
+ "github.com/portainer/portainer/api/internal/testhelpers"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestHandler_getDockerStacks(t *testing.T) {
+ environment := &portaineree.Endpoint{
+ ID: 1,
+ SecuritySettings: portainer.EndpointSecuritySettings{
+ AllowStackManagementForRegularUsers: true,
+ },
+ }
+
+ containers := []types.Container{
+ {
+ Labels: map[string]string{
+ dockerconsts.ComposeStackNameLabel: "stack1",
+ },
+ },
+ {
+ Labels: map[string]string{
+ dockerconsts.ComposeStackNameLabel: "stack2",
+ },
+ },
+ }
+
+ services := []swarm.Service{
+ {
+ Spec: swarm.ServiceSpec{
+ Annotations: swarm.Annotations{
+ Labels: map[string]string{
+ dockerconsts.SwarmStackNameLabel: "stack3",
+ },
+ },
+ },
+ },
+ }
+
+ stack1 := portaineree.Stack{
+ ID: 1,
+ Name: "stack1",
+ EndpointID: 1,
+ Type: portainer.DockerComposeStack,
+ }
+
+ datastore := testhelpers.NewDatastore(
+ testhelpers.WithEndpoints([]portaineree.Endpoint{*environment}),
+ testhelpers.WithStacks([]portaineree.Stack{
+ stack1,
+ {
+ ID: 2,
+ Name: "stack2",
+ EndpointID: 2,
+ Type: portainer.DockerSwarmStack,
+ },
+ }),
+ )
+
+ stacksList, err := GetDockerStacks(datastore, &security.RestrictedRequestContext{
+ IsAdmin: true,
+ }, environment.ID, containers, services)
+ assert.NoError(t, err)
+ assert.Len(t, stacksList, 3)
+
+ expectedStacks := []StackViewModel{
+ {
+ InternalStack: &stack1,
+ ID: 1,
+ Name: "stack1",
+ IsExternal: false,
+ Type: portainer.DockerComposeStack,
+ },
+ {
+ Name: "stack2",
+ IsExternal: true,
+ Type: portainer.DockerComposeStack,
+ },
+ {
+ Name: "stack3",
+ IsExternal: true,
+ Type: portainer.DockerSwarmStack,
+ },
+ }
+
+ assert.ElementsMatch(t, expectedStacks, stacksList)
+}
diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go
index 14eb0aeb1..ddfa55d27 100644
--- a/api/http/handler/stacks/handler.go
+++ b/api/http/handler/stacks/handler.go
@@ -10,6 +10,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client"
+ "github.com/portainer/portainer/api/docker/consts"
"github.com/portainer/portainer/api/http/middlewares"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
@@ -197,7 +198,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
}
for _, container := range containers {
- containerNS, ok := container.Labels["com.docker.compose.project"]
+ containerNS, ok := container.Labels[consts.ComposeStackNameLabel]
if ok && containerNS == name {
return false, nil
diff --git a/api/http/security/authorization.go b/api/http/security/authorization.go
index e1d1a8d5b..c75b5867f 100644
--- a/api/http/security/authorization.go
+++ b/api/http/security/authorization.go
@@ -13,7 +13,11 @@ func IsAdmin(request *http.Request) (bool, error) {
return false, err
}
- return tokenData.Role == portainer.AdministratorRole, nil
+ return IsAdminRole(tokenData.Role), nil
+}
+
+func IsAdminRole(role portainer.UserRole) bool {
+ return role == portainer.AdministratorRole
}
// AuthorizedResourceControlAccess checks whether the user can alter an existing resource control.
diff --git a/api/http/security/context.go b/api/http/security/context.go
index 5e96b5b88..494819057 100644
--- a/api/http/security/context.go
+++ b/api/http/security/context.go
@@ -6,6 +6,7 @@ import (
"net/http"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/dataservices"
)
type (
@@ -49,3 +50,17 @@ func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequest
requestContext := contextData.(*RestrictedRequestContext)
return requestContext, nil
}
+
+func RetrieveUserFromRequest(r *http.Request, tx dataservices.DataStoreTx) (*portainer.User, error) {
+ rrc, err := RetrieveRestrictedRequestContext(r)
+ if err != nil {
+ return nil, err
+ }
+
+ user, err := tx.User().Read(rrc.UserID)
+ if err != nil {
+ return nil, err
+ }
+
+ return user, nil
+}
diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go
index fdc5b5bd3..a171ea023 100644
--- a/api/internal/testhelpers/datastore.go
+++ b/api/internal/testhelpers/datastore.go
@@ -337,3 +337,96 @@ func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
d.endpoint = &stubEndpointService{endpoints: endpoints}
}
}
+
+type stubStacksService struct {
+ stacks []portainer.Stack
+}
+
+func (s *stubStacksService) BucketName() string { return "stacks" }
+
+func (s *stubStacksService) Create(stack *portainer.Stack) error {
+ return nil
+}
+
+func (s *stubStacksService) Update(ID portainer.StackID, stack *portainer.Stack) error {
+ return nil
+}
+
+func (s *stubStacksService) Delete(ID portainer.StackID) error {
+ return nil
+}
+
+func (s *stubStacksService) Read(ID portainer.StackID) (*portainer.Stack, error) {
+ for _, stack := range s.stacks {
+ if stack.ID == ID {
+ return &stack, nil
+ }
+ }
+ return nil, errors.ErrObjectNotFound
+}
+
+func (s *stubStacksService) ReadAll() ([]portainer.Stack, error) {
+ return s.stacks, nil
+}
+
+func (s *stubStacksService) StacksByEndpointID(endpointID portainer.EndpointID) ([]portainer.Stack, error) {
+ result := make([]portainer.Stack, 0)
+
+ for _, stack := range s.stacks {
+ if stack.EndpointID == endpointID {
+ result = append(result, stack)
+ }
+ }
+ return result, nil
+}
+
+func (s *stubStacksService) RefreshableStacks() ([]portainer.Stack, error) {
+ result := make([]portainer.Stack, 0)
+
+ for _, stack := range s.stacks {
+ if stack.AutoUpdate != nil {
+ result = append(result, stack)
+ }
+ }
+ return result, nil
+}
+
+func (s *stubStacksService) StackByName(name string) (*portainer.Stack, error) {
+ for _, stack := range s.stacks {
+ if stack.Name == name {
+ return &stack, nil
+ }
+ }
+ return nil, errors.ErrObjectNotFound
+}
+
+func (s *stubStacksService) StacksByName(name string) ([]portainer.Stack, error) {
+ result := make([]portainer.Stack, 0)
+
+ for _, stack := range s.stacks {
+ if stack.Name == name {
+ result = append(result, stack)
+ }
+ }
+ return result, nil
+}
+
+func (s *stubStacksService) StackByWebhookID(webhookID string) (*portainer.Stack, error) {
+ for _, stack := range s.stacks {
+ if stack.AutoUpdate != nil && stack.AutoUpdate.Webhook == webhookID {
+ return &stack, nil
+ }
+ }
+ return nil, errors.ErrObjectNotFound
+}
+
+func (s *stubStacksService) GetNextIdentifier() int {
+ return len(s.stacks)
+}
+
+// WithStacks option will instruct testDatastore to return provided stacks
+func WithStacks(stacks []portainer.Stack) datastoreOption {
+ return func(d *testDatastore) {
+ d.stack = &stubStacksService{stacks: stacks}
+ }
+}
diff --git a/app/docker/__module.js b/app/docker/__module.js
index 291ab683b..12a66d44e 100644
--- a/app/docker/__module.js
+++ b/app/docker/__module.js
@@ -150,8 +150,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/dashboard',
views: {
'content@': {
- templateUrl: './views/dashboard/dashboard.html',
- controller: 'DashboardController',
+ component: 'dockerDashboardView',
},
},
data: {
diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js b/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js
deleted file mode 100644
index 9979a8da5..000000000
--- a/app/docker/components/dashboard-cluster-agent-info/dashboard-cluster-agent-info.js
+++ /dev/null
@@ -1,7 +0,0 @@
-angular.module('portainer.docker').component('dashboardClusterAgentInfo', {
- templateUrl: './dashboardClusterAgentInfo.html',
- controller: 'DashboardClusterAgentInfoController',
- bindings: {
- endpointId: '<',
- },
-});
diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html
deleted file mode 100644
index 9108ebb1b..000000000
--- a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfo.html
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
-
-
-
-
- Nodes in the cluster
- {{ $ctrl.agentCount }}
-
-
-
-
-
-
-
-
-
-
diff --git a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js b/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js
deleted file mode 100644
index 2419181b9..000000000
--- a/app/docker/components/dashboard-cluster-agent-info/dashboardClusterAgentInfoController.js
+++ /dev/null
@@ -1,17 +0,0 @@
-angular.module('portainer.docker').controller('DashboardClusterAgentInfoController', [
- 'AgentService',
- 'Notifications',
- function (AgentService, Notifications) {
- var ctrl = this;
-
- this.$onInit = function () {
- AgentService.agents(ctrl.endpointId)
- .then(function success(data) {
- ctrl.agentCount = data.length;
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to retrieve agent information');
- });
- };
- },
-]);
diff --git a/app/docker/react/views/index.ts b/app/docker/react/views/index.ts
index 97a550467..6205f0b22 100644
--- a/app/docker/react/views/index.ts
+++ b/app/docker/react/views/index.ts
@@ -3,15 +3,18 @@ import angular from 'angular';
import { ItemView as NetworksItemView } from '@/react/docker/networks/ItemView';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
-import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
+import { DashboardView } from '@/react/docker/DashboardView/DashboardView';
import { containersModule } from './containers';
export const viewsModule = angular
.module('portainer.docker.react.views', [containersModule])
-
+ .component(
+ 'dockerDashboardView',
+ r2a(withUIRouter(withCurrentUser(DashboardView)), [])
+ )
.component(
'networkDetailsView',
- r2a(withUIRouter(withReactQuery(withCurrentUser(NetworksItemView))), [])
+ r2a(withUIRouter(withCurrentUser(NetworksItemView)), [])
).name;
diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html
deleted file mode 100644
index 73442d156..000000000
--- a/app/docker/views/dashboard/dashboard.html
+++ /dev/null
@@ -1,117 +0,0 @@
-
-
-
-
-
-
-
- Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look at
- for more details.
-
-
-
- Portainer is connected to a worker node. Swarm management features will not be available.
-
-
-
-
-
-
-
-
-
-
-
-
-
- Environment
-
- {{ endpoint.Name }}
-
- {{ endpoint.Snapshots[0].TotalCPU }}
- {{ endpoint.Snapshots[0].TotalMemory | humansize }}
-
-
- - {{ info.Swarm && info.Swarm.NodeID !== '' ? 'Swarm' : 'Standalone' }} {{ info.ServerVersion }}
-
-
- Agent
-
-
-
- URL
- {{ endpoint.URL | stripprotocol }}
-
-
- {{ endpoint.Gpus.length <= 1 ? 'GPU' : 'GPUs' }}
- {{ gpuInfoStr }}
-
-
- Tags
- {{ endpointTags }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/docker/views/dashboard/dashboardController.js b/app/docker/views/dashboard/dashboardController.js
deleted file mode 100644
index 893664168..000000000
--- a/app/docker/views/dashboard/dashboardController.js
+++ /dev/null
@@ -1,141 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-
-import { PortainerEndpointTypes } from 'Portainer/models/endpoint/models';
-import { useContainerStatusComponent } from '@/react/docker/DashboardView/ContainerStatus';
-import { useImagesTotalSizeComponent } from '@/react/docker/DashboardView/ImagesTotalSize';
-
-angular.module('portainer.docker').controller('DashboardController', [
- '$scope',
- '$q',
- 'Authentication',
- 'ContainerService',
- 'ImageService',
- 'NetworkService',
- 'VolumeService',
- 'SystemService',
- 'ServiceService',
- 'StackService',
- 'Notifications',
- 'StateManager',
- 'TagService',
- 'endpoint',
- function (
- $scope,
- $q,
- Authentication,
- ContainerService,
- ImageService,
- NetworkService,
- VolumeService,
- SystemService,
- ServiceService,
- StackService,
- Notifications,
- StateManager,
- TagService,
- endpoint
- ) {
- $scope.dismissInformationPanel = function (id) {
- StateManager.dismissInformationPanel(id);
- };
-
- $scope.showStacks = false;
-
- $scope.buildGpusStr = function (gpuUseSet) {
- var gpusAvailable = new Object();
- for (let i = 0; i < ($scope.endpoint.Gpus || []).length; i++) {
- if (!gpuUseSet.has($scope.endpoint.Gpus[i].name)) {
- var exist = false;
- for (let gpuAvailable in gpusAvailable) {
- if ($scope.endpoint.Gpus[i].value == gpuAvailable) {
- gpusAvailable[gpuAvailable] += 1;
- exist = true;
- }
- }
- if (exist === false) {
- gpusAvailable[$scope.endpoint.Gpus[i].value] = 1;
- }
- }
- }
- var retStr = Object.keys(gpusAvailable).length
- ? _.join(
- _.map(Object.keys(gpusAvailable), (gpuAvailable) => {
- var _str = gpusAvailable[gpuAvailable];
- _str += ' x ';
- _str += gpuAvailable;
- return _str;
- }),
- ' + '
- )
- : 'none';
- return retStr;
- };
-
- async function initView() {
- const endpointMode = $scope.applicationState.endpoint.mode;
- $scope.endpoint = endpoint;
-
- $scope.showStacks = await shouldShowStacks();
- $scope.showEnvUrl = endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnDockerEnvironment && endpoint.Type !== PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment;
- $q.all({
- containers: ContainerService.containers(endpoint.Id, 1),
- images: ImageService.images(),
- volumes: VolumeService.volumes(),
- networks: NetworkService.networks(true, true, true),
- services: endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' ? ServiceService.services() : [],
- stacks: StackService.stacks(true, endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER', endpoint.Id),
- info: SystemService.info(),
- tags: TagService.tags(),
- })
- .then(function success(data) {
- $scope.containers = data.containers;
- $scope.containerStatusComponent = useContainerStatusComponent(data.containers);
-
- $scope.images = data.images;
- $scope.imagesTotalSizeComponent = useImagesTotalSizeComponent(imagesTotalSize(data.images));
-
- $scope.volumeCount = data.volumes.length;
- $scope.networkCount = data.networks.length;
- $scope.serviceCount = data.services.length;
- $scope.stackCount = data.stacks.length;
- $scope.info = data.info;
-
- $scope.gpuInfoStr = $scope.buildGpusStr(new Set());
- $scope.gpuUseAll = _.get($scope, 'endpoint.Snapshots[0].GpuUseAll', false);
- $scope.gpuUseList = _.get($scope, 'endpoint.Snapshots[0].GpuUseList', []);
- $scope.gpuFreeStr = 'all';
- if ($scope.gpuUseAll == true) $scope.gpuFreeStr = 'none';
- else $scope.gpuFreeStr = $scope.buildGpusStr(new Set($scope.gpuUseList));
-
- $scope.endpointTags = endpoint.TagIds.length
- ? _.join(
- _.filter(
- _.map(endpoint.TagIds, (id) => {
- const tag = data.tags.find((tag) => tag.Id === id);
- return tag ? tag.Name : '';
- }),
- Boolean
- ),
- ', '
- )
- : '-';
- })
- .catch(function error(err) {
- Notifications.error('Failure', err, 'Unable to load dashboard data');
- });
- }
-
- async function shouldShowStacks() {
- const isAdmin = Authentication.isAdmin();
-
- return isAdmin || endpoint.SecuritySettings.allowStackManagementForRegularUsers;
- }
-
- initView();
- },
-]);
-
-function imagesTotalSize(images) {
- return images.reduce((acc, image) => acc + image.Size, 0);
-}
diff --git a/app/portainer/react/components/index.ts b/app/portainer/react/components/index.ts
index b28d89fc4..329bc0608 100644
--- a/app/portainer/react/components/index.ts
+++ b/app/portainer/react/components/index.ts
@@ -154,7 +154,7 @@ export const ngModule = angular
'pluralType',
'isLoading',
'isRefetching',
- 'dataCy',
+ 'data-cy',
'iconClass',
])
)
diff --git a/app/react/azure/DashboardView/.keep b/app/react/azure/DashboardView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/azure/DashboardView/DashboardView.tsx b/app/react/azure/DashboardView/DashboardView.tsx
index 7300d2d43..05ebc0a57 100644
--- a/app/react/azure/DashboardView/DashboardView.tsx
+++ b/app/react/azure/DashboardView/DashboardView.tsx
@@ -34,7 +34,7 @@ export function DashboardView() {
);
}
@@ -43,7 +43,7 @@ export function WithLink() {
value={1}
icon={List}
type="Example resource"
- dataCy="data-cy-example"
+ data-cy="data-cy-example"
/>
);
@@ -55,7 +55,7 @@ export function WithChildren() {
value={1}
icon={List}
type="Example resource"
- dataCy="data-cy-example"
+ data-cy="data-cy-example"
>
Children
diff --git a/app/react/components/DashboardItem/DashboardItem.test.tsx b/app/react/components/DashboardItem/DashboardItem.test.tsx
index 00d164d7a..a3b653408 100644
--- a/app/react/components/DashboardItem/DashboardItem.test.tsx
+++ b/app/react/components/DashboardItem/DashboardItem.test.tsx
@@ -27,6 +27,6 @@ test('should have accessibility label created from the provided resource type',
function renderComponent(value = 0, icon = User, type = '') {
return render(
-
+
);
}
diff --git a/app/react/components/DashboardItem/DashboardItem.tsx b/app/react/components/DashboardItem/DashboardItem.tsx
index 0cd96255a..73b934ecc 100644
--- a/app/react/components/DashboardItem/DashboardItem.tsx
+++ b/app/react/components/DashboardItem/DashboardItem.tsx
@@ -4,10 +4,11 @@ import { Loader2 } from 'lucide-react';
import { Icon, IconProps } from '@/react/components/Icon';
import { pluralize } from '@/portainer/helpers/strings';
+import { AutomationTestingProps } from '@/types';
import { Link } from '@@/Link';
-interface Props extends IconProps {
+interface Props extends IconProps, AutomationTestingProps {
type: string;
pluralType?: string; // in case the pluralise function isn't suitable
isLoading?: boolean;
@@ -16,7 +17,6 @@ interface Props extends IconProps {
to?: string;
params?: object;
children?: ReactNode;
- dataCy: string;
}
export function DashboardItem({
@@ -29,7 +29,7 @@ export function DashboardItem({
to,
params,
children,
- dataCy,
+ 'data-cy': dataCy,
}: Props) {
const Item = (
) {
return (
-
-
-
-
-
- {title && (
-
- {title}
- {!!onDismiss && (
-
- onDismiss()}
- data-cy="dismiss-information-panel-button"
- >
- dismiss
-
-
- )}
-
+
+
+
+ {title && (
+
+
{title}
+ {!!onDismiss && (
+
+ onDismiss()}
+ data-cy="dismiss-information-panel-button"
+ >
+ dismiss
+
+
)}
-
{children}
-
-
-
-
+ )}
+ {children}
+
+
+
);
}
diff --git a/app/react/docker/DashboardView/.keep b/app/react/docker/DashboardView/.keep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/app/react/docker/DashboardView/ClusterAgentInfo.tsx b/app/react/docker/DashboardView/ClusterAgentInfo.tsx
new file mode 100644
index 000000000..5d647255d
--- /dev/null
+++ b/app/react/docker/DashboardView/ClusterAgentInfo.tsx
@@ -0,0 +1,37 @@
+import { GaugeIcon } from 'lucide-react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { Widget } from '@@/Widget';
+import { DetailsTable } from '@@/DetailsTable';
+
+import { useAgentNodes } from '../agent/queries/useAgentNodes';
+import { useApiVersion } from '../agent/queries/useApiVersion';
+
+import { ClusterVisualizerLink } from './ClusterVisualizerLink';
+
+export function ClusterAgentInfo() {
+ const environmentId = useEnvironmentId();
+
+ const apiVersionQuery = useApiVersion(environmentId);
+
+ const nodesCountQuery = useAgentNodes(environmentId, apiVersionQuery.data!, {
+ select: (data) => data.length,
+ enabled: apiVersionQuery.data !== undefined,
+ });
+
+ return (
+
+
+
+
+
+ {nodesCountQuery.data}
+
+
+
+
+
+
+ );
+}
diff --git a/app/react/docker/DashboardView/ClusterVisualizerLink.tsx b/app/react/docker/DashboardView/ClusterVisualizerLink.tsx
new file mode 100644
index 000000000..df5363e67
--- /dev/null
+++ b/app/react/docker/DashboardView/ClusterVisualizerLink.tsx
@@ -0,0 +1,25 @@
+import { TrelloIcon } from 'lucide-react';
+
+import { Link } from '@@/Link';
+import { Button } from '@@/buttons';
+
+export function ClusterVisualizerLink() {
+ return (
+
+
+
+ Go to cluster visualizer
+
+
+
+ );
+}
diff --git a/app/react/docker/DashboardView/ContainerStatus.tsx b/app/react/docker/DashboardView/ContainerStatus.tsx
index d6e552459..2c22bd181 100644
--- a/app/react/docker/DashboardView/ContainerStatus.tsx
+++ b/app/react/docker/DashboardView/ContainerStatus.tsx
@@ -2,63 +2,38 @@ import { Heart, Power } from 'lucide-react';
import { Icon } from '@/react/components/Icon';
-import {
- DockerContainer,
- ContainerStatus as Status,
-} from '../containers/types';
-
interface Props {
- containers: DockerContainer[];
+ stats: {
+ running: number;
+ stopped: number;
+ healthy: number;
+ unhealthy: number;
+ };
}
-export function useContainerStatusComponent(containers: DockerContainer[]) {
- return
;
-}
-
-export function ContainerStatus({ containers }: Props) {
+export function ContainerStatus({ stats }: Props) {
return (
- {runningContainersFilter(containers)} running
+ {stats.running} running
- {stoppedContainersFilter(containers)} stopped
+ {stats.stopped} stopped
- {healthyContainersFilter(containers)} healthy
+ {stats.healthy} healthy
- {unhealthyContainersFilter(containers)} unhealthy
+ {stats.unhealthy} unhealthy
);
}
-
-function runningContainersFilter(containers: DockerContainer[]) {
- return containers.filter(
- (container) =>
- container.Status === Status.Running || container.Status === Status.Healthy
- ).length;
-}
-function stoppedContainersFilter(containers: DockerContainer[]) {
- return containers.filter(
- (container) =>
- container.Status === Status.Exited || container.Status === Status.Stopped
- ).length;
-}
-function healthyContainersFilter(containers: DockerContainer[]) {
- return containers.filter((container) => container.Status === Status.Healthy)
- .length;
-}
-function unhealthyContainersFilter(containers: DockerContainer[]) {
- return containers.filter((container) => container.Status === Status.Unhealthy)
- .length;
-}
diff --git a/app/react/docker/DashboardView/DashboardView.tsx b/app/react/docker/DashboardView/DashboardView.tsx
new file mode 100644
index 000000000..e8afb0dbf
--- /dev/null
+++ b/app/react/docker/DashboardView/DashboardView.tsx
@@ -0,0 +1,143 @@
+import {
+ BoxIcon,
+ CpuIcon,
+ DatabaseIcon,
+ LayersIcon,
+ ListIcon,
+ NetworkIcon,
+ ShuffleIcon,
+} from 'lucide-react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { isAgentEnvironment } from '@/react/portainer/environments/utils';
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { useIsEnvironmentAdmin } from '@/react/hooks/useUser';
+
+import { PageHeader } from '@@/PageHeader';
+import { DashboardGrid } from '@@/DashboardItem/DashboardGrid';
+import { DashboardItem } from '@@/DashboardItem';
+
+import { useIsSwarm, useIsSwarmManager } from '../proxy/queries/useInfo';
+
+import { NonAgentSwarmInfo } from './NonAgentSwarmInfo';
+import { ClusterAgentInfo } from './ClusterAgentInfo';
+import { EnvironmentInfo } from './EnvironmentInfo';
+import { ContainerStatus } from './ContainerStatus';
+import { ImagesTotalSize } from './ImagesTotalSize';
+import { useDashboard } from './useDashboard';
+
+export function DashboardView() {
+ const envId = useEnvironmentId();
+ const envQuery = useCurrentEnvironment();
+ const isEnvAdminQuery = useIsEnvironmentAdmin();
+ const isSwarmManager = useIsSwarmManager(envId);
+ const isStandalone = useIsSwarm(envId);
+ const dashboardStatsQuery = useDashboard(envId);
+
+ if (!envQuery.data || !dashboardStatsQuery.data) {
+ return null;
+ }
+
+ const env = envQuery.data;
+ const isStacksVisible = shouldShowStacks();
+ const dashboardStats = dashboardStatsQuery.data;
+
+ return (
+ <>
+
+
+
+
+
+
+ {isStacksVisible && (
+
+ )}
+
+ {isSwarmManager && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {env.EnableGPUManagement && isStandalone && (
+
+ )}
+
+
+
+
+ >
+ );
+
+ function shouldShowStacks() {
+ return (
+ env.SecuritySettings.allowStackManagementForRegularUsers ||
+ isEnvAdminQuery.authorized
+ );
+ }
+}
+
+function InfoPanels({ isAgent }: { isAgent: boolean }) {
+ const envId = useEnvironmentId();
+ const isSwarm = useIsSwarm(envId);
+
+ return (
+ <>
+ {isSwarm && !isAgent &&
}
+ {isSwarm && isAgent &&
}
+ {(!isSwarm || !isAgent) &&
}
+ >
+ );
+}
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx b/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx
new file mode 100644
index 000000000..98f565ad4
--- /dev/null
+++ b/app/react/docker/DashboardView/EnvironmentInfo.DockerInfo.tsx
@@ -0,0 +1,32 @@
+import { ZapIcon } from 'lucide-react';
+
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+
+import { Icon } from '@@/Icon';
+
+import { useInfo } from '../proxy/queries/useInfo';
+
+export function DockerInfo({ isAgent }: { isAgent: boolean }) {
+ const envId = useEnvironmentId();
+ const infoQuery = useInfo(envId);
+
+ if (!infoQuery.data) {
+ return null;
+ }
+
+ const info = infoQuery.data;
+
+ const isSwarm = info.Swarm && info.Swarm.NodeID !== '';
+
+ return (
+
+ {isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
+ {isAgent && (
+
+
+ Agent
+
+ )}
+
+ );
+}
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.GpuInfo.tsx b/app/react/docker/DashboardView/EnvironmentInfo.GpuInfo.tsx
new file mode 100644
index 000000000..fe4470889
--- /dev/null
+++ b/app/react/docker/DashboardView/EnvironmentInfo.GpuInfo.tsx
@@ -0,0 +1,61 @@
+import { DetailsTable } from '@@/DetailsTable';
+
+import { DockerSnapshot } from '../snapshots/types';
+
+export function GpuInfo({
+ gpus,
+ snapshot,
+}: {
+ gpus: Array<{ name: string }>;
+ snapshot?: DockerSnapshot;
+}) {
+ if (!snapshot) {
+ return null;
+ }
+
+ const gpuUseAll = snapshot.GpuUseAll;
+ const gpuUseList = snapshot.GpuUseList;
+ let gpuFreeStr = '';
+ if (gpuUseAll) {
+ gpuFreeStr = 'none';
+ } else {
+ gpuFreeStr = buildGpusStr(gpuUseList, gpus);
+ }
+
+ return (
+
+ {gpuFreeStr}
+
+ );
+
+ function buildGpusStr(
+ gpuUseList: Array
,
+ gpus: Array<{ name: string }> = []
+ ) {
+ if (!gpus.length) {
+ return 'none';
+ }
+
+ const gpuUseSet = new Set(gpuUseList);
+ const gpusAvailable: Record = {};
+ for (let i = 0; i < gpus.length; i++) {
+ if (!gpuUseSet.has(gpus[i].name)) {
+ if (gpusAvailable[gpus[i].name]) {
+ gpusAvailable[gpus[i].name] += 1;
+ } else {
+ gpusAvailable[gpus[i].name] = 1;
+ }
+ }
+ }
+
+ const gpusKeys = Object.keys(gpusAvailable);
+
+ if (!gpusKeys.length) {
+ return 'none';
+ }
+
+ return Object.keys(gpusAvailable)
+ .map((gpuAvailable) => `${gpusAvailable[gpuAvailable]} x ${gpuAvailable}`)
+ .join(' + ');
+ }
+}
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.SnapshotStats.tsx b/app/react/docker/DashboardView/EnvironmentInfo.SnapshotStats.tsx
new file mode 100644
index 000000000..596637720
--- /dev/null
+++ b/app/react/docker/DashboardView/EnvironmentInfo.SnapshotStats.tsx
@@ -0,0 +1,30 @@
+import { CpuIcon } from 'lucide-react';
+
+import { humanize } from '@/portainer/filters/filters';
+import memoryIcon from '@/assets/ico/memory.svg?c';
+
+import { Icon } from '@@/Icon';
+
+import { DockerSnapshot } from '../snapshots/types';
+
+export function SnapshotStats({
+ snapshot,
+}: {
+ snapshot: DockerSnapshot | undefined;
+}) {
+ if (!snapshot) {
+ return null;
+ }
+
+ return (
+
+
+ {snapshot.TotalCPU}
+
+
+
+ {humanize(snapshot.TotalMemory)}
+
+
+ );
+}
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.TagsInfo.tsx b/app/react/docker/DashboardView/EnvironmentInfo.TagsInfo.tsx
new file mode 100644
index 000000000..659d350a7
--- /dev/null
+++ b/app/react/docker/DashboardView/EnvironmentInfo.TagsInfo.tsx
@@ -0,0 +1,28 @@
+import _ from 'lodash';
+
+import { useTags } from '@/portainer/tags/queries';
+
+import { DetailsTable } from '@@/DetailsTable';
+
+export function TagsInfo({ ids }: { ids: number[] }) {
+ const tagsQuery = useTags();
+
+ if (!tagsQuery.data) {
+ return null;
+ }
+
+ const tags = tagsQuery.data;
+
+ const tagNameList = ids.length
+ ? _.compact(
+ ids
+ .map((id) => {
+ const tag = tags.find((tag) => tag.ID === id);
+ return tag ? tag.Name : '';
+ })
+ .join(', ')
+ )
+ : '-';
+
+ return {tagNameList} ;
+}
diff --git a/app/react/docker/DashboardView/EnvironmentInfo.tsx b/app/react/docker/DashboardView/EnvironmentInfo.tsx
new file mode 100644
index 000000000..9c32ba7ad
--- /dev/null
+++ b/app/react/docker/DashboardView/EnvironmentInfo.tsx
@@ -0,0 +1,70 @@
+import { GaugeIcon } from 'lucide-react';
+
+import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
+import { stripProtocol } from '@/portainer/filters/filters';
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import {
+ isAgentEnvironment,
+ isEdgeEnvironment,
+} from '@/react/portainer/environments/utils';
+
+import { DetailsTable } from '@@/DetailsTable';
+import { Widget } from '@@/Widget';
+
+import { useIsSwarmManager } from '../proxy/queries/useInfo';
+
+import { GpuInfo } from './EnvironmentInfo.GpuInfo';
+import { SnapshotStats } from './EnvironmentInfo.SnapshotStats';
+import { DockerInfo } from './EnvironmentInfo.DockerInfo';
+import { TagsInfo } from './EnvironmentInfo.TagsInfo';
+import { ClusterVisualizerLink } from './ClusterVisualizerLink';
+
+export function EnvironmentInfo() {
+ const environmentId = useEnvironmentId();
+ const envQuery = useCurrentEnvironment();
+
+ const isSwarmManager = useIsSwarmManager(environmentId);
+
+ if (!envQuery.data) {
+ return null;
+ }
+
+ const environment = envQuery.data;
+
+ const isAgent = isAgentEnvironment(environment.Type);
+ const isEdgeAgent = isEdgeEnvironment(environment.Type);
+
+ const isEnvUrlVisible = !isEdgeAgent;
+
+ return (
+
+
+
+
+
+
+ {environment.Name}
+ -
+
+
+
+
+ {isEnvUrlVisible && (
+
+ {stripProtocol(environment.URL)}
+
+ )}
+
+
+
+
+
+ {isSwarmManager && }
+
+
+
+ );
+}
diff --git a/app/react/docker/DashboardView/ImagesTotalSize.tsx b/app/react/docker/DashboardView/ImagesTotalSize.tsx
index a5e275d05..63f99db61 100644
--- a/app/react/docker/DashboardView/ImagesTotalSize.tsx
+++ b/app/react/docker/DashboardView/ImagesTotalSize.tsx
@@ -8,10 +8,6 @@ interface Props {
imagesTotalSize: number;
}
-export function useImagesTotalSizeComponent(imagesTotalSize: number) {
- return ;
-}
-
export function ImagesTotalSize({ imagesTotalSize }: Props) {
return (
diff --git a/app/react/docker/DashboardView/NonAgentSwarmInfo.tsx b/app/react/docker/DashboardView/NonAgentSwarmInfo.tsx
new file mode 100644
index 000000000..553b16017
--- /dev/null
+++ b/app/react/docker/DashboardView/NonAgentSwarmInfo.tsx
@@ -0,0 +1,49 @@
+import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
+import { useInfoPanelState } from '@/react/hooks/useInfoPanelState';
+
+import { InformationPanel } from '@@/InformationPanel';
+import { TextTip } from '@@/Tip/TextTip';
+import { HelpLink } from '@@/HelpLink';
+
+import { useInfo } from '../proxy/queries/useInfo';
+
+const infoPanelId = 'docker-dashboard-info-01';
+
+export function NonAgentSwarmInfo() {
+ const { isVisible, dismiss } = useInfoPanelState(infoPanelId);
+ const envId = useEnvironmentId();
+ const isManagerQuery = useInfo(envId, {
+ select: (info) => !!info.Swarm?.ControlAvailable,
+ });
+ if (!isVisible || isManagerQuery.isLoading) {
+ return null;
+ }
+
+ const isManager = isManagerQuery.data;
+
+ return (
+
dismiss()}>
+
+ {isManager ? (
+ <>
+ Portainer is connected to a node that is part of a Swarm cluster.
+ Some resources located on other nodes in the cluster might not be
+ available for management, have a look at{' '}
+
+ our agent setup
+ {' '}
+ for more details.
+ >
+ ) : (
+ <>
+ Portainer is connected to a worker node. Swarm management features
+ will not be available.
+ >
+ )}
+
+
+ );
+}
diff --git a/app/react/docker/DashboardView/useDashboard.ts b/app/react/docker/DashboardView/useDashboard.ts
new file mode 100644
index 000000000..69bef67da
--- /dev/null
+++ b/app/react/docker/DashboardView/useDashboard.ts
@@ -0,0 +1,41 @@
+import { useQuery } from '@tanstack/react-query';
+
+import axios, { parseAxiosError } from '@/portainer/services/axios';
+import { EnvironmentId } from '@/react/portainer/environments/types';
+
+import { queryKeys } from '../queries/utils';
+import { buildDockerUrl } from '../queries/utils/root';
+
+interface DashboardResponse {
+ containers: {
+ total: number;
+ running: number;
+ stopped: number;
+ healthy: number;
+ unhealthy: number;
+ };
+ services: number;
+ images: {
+ total: number;
+ size: number;
+ };
+ volumes: number;
+ networks: number;
+ stacks: number;
+}
+
+export function useDashboard(envId: EnvironmentId) {
+ return useQuery({
+ queryFn: async () => {
+ try {
+ const res = await axios.get
(
+ `${buildDockerUrl(envId)}/dashboard`
+ );
+ return res.data;
+ } catch (error) {
+ throw parseAxiosError(error);
+ }
+ },
+ queryKey: [...queryKeys.root(envId), 'dashboard'] as const,
+ });
+}
diff --git a/app/react/docker/containers/CreateView/CreateView.tsx b/app/react/docker/containers/CreateView/CreateView.tsx
index 1e39a193b..98354be44 100644
--- a/app/react/docker/containers/CreateView/CreateView.tsx
+++ b/app/react/docker/containers/CreateView/CreateView.tsx
@@ -101,17 +101,21 @@ function CreateForm() {
return (
<>
{isDuplicating && (
-
-
- The new container may fail to start if the image is changed, and
- settings from the previous container aren't compatible. Common
- causes include entrypoint, cmd or{' '}
-
- other settings
- {' '}
- set by an image.
-
-
+
+
+
+
+ The new container may fail to start if the image is changed, and
+ settings from the previous container aren't compatible.
+ Common causes include entrypoint, cmd or{' '}
+
+ other settings
+ {' '}
+ set by an image.
+
+
+
+
)}
setFieldValue('gpu', gpu)}
- gpus={environment.Gpus}
+ gpus={environment.Gpus || []}
enableGpuManagement={environment.EnableGPUManagement}
usedGpus={gpuUseList}
usedAllGpus={gpuUseAll}
diff --git a/app/react/docker/containers/LogView/LogView.tsx b/app/react/docker/containers/LogView/LogView.tsx
index 37454053d..990e72280 100644
--- a/app/react/docker/containers/LogView/LogView.tsx
+++ b/app/react/docker/containers/LogView/LogView.tsx
@@ -29,19 +29,24 @@ function LogsDisabledInfoPanel() {
} = useCurrentStateAndParams();
return (
-
-
- Logging is disabled for this container. If you want to re-enable
- logging, please{' '}
-
- redeploy your container
- {' '}
- and select a logging driver in the "Command & logging" panel.
-
-
+
+
+
+
+ Logging is disabled for this container. If you want to re-enable
+ logging, please{' '}
+
+ redeploy your container
+ {' '}
+ and select a logging driver in the "Command & logging"
+ panel.
+
+
+
+
);
}
diff --git a/app/react/docker/proxy/queries/useInfo.ts b/app/react/docker/proxy/queries/useInfo.ts
index 2e97c976a..74b287e88 100644
--- a/app/react/docker/proxy/queries/useInfo.ts
+++ b/app/react/docker/proxy/queries/useInfo.ts
@@ -72,3 +72,11 @@ export function useSystemLimits(environmentId: EnvironmentId) {
return { maxCpu, maxMemory };
}
+
+export function useIsSwarmManager(environmentId: EnvironmentId) {
+ const query = useInfo(environmentId, {
+ select: (info) => !!info.Swarm?.NodeID && info.Swarm.ControlAvailable,
+ });
+
+ return !!query.data;
+}
diff --git a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx
index 8ee498b10..0a6428611 100644
--- a/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx
+++ b/app/react/edge/edge-devices/WaitingRoomView/WaitingRoomView.tsx
@@ -22,13 +22,17 @@ function WaitingRoomView() {
reload
/>
-
-
- Only environments generated from the AEEC script will appear here,
- manually added environments and edge devices will bypass the waiting
- room.
-
-
+
+
+
+
+ Only environments generated from the AEEC script will appear here,
+ manually added environments and edge devices will bypass the
+ waiting room.
+
+
+
+
{licenseOverused && (
diff --git a/app/react/edge/edge-jobs/ListView/ListView.tsx b/app/react/edge/edge-jobs/ListView/ListView.tsx
index 3d1fdab56..448170421 100644
--- a/app/react/edge/edge-jobs/ListView/ListView.tsx
+++ b/app/react/edge/edge-jobs/ListView/ListView.tsx
@@ -8,12 +8,16 @@ export function ListView() {
<>
-
-
- Edge Jobs requires Docker Standalone and a cron implementation that
- reads jobs from /etc/cron.d
-
-
+
+
+
+
+ Edge Jobs requires Docker Standalone and a cron implementation
+ that reads jobs from /etc/cron.d
+
+
+
+
>
diff --git a/app/react/hooks/useInfoPanelState.tsx b/app/react/hooks/useInfoPanelState.tsx
new file mode 100644
index 000000000..4ca34ff09
--- /dev/null
+++ b/app/react/hooks/useInfoPanelState.tsx
@@ -0,0 +1,16 @@
+import { useUIState } from '@/react/hooks/useUIState';
+
+export function useInfoPanelState(panelId: string) {
+ const uiStateStore = useUIState();
+
+ const isVisible = !uiStateStore.dismissedInfoPanels[panelId];
+
+ return {
+ isVisible,
+ dismiss,
+ };
+
+ function dismiss() {
+ uiStateStore.dismissInfoPanel(panelId);
+ }
+}
diff --git a/app/react/kubernetes/dashboard/DashboardView.tsx b/app/react/kubernetes/dashboard/DashboardView.tsx
index ac5e5b1f7..169512a3d 100644
--- a/app/react/kubernetes/dashboard/DashboardView.tsx
+++ b/app/react/kubernetes/dashboard/DashboardView.tsx
@@ -70,7 +70,7 @@ export function DashboardView() {
icon={Layers}
to="kubernetes.resourcePools"
type="Namespace"
- dataCy="dashboard-namespace"
+ data-cy="dashboard-namespace"
/>
diff --git a/app/react/portainer/HomeView/BackupFailedPanel.tsx b/app/react/portainer/HomeView/BackupFailedPanel.tsx
index 8cfbb0273..51fe0f1a4 100644
--- a/app/react/portainer/HomeView/BackupFailedPanel.tsx
+++ b/app/react/portainer/HomeView/BackupFailedPanel.tsx
@@ -16,16 +16,21 @@ export function BackupFailedPanel() {
}
return (
-
-
- The latest automated backup has failed at {isoDate(status.TimestampUTC)}
- . For details please see the log files and have a look at the{' '}
-
- settings
- {' '}
- to verify the backup configuration.
-
-
+
+
+
+
+ The latest automated backup has failed at{' '}
+ {isoDate(status.TimestampUTC)}. For details please see the log files
+ and have a look at the{' '}
+
+ settings
+ {' '}
+ to verify the backup configuration.
+
+
+
+
);
}
diff --git a/app/react/portainer/HomeView/EnvironmentList/NoEnvironmentsInfoPanel.tsx b/app/react/portainer/HomeView/EnvironmentList/NoEnvironmentsInfoPanel.tsx
index 955275acb..f976d70f0 100644
--- a/app/react/portainer/HomeView/EnvironmentList/NoEnvironmentsInfoPanel.tsx
+++ b/app/react/portainer/HomeView/EnvironmentList/NoEnvironmentsInfoPanel.tsx
@@ -4,26 +4,30 @@ import { TextTip } from '@@/Tip/TextTip';
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
return (
-
-
- {isAdmin ? (
-
- No environment available for management. Please head over the{' '}
-
- environment wizard
- {' '}
- to add an environment.
-
- ) : (
-
- You do not have access to any environment. Please contact your
- administrator.
-
- )}
-
-
+
+
+
+
+ {isAdmin ? (
+
+ No environment available for management. Please head over the{' '}
+
+ environment wizard
+ {' '}
+ to add an environment.
+
+ ) : (
+
+ You do not have access to any environment. Please contact your
+ administrator.
+
+ )}
+
+
+
+
);
}
diff --git a/app/react/portainer/HomeView/MotdPanel.tsx b/app/react/portainer/HomeView/MotdPanel.tsx
index 98435fded..027f70491 100644
--- a/app/react/portainer/HomeView/MotdPanel.tsx
+++ b/app/react/portainer/HomeView/MotdPanel.tsx
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import _ from 'lodash';
+import sanitize from 'sanitize-html';
import { useUIState } from '@/react/hooks/useUIState';
@@ -23,17 +24,21 @@ export function MotdPanel() {
return (
<>
{!!motd.Style && }
- onDismiss(motd.Hash)}
- title={motd.Title}
- wrapperStyle={camelCaseKeys(motd.ContentLayout)}
- bodyClassName="motd-body"
- >
-
- {/* eslint-disable-next-line react/no-danger */}
-
-
-
+
+
+
onDismiss(motd.Hash)}
+ title={motd.Title}
+ wrapperStyle={camelCaseKeys(motd.ContentLayout)}
+ bodyClassName="motd-body"
+ >
+
+ {/* eslint-disable-next-line react/no-danger */}
+
+
+
+
+
>
);
diff --git a/app/react/portainer/environments/types.ts b/app/react/portainer/environments/types.ts
index 88af0b511..47b5a4284 100644
--- a/app/react/portainer/environments/types.ts
+++ b/app/react/portainer/environments/types.ts
@@ -144,7 +144,7 @@ export type Environment = {
AMTDeviceGUID?: string;
Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings;
- Gpus: { name: string; value: string }[];
+ Gpus?: { name: string; value: string }[];
EnableImageNotification: boolean;
LocalTimeZone?: string;
diff --git a/app/react/portainer/registries/ListView/ListView.tsx b/app/react/portainer/registries/ListView/ListView.tsx
index c4b65dc4c..ec846a5ea 100644
--- a/app/react/portainer/registries/ListView/ListView.tsx
+++ b/app/react/portainer/registries/ListView/ListView.tsx
@@ -8,12 +8,16 @@ export function ListView() {
<>
-
-
- View registries via an environment to manage access for user(s) and/or
- team(s)
-
-
+
+
+
+
+ View registries via an environment to manage access for user(s)
+ and/or team(s)
+
+
+
+
>