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 && ( - - - - )} -
+ + +
+ {title && ( +
+ {title} + {!!onDismiss && ( + + + )} -
{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 ( + + + + + + ); +} 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) + + +
+