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) 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)
@ -81,6 +84,7 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
}
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}
}
}

View File

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

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

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',
'isLoading',
'isRefetching',
'dataCy',
'data-cy',
'iconClass',
])
)

View File

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

View File

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

View File

@ -19,8 +19,6 @@ export function InformationPanel({
children,
}: PropsWithChildren<Props>) {
return (
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody className={bodyClassName}>
<div style={wrapperStyle}>
@ -45,7 +43,5 @@ export function InformationPanel({
</div>
</WidgetBody>
</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 {
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 <ContainerStatus containers={containers} />;
}
export function ContainerStatus({ containers }: Props) {
export function ContainerStatus({ stats }: Props) {
return (
<div className="pull-right">
<div>
<div className="vertical-center space-right pr-5">
<Icon icon={Power} mode="success" size="sm" />
{runningContainersFilter(containers)} running
{stats.running} running
</div>
<div className="vertical-center space-right">
<Icon icon={Power} mode="danger" size="sm" />
{stoppedContainersFilter(containers)} stopped
{stats.stopped} stopped
</div>
</div>
<div>
<div className="vertical-center space-right pr-5">
<Icon icon={Heart} mode="success" size="sm" />
{healthyContainersFilter(containers)} healthy
{stats.healthy} healthy
</div>
<div className="vertical-center space-right">
<Icon icon={Heart} mode="danger" size="sm" />
{unhealthyContainersFilter(containers)} unhealthy
{stats.unhealthy} unhealthy
</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;
}
export function useImagesTotalSizeComponent(imagesTotalSize: number) {
return <ImagesTotalSize imagesTotalSize={imagesTotalSize} />;
}
export function ImagesTotalSize({ imagesTotalSize }: Props) {
return (
<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 (
<>
{isDuplicating && (
<div className="row">
<div className="col-sm-12">
<InformationPanel title-text="Caution">
<TextTip>
The new container may fail to start if the image is changed, and
settings from the previous container aren&apos;t compatible. Common
causes include entrypoint, cmd or{' '}
settings from the previous container aren&apos;t compatible.
Common causes include entrypoint, cmd or{' '}
<HelpLink docLink="/user/docker/containers/advanced">
other settings
</HelpLink>{' '}
set by an image.
</TextTip>
</InformationPanel>
</div>
</div>
)}
<Formik

View File

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

View File

@ -29,6 +29,8 @@ function LogsDisabledInfoPanel() {
} = useCurrentStateAndParams();
return (
<div className="row">
<div className="col-sm-12">
<InformationPanel>
<TextTip color="blue">
Logging is disabled for this container. If you want to re-enable
@ -40,8 +42,11 @@ function LogsDisabledInfoPanel() {
>
redeploy your container
</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>
</InformationPanel>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -8,12 +8,16 @@ export function ListView() {
<>
<PageHeader title="Edge Jobs" breadcrumbs="Edge Jobs" reload />
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information">
<p className="small text-muted">
Edge Jobs requires Docker Standalone and a cron implementation that
reads jobs from <code>/etc/cron.d</code>
Edge Jobs requires Docker Standalone and a cron implementation
that reads jobs from <code>/etc/cron.d</code>
</p>
</InformationPanel>
</div>
</div>
<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}
to="kubernetes.resourcePools"
type="Namespace"
dataCy="dashboard-namespace"
data-cy="dashboard-namespace"
/>
<DashboardItem
value={applications?.length}
@ -81,7 +81,7 @@ export function DashboardView() {
icon={Box}
to="kubernetes.applications"
type="Application"
dataCy="dashboard-application"
data-cy="dashboard-application"
/>
<DashboardItem
value={services?.length}
@ -92,7 +92,7 @@ export function DashboardView() {
icon={Shuffle}
to="kubernetes.services"
type="Service"
dataCy="dashboard-service"
data-cy="dashboard-service"
/>
<DashboardItem
value={ingresses?.length}
@ -104,7 +104,7 @@ export function DashboardView() {
to="kubernetes.ingresses"
type="Ingress"
pluralType="Ingresses"
dataCy="dashboard-ingress"
data-cy="dashboard-ingress"
/>
<DashboardItem
value={configMaps?.length}
@ -116,7 +116,7 @@ export function DashboardView() {
to="kubernetes.configurations"
params={{ tab: 'configmaps' }}
type="ConfigMap"
dataCy="dashboard-configmaps"
data-cy="dashboard-configmaps"
/>
<DashboardItem
value={secrets?.length}
@ -128,7 +128,7 @@ export function DashboardView() {
to="kubernetes.configurations"
params={{ tab: 'secrets' }}
type="Secret"
dataCy="dashboard-secrets"
data-cy="dashboard-secrets"
/>
<DashboardItem
value={pvcs?.length}
@ -139,7 +139,7 @@ export function DashboardView() {
icon={Database}
to="kubernetes.volumes"
type="Volume"
dataCy="dashboard-volume"
data-cy="dashboard-volume"
/>
</DashboardGrid>
</div>

View File

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

View File

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

View File

@ -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,6 +24,8 @@ export function MotdPanel() {
return (
<>
{!!motd.Style && <style>{motd.Style}</style>}
<div className="row">
<div className="col-sm-12">
<InformationPanel
onDismiss={() => onDismiss(motd.Hash)}
title={motd.Title}
@ -31,9 +34,11 @@ export function MotdPanel() {
>
<span className="text-muted">
{/* eslint-disable-next-line react/no-danger */}
<p dangerouslySetInnerHTML={{ __html: motd.Message }} />
<p dangerouslySetInnerHTML={{ __html: sanitize(motd.Message) }} />
</span>
</InformationPanel>
</div>
</div>
</>
);

View File

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

View File

@ -8,12 +8,16 @@ export function ListView() {
<>
<PageHeader title="Registries" breadcrumbs="Registry management" reload />
<div className="row">
<div className="col-sm-12">
<InformationPanel title="Information">
<span className="small text-muted">
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)
</span>
</InformationPanel>
</div>
</div>
<RegistriesDatatable />
</>