mirror of https://github.com/portainer/portainer
refactor(docker): migrate dashboard to react [EE-2191] (#11574)
parent
2669a44d79
commit
014a590704
|
@ -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)
|
||||||
|
@ -80,7 +83,8 @@ func (tx *StoreTx) TeamMembership() dataservices.TeamMembershipService {
|
||||||
return tx.store.TeamMembershipService.Tx(tx.tx)
|
return tx.store.TeamMembershipService.Tx(tx.tx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
|
func (tx *StoreTx) Team() dataservices.TeamService { return nil }
|
||||||
|
|
||||||
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
|
func (tx *StoreTx) TunnelServer() dataservices.TunnelServerService { return nil }
|
||||||
|
|
||||||
func (tx *StoreTx) User() dataservices.UserService {
|
func (tx *StoreTx) User() dataservices.UserService {
|
||||||
|
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
|
@ -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),
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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})
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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] != ""
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
angular.module('portainer.docker').component('dashboardClusterAgentInfo', {
|
|
||||||
templateUrl: './dashboardClusterAgentInfo.html',
|
|
||||||
controller: 'DashboardClusterAgentInfoController',
|
|
||||||
bindings: {
|
|
||||||
endpointId: '<',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -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>
|
|
|
@ -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');
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
]);
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -154,7 +154,7 @@ export const ngModule = angular
|
||||||
'pluralType',
|
'pluralType',
|
||||||
'isLoading',
|
'isLoading',
|
||||||
'isRefetching',
|
'isRefetching',
|
||||||
'dataCy',
|
'data-cy',
|
||||||
'iconClass',
|
'iconClass',
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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" />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -19,33 +19,29 @@ export function InformationPanel({
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren<Props>) {
|
}: PropsWithChildren<Props>) {
|
||||||
return (
|
return (
|
||||||
<div className="row">
|
<Widget>
|
||||||
<div className="col-sm-12">
|
<WidgetBody className={bodyClassName}>
|
||||||
<Widget>
|
<div style={wrapperStyle}>
|
||||||
<WidgetBody className={bodyClassName}>
|
{title && (
|
||||||
<div style={wrapperStyle}>
|
<div className="form-section-title">
|
||||||
{title && (
|
<span>{title}</span>
|
||||||
<div className="form-section-title">
|
{!!onDismiss && (
|
||||||
<span>{title}</span>
|
<span className="small" style={{ float: 'right' }}>
|
||||||
{!!onDismiss && (
|
<Button
|
||||||
<span className="small" style={{ float: 'right' }}>
|
color="link"
|
||||||
<Button
|
icon={X}
|
||||||
color="link"
|
onClick={() => onDismiss()}
|
||||||
icon={X}
|
data-cy="dismiss-information-panel-button"
|
||||||
onClick={() => onDismiss()}
|
>
|
||||||
data-cy="dismiss-information-panel-button"
|
dismiss
|
||||||
>
|
</Button>
|
||||||
dismiss
|
</span>
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div>{children}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</WidgetBody>
|
)}
|
||||||
</Widget>
|
<div>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
|
@ -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 />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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(' + ');
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
|
@ -101,17 +101,21 @@ function CreateForm() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isDuplicating && (
|
{isDuplicating && (
|
||||||
<InformationPanel title-text="Caution">
|
<div className="row">
|
||||||
<TextTip>
|
<div className="col-sm-12">
|
||||||
The new container may fail to start if the image is changed, and
|
<InformationPanel title-text="Caution">
|
||||||
settings from the previous container aren't compatible. Common
|
<TextTip>
|
||||||
causes include entrypoint, cmd or{' '}
|
The new container may fail to start if the image is changed, and
|
||||||
<HelpLink docLink="/user/docker/containers/advanced">
|
settings from the previous container aren't compatible.
|
||||||
other settings
|
Common causes include entrypoint, cmd or{' '}
|
||||||
</HelpLink>{' '}
|
<HelpLink docLink="/user/docker/containers/advanced">
|
||||||
set by an image.
|
other settings
|
||||||
</TextTip>
|
</HelpLink>{' '}
|
||||||
</InformationPanel>
|
set by an image.
|
||||||
|
</TextTip>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Formik
|
<Formik
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -29,19 +29,24 @@ function LogsDisabledInfoPanel() {
|
||||||
} = useCurrentStateAndParams();
|
} = useCurrentStateAndParams();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InformationPanel>
|
<div className="row">
|
||||||
<TextTip color="blue">
|
<div className="col-sm-12">
|
||||||
Logging is disabled for this container. If you want to re-enable
|
<InformationPanel>
|
||||||
logging, please{' '}
|
<TextTip color="blue">
|
||||||
<Link
|
Logging is disabled for this container. If you want to re-enable
|
||||||
to="docker.containers.new"
|
logging, please{' '}
|
||||||
params={{ from: containerId, nodeName }}
|
<Link
|
||||||
data-cy="redeploy-container-link"
|
to="docker.containers.new"
|
||||||
>
|
params={{ from: containerId, nodeName }}
|
||||||
redeploy your container
|
data-cy="redeploy-container-link"
|
||||||
</Link>{' '}
|
>
|
||||||
and select a logging driver in the "Command & logging" panel.
|
redeploy your container
|
||||||
</TextTip>
|
</Link>{' '}
|
||||||
</InformationPanel>
|
and select a logging driver in the "Command & logging"
|
||||||
|
panel.
|
||||||
|
</TextTip>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -22,13 +22,17 @@ function WaitingRoomView() {
|
||||||
reload
|
reload
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InformationPanel>
|
<div className="row">
|
||||||
<TextTip color="blue">
|
<div className="col-sm-12">
|
||||||
Only environments generated from the AEEC script will appear here,
|
<InformationPanel>
|
||||||
manually added environments and edge devices will bypass the waiting
|
<TextTip color="blue">
|
||||||
room.
|
Only environments generated from the AEEC script will appear here,
|
||||||
</TextTip>
|
manually added environments and edge devices will bypass the
|
||||||
</InformationPanel>
|
waiting room.
|
||||||
|
</TextTip>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{licenseOverused && (
|
{licenseOverused && (
|
||||||
<div className="row">
|
<div className="row">
|
||||||
|
|
|
@ -8,12 +8,16 @@ export function ListView() {
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Edge Jobs" breadcrumbs="Edge Jobs" reload />
|
<PageHeader title="Edge Jobs" breadcrumbs="Edge Jobs" reload />
|
||||||
|
|
||||||
<InformationPanel title="Information">
|
<div className="row">
|
||||||
<p className="small text-muted">
|
<div className="col-sm-12">
|
||||||
Edge Jobs requires Docker Standalone and a cron implementation that
|
<InformationPanel title="Information">
|
||||||
reads jobs from <code>/etc/cron.d</code>
|
<p className="small text-muted">
|
||||||
</p>
|
Edge Jobs requires Docker Standalone and a cron implementation
|
||||||
</InformationPanel>
|
that reads jobs from <code>/etc/cron.d</code>
|
||||||
|
</p>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<EdgeJobsDatatable />
|
<EdgeJobsDatatable />
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -16,16 +16,21 @@ export function BackupFailedPanel() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InformationPanel title="Information">
|
<div className="row">
|
||||||
<TextTip>
|
<div className="col-sm-12">
|
||||||
The latest automated backup has failed at {isoDate(status.TimestampUTC)}
|
<InformationPanel title="Information">
|
||||||
. For details please see the log files and have a look at the{' '}
|
<TextTip>
|
||||||
<Link to="portainer.settings" data-cy="backup-failed-settings-link">
|
The latest automated backup has failed at{' '}
|
||||||
settings
|
{isoDate(status.TimestampUTC)}. For details please see the log files
|
||||||
</Link>{' '}
|
and have a look at the{' '}
|
||||||
to verify the backup configuration.
|
<Link to="portainer.settings" data-cy="backup-failed-settings-link">
|
||||||
</TextTip>
|
settings
|
||||||
</InformationPanel>
|
</Link>{' '}
|
||||||
|
to verify the backup configuration.
|
||||||
|
</TextTip>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -4,26 +4,30 @@ import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
|
||||||
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
|
export function NoEnvironmentsInfoPanel({ isAdmin }: { isAdmin: boolean }) {
|
||||||
return (
|
return (
|
||||||
<InformationPanel title="Information">
|
<div className="row">
|
||||||
<TextTip>
|
<div className="col-sm-12">
|
||||||
{isAdmin ? (
|
<InformationPanel title="Information">
|
||||||
<span>
|
<TextTip>
|
||||||
No environment available for management. Please head over the{' '}
|
{isAdmin ? (
|
||||||
<Link
|
<span>
|
||||||
to="portainer.wizard.endpoints"
|
No environment available for management. Please head over the{' '}
|
||||||
data-cy="wizard-add-environments-link"
|
<Link
|
||||||
>
|
to="portainer.wizard.endpoints"
|
||||||
environment wizard
|
data-cy="wizard-add-environments-link"
|
||||||
</Link>{' '}
|
>
|
||||||
to add an environment.
|
environment wizard
|
||||||
</span>
|
</Link>{' '}
|
||||||
) : (
|
to add an environment.
|
||||||
<span>
|
</span>
|
||||||
You do not have access to any environment. Please contact your
|
) : (
|
||||||
administrator.
|
<span>
|
||||||
</span>
|
You do not have access to any environment. Please contact your
|
||||||
)}
|
administrator.
|
||||||
</TextTip>
|
</span>
|
||||||
</InformationPanel>
|
)}
|
||||||
|
</TextTip>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,17 +24,21 @@ export function MotdPanel() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!!motd.Style && <style>{motd.Style}</style>}
|
{!!motd.Style && <style>{motd.Style}</style>}
|
||||||
<InformationPanel
|
<div className="row">
|
||||||
onDismiss={() => onDismiss(motd.Hash)}
|
<div className="col-sm-12">
|
||||||
title={motd.Title}
|
<InformationPanel
|
||||||
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
|
onDismiss={() => onDismiss(motd.Hash)}
|
||||||
bodyClassName="motd-body"
|
title={motd.Title}
|
||||||
>
|
wrapperStyle={camelCaseKeys(motd.ContentLayout)}
|
||||||
<span className="text-muted">
|
bodyClassName="motd-body"
|
||||||
{/* eslint-disable-next-line react/no-danger */}
|
>
|
||||||
<p dangerouslySetInnerHTML={{ __html: motd.Message }} />
|
<span className="text-muted">
|
||||||
</span>
|
{/* eslint-disable-next-line react/no-danger */}
|
||||||
</InformationPanel>
|
<p dangerouslySetInnerHTML={{ __html: sanitize(motd.Message) }} />
|
||||||
|
</span>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -8,12 +8,16 @@ export function ListView() {
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Registries" breadcrumbs="Registry management" reload />
|
<PageHeader title="Registries" breadcrumbs="Registry management" reload />
|
||||||
|
|
||||||
<InformationPanel title="Information">
|
<div className="row">
|
||||||
<span className="small text-muted">
|
<div className="col-sm-12">
|
||||||
View registries via an environment to manage access for user(s) and/or
|
<InformationPanel title="Information">
|
||||||
team(s)
|
<span className="small text-muted">
|
||||||
</span>
|
View registries via an environment to manage access for user(s)
|
||||||
</InformationPanel>
|
and/or team(s)
|
||||||
|
</span>
|
||||||
|
</InformationPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RegistriesDatatable />
|
<RegistriesDatatable />
|
||||||
</>
|
</>
|
||||||
|
|
Loading…
Reference in New Issue