1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-02 20:35:25 +02:00

refactor(docker): migrate dashboard to react [EE-2191] (#11574)
Some checks are pending
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:s390x platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
ci / build_images (map[arch:arm platform:linux version:]) (push) Waiting to run
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Waiting to run
ci / build_manifests (push) Blocked by required conditions
/ triage (push) Waiting to run
Lint / Run linters (push) Waiting to run
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run

This commit is contained in:
Chaim Lev-Ari 2024-05-20 09:34:51 +03:00 committed by GitHub
parent 2669a44d79
commit 014a590704
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1297 additions and 507 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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] != ""
}

View file

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

View file

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

View file

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

View file

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

View file

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