refactor(docker): migrate dashboard to react [EE-2191] (#11574)

pull/11848/head
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) 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 { func (tx *StoreTx) Tag() dataservices.TagService {
return tx.store.TagService.Tx(tx.tx) return tx.store.TagService.Tx(tx.tx)
@ -81,6 +84,7 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
} }
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) TunnelServer() dataservices.TunnelServerService { return nil }
func (tx *StoreTx) User() dataservices.UserService { func (tx *StoreTx) User() dataservices.UserService {

View File

@ -5,4 +5,5 @@ const (
SwarmStackNameLabel = "com.docker.stack.namespace" SwarmStackNameLabel = "com.docker.stack.namespace"
SwarmServiceIdLabel = "com.docker.swarm.service.id" SwarmServiceIdLabel = "com.docker.swarm.service.id"
SwarmNodeIdLabel = "com.docker.swarm.node.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 return err
} }
runningContainers := 0
stoppedContainers := 0
healthyContainers := 0
unhealthyContainers := 0
stacks := make(map[string]struct{}) stacks := make(map[string]struct{})
gpuUseSet := make(map[string]struct{}) gpuUseSet := make(map[string]struct{})
gpuUseAll := false gpuUseAll := false
for _, container := range containers { for _, container := range containers {
if container.State == "exited" || container.State == "stopped" { if container.State == "running" {
stoppedContainers++
} else if container.State == "running" {
runningContainers++
// snapshot GPUs // snapshot GPUs
response, err := cli.ContainerInspect(context.Background(), container.ID) response, err := cli.ContainerInspect(context.Background(), container.ID)
if err != nil { 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 { for k, v := range container.Labels {
if k == consts.ComposeStackNameLabel { if k == consts.ComposeStackNameLabel {
stacks[v] = struct{}{} stacks[v] = struct{}{}
@ -226,11 +209,13 @@ func snapshotContainers(snapshot *portainer.DockerSnapshot, cli *client.Client)
snapshot.GpuUseAll = gpuUseAll snapshot.GpuUseAll = gpuUseAll
snapshot.GpuUseList = gpuUseList snapshot.GpuUseList = gpuUseList
snapshot.ContainerCount = len(containers) stats := CalculateContainerStats(containers)
snapshot.RunningContainerCount = runningContainers
snapshot.StoppedContainerCount = stoppedContainers snapshot.ContainerCount = stats.Total
snapshot.HealthyContainerCount = healthyContainers snapshot.RunningContainerCount = stats.Running
snapshot.UnhealthyContainerCount = unhealthyContainers snapshot.StoppedContainerCount = stats.Stopped
snapshot.HealthyContainerCount = stats.Healthy
snapshot.UnhealthyContainerCount = stats.Unhealthy
snapshot.StackCount += len(stacks) snapshot.StackCount += len(stacks)
for _, container := range containers { for _, container := range containers {
snapshot.SnapshotRaw.Containers = append(snapshot.SnapshotRaw.Containers, portainer.DockerContainerSnapshot{Container: container}) 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 // endpoints
endpointRouter := h.PathPrefix("/docker/{id}").Subrouter() endpointRouter := h.PathPrefix("/docker/{id}").Subrouter()
endpointRouter.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id")) endpointRouter.Use(bouncer.AuthenticatedAccess)
endpointRouter.Use(dockerOnlyMiddleware) 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) containersHandler := containers.NewHandler("/docker/{id}/containers", bouncer, dataStore, dockerClientFactory, containerService)
endpointRouter.PathPrefix("/containers").Handler(containersHandler) 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" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/dataservices"
dockerclient "github.com/portainer/portainer/api/docker/client" 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/middlewares"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
@ -197,7 +198,7 @@ func (handler *Handler) checkUniqueStackNameInDocker(endpoint *portainer.Endpoin
} }
for _, container := range containers { for _, container := range containers {
containerNS, ok := container.Labels["com.docker.compose.project"] containerNS, ok := container.Labels[consts.ComposeStackNameLabel]
if ok && containerNS == name { if ok && containerNS == name {
return false, nil return false, nil

View File

@ -13,7 +13,11 @@ func IsAdmin(request *http.Request) (bool, error) {
return false, err 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. // AuthorizedResourceControlAccess checks whether the user can alter an existing resource control.

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
) )
type ( type (
@ -49,3 +50,17 @@ func RetrieveRestrictedRequestContext(request *http.Request) (*RestrictedRequest
requestContext := contextData.(*RestrictedRequestContext) requestContext := contextData.(*RestrictedRequestContext)
return requestContext, nil 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} 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}
}
}

View File

@ -150,8 +150,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/dashboard', url: '/dashboard',
views: { views: {
'content@': { 'content@': {
templateUrl: './views/dashboard/dashboard.html', component: 'dockerDashboardView',
controller: 'DashboardController',
}, },
}, },
data: { data: {

View File

@ -1,7 +0,0 @@
angular.module('portainer.docker').component('dashboardClusterAgentInfo', {
templateUrl: './dashboardClusterAgentInfo.html',
controller: 'DashboardClusterAgentInfoController',
bindings: {
endpointId: '<',
},
});

View File

@ -1,20 +0,0 @@
<rd-widget>
<rd-widget-header icon="gauge" title-text="Cluster information"></rd-widget-header>
<rd-widget-body classes="!px-5 !py-0">
<table class="table">
<tbody>
<tr>
<td>Nodes in the cluster</td>
<td>{{ $ctrl.agentCount }}</td>
</tr>
<tr>
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a ui-sref="docker.swarm.visualizer" class="vertical-center"><pr-icon icon="'trello'" class-name="'icon'"></pr-icon>Go to cluster visualizer</a>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>

View File

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

View File

@ -3,15 +3,18 @@ import angular from 'angular';
import { ItemView as NetworksItemView } from '@/react/docker/networks/ItemView'; import { ItemView as NetworksItemView } from '@/react/docker/networks/ItemView';
import { r2a } from '@/react-tools/react2angular'; import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter'; import { withUIRouter } from '@/react-tools/withUIRouter';
import { DashboardView } from '@/react/docker/DashboardView/DashboardView';
import { containersModule } from './containers'; import { containersModule } from './containers';
export const viewsModule = angular export const viewsModule = angular
.module('portainer.docker.react.views', [containersModule]) .module('portainer.docker.react.views', [containersModule])
.component(
'dockerDashboardView',
r2a(withUIRouter(withCurrentUser(DashboardView)), [])
)
.component( .component(
'networkDetailsView', 'networkDetailsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(NetworksItemView))), []) r2a(withUIRouter(withCurrentUser(NetworksItemView)), [])
).name; ).name;

View File

@ -1,117 +0,0 @@
<page-header title="'Dashboard'" breadcrumbs="['Environment summary']"> </page-header>
<div class="row" ng-if="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<div class="col-sm-12">
<dashboard-cluster-agent-info endpoint-id="endpoint.Id"></dashboard-cluster-agent-info>
</div>
</div>
<information-panel
ng-if="
!applicationState.UI.dismissedInfoPanels['docker-dashboard-info-01'] &&
!applicationState.endpoint.mode.agentProxy &&
applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'
"
title-text="Information"
dismiss-action="dismissInformationPanel('docker-dashboard-info-01')"
>
<span class="small">
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'MANAGER'">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
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
<help-link doc-link="'/admin/environments/add/swarm/agent'" target="'_blank'" children="'our agent setup'"></help-link> for more details.
</p>
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'WORKER'">
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
Portainer is connected to a worker node. Swarm management features will not be available.
</p>
</span>
</information-panel>
<div ng-if="info">
<div class="row" ng-if="(!applicationState.endpoint.mode.agentProxy || applicationState.endpoint.mode.provider !== 'DOCKER_SWARM_MODE') && info && endpoint">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="gauge" title-text="Environment info"></rd-widget-header>
<rd-widget-body classes="!px-5 !py-0">
<table class="table">
<tbody>
<tr>
<td>Environment</td>
<td>
{{ endpoint.Name }}
<span class="small text-muted space-left">
<pr-icon icon="'cpu'"></pr-icon> {{ endpoint.Snapshots[0].TotalCPU }} <pr-icon icon="'svg-memory'"></pr-icon>
{{ endpoint.Snapshots[0].TotalMemory | humansize }}
</span>
<span class="small text-muted">
- {{ info.Swarm && info.Swarm.NodeID !== '' ? 'Swarm' : 'Standalone' }} {{ info.ServerVersion }}
<span ng-if="endpoint.Type === 2">
<pr-icon icon="'zap'"></pr-icon>
Agent</span
></span
>
</td>
</tr>
<tr ng-if="showEnvUrl">
<td>URL</td>
<td>{{ endpoint.URL | stripprotocol }}</td>
</tr>
<tr>
<td>{{ endpoint.Gpus.length <= 1 ? 'GPU' : 'GPUs' }}</td>
<td>{{ gpuInfoStr }}</td>
</tr>
<tr>
<td>Tags</td>
<td>{{ endpointTags }}</td>
</tr>
<tr ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a ui-sref="docker.swarm.visualizer" class="vertical-center"><pr-icon icon="'trello'" class-name="'icon'"></pr-icon>Go to cluster visualizer</a>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="mx-4 grid grid-cols-2 gap-3">
<a class="no-link" ui-sref="docker.stacks" ng-if="showStacks">
<dashboard-item icon="'layers'" type="'Stack'" value="stackCount"></dashboard-item>
</a>
<div ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a class="no-link" ui-sref="docker.services">
<dashboard-item icon="'shuffle'" type="'Service'" value="serviceCount"></dashboard-item>
</a>
</div>
<a class="no-link" ng-if="containers" ui-sref="docker.containers">
<dashboard-item icon="'box'" type="'Container'" value="containers.length" children="containerStatusComponent"></dashboard-item>
</a>
<a class="no-link" ng-if="images" ui-sref="docker.images">
<dashboard-item icon="'list'" type="'Image'" value="images.length" children="imagesTotalSizeComponent"></dashboard-item>
</a>
<a class="no-link" ui-sref="docker.volumes">
<dashboard-item icon="'database'" type="'Volume'" value="volumeCount"></dashboard-item>
</a>
<a class="no-link" ui-sref="docker.networks">
<dashboard-item icon="'Network'" type="'Network'" value="networkCount"></dashboard-item>
</a>
<div>
<dashboard-item
ng-if="endpoint.EnableGPUManagement && applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'"
icon="'cpu'"
type="'GPU'"
value="endpoint.Gpus.length"
></dashboard-item>
</div>
</div>
</div>

View File

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

View File

@ -154,7 +154,7 @@ export const ngModule = angular
'pluralType', 'pluralType',
'isLoading', 'isLoading',
'isRefetching', 'isRefetching',
'dataCy', 'data-cy',
'iconClass', 'iconClass',
]) ])
) )

View File

@ -34,7 +34,7 @@ export function DashboardView() {
<DashboardGrid> <DashboardGrid>
<DashboardItem <DashboardItem
value={subscriptionsCount as number} value={subscriptionsCount as number}
dataCy="subscriptions-count" data-cy="subscriptions-count"
isLoading={subscriptionsQuery.isLoading} isLoading={subscriptionsQuery.isLoading}
isRefetching={subscriptionsQuery.isRefetching} isRefetching={subscriptionsQuery.isRefetching}
icon={Subscription} icon={Subscription}
@ -43,7 +43,7 @@ export function DashboardView() {
{!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && ( {!resourceGroupsQuery.isError && !resourceGroupsQuery.isLoading && (
<DashboardItem <DashboardItem
value={resourceGroupsCount} value={resourceGroupsCount}
dataCy="resource-groups-count" data-cy="resource-groups-count"
isLoading={resourceGroupsQuery.isLoading} isLoading={resourceGroupsQuery.isLoading}
icon={Package} icon={Package}
type="Resource group" type="Resource group"

View File

@ -24,7 +24,7 @@ function Template({ value, icon, type }: StoryProps) {
value={value} value={value}
icon={icon} icon={icon}
type={type} type={type}
dataCy="data-cy-example" data-cy="data-cy-example"
/> />
); );
} }
@ -43,7 +43,7 @@ export function WithLink() {
value={1} value={1}
icon={List} icon={List}
type="Example resource" type="Example resource"
dataCy="data-cy-example" data-cy="data-cy-example"
/> />
</Link> </Link>
); );
@ -55,7 +55,7 @@ export function WithChildren() {
value={1} value={1}
icon={List} icon={List}
type="Example resource" type="Example resource"
dataCy="data-cy-example" data-cy="data-cy-example"
> >
<div>Children</div> <div>Children</div>
</DashboardItem> </DashboardItem>

View File

@ -27,6 +27,6 @@ test('should have accessibility label created from the provided resource type',
function renderComponent(value = 0, icon = User, type = '') { function renderComponent(value = 0, icon = User, type = '') {
return render( return render(
<DashboardItem value={value} icon={icon} type={type} dataCy="example" /> <DashboardItem value={value} icon={icon} type={type} data-cy="example" />
); );
} }

View File

@ -4,10 +4,11 @@ import { Loader2 } from 'lucide-react';
import { Icon, IconProps } from '@/react/components/Icon'; import { Icon, IconProps } from '@/react/components/Icon';
import { pluralize } from '@/portainer/helpers/strings'; import { pluralize } from '@/portainer/helpers/strings';
import { AutomationTestingProps } from '@/types';
import { Link } from '@@/Link'; import { Link } from '@@/Link';
interface Props extends IconProps { interface Props extends IconProps, AutomationTestingProps {
type: string; type: string;
pluralType?: string; // in case the pluralise function isn't suitable pluralType?: string; // in case the pluralise function isn't suitable
isLoading?: boolean; isLoading?: boolean;
@ -16,7 +17,6 @@ interface Props extends IconProps {
to?: string; to?: string;
params?: object; params?: object;
children?: ReactNode; children?: ReactNode;
dataCy: string;
} }
export function DashboardItem({ export function DashboardItem({
@ -29,7 +29,7 @@ export function DashboardItem({
to, to,
params, params,
children, children,
dataCy, 'data-cy': dataCy,
}: Props) { }: Props) {
const Item = ( const Item = (
<div <div

View File

@ -19,8 +19,6 @@ export function InformationPanel({
children, children,
}: PropsWithChildren<Props>) { }: PropsWithChildren<Props>) {
return ( return (
<div className="row">
<div className="col-sm-12">
<Widget> <Widget>
<WidgetBody className={bodyClassName}> <WidgetBody className={bodyClassName}>
<div style={wrapperStyle}> <div style={wrapperStyle}>
@ -45,7 +43,5 @@ export function InformationPanel({
</div> </div>
</WidgetBody> </WidgetBody>
</Widget> </Widget>
</div>
</div>
); );
} }

View File

@ -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 (
<Widget>
<Widget.Title icon={GaugeIcon} title="Cluster information" />
<Widget.Body className="!px-5 !py-0">
<DetailsTable dataCy="cluster-agent-info">
<DetailsTable.Row label="Nodes in the cluster">
{nodesCountQuery.data}
</DetailsTable.Row>
<ClusterVisualizerLink />
</DetailsTable>
</Widget.Body>
</Widget>
);
}

View File

@ -0,0 +1,25 @@
import { TrelloIcon } from 'lucide-react';
import { Link } from '@@/Link';
import { Button } from '@@/buttons';
export function ClusterVisualizerLink() {
return (
<tr>
<td colSpan={2}>
<Button
as={Link}
color="link"
icon={TrelloIcon}
props={{
to: 'docker.swarm.visualizer',
'data-cy': 'cluster-visualizer',
}}
data-cy="cluster-visualizer"
>
Go to cluster visualizer
</Button>
</td>
</tr>
);
}

View File

@ -2,63 +2,38 @@ import { Heart, Power } from 'lucide-react';
import { Icon } from '@/react/components/Icon'; import { Icon } from '@/react/components/Icon';
import {
DockerContainer,
ContainerStatus as Status,
} from '../containers/types';
interface Props { interface Props {
containers: DockerContainer[]; stats: {
running: number;
stopped: number;
healthy: number;
unhealthy: number;
};
} }
export function useContainerStatusComponent(containers: DockerContainer[]) { export function ContainerStatus({ stats }: Props) {
return <ContainerStatus containers={containers} />;
}
export function ContainerStatus({ containers }: Props) {
return ( return (
<div className="pull-right"> <div className="pull-right">
<div> <div>
<div className="vertical-center space-right pr-5"> <div className="vertical-center space-right pr-5">
<Icon icon={Power} mode="success" size="sm" /> <Icon icon={Power} mode="success" size="sm" />
{runningContainersFilter(containers)} running {stats.running} running
</div> </div>
<div className="vertical-center space-right"> <div className="vertical-center space-right">
<Icon icon={Power} mode="danger" size="sm" /> <Icon icon={Power} mode="danger" size="sm" />
{stoppedContainersFilter(containers)} stopped {stats.stopped} stopped
</div> </div>
</div> </div>
<div> <div>
<div className="vertical-center space-right pr-5"> <div className="vertical-center space-right pr-5">
<Icon icon={Heart} mode="success" size="sm" /> <Icon icon={Heart} mode="success" size="sm" />
{healthyContainersFilter(containers)} healthy {stats.healthy} healthy
</div> </div>
<div className="vertical-center space-right"> <div className="vertical-center space-right">
<Icon icon={Heart} mode="danger" size="sm" /> <Icon icon={Heart} mode="danger" size="sm" />
{unhealthyContainersFilter(containers)} unhealthy {stats.unhealthy} unhealthy
</div> </div>
</div> </div>
</div> </div>
); );
} }
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;
}

View File

@ -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 (
<>
<PageHeader title="Dashboard" breadcrumbs="Environment summary" reload />
<div className="mx-4 space-y-6">
<InfoPanels isAgent={isAgentEnvironment(env.Type)} />
<DashboardGrid>
{isStacksVisible && (
<DashboardItem
to="docker.stacks"
icon={LayersIcon}
type="Stack"
value={dashboardStats.stacks}
data-cy="stacks"
/>
)}
{isSwarmManager && (
<DashboardItem
to="docker.services"
icon={ShuffleIcon}
type="Service"
value={dashboardStats.services}
data-cy="services"
/>
)}
<DashboardItem
to="docker.containers"
icon={BoxIcon}
type="Container"
value={dashboardStats.containers.total}
data-cy="containers"
>
<ContainerStatus stats={dashboardStats.containers} />
</DashboardItem>
<DashboardItem
to="docker.images"
icon={ListIcon}
type="Image"
value={dashboardStats.images.total}
data-cy="images"
>
<ImagesTotalSize imagesTotalSize={dashboardStats.images.size} />
</DashboardItem>
<DashboardItem
to="docker.volumes"
icon={DatabaseIcon}
type="Volume"
value={dashboardStats.volumes}
data-cy="volumes"
/>
<DashboardItem
to="docker.networks"
icon={NetworkIcon}
type="Network"
value={dashboardStats.networks}
data-cy="networks"
/>
{env.EnableGPUManagement && isStandalone && (
<DashboardItem
icon={CpuIcon}
type="GPU"
value={env.Gpus?.length}
data-cy="gpus"
/>
)}
</DashboardGrid>
</div>
<div className="pt-6" />
</>
);
function shouldShowStacks() {
return (
env.SecuritySettings.allowStackManagementForRegularUsers ||
isEnvAdminQuery.authorized
);
}
}
function InfoPanels({ isAgent }: { isAgent: boolean }) {
const envId = useEnvironmentId();
const isSwarm = useIsSwarm(envId);
return (
<>
{isSwarm && !isAgent && <NonAgentSwarmInfo />}
{isSwarm && isAgent && <ClusterAgentInfo />}
{(!isSwarm || !isAgent) && <EnvironmentInfo />}
</>
);
}

View File

@ -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 (
<span className="small text-muted">
{isSwarm ? 'Swarm' : 'Standalone'} {info.ServerVersion}
{isAgent && (
<span className="flex gap-1 items-center">
<Icon icon={ZapIcon} />
Agent
</span>
)}
</span>
);
}

View File

@ -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 (
<DetailsTable.Row label={gpus.length <= 1 ? 'GPU' : 'GPUs'}>
{gpuFreeStr}
</DetailsTable.Row>
);
function buildGpusStr(
gpuUseList: Array<string>,
gpus: Array<{ name: string }> = []
) {
if (!gpus.length) {
return 'none';
}
const gpuUseSet = new Set(gpuUseList);
const gpusAvailable: Record<string, number> = {};
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(' + ');
}
}

View File

@ -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 (
<span className="small text-muted flex gap-2">
<span className="flex gap-1 items-center">
<Icon icon={CpuIcon} /> {snapshot.TotalCPU}
</span>
<span className="flex gap-1 items-center">
<Icon icon={memoryIcon} />
{humanize(snapshot.TotalMemory)}
</span>
</span>
);
}

View File

@ -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 <DetailsTable.Row label="Tags">{tagNameList}</DetailsTable.Row>;
}

View File

@ -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 (
<Widget>
<Widget.Title icon={GaugeIcon} title="Environment info" />
<Widget.Body className="!px-5 !py-0">
<DetailsTable dataCy="environment-info">
<DetailsTable.Row label="Environment">
<div className="flex items-center gap-2">
{environment.Name}
<SnapshotStats snapshot={environment.Snapshots[0]} />-
<DockerInfo isAgent={isAgent} />
</div>
</DetailsTable.Row>
{isEnvUrlVisible && (
<DetailsTable.Row label="URL">
{stripProtocol(environment.URL)}
</DetailsTable.Row>
)}
<GpuInfo
gpus={environment.Gpus || []}
snapshot={environment.Snapshots[0]}
/>
<TagsInfo ids={environment.TagIds} />
{isSwarmManager && <ClusterVisualizerLink />}
</DetailsTable>
</Widget.Body>
</Widget>
);
}

View File

@ -8,10 +8,6 @@ interface Props {
imagesTotalSize: number; imagesTotalSize: number;
} }
export function useImagesTotalSizeComponent(imagesTotalSize: number) {
return <ImagesTotalSize imagesTotalSize={imagesTotalSize} />;
}
export function ImagesTotalSize({ imagesTotalSize }: Props) { export function ImagesTotalSize({ imagesTotalSize }: Props) {
return ( return (
<div className="vertical-center"> <div className="vertical-center">

View File

@ -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 (
<InformationPanel title="Information" onDismiss={() => dismiss()}>
<TextTip color="blue">
{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{' '}
<HelpLink
docLink="/admin/environments/add/swarm/agent"
target="_blank"
>
our agent setup
</HelpLink>{' '}
for more details.
</>
) : (
<>
Portainer is connected to a worker node. Swarm management features
will not be available.
</>
)}
</TextTip>
</InformationPanel>
);
}

View File

@ -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<DashboardResponse>(
`${buildDockerUrl(envId)}/dashboard`
);
return res.data;
} catch (error) {
throw parseAxiosError(error);
}
},
queryKey: [...queryKeys.root(envId), 'dashboard'] as const,
});
}

View File

@ -101,17 +101,21 @@ function CreateForm() {
return ( return (
<> <>
{isDuplicating && ( {isDuplicating && (
<div className="row">
<div className="col-sm-12">
<InformationPanel title-text="Caution"> <InformationPanel title-text="Caution">
<TextTip> <TextTip>
The new container may fail to start if the image is changed, and The new container may fail to start if the image is changed, and
settings from the previous container aren&apos;t compatible. Common settings from the previous container aren&apos;t compatible.
causes include entrypoint, cmd or{' '} Common causes include entrypoint, cmd or{' '}
<HelpLink docLink="/user/docker/containers/advanced"> <HelpLink docLink="/user/docker/containers/advanced">
other settings other settings
</HelpLink>{' '} </HelpLink>{' '}
set by an image. set by an image.
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
</div>
</div>
)} )}
<Formik <Formik

View File

@ -111,7 +111,7 @@ export function ResourcesTab({
<GpuFieldset <GpuFieldset
values={values.gpu} values={values.gpu}
onChange={(gpu) => setFieldValue('gpu', gpu)} onChange={(gpu) => setFieldValue('gpu', gpu)}
gpus={environment.Gpus} gpus={environment.Gpus || []}
enableGpuManagement={environment.EnableGPUManagement} enableGpuManagement={environment.EnableGPUManagement}
usedGpus={gpuUseList} usedGpus={gpuUseList}
usedAllGpus={gpuUseAll} usedAllGpus={gpuUseAll}

View File

@ -29,6 +29,8 @@ function LogsDisabledInfoPanel() {
} = useCurrentStateAndParams(); } = useCurrentStateAndParams();
return ( return (
<div className="row">
<div className="col-sm-12">
<InformationPanel> <InformationPanel>
<TextTip color="blue"> <TextTip color="blue">
Logging is disabled for this container. If you want to re-enable Logging is disabled for this container. If you want to re-enable
@ -40,8 +42,11 @@ function LogsDisabledInfoPanel() {
> >
redeploy your container redeploy your container
</Link>{' '} </Link>{' '}
and select a logging driver in the &quot;Command & logging&quot; panel. and select a logging driver in the &quot;Command & logging&quot;
panel.
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
</div>
</div>
); );
} }

View File

@ -72,3 +72,11 @@ export function useSystemLimits(environmentId: EnvironmentId) {
return { maxCpu, maxMemory }; return { maxCpu, maxMemory };
} }
export function useIsSwarmManager(environmentId: EnvironmentId) {
const query = useInfo(environmentId, {
select: (info) => !!info.Swarm?.NodeID && info.Swarm.ControlAvailable,
});
return !!query.data;
}

View File

@ -22,13 +22,17 @@ function WaitingRoomView() {
reload reload
/> />
<div className="row">
<div className="col-sm-12">
<InformationPanel> <InformationPanel>
<TextTip color="blue"> <TextTip color="blue">
Only environments generated from the AEEC script will appear here, Only environments generated from the AEEC script will appear here,
manually added environments and edge devices will bypass the waiting manually added environments and edge devices will bypass the
room. waiting room.
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
</div>
</div>
{licenseOverused && ( {licenseOverused && (
<div className="row"> <div className="row">

View File

@ -8,12 +8,16 @@ export function ListView() {
<> <>
<PageHeader title="Edge Jobs" breadcrumbs="Edge Jobs" reload /> <PageHeader title="Edge Jobs" breadcrumbs="Edge Jobs" reload />
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information"> <InformationPanel title="Information">
<p className="small text-muted"> <p className="small text-muted">
Edge Jobs requires Docker Standalone and a cron implementation that Edge Jobs requires Docker Standalone and a cron implementation
reads jobs from <code>/etc/cron.d</code> that reads jobs from <code>/etc/cron.d</code>
</p> </p>
</InformationPanel> </InformationPanel>
</div>
</div>
<EdgeJobsDatatable /> <EdgeJobsDatatable />
</> </>

View File

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

View File

@ -70,7 +70,7 @@ export function DashboardView() {
icon={Layers} icon={Layers}
to="kubernetes.resourcePools" to="kubernetes.resourcePools"
type="Namespace" type="Namespace"
dataCy="dashboard-namespace" data-cy="dashboard-namespace"
/> />
<DashboardItem <DashboardItem
value={applications?.length} value={applications?.length}
@ -81,7 +81,7 @@ export function DashboardView() {
icon={Box} icon={Box}
to="kubernetes.applications" to="kubernetes.applications"
type="Application" type="Application"
dataCy="dashboard-application" data-cy="dashboard-application"
/> />
<DashboardItem <DashboardItem
value={services?.length} value={services?.length}
@ -92,7 +92,7 @@ export function DashboardView() {
icon={Shuffle} icon={Shuffle}
to="kubernetes.services" to="kubernetes.services"
type="Service" type="Service"
dataCy="dashboard-service" data-cy="dashboard-service"
/> />
<DashboardItem <DashboardItem
value={ingresses?.length} value={ingresses?.length}
@ -104,7 +104,7 @@ export function DashboardView() {
to="kubernetes.ingresses" to="kubernetes.ingresses"
type="Ingress" type="Ingress"
pluralType="Ingresses" pluralType="Ingresses"
dataCy="dashboard-ingress" data-cy="dashboard-ingress"
/> />
<DashboardItem <DashboardItem
value={configMaps?.length} value={configMaps?.length}
@ -116,7 +116,7 @@ export function DashboardView() {
to="kubernetes.configurations" to="kubernetes.configurations"
params={{ tab: 'configmaps' }} params={{ tab: 'configmaps' }}
type="ConfigMap" type="ConfigMap"
dataCy="dashboard-configmaps" data-cy="dashboard-configmaps"
/> />
<DashboardItem <DashboardItem
value={secrets?.length} value={secrets?.length}
@ -128,7 +128,7 @@ export function DashboardView() {
to="kubernetes.configurations" to="kubernetes.configurations"
params={{ tab: 'secrets' }} params={{ tab: 'secrets' }}
type="Secret" type="Secret"
dataCy="dashboard-secrets" data-cy="dashboard-secrets"
/> />
<DashboardItem <DashboardItem
value={pvcs?.length} value={pvcs?.length}
@ -139,7 +139,7 @@ export function DashboardView() {
icon={Database} icon={Database}
to="kubernetes.volumes" to="kubernetes.volumes"
type="Volume" type="Volume"
dataCy="dashboard-volume" data-cy="dashboard-volume"
/> />
</DashboardGrid> </DashboardGrid>
</div> </div>

View File

@ -16,16 +16,21 @@ export function BackupFailedPanel() {
} }
return ( return (
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information"> <InformationPanel title="Information">
<TextTip> <TextTip>
The latest automated backup has failed at {isoDate(status.TimestampUTC)} The latest automated backup has failed at{' '}
. For details please see the log files and have a look at the{' '} {isoDate(status.TimestampUTC)}. For details please see the log files
and have a look at the{' '}
<Link to="portainer.settings" data-cy="backup-failed-settings-link"> <Link to="portainer.settings" data-cy="backup-failed-settings-link">
settings settings
</Link>{' '} </Link>{' '}
to verify the backup configuration. to verify the backup configuration.
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
</div>
</div>
); );
} }

View File

@ -4,6 +4,8 @@ import { TextTip } from '@@/Tip/TextTip';
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) { export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
return ( return (
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information"> <InformationPanel title="Information">
<TextTip> <TextTip>
{isAdmin ? ( {isAdmin ? (
@ -25,5 +27,7 @@ export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
)} )}
</TextTip> </TextTip>
</InformationPanel> </InformationPanel>
</div>
</div>
); );
} }

View File

@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import _ from 'lodash'; import _ from 'lodash';
import sanitize from 'sanitize-html';
import { useUIState } from '@/react/hooks/useUIState'; import { useUIState } from '@/react/hooks/useUIState';
@ -23,6 +24,8 @@ export function MotdPanel() {
return ( return (
<> <>
{!!motd.Style && <style>{motd.Style}</style>} {!!motd.Style && <style>{motd.Style}</style>}
<div className="row">
<div className="col-sm-12">
<InformationPanel <InformationPanel
onDismiss={() => onDismiss(motd.Hash)} onDismiss={() => onDismiss(motd.Hash)}
title={motd.Title} title={motd.Title}
@ -31,9 +34,11 @@ export function MotdPanel() {
> >
<span className="text-muted"> <span className="text-muted">
{/* eslint-disable-next-line react/no-danger */} {/* eslint-disable-next-line react/no-danger */}
<p dangerouslySetInnerHTML={{ __html: motd.Message }} /> <p dangerouslySetInnerHTML={{ __html: sanitize(motd.Message) }} />
</span> </span>
</InformationPanel> </InformationPanel>
</div>
</div>
</> </>
); );

View File

@ -144,7 +144,7 @@ export type Environment = {
AMTDeviceGUID?: string; AMTDeviceGUID?: string;
Edge: EnvironmentEdge; Edge: EnvironmentEdge;
SecuritySettings: EnvironmentSecuritySettings; SecuritySettings: EnvironmentSecuritySettings;
Gpus: { name: string; value: string }[]; Gpus?: { name: string; value: string }[];
EnableImageNotification: boolean; EnableImageNotification: boolean;
LocalTimeZone?: string; LocalTimeZone?: string;

View File

@ -8,12 +8,16 @@ export function ListView() {
<> <>
<PageHeader title="Registries" breadcrumbs="Registry management" reload /> <PageHeader title="Registries" breadcrumbs="Registry management" reload />
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information"> <InformationPanel title="Information">
<span className="small text-muted"> <span className="small text-muted">
View registries via an environment to manage access for user(s) and/or View registries via an environment to manage access for user(s)
team(s) and/or team(s)
</span> </span>
</InformationPanel> </InformationPanel>
</div>
</div>
<RegistriesDatatable /> <RegistriesDatatable />
</> </>