Compare commits
36 Commits
2.27.1
...
yd-develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b57855f20d | ||
|
|
438b1f9815 | ||
|
|
2bccb3589e | ||
|
|
52bb06eb7b | ||
|
|
8e6d0e7d42 | ||
|
|
5526fd8296 | ||
|
|
a554a8c49f | ||
|
|
7759d762ab | ||
|
|
dd98097897 | ||
|
|
cc73b7831f | ||
|
|
9c243cc8dd | ||
|
|
5d568a3f32 | ||
|
|
1b83542d41 | ||
|
|
cf95d91db3 | ||
|
|
41c1d88615 | ||
|
|
df8673ba40 | ||
|
|
96b1869a0c | ||
|
|
e45b852c09 | ||
|
|
2d3e5c3499 | ||
|
|
b25bf1e341 | ||
|
|
4bb80d3e3a | ||
|
|
03575186a7 | ||
|
|
935c7dd496 | ||
|
|
1b2dc6a133 | ||
|
|
d4e2b2188e | ||
|
|
9658f757c2 | ||
|
|
371e84d9a5 | ||
|
|
5423a2f1b9 | ||
|
|
7001f8e088 | ||
|
|
678cd54553 | ||
|
|
bc19d6592f | ||
|
|
5af0859f67 | ||
|
|
379711951c | ||
|
|
a50a9c5617 | ||
|
|
c0d30a455f | ||
|
|
9a3f6b21d2 |
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -121,10 +121,6 @@ body:
|
|||||||
- '2.19.2'
|
- '2.19.2'
|
||||||
- '2.19.1'
|
- '2.19.1'
|
||||||
- '2.19.0'
|
- '2.19.0'
|
||||||
- '2.18.4'
|
|
||||||
- '2.18.3'
|
|
||||||
- '2.18.2'
|
|
||||||
- '2.18.1'
|
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ type Service struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ dataservices.EndpointRelationService = &Service{}
|
||||||
|
|
||||||
func (service *Service) BucketName() string {
|
func (service *Service) BucketName() string {
|
||||||
return BucketName
|
return BucketName
|
||||||
}
|
}
|
||||||
@@ -109,6 +111,18 @@ func (service *Service) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service *Service) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
return service.Tx(tx).AddEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
return service.connection.ViewTx(func(tx portainer.Transaction) error {
|
||||||
|
return service.Tx(tx).RemoveEndpointRelationsForEdgeStack(endpointIDs, edgeStackID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||||
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
func (service *Service) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ type ServiceTx struct {
|
|||||||
tx portainer.Transaction
|
tx portainer.Transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var _ dataservices.EndpointRelationService = &ServiceTx{}
|
||||||
|
|
||||||
func (service ServiceTx) BucketName() string {
|
func (service ServiceTx) BucketName() string {
|
||||||
return BucketName
|
return BucketName
|
||||||
}
|
}
|
||||||
@@ -74,6 +76,58 @@ func (service ServiceTx) UpdateEndpointRelation(endpointID portainer.EndpointID,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
|
rel, err := service.EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rel.EdgeStacks[edgeStackID] = true
|
||||||
|
|
||||||
|
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||||
|
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||||
|
cache.Del(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
edgeStack.NumDeployments += len(endpointIDs)
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service ServiceTx) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
|
rel, err := service.EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(rel.EdgeStacks, edgeStackID)
|
||||||
|
|
||||||
|
identifier := service.service.connection.ConvertToKey(int(endpointID))
|
||||||
|
err = service.tx.UpdateObject(BucketName, identifier, rel)
|
||||||
|
cache.Del(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := service.service.updateStackFnTx(service.tx, edgeStackID, func(edgeStack *portainer.EdgeStack) {
|
||||||
|
edgeStack.NumDeployments -= len(endpointIDs)
|
||||||
|
}); err != nil {
|
||||||
|
log.Error().Err(err).Msg("could not update the number of deployments")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
// DeleteEndpointRelation deletes an Environment(Endpoint) relation object
|
||||||
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
func (service ServiceTx) DeleteEndpointRelation(endpointID portainer.EndpointID) error {
|
||||||
deletedRelation, _ := service.EndpointRelation(endpointID)
|
deletedRelation, _ := service.EndpointRelation(endpointID)
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ type (
|
|||||||
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
EndpointRelation(EndpointID portainer.EndpointID) (*portainer.EndpointRelation, error)
|
||||||
Create(endpointRelation *portainer.EndpointRelation) error
|
Create(endpointRelation *portainer.EndpointRelation) error
|
||||||
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
UpdateEndpointRelation(EndpointID portainer.EndpointID, endpointRelation *portainer.EndpointRelation) error
|
||||||
|
AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||||
|
RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error
|
||||||
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
DeleteEndpointRelation(EndpointID portainer.EndpointID) error
|
||||||
BucketName() string
|
BucketName() string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *porta
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
args = append(args, "stack", "rm", stack.Name)
|
args = append(args, "stack", "rm", "--detach=false", stack.Name)
|
||||||
|
|
||||||
return runCommandAndCaptureStdErr(command, args, nil, "")
|
return runCommandAndCaptureStdErr(command, args, nil, "")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,10 @@ func deduplicate(dirEntries []DirEntry) []DirEntry {
|
|||||||
|
|
||||||
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
|
// FilterDirForPerDevConfigs filers the given dirEntries, returns entries for the given device
|
||||||
// For given configPath A/B/C, return entries:
|
// For given configPath A/B/C, return entries:
|
||||||
// 1. all entries outside of dir A
|
// 1. all entries outside of dir A/B/C
|
||||||
// 2. dir entries A, A/B, A/B/C
|
// 2. For filterType file:
|
||||||
// 3. For filterType file:
|
|
||||||
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
|
// file entries: A/B/C/<deviceName> and A/B/C/<deviceName>.*
|
||||||
// 4. For filterType dir:
|
// 3. For filterType dir:
|
||||||
// dir entry: A/B/C/<deviceName>
|
// dir entry: A/B/C/<deviceName>
|
||||||
// all entries: A/B/C/<deviceName>/*
|
// all entries: A/B/C/<deviceName>/*
|
||||||
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
|
func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) []DirEntry {
|
||||||
@@ -66,12 +65,7 @@ func FilterDirForPerDevConfigs(dirEntries []DirEntry, deviceName, configPath str
|
|||||||
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
|
func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filterType portainer.PerDevConfigsFilterType) bool {
|
||||||
|
|
||||||
// Include all entries outside of dir A
|
// Include all entries outside of dir A
|
||||||
if !isInConfigRootDir(dirEntry, configPath) {
|
if !isInConfigDir(dirEntry, configPath) {
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include dir entries A, A/B, A/B/C
|
|
||||||
if isParentDir(dirEntry, configPath) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,21 +84,9 @@ func shouldIncludeEntry(dirEntry DirEntry, deviceName, configPath string, filter
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func isInConfigRootDir(dirEntry DirEntry, configPath string) bool {
|
func isInConfigDir(dirEntry DirEntry, configPath string) bool {
|
||||||
// get the first element of the configPath
|
// return true if entry name starts with "A/B"
|
||||||
rootDir := strings.Split(configPath, string(os.PathSeparator))[0]
|
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(configPath))
|
||||||
|
|
||||||
// return true if entry name starts with "A/"
|
|
||||||
return strings.HasPrefix(dirEntry.Name, appendTailSeparator(rootDir))
|
|
||||||
}
|
|
||||||
|
|
||||||
func isParentDir(dirEntry DirEntry, configPath string) bool {
|
|
||||||
if dirEntry.IsFile {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// return true for dir entries A, A/B, A/B/C
|
|
||||||
return strings.HasPrefix(appendTailSeparator(configPath), appendTailSeparator(dirEntry.Name))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
|
func shouldIncludeFile(dirEntry DirEntry, deviceName, configPath string) bool {
|
||||||
|
|||||||
@@ -90,3 +90,24 @@ func TestMultiFilterDirForPerDevConfigs(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsInConfigDir(t *testing.T) {
|
||||||
|
f := func(dirEntry DirEntry, configPath string, expect bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
actual := isInConfigDir(dirEntry, configPath)
|
||||||
|
assert.Equal(t, expect, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
f(DirEntry{Name: "edge-configs"}, "edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edge-configs_backup"}, "edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edge-configs/standalone-edge-agent-standard"}, "edge-configs", true)
|
||||||
|
f(DirEntry{Name: "parent/edge-configs/"}, "edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edgestacktest"}, "edgestacktest/edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edgestacktest/edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edgestacktest/file1.conf"}, "edgestacktest/edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edgeconfigs-test.yaml"}, "edgestacktest/edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edgestacktest/edge-configs"}, "edgestacktest/edge-configs", false)
|
||||||
|
f(DirEntry{Name: "edgestacktest/edge-configs/standalone-edge-agent-async"}, "edgestacktest/edge-configs", true)
|
||||||
|
f(DirEntry{Name: "edgestacktest/edge-configs/abc.txt"}, "edgestacktest/edge-configs", true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -138,57 +138,19 @@ func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edge
|
|||||||
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||||
}
|
}
|
||||||
|
|
||||||
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
|
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||||
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
|
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||||
|
|
||||||
endpointsToRemove := set.Set[portainer.EndpointID]{}
|
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
|
||||||
for endpointID := range oldRelatedSet {
|
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||||
if !newRelatedSet[endpointID] {
|
|
||||||
endpointsToRemove[endpointID] = true
|
if len(relatedEnvironmentsToRemove) > 0 {
|
||||||
}
|
tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStackID)
|
||||||
}
|
}
|
||||||
|
|
||||||
for endpointID := range endpointsToRemove {
|
if len(relatedEnvironmentsToAdd) > 0 {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStackID)
|
||||||
if err != nil {
|
|
||||||
if tx.IsErrObjectNotFound(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(relation.EdgeStacks, edgeStackID)
|
|
||||||
|
|
||||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
|
||||||
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointsToAdd := set.Set[portainer.EndpointID]{}
|
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
|
||||||
for endpointID := range newRelatedSet {
|
|
||||||
if !oldRelatedSet[endpointID] {
|
|
||||||
endpointsToAdd[endpointID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for endpointID := range endpointsToAdd {
|
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
|
||||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
|
||||||
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
|
||||||
}
|
|
||||||
|
|
||||||
if relation == nil {
|
|
||||||
relation = &portainer.EndpointRelation{
|
|
||||||
EndpointID: endpointID,
|
|
||||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
relation.EdgeStacks[edgeStackID] = true
|
|
||||||
|
|
||||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
|
||||||
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return newRelatedEnvironmentIDs, endpointsToAdd, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
edgetypes "github.com/portainer/portainer/api/internal/edge/types"
|
||||||
"github.com/rs/zerolog/log"
|
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
@@ -100,12 +99,15 @@ func (service *Service) PersistEdgeStack(
|
|||||||
stack.ManifestPath = manifestPath
|
stack.ManifestPath = manifestPath
|
||||||
stack.ProjectPath = projectPath
|
stack.ProjectPath = projectPath
|
||||||
stack.EntryPoint = composePath
|
stack.EntryPoint = composePath
|
||||||
stack.NumDeployments = len(relatedEndpointIds)
|
|
||||||
|
|
||||||
if err := tx.EdgeStack().Create(stack.ID, stack); err != nil {
|
if err := tx.EdgeStack().Create(stack.ID, stack); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEndpointIds, stack.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to add endpoint relations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil {
|
if err := service.updateEndpointRelations(tx, stack.ID, relatedEndpointIds); err != nil {
|
||||||
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
|
return nil, fmt.Errorf("unable to update endpoint relations: %w", err)
|
||||||
}
|
}
|
||||||
@@ -148,25 +150,8 @@ func (service *Service) DeleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID
|
|||||||
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
return errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, endpointID := range relatedEndpointIds {
|
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEndpointIds, edgeStackID); err != nil {
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
return errors.WithMessage(err, "unable to remove environment relation in database")
|
||||||
if err != nil {
|
|
||||||
if tx.IsErrObjectNotFound(err) {
|
|
||||||
log.Warn().
|
|
||||||
Int("endpoint_id", int(endpointID)).
|
|
||||||
Msg("Unable to find endpoint relation in database, skipping")
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
return errors.WithMessage(err, "Unable to find environment relation in database")
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(relation.EdgeStacks, edgeStackID)
|
|
||||||
|
|
||||||
if err := tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation); err != nil {
|
|
||||||
return errors.WithMessage(err, "Unable to persist environment relation in database")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
|
if err := tx.EdgeStack().DeleteEdgeStack(edgeStackID); err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"github.com/portainer/portainer/api/dataservices/errors"
|
"github.com/portainer/portainer/api/dataservices/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var _ dataservices.DataStore = &testDatastore{}
|
||||||
|
|
||||||
type testDatastore struct {
|
type testDatastore struct {
|
||||||
customTemplate dataservices.CustomTemplateService
|
customTemplate dataservices.CustomTemplateService
|
||||||
edgeGroup dataservices.EdgeGroupService
|
edgeGroup dataservices.EdgeGroupService
|
||||||
@@ -227,6 +229,30 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointRelationService) AddEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
|
for i, r := range s.relations {
|
||||||
|
if r.EndpointID == endpointID {
|
||||||
|
s.relations[i].EdgeStacks[edgeStackID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointRelationService) RemoveEndpointRelationsForEdgeStack(endpointIDs []portainer.EndpointID, edgeStackID portainer.EdgeStackID) error {
|
||||||
|
for _, endpointID := range endpointIDs {
|
||||||
|
for i, r := range s.relations {
|
||||||
|
if r.EndpointID == endpointID {
|
||||||
|
delete(s.relations[i].EdgeStacks, edgeStackID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
6114
api/swagger.yaml
6114
api/swagger.yaml
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
import { buildImageFullURIFromModel, imageContainsURL } from '@/react/docker/images/utils';
|
import { buildImageFullURIFromModel, imageContainsURL, fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
||||||
|
|
||||||
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
|
angular.module('portainer.docker').factory('ImageHelper', ImageHelperFactory);
|
||||||
function ImageHelperFactory() {
|
function ImageHelperFactory() {
|
||||||
@@ -18,8 +18,12 @@ function ImageHelperFactory() {
|
|||||||
* @param {PorImageRegistryModel} registry
|
* @param {PorImageRegistryModel} registry
|
||||||
*/
|
*/
|
||||||
function createImageConfigForContainer(imageModel) {
|
function createImageConfigForContainer(imageModel) {
|
||||||
|
const fromImage = buildImageFullURIFromModel(imageModel);
|
||||||
|
const { tag, repo } = fullURIIntoRepoAndTag(fromImage);
|
||||||
return {
|
return {
|
||||||
fromImage: buildImageFullURIFromModel(imageModel),
|
fromImage,
|
||||||
|
tag,
|
||||||
|
repo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -207,9 +207,9 @@ angular.module('portainer.docker').controller('ContainerController', [
|
|||||||
async function commitContainerAsync() {
|
async function commitContainerAsync() {
|
||||||
$scope.config.commitInProgress = true;
|
$scope.config.commitInProgress = true;
|
||||||
const registryModel = $scope.config.RegistryModel;
|
const registryModel = $scope.config.RegistryModel;
|
||||||
const imageConfig = ImageHelper.createImageConfigForContainer(registryModel);
|
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||||
try {
|
try {
|
||||||
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo: imageConfig.fromImage });
|
await commitContainer(endpoint.Id, { container: $transition$.params().id, repo, tag });
|
||||||
Notifications.success('Image created', $transition$.params().id);
|
Notifications.success('Image created', $transition$.params().id);
|
||||||
$state.reload();
|
$state.reload();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import _ from 'lodash-es';
|
|||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
|
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
|
||||||
import { confirmDelete } from '@@/modals/confirm';
|
import { confirmDelete } from '@@/modals/confirm';
|
||||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('ImageController', [
|
angular.module('portainer.docker').controller('ImageController', [
|
||||||
'$async',
|
'$async',
|
||||||
@@ -71,8 +70,7 @@ angular.module('portainer.docker').controller('ImageController', [
|
|||||||
$scope.tagImage = function () {
|
$scope.tagImage = function () {
|
||||||
const registryModel = $scope.formValues.RegistryModel;
|
const registryModel = $scope.formValues.RegistryModel;
|
||||||
|
|
||||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
|
||||||
|
|
||||||
ImageService.tagImage($transition$.params().id, repo, tag)
|
ImageService.tagImage($transition$.params().id, repo, tag)
|
||||||
.then(function success() {
|
.then(function success() {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||||
import { fullURIIntoRepoAndTag } from '@/react/docker/images/utils';
|
|
||||||
|
|
||||||
angular.module('portainer.docker').controller('ImportImageController', [
|
angular.module('portainer.docker').controller('ImportImageController', [
|
||||||
'$scope',
|
'$scope',
|
||||||
@@ -34,8 +33,7 @@ angular.module('portainer.docker').controller('ImportImageController', [
|
|||||||
async function tagImage(id) {
|
async function tagImage(id) {
|
||||||
const registryModel = $scope.formValues.RegistryModel;
|
const registryModel = $scope.formValues.RegistryModel;
|
||||||
if (registryModel.Image) {
|
if (registryModel.Image) {
|
||||||
const image = ImageHelper.createImageConfigForContainer(registryModel);
|
const { repo, tag } = ImageHelper.createImageConfigForContainer(registryModel);
|
||||||
const { repo, tag } = fullURIIntoRepoAndTag(image.fromImage);
|
|
||||||
try {
|
try {
|
||||||
await ImageService.tagImage(id, repo, tag);
|
await ImageService.tagImage(id, repo, tag);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,274 +1,281 @@
|
|||||||
<page-header title="'Service details'" breadcrumbs="[{label:'Services', link:'docker.services'}, service.Name]" reload="true"> </page-header>
|
<page-header title="'Service details'" breadcrumbs="[{label:'Services', link:'docker.services'}, service.Name]" reload="true"> </page-header>
|
||||||
|
|
||||||
<div class="row">
|
<div ng-if="!isLoading">
|
||||||
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
|
<div class="row">
|
||||||
<div class="alert alert-info" role="alert" id="service-update-alert">
|
<div ng-if="isUpdating" class="col-lg-12 col-md-12 col-xs-12">
|
||||||
<p>This service is being updated. Editing this service is currently disabled.</p>
|
<div class="alert alert-info" role="alert" id="service-update-alert">
|
||||||
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
|
<p>This service is being updated. Editing this service is currently disabled.</p>
|
||||||
|
<a ui-sref="docker.services.service({id: service.Id}, {reload: true})">Refresh to see if this service has finished updated.</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-lg-9 col-md-9 col-xs-9">
|
||||||
<div class="col-lg-9 col-md-9 col-xs-9">
|
<rd-widget>
|
||||||
<rd-widget>
|
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
|
||||||
<rd-widget-header icon="shuffle" title-text="Service details"></rd-widget-header>
|
<rd-widget-body classes="no-padding">
|
||||||
<rd-widget-body classes="no-padding">
|
<table class="table">
|
||||||
<table class="table">
|
<tbody>
|
||||||
<tbody>
|
<tr>
|
||||||
<tr>
|
<td class="w-1/5">Name</td>
|
||||||
<td class="w-1/5">Name</td>
|
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
|
||||||
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-control"
|
|
||||||
ng-model="service.Name"
|
|
||||||
ng-change="updateServiceAttribute(service, 'Name')"
|
|
||||||
ng-disabled="isUpdating"
|
|
||||||
data-cy="docker-service-edit-name"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>ID</td>
|
|
||||||
<td> {{ service.Id }} </td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="service.CreatedAt">
|
|
||||||
<td>Created at</td>
|
|
||||||
<td>{{ service.CreatedAt | getisodate }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="service.UpdatedAt">
|
|
||||||
<td>Last updated at</td>
|
|
||||||
<td>{{ service.UpdatedAt | getisodate }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="service.Version">
|
|
||||||
<td>Version</td>
|
|
||||||
<td>{{ service.Version }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>Scheduling mode</td>
|
|
||||||
<td>{{ service.Mode }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr ng-if="service.Mode === 'replicated'">
|
|
||||||
<td>Replicas</td>
|
|
||||||
<td>
|
|
||||||
<span ng-if="service.Mode === 'replicated'">
|
|
||||||
<input
|
<input
|
||||||
class="input-sm"
|
type="text"
|
||||||
type="number"
|
class="form-control"
|
||||||
data-cy="docker-service-edit-replicas-input"
|
ng-model="service.Name"
|
||||||
ng-model="service.Replicas"
|
ng-change="updateServiceAttribute(service, 'Name')"
|
||||||
ng-change="updateServiceAttribute(service, 'Replicas')"
|
ng-disabled="isUpdating"
|
||||||
disable-authorization="DockerServiceUpdate"
|
data-cy="docker-service-edit-name"
|
||||||
/>
|
/>
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Image</td>
|
<td>ID</td>
|
||||||
<td>{{ service.Image }}</td>
|
<td> {{ service.Id }} </td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
<tr ng-if="service.CreatedAt">
|
||||||
<td>
|
<td>Created at</td>
|
||||||
<div class="inline-flex items-center">
|
<td>{{ service.CreatedAt | getisodate }}</td>
|
||||||
<div> Service webhook </div>
|
</tr>
|
||||||
<portainer-tooltip
|
<tr ng-if="service.UpdatedAt">
|
||||||
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
|
<td>Last updated at</td>
|
||||||
>
|
<td>{{ service.UpdatedAt | getisodate }}</td>
|
||||||
</portainer-tooltip>
|
</tr>
|
||||||
</div>
|
<tr ng-if="service.Version">
|
||||||
</td>
|
<td>Version</td>
|
||||||
<td>
|
<td>{{ service.Version }}</td>
|
||||||
<div class="flex flex-wrap items-center">
|
</tr>
|
||||||
<por-switch-field label-class="'!mr-0'" checked="WebhookExists" disabled="disabledWebhookButton(WebhookExists)" on-change="(onWebhookChange)"></por-switch-field>
|
<tr>
|
||||||
<span ng-if="webhookURL">
|
<td>Scheduling mode</td>
|
||||||
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
|
<td>{{ service.Mode }}</td>
|
||||||
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
|
</tr>
|
||||||
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
|
<tr ng-if="service.Mode === 'replicated'">
|
||||||
Copy link
|
<td>Replicas</td>
|
||||||
</button>
|
<td>
|
||||||
<span>
|
<span ng-if="service.Mode === 'replicated'">
|
||||||
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
<input
|
||||||
</span>
|
class="input-sm"
|
||||||
|
type="number"
|
||||||
|
data-cy="docker-service-edit-replicas-input"
|
||||||
|
ng-model="service.Replicas"
|
||||||
|
ng-change="updateServiceAttribute(service, 'Replicas')"
|
||||||
|
disable-authorization="DockerServiceUpdate"
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
|
<td>Image</td>
|
||||||
<td colspan="2">
|
<td>{{ service.Image }}</td>
|
||||||
<p class="small text-muted" authorization="DockerServiceUpdate">
|
</tr>
|
||||||
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
<tr ng-if="isAdmin && applicationState.endpoint.type !== 4">
|
||||||
><div class="flex flex-wrap gap-x-2 gap-y-1">
|
<td>
|
||||||
<a
|
<div class="inline-flex items-center">
|
||||||
authorization="DockerServiceLogs"
|
<div> Service webhook </div>
|
||||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
<portainer-tooltip
|
||||||
class="btn btn-primary btn-sm"
|
message="'Webhook (or callback URI) used to automate the update of this service. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this service.'"
|
||||||
type="button"
|
|
||||||
ui-sref="docker.services.service.logs({id: service.Id})"
|
|
||||||
>
|
|
||||||
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
authorization="DockerServiceUpdate"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm !ml-0"
|
|
||||||
ng-disabled="state.updateInProgress || isUpdating"
|
|
||||||
ng-click="forceUpdateService(service)"
|
|
||||||
button-spinner="state.updateInProgress"
|
|
||||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
|
||||||
>
|
|
||||||
<span ng-hide="state.updateInProgress" class="vertical-center">
|
|
||||||
<pr-icon icon="'refresh-cw'"></pr-icon>
|
|
||||||
Update the service</span
|
|
||||||
>
|
>
|
||||||
<span ng-show="state.updateInProgress">Update in progress...</span>
|
</portainer-tooltip>
|
||||||
</button>
|
</div>
|
||||||
<button
|
</td>
|
||||||
authorization="DockerServiceUpdate"
|
<td>
|
||||||
type="button"
|
<div class="flex flex-wrap items-center">
|
||||||
class="btn btn-primary btn-sm !ml-0"
|
<por-switch-field
|
||||||
ng-disabled="state.rollbackInProgress || isUpdating"
|
label-class="'!mr-0'"
|
||||||
ng-click="rollbackService(service)"
|
checked="WebhookExists"
|
||||||
button-spinner="state.rollbackInProgress"
|
disabled="disabledWebhookButton(WebhookExists)"
|
||||||
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
on-change="(onWebhookChange)"
|
||||||
>
|
></por-switch-field>
|
||||||
<span ng-hide="state.rollbackInProgress" class="vertical-center">
|
<span ng-if="webhookURL">
|
||||||
<pr-icon icon="'rotate-ccw'"></pr-icon>
|
<span class="text-muted">{{ webhookURL | truncatelr }}</span>
|
||||||
Rollback the service</span
|
<button type="button" class="btn btn-sm btn-primary btn-sm space-left" ng-if="webhookURL" ng-click="copyWebhook()">
|
||||||
|
<pr-icon icon="'copy'" class-name="'mr-1'"></pr-icon>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
<pr-icon id="copyNotification" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr authorization="DockerServiceLogs, DockerServiceUpdate, DockerServiceDelete">
|
||||||
|
<td colspan="2">
|
||||||
|
<p class="small text-muted" authorization="DockerServiceUpdate">
|
||||||
|
Note: you can only rollback one level of changes. Clicking the rollback button without making a new change will undo your previous rollback </p
|
||||||
|
><div class="flex flex-wrap gap-x-2 gap-y-1">
|
||||||
|
<a
|
||||||
|
authorization="DockerServiceLogs"
|
||||||
|
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
type="button"
|
||||||
|
ui-sref="docker.services.service.logs({id: service.Id})"
|
||||||
>
|
>
|
||||||
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
|
<pr-icon icon="'file-text'"></pr-icon>Service logs</a
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
authorization="DockerServiceDelete"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-danger btn-sm !ml-0"
|
|
||||||
ng-disabled="state.deletionInProgress || isUpdating"
|
|
||||||
ng-click="removeService()"
|
|
||||||
button-spinner="state.deletionInProgress"
|
|
||||||
>
|
|
||||||
<span ng-hide="state.deletionInProgress" class="vertical-center">
|
|
||||||
<pr-icon icon="'trash-2'"></pr-icon>
|
|
||||||
Delete the service</span
|
|
||||||
>
|
>
|
||||||
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
|
<button
|
||||||
</button>
|
authorization="DockerServiceUpdate"
|
||||||
</div>
|
type="button"
|
||||||
</td>
|
class="btn btn-primary btn-sm !ml-0"
|
||||||
</tr>
|
ng-disabled="state.updateInProgress || isUpdating"
|
||||||
</tbody>
|
ng-click="forceUpdateService(service)"
|
||||||
</table>
|
button-spinner="state.updateInProgress"
|
||||||
</rd-widget-body>
|
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
>
|
||||||
<p class="small text-muted">
|
<span ng-hide="state.updateInProgress" class="vertical-center">
|
||||||
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
|
<pr-icon icon="'refresh-cw'"></pr-icon>
|
||||||
</p>
|
Update the service</span
|
||||||
<div class="btn-toolbar" role="toolbar">
|
>
|
||||||
<div class="btn-group" role="group">
|
<span ng-show="state.updateInProgress">Update in progress...</span>
|
||||||
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
|
</button>
|
||||||
>Apply changes</button
|
<button
|
||||||
>
|
authorization="DockerServiceUpdate"
|
||||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
type="button"
|
||||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
class="btn btn-primary btn-sm !ml-0"
|
||||||
</button>
|
ng-disabled="state.rollbackInProgress || isUpdating"
|
||||||
<ul class="dropdown-menu">
|
ng-click="rollbackService(service)"
|
||||||
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
button-spinner="state.rollbackInProgress"
|
||||||
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
ng-if="applicationState.endpoint.apiVersion >= 1.25"
|
||||||
</ul>
|
>
|
||||||
|
<span ng-hide="state.rollbackInProgress" class="vertical-center">
|
||||||
|
<pr-icon icon="'rotate-ccw'"></pr-icon>
|
||||||
|
Rollback the service</span
|
||||||
|
>
|
||||||
|
<span ng-show="state.rollbackInProgress">Rollback in progress...</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
authorization="DockerServiceDelete"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-danger btn-sm !ml-0"
|
||||||
|
ng-disabled="state.deletionInProgress || isUpdating"
|
||||||
|
ng-click="removeService()"
|
||||||
|
button-spinner="state.deletionInProgress"
|
||||||
|
>
|
||||||
|
<span ng-hide="state.deletionInProgress" class="vertical-center">
|
||||||
|
<pr-icon icon="'trash-2'"></pr-icon>
|
||||||
|
Delete the service</span
|
||||||
|
>
|
||||||
|
<span ng-show="state.deletionInProgress">Deletion in progress...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</rd-widget-body>
|
||||||
|
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||||
|
<p class="small text-muted">
|
||||||
|
Do you need help? View the Docker Service documentation <a href="https://docs.docker.com/engine/reference/commandline/service_update/" target="self">here</a>.
|
||||||
|
</p>
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(service, ['Mode', 'Replicas', 'Name', 'Webhooks'])" ng-click="updateService(service)"
|
||||||
|
>Apply changes</button
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
||||||
|
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</rd-widget-footer>
|
||||||
</rd-widget-footer>
|
</rd-widget>
|
||||||
</rd-widget>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-3 col-md-3 col-xs-3">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
||||||
|
<rd-widget-body classes="no-padding">
|
||||||
|
<ul class="nav nav-pills nav-stacked">
|
||||||
|
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
||||||
|
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
||||||
|
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
||||||
|
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
||||||
|
</ul>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-3 col-md-3 col-xs-3">
|
<!-- access-control-panel -->
|
||||||
<rd-widget>
|
<access-control-panel
|
||||||
<rd-widget-header icon="menu" title-text="Quick navigation"></rd-widget-header>
|
ng-if="service"
|
||||||
<rd-widget-body classes="no-padding">
|
resource-id="service.Id"
|
||||||
<ul class="nav nav-pills nav-stacked">
|
resource-control="service.ResourceControl"
|
||||||
<li><a href ng-click="goToItem('service-env-variables')">Environment variables</a></li>
|
resource-type="resourceType"
|
||||||
<li><a href ng-click="goToItem('service-container-image')">Container image</a></li>
|
on-update-success="(onUpdateResourceControlSuccess)"
|
||||||
<li><a href ng-click="goToItem('service-container-labels')">Container labels</a></li>
|
environment-id="endpoint.Id"
|
||||||
<li><a href ng-click="goToItem('service-mounts')">Mounts</a></li>
|
>
|
||||||
<li><a href ng-click="goToItem('service-network-specs')">Network & published ports</a></li>
|
</access-control-panel>
|
||||||
<li><a href ng-click="goToItem('service-resources')">Resource limits & reservations</a></li>
|
<!-- !access-control-panel -->
|
||||||
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
|
|
||||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.3"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
|
<div class="row">
|
||||||
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
|
<hr />
|
||||||
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
|
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||||
<li><a href ng-click="goToItem('service-logging')">Logging</a></li>
|
<h3 id="container-specs">Container specification</h3>
|
||||||
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
|
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
||||||
<li><a href ng-click="goToItem('service-configs')">Configs</a></li>
|
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
||||||
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
|
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
||||||
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
|
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
||||||
</ul>
|
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
||||||
</rd-widget-body>
|
</div>
|
||||||
</rd-widget>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<hr />
|
||||||
|
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||||
|
<h3 id="service-network-specs">Networks & ports</h3>
|
||||||
|
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||||
|
|
||||||
|
<docker-service-ports-mapping-field
|
||||||
|
id="service-published-ports"
|
||||||
|
class="block padding-top"
|
||||||
|
values="formValues.ports"
|
||||||
|
on-change="(onChangePorts)"
|
||||||
|
has-changes="hasChanges(service, ['Ports'])"
|
||||||
|
on-reset="(onResetPorts)"
|
||||||
|
on-submit="(onSubmit)"
|
||||||
|
></docker-service-ports-mapping-field>
|
||||||
|
|
||||||
|
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<hr />
|
||||||
|
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||||
|
<h3 id="service-specs">Service specification</h3>
|
||||||
|
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
||||||
|
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
||||||
|
<div
|
||||||
|
id="service-placement-preferences"
|
||||||
|
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
||||||
|
class="padding-top"
|
||||||
|
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
||||||
|
></div>
|
||||||
|
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
||||||
|
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
||||||
|
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
||||||
|
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
||||||
|
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
||||||
|
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- access-control-panel -->
|
|
||||||
<access-control-panel
|
|
||||||
ng-if="service"
|
|
||||||
resource-id="service.Id"
|
|
||||||
resource-control="service.ResourceControl"
|
|
||||||
resource-type="resourceType"
|
|
||||||
on-update-success="(onUpdateResourceControlSuccess)"
|
|
||||||
environment-id="endpoint.Id"
|
|
||||||
>
|
|
||||||
</access-control-panel>
|
|
||||||
<!-- !access-control-panel -->
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<hr />
|
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
|
||||||
<h3 id="container-specs">Container specification</h3>
|
|
||||||
<div id="service-container-spec" class="padding-top" ng-include="'app/docker/views/services/edit/includes/container-specs.html'"></div>
|
|
||||||
<div id="service-container-image" class="padding-top" ng-include="'app/docker/views/services/edit/includes/image.html'"></div>
|
|
||||||
<div id="service-env-variables" class="padding-top" ng-include="'app/docker/views/services/edit/includes/environmentvariables.html'"></div>
|
|
||||||
<div id="service-container-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/containerlabels.html'"></div>
|
|
||||||
<div id="service-mounts" class="padding-top" ng-include="'app/docker/views/services/edit/includes/mounts.html'"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<hr />
|
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
|
||||||
<h3 id="service-network-specs">Networks & ports</h3>
|
|
||||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
|
||||||
|
|
||||||
<docker-service-ports-mapping-field
|
|
||||||
id="service-published-ports"
|
|
||||||
class="block padding-top"
|
|
||||||
values="formValues.ports"
|
|
||||||
on-change="(onChangePorts)"
|
|
||||||
has-changes="hasChanges(service, ['Ports'])"
|
|
||||||
on-reset="(onResetPorts)"
|
|
||||||
on-submit="(onSubmit)"
|
|
||||||
></docker-service-ports-mapping-field>
|
|
||||||
|
|
||||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<hr />
|
|
||||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
|
||||||
<h3 id="service-specs">Service specification</h3>
|
|
||||||
<div id="service-resources" class="padding-top" ng-include="'app/docker/views/services/edit/includes/resources.html'"></div>
|
|
||||||
<div id="service-placement-constraints" class="padding-top" ng-include="'app/docker/views/services/edit/includes/constraints.html'"></div>
|
|
||||||
<div
|
|
||||||
id="service-placement-preferences"
|
|
||||||
ng-if="applicationState.endpoint.apiVersion >= 1.3"
|
|
||||||
class="padding-top"
|
|
||||||
ng-include="'app/docker/views/services/edit/includes/placementPreferences.html'"
|
|
||||||
></div>
|
|
||||||
<div id="service-restart-policy" class="padding-top" ng-include="'app/docker/views/services/edit/includes/restart.html'"></div>
|
|
||||||
<div id="service-update-config" class="padding-top" ng-include="'app/docker/views/services/edit/includes/updateconfig.html'"></div>
|
|
||||||
<div id="service-logging" class="padding-top" ng-include="'app/docker/views/services/edit/includes/logging.html'"></div>
|
|
||||||
<div id="service-labels" class="padding-top" ng-include="'app/docker/views/services/edit/includes/servicelabels.html'"></div>
|
|
||||||
<div id="service-configs" class="padding-top" ng-include="'app/docker/views/services/edit/includes/configs.html'"></div>
|
|
||||||
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/docker/views/services/edit/includes/secrets.html'"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="service-tasks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/tasks.html'"></div>
|
|
||||||
|
|||||||
@@ -731,6 +731,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
|||||||
};
|
};
|
||||||
|
|
||||||
function initView() {
|
function initView() {
|
||||||
|
$scope.isLoading = true;
|
||||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||||
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
|
||||||
|
|
||||||
@@ -855,6 +856,9 @@ angular.module('portainer.docker').controller('ServiceController', [
|
|||||||
$scope.secrets = [];
|
$scope.secrets = [];
|
||||||
$scope.configs = [];
|
$scope.configs = [];
|
||||||
Notifications.error('Failure', err, 'Unable to retrieve service details');
|
Notifications.error('Failure', err, 'Unable to retrieve service details');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
$scope.isLoading = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,37 +31,31 @@
|
|||||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
||||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
||||||
>
|
>
|
||||||
<div class="w-full">
|
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
|
||||||
<div class="small text-muted mb-2"
|
<div class="mt-0.5 shrink-0">
|
||||||
>Select the Helm chart to use. Bring further Helm charts into your selection list via
|
<svg
|
||||||
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
width="24"
|
||||||
<div class="relative flex w-fit gap-1 rounded-lg bg-gray-modern-3 p-4 text-sm th-highcontrast:bg-legacy-grey-3 th-dark:bg-legacy-grey-3 mt-2">
|
height="24"
|
||||||
<div class="mt-0.5 shrink-0">
|
viewBox="0 0 24 24"
|
||||||
<svg
|
fill="none"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
stroke="currentColor"
|
||||||
width="24"
|
stroke-width="2"
|
||||||
height="24"
|
stroke-linecap="round"
|
||||||
viewBox="0 0 24 24"
|
stroke-linejoin="round"
|
||||||
fill="none"
|
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
|
||||||
stroke="currentColor"
|
>
|
||||||
stroke-width="2"
|
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
|
||||||
stroke-linecap="round"
|
<path d="M9 18h6"></path>
|
||||||
stroke-linejoin="round"
|
<path d="M10 22h4"></path>
|
||||||
class="lucide lucide-lightbulb h-4 text-warning-7 th-highcontrast:text-warning-6 th-dark:text-warning-6"
|
</svg>
|
||||||
>
|
</div>
|
||||||
<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path>
|
<div>
|
||||||
<path d="M9 18h6"></path>
|
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
|
||||||
<path d="M10 22h4"></path>
|
<div class="small">
|
||||||
</svg>
|
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
|
||||||
</div>
|
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
|
||||||
<div>
|
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
|
||||||
<p class="align-middle text-[0.9em] font-medium pr-10 mb-2">Disclaimer</p>
|
|
||||||
<div class="small">
|
|
||||||
At present Portainer does not support OCI format Helm charts. Support for OCI charts will be available in a future release.<br />
|
|
||||||
If you would like to provide feedback on OCI support or get access to early releases to test this functionality,
|
|
||||||
<a href="https://bit.ly/3WVkayl" target="_blank" rel="noopener noreferrer">please get in touch</a>.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,7 +69,7 @@
|
|||||||
on-select="($ctrl.selectAction)"
|
on-select="($ctrl.selectAction)"
|
||||||
>
|
>
|
||||||
</helm-templates-list-item>
|
</helm-templates-list-item>
|
||||||
<div ng-if="!allCharts.length" class="text-muted small mt-4"> No Helm charts found </div>
|
<div ng-if="!$ctrl.loading && !allCharts.length && $ctrl.charts.length !== 0" class="text-muted small mt-4"> No Helm charts found </div>
|
||||||
<div ng-if="$ctrl.loading" class="text-muted text-center">
|
<div ng-if="$ctrl.loading" class="text-muted text-center">
|
||||||
Loading...
|
Loading...
|
||||||
<div class="text-muted text-center"> Initial download of Helm charts can take a few minutes </div>
|
<div class="text-muted text-center"> Initial download of Helm charts can take a few minutes </div>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { VolumesView } from '@/react/kubernetes/volumes/ListView/VolumesView';
|
|||||||
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
import { NamespaceView } from '@/react/kubernetes/namespaces/ItemView/NamespaceView';
|
||||||
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
import { AccessView } from '@/react/kubernetes/namespaces/AccessView/AccessView';
|
||||||
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
import { JobsView } from '@/react/kubernetes/more-resources/JobsView/JobsView';
|
||||||
|
import { ClusterView } from '@/react/kubernetes/cluster/ClusterView';
|
||||||
|
import { HelmApplicationView } from '@/react/kubernetes/helm/HelmApplicationView';
|
||||||
|
|
||||||
export const viewsModule = angular
|
export const viewsModule = angular
|
||||||
.module('portainer.kubernetes.react.views', [])
|
.module('portainer.kubernetes.react.views', [])
|
||||||
@@ -78,6 +80,14 @@ export const viewsModule = angular
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.component(
|
||||||
|
'kubernetesHelmApplicationView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(HelmApplicationView))), [])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'kubernetesClusterView',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ClusterView))), [])
|
||||||
|
)
|
||||||
.component(
|
.component(
|
||||||
'kubernetesConfigureView',
|
'kubernetesConfigureView',
|
||||||
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
r2a(withUIRouter(withReactQuery(withCurrentUser(ConfigureView))), [])
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import PortainerError from 'Portainer/error';
|
|
||||||
|
|
||||||
export default class KubernetesHelmApplicationController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async, $state, Authentication, Notifications, HelmService) {
|
|
||||||
this.$async = $async;
|
|
||||||
this.$state = $state;
|
|
||||||
this.Authentication = Authentication;
|
|
||||||
this.Notifications = Notifications;
|
|
||||||
this.HelmService = HelmService;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* APPLICATION
|
|
||||||
*/
|
|
||||||
async getHelmApplication() {
|
|
||||||
try {
|
|
||||||
this.state.dataLoading = true;
|
|
||||||
const releases = await this.HelmService.listReleases(this.endpoint.Id, { filter: `^${this.state.params.name}$`, namespace: this.state.params.namespace });
|
|
||||||
if (releases.length > 0) {
|
|
||||||
this.state.release = releases[0];
|
|
||||||
} else {
|
|
||||||
throw new PortainerError(`Release ${this.state.params.name} not found`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve helm application details');
|
|
||||||
} finally {
|
|
||||||
this.state.dataLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
return this.$async(async () => {
|
|
||||||
this.state = {
|
|
||||||
dataLoading: true,
|
|
||||||
viewReady: false,
|
|
||||||
params: {
|
|
||||||
name: this.$state.params.name,
|
|
||||||
namespace: this.$state.params.namespace,
|
|
||||||
},
|
|
||||||
release: {
|
|
||||||
name: undefined,
|
|
||||||
chart: undefined,
|
|
||||||
app_version: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.getHelmApplication();
|
|
||||||
this.state.viewReady = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
.release-table tr {
|
|
||||||
display: grid;
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-template-columns: 1fr 4fr;
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<page-header
|
|
||||||
ng-if="$ctrl.state.viewReady"
|
|
||||||
title="'Helm details'"
|
|
||||||
breadcrumbs="[{label:'Applications', link:'kubernetes.applications'}, $ctrl.state.params.name]"
|
|
||||||
reload="true"
|
|
||||||
></page-header>
|
|
||||||
|
|
||||||
<kubernetes-view-loading view-ready="$ctrl.state.viewReady"></kubernetes-view-loading>
|
|
||||||
|
|
||||||
<div ng-if="$ctrl.state.viewReady">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<div class="toolBar vertical-center w-full flex-wrap !gap-x-5 !gap-y-1 p-5">
|
|
||||||
<div class="toolBarTitle vertical-center">
|
|
||||||
<div class="widget-icon space-right">
|
|
||||||
<pr-icon icon="'svg-helm'"></pr-icon>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
Release
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<rd-widget-body>
|
|
||||||
<table class="table">
|
|
||||||
<tbody class="release-table">
|
|
||||||
<tr>
|
|
||||||
<td class="vertical-center">Name</td>
|
|
||||||
<td class="vertical-center !p-2" data-cy="k8sAppDetail-appName">
|
|
||||||
{{ $ctrl.state.release.name }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="vertical-center">Chart</td>
|
|
||||||
<td class="vertical-center !p-2">
|
|
||||||
{{ $ctrl.state.release.chart }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="vertical-center">App version</td>
|
|
||||||
<td class="vertical-center !p-2">
|
|
||||||
{{ $ctrl.state.release.app_version }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import angular from 'angular';
|
|
||||||
import controller from './helm.controller';
|
|
||||||
import './helm.css';
|
|
||||||
|
|
||||||
angular.module('portainer.kubernetes').component('kubernetesHelmApplicationView', {
|
|
||||||
templateUrl: './helm.html',
|
|
||||||
controller,
|
|
||||||
bindings: {
|
|
||||||
endpoint: '<',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<page-header ng-if="ctrl.state.viewReady" title="'Cluster'" breadcrumbs="['Cluster information']" reload="true"></page-header>
|
|
||||||
|
|
||||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
|
||||||
|
|
||||||
<div ng-if="ctrl.state.viewReady">
|
|
||||||
<div class="row" ng-if="ctrl.isAdmin">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<!-- resource-reservation -->
|
|
||||||
<form class="form-horizontal" ng-if="ctrl.resourceReservation">
|
|
||||||
<kubernetes-resource-reservation
|
|
||||||
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
|
||||||
cpu-reservation="ctrl.resourceReservation.CPU"
|
|
||||||
cpu-limit="ctrl.CPULimit"
|
|
||||||
memory-reservation="ctrl.resourceReservation.Memory"
|
|
||||||
memory-limit="ctrl.MemoryLimit"
|
|
||||||
display-usage="ctrl.hasResourceUsageAccess()"
|
|
||||||
cpu-usage="ctrl.resourceUsage.CPU"
|
|
||||||
memory-usage="ctrl.resourceUsage.Memory"
|
|
||||||
>
|
|
||||||
</kubernetes-resource-reservation>
|
|
||||||
</form>
|
|
||||||
<!-- !resource-reservation -->
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<kube-nodes-datatable></kube-nodes-datatable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
angular.module('portainer.kubernetes').component('kubernetesClusterView', {
|
|
||||||
templateUrl: './cluster.html',
|
|
||||||
controller: 'KubernetesClusterController',
|
|
||||||
controllerAs: 'ctrl',
|
|
||||||
bindings: {
|
|
||||||
endpoint: '<',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import angular from 'angular';
|
|
||||||
import _ from 'lodash-es';
|
|
||||||
import filesizeParser from 'filesize-parser';
|
|
||||||
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
|
|
||||||
import { KubernetesResourceReservation } from 'Kubernetes/models/resource-reservation/models';
|
|
||||||
import { getMetricsForAllNodes, getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics.ts';
|
|
||||||
|
|
||||||
class KubernetesClusterController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($async, $state, Notifications, LocalStorage, Authentication, KubernetesNodeService, KubernetesApplicationService, KubernetesEndpointService, EndpointService) {
|
|
||||||
this.$async = $async;
|
|
||||||
this.$state = $state;
|
|
||||||
this.Authentication = Authentication;
|
|
||||||
this.Notifications = Notifications;
|
|
||||||
this.LocalStorage = LocalStorage;
|
|
||||||
this.KubernetesNodeService = KubernetesNodeService;
|
|
||||||
this.KubernetesApplicationService = KubernetesApplicationService;
|
|
||||||
this.KubernetesEndpointService = KubernetesEndpointService;
|
|
||||||
this.EndpointService = EndpointService;
|
|
||||||
|
|
||||||
this.onInit = this.onInit.bind(this);
|
|
||||||
this.getNodes = this.getNodes.bind(this);
|
|
||||||
this.getNodesAsync = this.getNodesAsync.bind(this);
|
|
||||||
this.getApplicationsAsync = this.getApplicationsAsync.bind(this);
|
|
||||||
this.getEndpointsAsync = this.getEndpointsAsync.bind(this);
|
|
||||||
this.hasResourceUsageAccess = this.hasResourceUsageAccess.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEndpointsAsync() {
|
|
||||||
try {
|
|
||||||
const endpoints = await this.KubernetesEndpointService.get();
|
|
||||||
const systemEndpoints = _.filter(endpoints, { Namespace: 'kube-system' });
|
|
||||||
this.systemEndpoints = _.filter(systemEndpoints, (ep) => ep.HolderIdentity);
|
|
||||||
|
|
||||||
const kubernetesEndpoint = _.find(endpoints, { Name: 'kubernetes' });
|
|
||||||
if (kubernetesEndpoint && kubernetesEndpoint.Subsets) {
|
|
||||||
const ips = _.flatten(_.map(kubernetesEndpoint.Subsets, 'Ips'));
|
|
||||||
_.forEach(this.nodes, (node) => {
|
|
||||||
node.Api = _.includes(ips, node.IPAddress);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve environments');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getEndpoints() {
|
|
||||||
return this.$async(this.getEndpointsAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getNodesAsync() {
|
|
||||||
try {
|
|
||||||
const nodes = await this.KubernetesNodeService.get();
|
|
||||||
_.forEach(nodes, (node) => (node.Memory = filesizeParser(node.Memory)));
|
|
||||||
this.nodes = nodes;
|
|
||||||
this.CPULimit = _.reduce(this.nodes, (acc, node) => node.CPU + acc, 0);
|
|
||||||
this.CPULimit = Math.round(this.CPULimit * 10000) / 10000;
|
|
||||||
this.MemoryLimit = _.reduce(this.nodes, (acc, node) => KubernetesResourceReservationHelper.megaBytesValue(node.Memory) + acc, 0);
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve nodes');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getNodes() {
|
|
||||||
return this.$async(this.getNodesAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getApplicationsAsync() {
|
|
||||||
try {
|
|
||||||
this.state.applicationsLoading = true;
|
|
||||||
|
|
||||||
const applicationsResources = await getTotalResourcesForAllApplications(this.endpoint.Id);
|
|
||||||
this.resourceReservation = new KubernetesResourceReservation();
|
|
||||||
this.resourceReservation.CPU = Math.round(applicationsResources.CpuRequest / 1000);
|
|
||||||
this.resourceReservation.Memory = KubernetesResourceReservationHelper.megaBytesValue(applicationsResources.MemoryRequest);
|
|
||||||
|
|
||||||
if (this.hasResourceUsageAccess()) {
|
|
||||||
await this.getResourceUsage(this.endpoint.Id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve applications');
|
|
||||||
} finally {
|
|
||||||
this.state.applicationsLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getApplications() {
|
|
||||||
return this.$async(this.getApplicationsAsync);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getResourceUsage(endpointId) {
|
|
||||||
try {
|
|
||||||
const nodeMetrics = await getMetricsForAllNodes(endpointId);
|
|
||||||
const resourceUsageList = nodeMetrics.items.map((i) => i.usage);
|
|
||||||
const clusterResourceUsage = resourceUsageList.reduce((total, u) => {
|
|
||||||
total.CPU += KubernetesResourceReservationHelper.parseCPU(u.cpu);
|
|
||||||
total.Memory += KubernetesResourceReservationHelper.megaBytesValue(u.memory);
|
|
||||||
return total;
|
|
||||||
}, new KubernetesResourceReservation());
|
|
||||||
this.resourceUsage = clusterResourceUsage;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve cluster resource usage');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if resource usage stats can be displayed
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
hasResourceUsageAccess() {
|
|
||||||
return this.isAdmin && this.state.useServerMetrics;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onInit() {
|
|
||||||
this.endpoint = await this.EndpointService.endpoint(this.endpoint.Id);
|
|
||||||
this.isAdmin = this.Authentication.isAdmin();
|
|
||||||
const useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
applicationsLoading: true,
|
|
||||||
viewReady: false,
|
|
||||||
useServerMetrics,
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.getNodes();
|
|
||||||
if (this.isAdmin) {
|
|
||||||
await Promise.allSettled([this.getEndpoints(), this.getApplicationsAsync()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.viewReady = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
return this.$async(this.onInit);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KubernetesClusterController;
|
|
||||||
angular.module('portainer.kubernetes').controller('KubernetesClusterController', KubernetesClusterController);
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
// Re-exporting so we don't have to update one meeeeellion files that are already importing these
|
||||||
return [1, -1].includes(Number(val)) ? word : plural;
|
// functions from here.
|
||||||
}
|
export {
|
||||||
|
pluralize,
|
||||||
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
addPlural,
|
||||||
return `${value} ${pluralize(value, word, plural)}`;
|
grammaticallyJoin,
|
||||||
}
|
} from '@/react/common/string-utils';
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
|
import { QueryObserverResult } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { Team } from '@/react/portainer/users/teams/types';
|
import { Team } from '@/react/portainer/users/teams/types';
|
||||||
import { Role, User, UserId } from '@/portainer/users/types';
|
import { Role, User, UserId } from '@/portainer/users/types';
|
||||||
@@ -134,3 +135,38 @@ export function createMockEnvironment(): Environment {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createMockQueryResult<TData, TError = unknown>(
|
||||||
|
data: TData,
|
||||||
|
overrides?: Partial<QueryObserverResult<TData, TError>>
|
||||||
|
) {
|
||||||
|
const defaultResult = {
|
||||||
|
data,
|
||||||
|
dataUpdatedAt: 0,
|
||||||
|
error: null,
|
||||||
|
errorUpdatedAt: 0,
|
||||||
|
failureCount: 0,
|
||||||
|
errorUpdateCount: 0,
|
||||||
|
failureReason: null,
|
||||||
|
isError: false,
|
||||||
|
isFetched: true,
|
||||||
|
isFetchedAfterMount: true,
|
||||||
|
isFetching: false,
|
||||||
|
isInitialLoading: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingError: false,
|
||||||
|
isPaused: false,
|
||||||
|
isPlaceholderData: false,
|
||||||
|
isPreviousData: false,
|
||||||
|
isRefetchError: false,
|
||||||
|
isRefetching: false,
|
||||||
|
isStale: false,
|
||||||
|
isSuccess: true,
|
||||||
|
refetch: async () => defaultResult,
|
||||||
|
remove: () => {},
|
||||||
|
status: 'success',
|
||||||
|
fetchStatus: 'idle',
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...defaultResult, ...overrides };
|
||||||
|
}
|
||||||
|
|||||||
27
app/react/common/string-utils.ts
Normal file
27
app/react/common/string-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export function capitalize(s: string) {
|
||||||
|
return s.slice(0, 1).toUpperCase() + s.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pluralize(val: number, word: string, plural = `${word}s`) {
|
||||||
|
return [1, -1].includes(Number(val)) ? word : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPlural(value: number, word: string, plural = `${word}s`) {
|
||||||
|
return `${value} ${pluralize(value, word, plural)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Joins an array of strings into a grammatically correct sentence.
|
||||||
|
*/
|
||||||
|
export function grammaticallyJoin(
|
||||||
|
values: string[],
|
||||||
|
separator = ', ',
|
||||||
|
lastSeparator = ' and '
|
||||||
|
) {
|
||||||
|
if (values.length === 0) return '';
|
||||||
|
if (values.length === 1) return values[0];
|
||||||
|
|
||||||
|
const allButLast = values.slice(0, -1);
|
||||||
|
const last = values[values.length - 1];
|
||||||
|
return `${allButLast.join(separator)}${lastSeparator}${last}`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { render, screen, fireEvent } from '@testing-library/react';
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
createColumnHelper,
|
createColumnHelper,
|
||||||
@@ -170,7 +171,7 @@ describe('Datatable', () => {
|
|||||||
fireEvent.click(selectAllCheckbox);
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
|
||||||
// Check if all rows on the page are selected
|
// Check if all rows on the page are selected
|
||||||
expect(screen.getByText('2 item(s) selected')).toBeInTheDocument();
|
expect(screen.getByText('2 items selected')).toBeInTheDocument();
|
||||||
|
|
||||||
// Deselect
|
// Deselect
|
||||||
fireEvent.click(selectAllCheckbox);
|
fireEvent.click(selectAllCheckbox);
|
||||||
@@ -192,13 +193,44 @@ describe('Datatable', () => {
|
|||||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||||
|
|
||||||
// Check if all rows on the page are selected
|
// Check if all rows on the page are selected
|
||||||
expect(screen.getByText('3 item(s) selected')).toBeInTheDocument();
|
expect(screen.getByText('3 items selected')).toBeInTheDocument();
|
||||||
|
|
||||||
// Deselect
|
// Deselect
|
||||||
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
fireEvent.click(selectAllCheckbox, { shiftKey: true });
|
||||||
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
const checkboxes: HTMLInputElement[] = screen.queryAllByRole('checkbox');
|
||||||
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
expect(checkboxes.filter((checkbox) => checkbox.checked).length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows indeterminate state and correct footer text when hidden rows are selected', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(
|
||||||
|
<DatatableWithStore
|
||||||
|
dataset={mockData}
|
||||||
|
columns={mockColumns}
|
||||||
|
data-cy="test-table"
|
||||||
|
title="Test table with search"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Select Jane
|
||||||
|
const checkboxes = screen.getAllByRole('checkbox');
|
||||||
|
await user.click(checkboxes[2]); // Select the second row
|
||||||
|
|
||||||
|
// Search for John (will hide selected Jane)
|
||||||
|
const searchInput = screen.getByPlaceholderText('Search...');
|
||||||
|
await user.type(searchInput, 'John');
|
||||||
|
|
||||||
|
// Check if the footer text is correct
|
||||||
|
expect(
|
||||||
|
await screen.findByText('1 item selected (1 hidden by filters)')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check if the checkbox is indeterminate
|
||||||
|
const selectAllCheckbox: HTMLInputElement =
|
||||||
|
screen.getByLabelText('Select all rows');
|
||||||
|
expect(selectAllCheckbox.indeterminate).toBe(true);
|
||||||
|
expect(selectAllCheckbox.checked).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test the defaultGlobalFilterFn used in searches
|
// Test the defaultGlobalFilterFn used in searches
|
||||||
|
|||||||
@@ -171,6 +171,14 @@ export function Datatable<D extends DefaultType>({
|
|||||||
|
|
||||||
const selectedRowModel = tableInstance.getSelectedRowModel();
|
const selectedRowModel = tableInstance.getSelectedRowModel();
|
||||||
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
const selectedItems = selectedRowModel.rows.map((row) => row.original);
|
||||||
|
const filteredItems = tableInstance
|
||||||
|
.getFilteredRowModel()
|
||||||
|
.rows.map((row) => row.original);
|
||||||
|
|
||||||
|
const hiddenSelectedItems = useMemo(
|
||||||
|
() => _.difference(selectedItems, filteredItems),
|
||||||
|
[selectedItems, filteredItems]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Container noWidget={noWidget} aria-label={title}>
|
<Table.Container noWidget={noWidget} aria-label={title}>
|
||||||
@@ -203,6 +211,7 @@ export function Datatable<D extends DefaultType>({
|
|||||||
pageSize={tableState.pagination.pageSize}
|
pageSize={tableState.pagination.pageSize}
|
||||||
pageCount={tableInstance.getPageCount()}
|
pageCount={tableInstance.getPageCount()}
|
||||||
totalSelected={selectedItems.length}
|
totalSelected={selectedItems.length}
|
||||||
|
totalHiddenSelected={hiddenSelectedItems.length}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { SelectedRowsCount } from './SelectedRowsCount';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
totalSelected: number;
|
totalSelected: number;
|
||||||
|
totalHiddenSelected: number;
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
page: number;
|
page: number;
|
||||||
onPageChange(page: number): void;
|
onPageChange(page: number): void;
|
||||||
@@ -14,6 +15,7 @@ interface Props {
|
|||||||
|
|
||||||
export function DatatableFooter({
|
export function DatatableFooter({
|
||||||
totalSelected,
|
totalSelected,
|
||||||
|
totalHiddenSelected,
|
||||||
pageSize,
|
pageSize,
|
||||||
page,
|
page,
|
||||||
onPageChange,
|
onPageChange,
|
||||||
@@ -22,7 +24,7 @@ export function DatatableFooter({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Table.Footer>
|
<Table.Footer>
|
||||||
<SelectedRowsCount value={totalSelected} />
|
<SelectedRowsCount value={totalSelected} hidden={totalHiddenSelected} />
|
||||||
<PaginationControls
|
<PaginationControls
|
||||||
showAll
|
showAll
|
||||||
pageLimit={pageSize}
|
pageLimit={pageSize}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import { addPlural } from '@/react/common/string-utils';
|
||||||
|
|
||||||
interface SelectedRowsCountProps {
|
interface SelectedRowsCountProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
hidden: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectedRowsCount({ value }: SelectedRowsCountProps) {
|
export function SelectedRowsCount({ value, hidden }: SelectedRowsCountProps) {
|
||||||
return value !== 0 ? (
|
return value !== 0 ? (
|
||||||
<div className="infoBar">{value} item(s) selected</div>
|
<div className="infoBar">
|
||||||
|
{addPlural(value, 'item')} selected
|
||||||
|
{hidden !== 0 && ` (${hidden} hidden by filters)`}
|
||||||
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { ColumnDef, Row } from '@tanstack/react-table';
|
import { ColumnDef, Row, Table } from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Checkbox } from '@@/form-components/Checkbox';
|
import { Checkbox } from '@@/form-components/Checkbox';
|
||||||
|
|
||||||
|
function allRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getCoreRowModel().rows.every((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function someRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getCoreRowModel().rows.some((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
|
function somePageRowsSelected<T>(table: Table<T>) {
|
||||||
|
return table.getRowModel().rows.some((row) => row.getIsSelected());
|
||||||
|
}
|
||||||
|
|
||||||
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
||||||
let lastSelectedId = '';
|
let lastSelectedId = '';
|
||||||
|
|
||||||
@@ -11,15 +23,15 @@ export function createSelectColumn<T>(dataCy: string): ColumnDef<T> {
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id="select-all"
|
id="select-all"
|
||||||
data-cy={`select-all-checkbox-${dataCy}`}
|
data-cy={`select-all-checkbox-${dataCy}`}
|
||||||
checked={table.getIsAllPageRowsSelected()}
|
checked={allRowsSelected(table)}
|
||||||
indeterminate={table.getIsSomeRowsSelected()}
|
indeterminate={!allRowsSelected(table) && someRowsSelected(table)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
// Select all rows if shift key is held down, otherwise only page rows
|
// Select all rows if shift key is held down, otherwise only page rows
|
||||||
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
if (e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey) {
|
||||||
table.getToggleAllRowsSelectedHandler()(e);
|
table.toggleAllRowsSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
table.getToggleAllPageRowsSelectedHandler()(e);
|
table.toggleAllPageRowsSelected(!somePageRowsSelected(table));
|
||||||
}}
|
}}
|
||||||
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
disabled={table.getRowModel().rows.every((row) => !row.getCanSelect())}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
|||||||
resolvedRef = defaultRef;
|
resolvedRef = defaultRef;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Need to check this on every render as the browser will always set the element's
|
||||||
|
// indeterminate state to false when the checkbox is clicked, even if the indeterminate prop hasn't changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resolvedRef === null || resolvedRef.current === null) {
|
if (resolvedRef === null || resolvedRef.current === null) {
|
||||||
return;
|
return;
|
||||||
@@ -50,7 +52,7 @@ export const Checkbox = forwardRef<HTMLInputElement, Props>(
|
|||||||
if (typeof indeterminate !== 'undefined') {
|
if (typeof indeterminate !== 'undefined') {
|
||||||
resolvedRef.current.indeterminate = indeterminate;
|
resolvedRef.current.indeterminate = indeterminate;
|
||||||
}
|
}
|
||||||
}, [resolvedRef, indeterminate]);
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="md-checkbox flex items-center" title={title || label}>
|
<div className="md-checkbox flex items-center" title={title || label}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ComponentProps, PropsWithChildren, ReactNode } from 'react';
|
import { PropsWithChildren, ReactNode } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { Tooltip } from '@@/Tip/Tooltip';
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
@@ -10,10 +10,11 @@ export type Size = 'xsmall' | 'small' | 'medium' | 'large' | 'vertical';
|
|||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
inputId?: string;
|
inputId?: string;
|
||||||
|
dataCy?: string;
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
size?: Size;
|
size?: Size;
|
||||||
tooltip?: ComponentProps<typeof Tooltip>['message'];
|
tooltip?: ReactNode;
|
||||||
setTooltipHtmlMessage?: ComponentProps<typeof Tooltip>['setHtmlMessage'];
|
setTooltipHtmlMessage?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
errors?: ReactNode;
|
errors?: ReactNode;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
@@ -24,6 +25,7 @@ export interface Props {
|
|||||||
|
|
||||||
export function FormControl({
|
export function FormControl({
|
||||||
inputId,
|
inputId,
|
||||||
|
dataCy,
|
||||||
label,
|
label,
|
||||||
size = 'small',
|
size = 'small',
|
||||||
tooltip = '',
|
tooltip = '',
|
||||||
@@ -42,6 +44,7 @@ export function FormControl({
|
|||||||
'form-group',
|
'form-group',
|
||||||
'after:clear-both after:table after:content-[""]' // to fix issues with float
|
'after:clear-both after:table after:content-[""]' // to fix issues with float
|
||||||
)}
|
)}
|
||||||
|
data-cy={dataCy}
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
htmlFor={inputId}
|
htmlFor={inputId}
|
||||||
@@ -56,10 +59,15 @@ export function FormControl({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className={sizeClassChildren(size)}>
|
<div className={clsx('flex flex-col', sizeClassChildren(size))}>
|
||||||
{isLoading && <InlineLoader>{loadingText}</InlineLoader>}
|
{isLoading && (
|
||||||
|
// 34px height to reduce layout shift when loading is complete
|
||||||
|
<div className="h-[34px] flex items-center">
|
||||||
|
<InlineLoader>{loadingText}</InlineLoader>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{!isLoading && children}
|
{!isLoading && children}
|
||||||
{errors && <FormError>{errors}</FormError>}
|
{!!errors && !isLoading && <FormError>{errors}</FormError>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { CellContext, Column } from '@tanstack/react-table';
|
import { CellContext, Column } from '@tanstack/react-table';
|
||||||
import { useSref } from '@uirouter/react';
|
|
||||||
|
|
||||||
import { truncate } from '@/portainer/filters/filters';
|
import { truncate } from '@/portainer/filters/filters';
|
||||||
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
|
||||||
@@ -7,6 +6,7 @@ import { ImagesListResponse } from '@/react/docker/images/queries/useImages';
|
|||||||
|
|
||||||
import { MultipleSelectionFilter } from '@@/datatables/Filter';
|
import { MultipleSelectionFilter } from '@@/datatables/Filter';
|
||||||
import { UnusedBadge } from '@@/Badge/UnusedBadge';
|
import { UnusedBadge } from '@@/Badge/UnusedBadge';
|
||||||
|
import { Link } from '@@/Link';
|
||||||
|
|
||||||
import { columnHelper } from './helper';
|
import { columnHelper } from './helper';
|
||||||
|
|
||||||
@@ -62,22 +62,20 @@ function FilterByUsage<TData extends { Used: boolean }>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Cell({
|
function Cell({
|
||||||
getValue,
|
row: { original: item },
|
||||||
row: { original: image },
|
|
||||||
}: CellContext<ImagesListResponse, string>) {
|
}: CellContext<ImagesListResponse, string>) {
|
||||||
const name = getValue();
|
|
||||||
|
|
||||||
const linkProps = useSref('.image', {
|
|
||||||
id: image.id,
|
|
||||||
imageId: image.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<>
|
||||||
<a href={linkProps.href} onClick={linkProps.onClick} title={name}>
|
<Link
|
||||||
{truncate(name, 40)}
|
to=".image"
|
||||||
</a>
|
params={{ id: item.id, nodeName: item.nodeName }}
|
||||||
{!image.used && <UnusedBadge />}
|
title={item.id}
|
||||||
</div>
|
data-cy={`image-link-${item.id}`}
|
||||||
|
className="mr-2"
|
||||||
|
>
|
||||||
|
{truncate(item.id, 40)}
|
||||||
|
</Link>
|
||||||
|
{!item.used && <UnusedBadge />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ describe('fullURIIntoRepoAndTag', () => {
|
|||||||
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
|
expect(result).toEqual({ repo: 'nginx', tag: 'latest' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('splits image-repo:port/image correctly', () => {
|
||||||
|
const result = fullURIIntoRepoAndTag('registry.example.com:5000/my-image');
|
||||||
|
expect(result).toEqual({
|
||||||
|
repo: 'registry.example.com:5000/my-image',
|
||||||
|
tag: 'latest',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits image-repo:port/image:tag correctly', () => {
|
||||||
|
const result = fullURIIntoRepoAndTag(
|
||||||
|
'registry.example.com:5000/my-image:v1'
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
repo: 'registry.example.com:5000/my-image',
|
||||||
|
tag: 'v1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('splits registry:port/image-repo:tag correctly', () => {
|
it('splits registry:port/image-repo:tag correctly', () => {
|
||||||
const result = fullURIIntoRepoAndTag(
|
const result = fullURIIntoRepoAndTag(
|
||||||
'registry.example.com:5000/my-image:v2.1'
|
'registry.example.com:5000/my-image:v2.1'
|
||||||
|
|||||||
@@ -121,9 +121,18 @@ export function fullURIIntoRepoAndTag(fullURI: string) {
|
|||||||
// - registry/image-repo:tag
|
// - registry/image-repo:tag
|
||||||
// - image-repo:tag
|
// - image-repo:tag
|
||||||
// - registry:port/image-repo:tag
|
// - registry:port/image-repo:tag
|
||||||
|
// - localhost:5000/nginx
|
||||||
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
|
// buildImageFullURIFromModel always gives a tag (defaulting to 'latest'), so the tag is always present after the last ':'
|
||||||
const parts = fullURI.split(':');
|
const parts = fullURI.split(':');
|
||||||
const tag = parts.pop() || 'latest';
|
const tag = parts.pop() || 'latest';
|
||||||
|
|
||||||
|
// handle the case of a repo with a non standard port
|
||||||
|
if (tag.includes('/')) {
|
||||||
|
return {
|
||||||
|
repo: fullURI,
|
||||||
|
tag: 'latest',
|
||||||
|
};
|
||||||
|
}
|
||||||
const repo = parts.join(':');
|
const repo = parts.join(':');
|
||||||
return {
|
return {
|
||||||
repo,
|
repo,
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { render, screen, within } from '@testing-library/react';
|
||||||
|
import { HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { server, http } from '@/setup-tests/server';
|
||||||
|
import {
|
||||||
|
createMockEnvironment,
|
||||||
|
createMockQueryResult,
|
||||||
|
} from '@/react-tools/test-mocks';
|
||||||
|
|
||||||
|
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||||
|
|
||||||
|
const mockUseAuthorizations = vi.fn();
|
||||||
|
const mockUseEnvironmentId = vi.fn(() => 3);
|
||||||
|
const mockUseCurrentEnvironment = vi.fn();
|
||||||
|
|
||||||
|
// Set up mock implementations for hooks
|
||||||
|
vi.mock('@/react/hooks/useUser', () => ({
|
||||||
|
useAuthorizations: () => mockUseAuthorizations(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useCurrentEnvironment', () => ({
|
||||||
|
useCurrentEnvironment: () => mockUseCurrentEnvironment(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const Wrapped = withTestQueryProvider(ClusterResourceReservation);
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ClusterResourceReservation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set the return values for the hooks
|
||||||
|
mockUseAuthorizations.mockReturnValue({
|
||||||
|
authorized: true,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseEnvironmentId.mockReturnValue(3);
|
||||||
|
|
||||||
|
const mockEnvironment = createMockEnvironment();
|
||||||
|
mockEnvironment.Kubernetes.Configuration.UseServerMetrics = true;
|
||||||
|
mockUseCurrentEnvironment.mockReturnValue(
|
||||||
|
createMockQueryResult(mockEnvironment)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup default mock responses
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/api/v1/nodes', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
status: {
|
||||||
|
allocatable: {
|
||||||
|
cpu: '4',
|
||||||
|
memory: '8Gi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/kubernetes/3/metrics/nodes', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
usage: {
|
||||||
|
cpu: '2',
|
||||||
|
memory: '4Gi',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
http.get('/api/kubernetes/3/metrics/applications_resources', () =>
|
||||||
|
HttpResponse.json({
|
||||||
|
CpuRequest: 1000,
|
||||||
|
MemoryRequest: '2Gi',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display resource limits, reservations and usage when all APIs respond successfully', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-usage')).findByText(
|
||||||
|
'4294 / 8589 MB - 50%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-usage')).findByText(
|
||||||
|
'2 / 4 - 50%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display resource usage if user does not have K8sClusterNodeR authorization', async () => {
|
||||||
|
mockUseAuthorizations.mockReturnValue({
|
||||||
|
authorized: false,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Should only show reservation bars
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Usage bars should not be present
|
||||||
|
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display resource usage if metrics server is not enabled', async () => {
|
||||||
|
const disabledMetricsEnvironment = createMockEnvironment();
|
||||||
|
disabledMetricsEnvironment.Kubernetes.Configuration.UseServerMetrics =
|
||||||
|
false;
|
||||||
|
mockUseCurrentEnvironment.mockReturnValue(
|
||||||
|
createMockQueryResult(disabledMetricsEnvironment)
|
||||||
|
);
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Should only show reservation bars
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Usage bars should not be present
|
||||||
|
expect(screen.queryByTestId('memory-usage')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('cpu-usage')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display warning if metrics server is enabled but usage query fails', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/kubernetes/3/metrics/nodes', () => HttpResponse.error())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock console.error so test logs are not polluted
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-reservation')).findByText(
|
||||||
|
'2147 / 8589 MB - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('memory-usage')).findByText(
|
||||||
|
'0 / 8589 MB - 0%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-reservation')).findByText(
|
||||||
|
'1 / 4 - 25%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(await screen.findByTestId('cpu-usage')).findByText(
|
||||||
|
'0 / 4 - 0%'
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Should show the warning message
|
||||||
|
expect(
|
||||||
|
await screen.findByText(
|
||||||
|
/Resource usage is not currently available as Metrics Server is not responding/
|
||||||
|
)
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Widget, WidgetBody } from '@/react/components/Widget';
|
||||||
|
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||||
|
|
||||||
|
import { useClusterResourceReservationData } from './useClusterResourceReservationData';
|
||||||
|
|
||||||
|
export function ClusterResourceReservation() {
|
||||||
|
// Load all data required for this component
|
||||||
|
const {
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
isLoading,
|
||||||
|
displayResourceUsage,
|
||||||
|
resourceUsage,
|
||||||
|
resourceReservation,
|
||||||
|
displayWarning,
|
||||||
|
} = useClusterResourceReservationData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<WidgetBody>
|
||||||
|
<ResourceReservation
|
||||||
|
isLoading={isLoading}
|
||||||
|
displayResourceUsage={displayResourceUsage}
|
||||||
|
resourceReservation={resourceReservation}
|
||||||
|
resourceUsage={resourceUsage}
|
||||||
|
cpuLimit={cpuLimit}
|
||||||
|
memoryLimit={memoryLimit}
|
||||||
|
description="Resource reservation represents the total amount of resource assigned to all the applications inside the cluster."
|
||||||
|
displayWarning={displayWarning}
|
||||||
|
warningMessage="Resource usage is not currently available as Metrics Server is not responding. If you've recently upgraded, Metrics Server may take a while to restart, so please check back shortly."
|
||||||
|
/>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
Normal file
33
app/react/kubernetes/cluster/ClusterView/ClusterView.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
import { PageHeader } from '@/react/components/PageHeader';
|
||||||
|
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
|
||||||
|
|
||||||
|
import { ClusterResourceReservation } from './ClusterResourceReservation';
|
||||||
|
|
||||||
|
export function ClusterView() {
|
||||||
|
const { data: environment } = useCurrentEnvironment();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Cluster"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Environments', link: 'portainer.endpoints' },
|
||||||
|
{
|
||||||
|
label: environment?.Name || '',
|
||||||
|
link: 'portainer.endpoints.endpoint',
|
||||||
|
linkParams: { id: environment?.Id },
|
||||||
|
},
|
||||||
|
'Cluster information',
|
||||||
|
]}
|
||||||
|
reload
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ClusterResourceReservation />
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<NodesDatatable />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
app/react/kubernetes/cluster/ClusterView/index.ts
Normal file
1
app/react/kubernetes/cluster/ClusterView/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ClusterView } from './ClusterView';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './useClusterResourceLimitsQuery';
|
||||||
|
export * from './useClusterResourceReservationQuery';
|
||||||
|
export * from './useClusterResourceUsageQuery';
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { round, reduce } from 'lodash';
|
||||||
|
import filesizeParser from 'filesize-parser';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||||
|
import { parseCpu } from '@/react/kubernetes/utils';
|
||||||
|
import { getNodes } from '@/react/kubernetes/cluster/HomeView/nodes.service';
|
||||||
|
|
||||||
|
export function useClusterResourceLimitsQuery(environmentId: EnvironmentId) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'clusterResourceLimits'],
|
||||||
|
async () => getNodes(environmentId),
|
||||||
|
{
|
||||||
|
...withGlobalError('Unable to retrieve resource limit data', 'Failure'),
|
||||||
|
enabled: !!environmentId,
|
||||||
|
select: aggregateResourceLimits,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes node data to calculate total CPU and memory limits for the cluster
|
||||||
|
* and sets the state for memory limit in MB and CPU limit rounded to 3 decimal places.
|
||||||
|
*/
|
||||||
|
function aggregateResourceLimits(nodes: Node[]) {
|
||||||
|
const processedNodes = nodes.map((node) => ({
|
||||||
|
...node,
|
||||||
|
memory: filesizeParser(node.status?.allocatable?.memory ?? ''),
|
||||||
|
cpu: parseCpu(node.status?.allocatable?.cpu ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
nodes: processedNodes,
|
||||||
|
memoryLimit: reduce(
|
||||||
|
processedNodes,
|
||||||
|
(acc, node) =>
|
||||||
|
KubernetesResourceReservationHelper.megaBytesValue(node.memory || 0) +
|
||||||
|
acc,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
cpuLimit: round(
|
||||||
|
reduce(processedNodes, (acc, node) => (node.cpu || 0) + acc, 0),
|
||||||
|
3
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { getTotalResourcesForAllApplications } from '@/react/kubernetes/metrics/metrics';
|
||||||
|
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||||
|
|
||||||
|
export function useClusterResourceReservationQuery(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
nodes: Node[]
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'clusterResourceReservation'],
|
||||||
|
() => getTotalResourcesForAllApplications(environmentId),
|
||||||
|
{
|
||||||
|
enabled: !!environmentId && nodes.length > 0,
|
||||||
|
select: (data) => ({
|
||||||
|
cpu: data.CpuRequest / 1000,
|
||||||
|
memory: KubernetesResourceReservationHelper.megaBytesValue(
|
||||||
|
data.MemoryRequest
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Node } from 'kubernetes-types/core/v1';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { getMetricsForAllNodes } from '@/react/kubernetes/metrics/metrics';
|
||||||
|
import KubernetesResourceReservationHelper from '@/kubernetes/helpers/resourceReservationHelper';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import { NodeMetrics } from '@/react/kubernetes/metrics/types';
|
||||||
|
|
||||||
|
export function useClusterResourceUsageQuery(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
serverMetricsEnabled: boolean,
|
||||||
|
authorized: boolean,
|
||||||
|
nodes: Node[]
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'clusterResourceUsage'],
|
||||||
|
() => getMetricsForAllNodes(environmentId),
|
||||||
|
{
|
||||||
|
enabled:
|
||||||
|
authorized &&
|
||||||
|
serverMetricsEnabled &&
|
||||||
|
!!environmentId &&
|
||||||
|
nodes.length > 0,
|
||||||
|
select: aggregateResourceUsage,
|
||||||
|
...withGlobalError('Unable to retrieve resource usage data.', 'Failure'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateResourceUsage(data: NodeMetrics) {
|
||||||
|
return data.items.reduce(
|
||||||
|
(total, item) => ({
|
||||||
|
cpu:
|
||||||
|
total.cpu +
|
||||||
|
KubernetesResourceReservationHelper.parseCPU(item.usage.cpu),
|
||||||
|
memory:
|
||||||
|
total.memory +
|
||||||
|
KubernetesResourceReservationHelper.megaBytesValue(item.usage.memory),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
cpu: 0,
|
||||||
|
memory: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||||
|
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
|
||||||
|
|
||||||
|
import {
|
||||||
|
useClusterResourceLimitsQuery,
|
||||||
|
useClusterResourceReservationQuery,
|
||||||
|
useClusterResourceUsageQuery,
|
||||||
|
} from './queries';
|
||||||
|
|
||||||
|
export function useClusterResourceReservationData() {
|
||||||
|
const { data: environment } = useCurrentEnvironment();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
// Check if server metrics is enabled
|
||||||
|
const serverMetricsEnabled =
|
||||||
|
environment?.Kubernetes?.Configuration?.UseServerMetrics || false;
|
||||||
|
|
||||||
|
// User needs to have K8sClusterNodeR authorization to view resource usage data
|
||||||
|
const { authorized: hasK8sClusterNodeR } = useAuthorizations(
|
||||||
|
['K8sClusterNodeR'],
|
||||||
|
undefined,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get resource limits for the cluster
|
||||||
|
const { data: resourceLimits, isLoading: isResourceLimitLoading } =
|
||||||
|
useClusterResourceLimitsQuery(environmentId);
|
||||||
|
|
||||||
|
// Get resource reservation info for the cluster
|
||||||
|
const {
|
||||||
|
data: resourceReservation,
|
||||||
|
isFetching: isResourceReservationLoading,
|
||||||
|
} = useClusterResourceReservationQuery(
|
||||||
|
environmentId,
|
||||||
|
resourceLimits?.nodes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get resource usage info for the cluster
|
||||||
|
const {
|
||||||
|
data: resourceUsage,
|
||||||
|
isFetching: isResourceUsageLoading,
|
||||||
|
isError: isResourceUsageError,
|
||||||
|
} = useClusterResourceUsageQuery(
|
||||||
|
environmentId,
|
||||||
|
serverMetricsEnabled,
|
||||||
|
hasK8sClusterNodeR,
|
||||||
|
resourceLimits?.nodes || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
memoryLimit: getSafeValue(resourceLimits?.memoryLimit || 0),
|
||||||
|
cpuLimit: getSafeValue(resourceLimits?.cpuLimit || 0),
|
||||||
|
displayResourceUsage: hasK8sClusterNodeR && serverMetricsEnabled,
|
||||||
|
resourceUsage: {
|
||||||
|
cpu: getSafeValue(resourceUsage?.cpu || 0),
|
||||||
|
memory: getSafeValue(resourceUsage?.memory || 0),
|
||||||
|
},
|
||||||
|
resourceReservation: {
|
||||||
|
cpu: getSafeValue(resourceReservation?.cpu || 0),
|
||||||
|
memory: getSafeValue(resourceReservation?.memory || 0),
|
||||||
|
},
|
||||||
|
isLoading:
|
||||||
|
isResourceLimitLoading ||
|
||||||
|
isResourceReservationLoading ||
|
||||||
|
isResourceUsageLoading,
|
||||||
|
// Display warning if server metrics isn't responding but should be
|
||||||
|
displayWarning:
|
||||||
|
hasK8sClusterNodeR && serverMetricsEnabled && isResourceUsageError,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ export function useNodeQuery(environmentId: EnvironmentId, nodeName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getNodes is used to get a list of nodes using the kubernetes API
|
// getNodes is used to get a list of nodes using the kubernetes API
|
||||||
async function getNodes(environmentId: EnvironmentId) {
|
export async function getNodes(environmentId: EnvironmentId) {
|
||||||
try {
|
try {
|
||||||
const { data: nodeList } = await axios.get<NodeList>(
|
const { data: nodeList } = await axios.get<NodeList>(
|
||||||
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
|
`/endpoints/${environmentId}/kubernetes/api/v1/nodes`
|
||||||
|
|||||||
129
app/react/kubernetes/components/ResourceReservation.tsx
Normal file
129
app/react/kubernetes/components/ResourceReservation.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { round } from 'lodash';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
import { FormSectionTitle } from '@/react/components/form-components/FormSectionTitle';
|
||||||
|
import { TextTip } from '@/react/components/Tip/TextTip';
|
||||||
|
import { ResourceUsageItem } from '@/react/kubernetes/components/ResourceUsageItem';
|
||||||
|
import { getPercentageString, getSafeValue } from '@/react/kubernetes/utils';
|
||||||
|
|
||||||
|
import { Icon } from '@@/Icon';
|
||||||
|
|
||||||
|
interface ResourceMetrics {
|
||||||
|
cpu: number;
|
||||||
|
memory: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
displayResourceUsage: boolean;
|
||||||
|
resourceReservation: ResourceMetrics;
|
||||||
|
resourceUsage: ResourceMetrics;
|
||||||
|
cpuLimit: number;
|
||||||
|
memoryLimit: number;
|
||||||
|
description: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
title?: string;
|
||||||
|
displayWarning?: boolean;
|
||||||
|
warningMessage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceReservation({
|
||||||
|
displayResourceUsage,
|
||||||
|
resourceReservation,
|
||||||
|
resourceUsage,
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
description,
|
||||||
|
title = 'Resource reservation',
|
||||||
|
isLoading = false,
|
||||||
|
displayWarning = false,
|
||||||
|
warningMessage = '',
|
||||||
|
}: Props) {
|
||||||
|
const memoryReservationAnnotation = `${getSafeValue(
|
||||||
|
resourceReservation.memory
|
||||||
|
)} / ${memoryLimit} MB ${getPercentageString(
|
||||||
|
resourceReservation.memory,
|
||||||
|
memoryLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const memoryUsageAnnotation = `${getSafeValue(
|
||||||
|
resourceUsage.memory
|
||||||
|
)} / ${memoryLimit} MB ${getPercentageString(
|
||||||
|
resourceUsage.memory,
|
||||||
|
memoryLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const cpuReservationAnnotation = `${round(
|
||||||
|
getSafeValue(resourceReservation.cpu),
|
||||||
|
2
|
||||||
|
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
|
||||||
|
resourceReservation.cpu,
|
||||||
|
cpuLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const cpuUsageAnnotation = `${round(
|
||||||
|
getSafeValue(resourceUsage.cpu),
|
||||||
|
2
|
||||||
|
)} / ${round(getSafeValue(cpuLimit), 2)} ${getPercentageString(
|
||||||
|
resourceUsage.cpu,
|
||||||
|
cpuLimit
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormSectionTitle>{title}</FormSectionTitle>
|
||||||
|
<TextTip color="blue" className="mb-2">
|
||||||
|
{description}
|
||||||
|
</TextTip>
|
||||||
|
<div className="form-horizontal">
|
||||||
|
{memoryLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceReservation.memory}
|
||||||
|
total={memoryLimit}
|
||||||
|
label="Memory reservation"
|
||||||
|
annotation={memoryReservationAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="memory-reservation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayResourceUsage && memoryLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceUsage.memory}
|
||||||
|
total={memoryLimit}
|
||||||
|
label="Memory usage"
|
||||||
|
annotation={memoryUsageAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="memory-usage"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cpuLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceReservation.cpu}
|
||||||
|
total={cpuLimit}
|
||||||
|
label="CPU reservation"
|
||||||
|
annotation={cpuReservationAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="cpu-reservation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayResourceUsage && cpuLimit > 0 && (
|
||||||
|
<ResourceUsageItem
|
||||||
|
value={resourceUsage.cpu}
|
||||||
|
total={cpuLimit}
|
||||||
|
label="CPU usage"
|
||||||
|
annotation={cpuUsageAnnotation}
|
||||||
|
isLoading={isLoading}
|
||||||
|
dataCy="cpu-usage"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{displayWarning && (
|
||||||
|
<div className="form-group">
|
||||||
|
<span className="col-sm-12 text-warning small vertical-center">
|
||||||
|
<Icon icon={AlertTriangle} mode="warning" />
|
||||||
|
{warningMessage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { ProgressBar } from '@@/ProgressBar';
|
|
||||||
import { FormControl } from '@@/form-components/FormControl';
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { ProgressBar } from '@@/ProgressBar';
|
||||||
|
|
||||||
interface ResourceUsageItemProps {
|
interface ResourceUsageItemProps {
|
||||||
value: number;
|
value: number;
|
||||||
total: number;
|
total: number;
|
||||||
annotation?: React.ReactNode;
|
annotation?: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
dataCy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceUsageItem({
|
export function ResourceUsageItem({
|
||||||
@@ -13,9 +15,16 @@ export function ResourceUsageItem({
|
|||||||
total,
|
total,
|
||||||
annotation,
|
annotation,
|
||||||
label,
|
label,
|
||||||
|
isLoading = false,
|
||||||
|
dataCy,
|
||||||
}: ResourceUsageItemProps) {
|
}: ResourceUsageItemProps) {
|
||||||
return (
|
return (
|
||||||
<FormControl label={label}>
|
<FormControl
|
||||||
|
label={label}
|
||||||
|
isLoading={isLoading}
|
||||||
|
className={isLoading ? 'mb-1.5' : ''}
|
||||||
|
dataCy={dataCy}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<ProgressBar
|
<ProgressBar
|
||||||
steps={[
|
steps={[
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { HttpResponse } from 'msw';
|
||||||
|
|
||||||
|
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||||
|
import { server, http } from '@/setup-tests/server';
|
||||||
|
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||||
|
import { UserViewModel } from '@/portainer/models/user';
|
||||||
|
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||||
|
|
||||||
|
import { HelmApplicationView } from './HelmApplicationView';
|
||||||
|
|
||||||
|
// Mock the necessary hooks and dependencies
|
||||||
|
const mockUseCurrentStateAndParams = vi.fn();
|
||||||
|
const mockUseEnvironmentId = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
|
||||||
|
...(await importOriginal()),
|
||||||
|
useCurrentStateAndParams: () => mockUseCurrentStateAndParams(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/react/hooks/useEnvironmentId', () => ({
|
||||||
|
useEnvironmentId: () => mockUseEnvironmentId(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderComponent() {
|
||||||
|
const user = new UserViewModel({ Username: 'user' });
|
||||||
|
const Wrapped = withTestQueryProvider(
|
||||||
|
withUserProvider(withTestRouter(HelmApplicationView), user)
|
||||||
|
);
|
||||||
|
return render(<Wrapped />);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelmApplicationView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Set up default mock values
|
||||||
|
mockUseEnvironmentId.mockReturnValue(3);
|
||||||
|
mockUseCurrentStateAndParams.mockReturnValue({
|
||||||
|
params: {
|
||||||
|
name: 'test-release',
|
||||||
|
namespace: 'default',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up default mock API responses
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm', () =>
|
||||||
|
HttpResponse.json([
|
||||||
|
{
|
||||||
|
name: 'test-release',
|
||||||
|
chart: 'test-chart-1.0.0',
|
||||||
|
app_version: '1.0.0',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display helm release details when data is loaded', async () => {
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Check for the page header
|
||||||
|
expect(await screen.findByText('Helm details')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the release details
|
||||||
|
expect(screen.getByText('Release')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the table content
|
||||||
|
expect(screen.getByText('Name')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Chart')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('App version')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for the actual values
|
||||||
|
expect(screen.getByTestId('k8sAppDetail-appName')).toHaveTextContent(
|
||||||
|
'test-release'
|
||||||
|
);
|
||||||
|
expect(screen.getByText('test-chart-1.0.0')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('1.0.0')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display error message when API request fails', async () => {
|
||||||
|
// Mock API failure
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.error())
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock console.error to prevent test output pollution
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Wait for the error message to appear
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Failed to load Helm application details')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display error message when release is not found', async () => {
|
||||||
|
// Mock empty response (no releases found)
|
||||||
|
server.use(
|
||||||
|
http.get('/api/endpoints/3/kubernetes/helm', () => HttpResponse.json([]))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock console.error to prevent test output pollution
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
|
||||||
|
renderComponent();
|
||||||
|
|
||||||
|
// Wait for the error message to appear
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Failed to load Helm application details')
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Restore console.error
|
||||||
|
vi.spyOn(console, 'error').mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useCurrentStateAndParams } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { PageHeader } from '@/react/components/PageHeader';
|
||||||
|
import { Widget, WidgetBody, WidgetTitle } from '@/react/components/Widget';
|
||||||
|
import helm from '@/assets/ico/vendor/helm.svg?c';
|
||||||
|
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||||
|
|
||||||
|
import { ViewLoading } from '@@/ViewLoading';
|
||||||
|
import { Alert } from '@@/Alert';
|
||||||
|
|
||||||
|
import { useHelmRelease } from './queries/useHelmRelease';
|
||||||
|
|
||||||
|
export function HelmApplicationView() {
|
||||||
|
const { params } = useCurrentStateAndParams();
|
||||||
|
const environmentId = useEnvironmentId();
|
||||||
|
|
||||||
|
const name = params.name as string;
|
||||||
|
const namespace = params.namespace as string;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: release,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useHelmRelease(environmentId, name, namespace);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ViewLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !release) {
|
||||||
|
return (
|
||||||
|
<Alert color="error" title="Failed to load Helm application details" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Helm details"
|
||||||
|
breadcrumbs={[
|
||||||
|
{ label: 'Applications', link: 'kubernetes.applications' },
|
||||||
|
name,
|
||||||
|
]}
|
||||||
|
reload
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<Widget>
|
||||||
|
<WidgetTitle icon={helm} title="Release" />
|
||||||
|
<WidgetBody>
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="!border-none w-40">Name</td>
|
||||||
|
<td
|
||||||
|
className="!border-none min-w-[140px]"
|
||||||
|
data-cy="k8sAppDetail-appName"
|
||||||
|
>
|
||||||
|
{release.name}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="!border-t">Chart</td>
|
||||||
|
<td className="!border-t">{release.chart}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>App version</td>
|
||||||
|
<td>{release.app_version}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</WidgetBody>
|
||||||
|
</Widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
app/react/kubernetes/helm/HelmApplicationView/index.ts
Normal file
1
app/react/kubernetes/helm/HelmApplicationView/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { HelmApplicationView } from './HelmApplicationView';
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import { withGlobalError } from '@/react-tools/react-query';
|
||||||
|
import PortainerError from 'Portainer/error';
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
interface HelmRelease {
|
||||||
|
name: string;
|
||||||
|
chart: string;
|
||||||
|
app_version: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* List all helm releases based on passed in options
|
||||||
|
* @param environmentId - Environment ID
|
||||||
|
* @param options - Options for filtering releases
|
||||||
|
* @returns List of helm releases
|
||||||
|
*/
|
||||||
|
export async function listReleases(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
options: {
|
||||||
|
namespace?: string;
|
||||||
|
filter?: string;
|
||||||
|
selector?: string;
|
||||||
|
output?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<HelmRelease[]> {
|
||||||
|
try {
|
||||||
|
const { namespace, filter, selector, output } = options;
|
||||||
|
const url = `endpoints/${environmentId}/kubernetes/helm`;
|
||||||
|
const { data } = await axios.get<HelmRelease[]>(url, {
|
||||||
|
params: { namespace, filter, selector, output },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error, 'Unable to retrieve release list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to fetch a specific Helm release
|
||||||
|
*/
|
||||||
|
export function useHelmRelease(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
name: string,
|
||||||
|
namespace: string
|
||||||
|
) {
|
||||||
|
return useQuery(
|
||||||
|
[environmentId, 'helm', namespace, name],
|
||||||
|
() => getHelmRelease(environmentId, name, namespace),
|
||||||
|
{
|
||||||
|
enabled: !!environmentId,
|
||||||
|
...withGlobalError('Unable to retrieve helm application details'),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific Helm release
|
||||||
|
*/
|
||||||
|
async function getHelmRelease(
|
||||||
|
environmentId: EnvironmentId,
|
||||||
|
name: string,
|
||||||
|
namespace: string
|
||||||
|
): Promise<HelmRelease> {
|
||||||
|
try {
|
||||||
|
const releases = await listReleases(environmentId, {
|
||||||
|
filter: `^${name}$`,
|
||||||
|
namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (releases.length > 0) {
|
||||||
|
return releases[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new PortainerError(`Release ${name} not found`);
|
||||||
|
} catch (err) {
|
||||||
|
throw new PortainerError(
|
||||||
|
'Unable to retrieve helm application details',
|
||||||
|
err as Error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,8 +37,8 @@ export type Usage = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ApplicationResource = {
|
export type ApplicationResource = {
|
||||||
cpuRequest: number;
|
CpuRequest: number;
|
||||||
cpuLimit: number;
|
CpuLimit: number;
|
||||||
memoryRequest: number;
|
MemoryRequest: number;
|
||||||
memoryLimit: number;
|
MemoryLimit: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { ResourceReservation } from '@/react/kubernetes/components/ResourceReservation';
|
||||||
|
|
||||||
|
import { ResourceQuotaFormValues } from './types';
|
||||||
|
import { useNamespaceResourceReservationData } from './useNamespaceResourceReservationData';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
namespaceName: string;
|
||||||
|
environmentId: number;
|
||||||
|
resourceQuotaValues: ResourceQuotaFormValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NamespaceResourceReservation({
|
||||||
|
environmentId,
|
||||||
|
namespaceName,
|
||||||
|
resourceQuotaValues,
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
cpuLimit,
|
||||||
|
memoryLimit,
|
||||||
|
displayResourceUsage,
|
||||||
|
resourceUsage,
|
||||||
|
resourceReservation,
|
||||||
|
isLoading,
|
||||||
|
} = useNamespaceResourceReservationData(
|
||||||
|
environmentId,
|
||||||
|
namespaceName,
|
||||||
|
resourceQuotaValues
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resourceQuotaValues.enabled) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceReservation
|
||||||
|
displayResourceUsage={displayResourceUsage}
|
||||||
|
resourceReservation={resourceReservation}
|
||||||
|
resourceUsage={resourceUsage}
|
||||||
|
cpuLimit={cpuLimit}
|
||||||
|
memoryLimit={memoryLimit}
|
||||||
|
description="Resource reservation represents the total amount of resource assigned to all the applications deployed inside this namespace."
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,8 +13,8 @@ import { SliderWithInput } from '@@/form-components/Slider/SliderWithInput';
|
|||||||
|
|
||||||
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
import { useClusterResourceLimitsQuery } from '../../../queries/useResourceLimitsQuery';
|
||||||
|
|
||||||
import { ResourceReservationUsage } from './ResourceReservationUsage';
|
|
||||||
import { ResourceQuotaFormValues } from './types';
|
import { ResourceQuotaFormValues } from './types';
|
||||||
|
import { NamespaceResourceReservation } from './NamespaceResourceReservation';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
values: ResourceQuotaFormValues;
|
values: ResourceQuotaFormValues;
|
||||||
@@ -128,7 +128,7 @@ export function ResourceQuotaFormSection({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{namespaceName && isEdit && (
|
{namespaceName && isEdit && (
|
||||||
<ResourceReservationUsage
|
<NamespaceResourceReservation
|
||||||
namespaceName={namespaceName}
|
namespaceName={namespaceName}
|
||||||
environmentId={environmentId}
|
environmentId={environmentId}
|
||||||
resourceQuotaValues={values}
|
resourceQuotaValues={values}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import { round } from 'lodash';
|
|
||||||
|
|
||||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
|
||||||
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
|
||||||
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
|
||||||
|
|
||||||
import { TextTip } from '@@/Tip/TextTip';
|
|
||||||
import { FormSectionTitle } from '@@/form-components/FormSectionTitle';
|
|
||||||
|
|
||||||
import { megaBytesValue, parseCPU } from '../../../resourceQuotaUtils';
|
|
||||||
import { ResourceUsageItem } from '../../ResourceUsageItem';
|
|
||||||
|
|
||||||
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
|
||||||
import { ResourceQuotaFormValues } from './types';
|
|
||||||
|
|
||||||
export function ResourceReservationUsage({
|
|
||||||
namespaceName,
|
|
||||||
environmentId,
|
|
||||||
resourceQuotaValues,
|
|
||||||
}: {
|
|
||||||
namespaceName: string;
|
|
||||||
environmentId: EnvironmentId;
|
|
||||||
resourceQuotaValues: ResourceQuotaFormValues;
|
|
||||||
}) {
|
|
||||||
const namespaceMetricsQuery = useMetricsForNamespace(
|
|
||||||
environmentId,
|
|
||||||
namespaceName,
|
|
||||||
{
|
|
||||||
select: aggregatePodUsage,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const usedResourceQuotaQuery = useResourceQuotaUsed(
|
|
||||||
environmentId,
|
|
||||||
namespaceName
|
|
||||||
);
|
|
||||||
const { data: namespaceMetrics } = namespaceMetricsQuery;
|
|
||||||
const { data: usedResourceQuota } = usedResourceQuotaQuery;
|
|
||||||
|
|
||||||
const memoryQuota = Number(resourceQuotaValues.memory) ?? 0;
|
|
||||||
const cpuQuota = Number(resourceQuotaValues.cpu) ?? 0;
|
|
||||||
|
|
||||||
if (!resourceQuotaValues.enabled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormSectionTitle>Resource reservation</FormSectionTitle>
|
|
||||||
<TextTip color="blue" className="mb-2">
|
|
||||||
Resource reservation represents the total amount of resource assigned to
|
|
||||||
all the applications deployed inside this namespace.
|
|
||||||
</TextTip>
|
|
||||||
{!!usedResourceQuota && memoryQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={usedResourceQuota.memory}
|
|
||||||
total={getSafeValue(memoryQuota)}
|
|
||||||
label="Memory reservation"
|
|
||||||
annotation={`${usedResourceQuota.memory} / ${getSafeValue(
|
|
||||||
memoryQuota
|
|
||||||
)} MB ${getPercentageString(usedResourceQuota.memory, memoryQuota)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!namespaceMetrics && memoryQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={namespaceMetrics.memory}
|
|
||||||
total={getSafeValue(memoryQuota)}
|
|
||||||
label="Memory used"
|
|
||||||
annotation={`${namespaceMetrics.memory} / ${getSafeValue(
|
|
||||||
memoryQuota
|
|
||||||
)} MB ${getPercentageString(namespaceMetrics.memory, memoryQuota)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!usedResourceQuota && cpuQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={usedResourceQuota.cpu}
|
|
||||||
total={cpuQuota}
|
|
||||||
label="CPU reservation"
|
|
||||||
annotation={`${
|
|
||||||
usedResourceQuota.cpu
|
|
||||||
} / ${cpuQuota} ${getPercentageString(
|
|
||||||
usedResourceQuota.cpu,
|
|
||||||
cpuQuota
|
|
||||||
)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!!namespaceMetrics && cpuQuota > 0 && (
|
|
||||||
<ResourceUsageItem
|
|
||||||
value={namespaceMetrics.cpu}
|
|
||||||
total={cpuQuota}
|
|
||||||
label="CPU used"
|
|
||||||
annotation={`${
|
|
||||||
namespaceMetrics.cpu
|
|
||||||
} / ${cpuQuota} ${getPercentageString(
|
|
||||||
namespaceMetrics.cpu,
|
|
||||||
cpuQuota
|
|
||||||
)}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSafeValue(value: number | string) {
|
|
||||||
const valueNumber = Number(value);
|
|
||||||
if (Number.isNaN(valueNumber)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return valueNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the percentage of the value over the total.
|
|
||||||
* @param value - The value to calculate the percentage for.
|
|
||||||
* @param total - The total value to compare the percentage to.
|
|
||||||
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
|
||||||
*/
|
|
||||||
function getPercentageString(value: number, total?: number | string) {
|
|
||||||
const totalNumber = Number(total);
|
|
||||||
if (
|
|
||||||
totalNumber === 0 ||
|
|
||||||
total === undefined ||
|
|
||||||
total === '' ||
|
|
||||||
Number.isNaN(totalNumber)
|
|
||||||
) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
if (value > totalNumber) {
|
|
||||||
return '- Exceeded';
|
|
||||||
}
|
|
||||||
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Aggregates the resource usage of all the containers in the namespace.
|
|
||||||
* @param podMetricsList - List of pod metrics
|
|
||||||
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
|
||||||
*/
|
|
||||||
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
|
||||||
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
|
||||||
i.containers.map((c) => c.usage)
|
|
||||||
);
|
|
||||||
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
|
||||||
(total, usage) => ({
|
|
||||||
cpu: total.cpu + parseCPU(usage.cpu),
|
|
||||||
memory: total.memory + megaBytesValue(usage.memory),
|
|
||||||
}),
|
|
||||||
{ cpu: 0, memory: 0 }
|
|
||||||
);
|
|
||||||
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
|
||||||
return namespaceResourceUsage;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { round } from 'lodash';
|
||||||
|
|
||||||
|
import { getSafeValue } from '@/react/kubernetes/utils';
|
||||||
|
import { PodMetrics } from '@/react/kubernetes/metrics/types';
|
||||||
|
import { useMetricsForNamespace } from '@/react/kubernetes/metrics/queries/useMetricsForNamespace';
|
||||||
|
import {
|
||||||
|
megaBytesValue,
|
||||||
|
parseCPU,
|
||||||
|
} from '@/react/kubernetes/namespaces/resourceQuotaUtils';
|
||||||
|
|
||||||
|
import { useResourceQuotaUsed } from './useResourceQuotaUsed';
|
||||||
|
import { ResourceQuotaFormValues } from './types';
|
||||||
|
|
||||||
|
export function useNamespaceResourceReservationData(
|
||||||
|
environmentId: number,
|
||||||
|
namespaceName: string,
|
||||||
|
resourceQuotaValues: ResourceQuotaFormValues
|
||||||
|
) {
|
||||||
|
const { data: quota, isLoading: isQuotaLoading } = useResourceQuotaUsed(
|
||||||
|
environmentId,
|
||||||
|
namespaceName
|
||||||
|
);
|
||||||
|
const { data: metrics, isLoading: isMetricsLoading } = useMetricsForNamespace(
|
||||||
|
environmentId,
|
||||||
|
namespaceName,
|
||||||
|
{
|
||||||
|
select: aggregatePodUsage,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpuLimit: Number(resourceQuotaValues.cpu) || 0,
|
||||||
|
memoryLimit: Number(resourceQuotaValues.memory) || 0,
|
||||||
|
displayResourceUsage: !!metrics,
|
||||||
|
resourceReservation: {
|
||||||
|
cpu: getSafeValue(quota?.cpu || 0),
|
||||||
|
memory: getSafeValue(quota?.memory || 0),
|
||||||
|
},
|
||||||
|
resourceUsage: {
|
||||||
|
cpu: getSafeValue(metrics?.cpu || 0),
|
||||||
|
memory: getSafeValue(metrics?.memory || 0),
|
||||||
|
},
|
||||||
|
isLoading: isQuotaLoading || isMetricsLoading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates the resource usage of all the containers in the namespace.
|
||||||
|
* @param podMetricsList - List of pod metrics
|
||||||
|
* @returns Aggregated resource usage. CPU cores are rounded to 3 decimal places. Memory is in MB.
|
||||||
|
*/
|
||||||
|
function aggregatePodUsage(podMetricsList: PodMetrics) {
|
||||||
|
const containerResourceUsageList = podMetricsList.items.flatMap((i) =>
|
||||||
|
i.containers.map((c) => c.usage)
|
||||||
|
);
|
||||||
|
const namespaceResourceUsage = containerResourceUsageList.reduce(
|
||||||
|
(total, usage) => ({
|
||||||
|
cpu: total.cpu + parseCPU(usage.cpu),
|
||||||
|
memory: total.memory + megaBytesValue(usage.memory),
|
||||||
|
}),
|
||||||
|
{ cpu: 0, memory: 0 }
|
||||||
|
);
|
||||||
|
namespaceResourceUsage.cpu = round(namespaceResourceUsage.cpu, 3);
|
||||||
|
return namespaceResourceUsage;
|
||||||
|
}
|
||||||
@@ -20,3 +20,38 @@ export function prepareAnnotations(annotations?: Annotation[]) {
|
|||||||
);
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the safe value of the given number or string.
|
||||||
|
* @param value - The value to get the safe value for.
|
||||||
|
* @returns The safe value of the given number or string.
|
||||||
|
*/
|
||||||
|
export function getSafeValue(value: number | string) {
|
||||||
|
const valueNumber = Number(value);
|
||||||
|
if (Number.isNaN(valueNumber)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return valueNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the percentage of the value over the total.
|
||||||
|
* @param value - The value to calculate the percentage for.
|
||||||
|
* @param total - The total value to compare the percentage to.
|
||||||
|
* @returns The percentage of the value over the total, with the '- ' string prefixed, for example '- 50%'.
|
||||||
|
*/
|
||||||
|
export function getPercentageString(value: number, total?: number | string) {
|
||||||
|
const totalNumber = Number(total);
|
||||||
|
if (
|
||||||
|
totalNumber === 0 ||
|
||||||
|
total === undefined ||
|
||||||
|
total === '' ||
|
||||||
|
Number.isNaN(totalNumber)
|
||||||
|
) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (value > totalNumber) {
|
||||||
|
return '- Exceeded';
|
||||||
|
}
|
||||||
|
return `- ${Math.round((value / totalNumber) * 100)}%`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export function AppTemplatesList({
|
|||||||
pageSize={listState.pageSize}
|
pageSize={listState.pageSize}
|
||||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
totalSelected={0}
|
totalSelected={0}
|
||||||
|
totalHiddenSelected={0}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export function CustomTemplatesList({
|
|||||||
pageSize={listState.pageSize}
|
pageSize={listState.pageSize}
|
||||||
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
pageCount={Math.ceil(filteredTemplates.length / listState.pageSize)}
|
||||||
totalSelected={0}
|
totalSelected={0}
|
||||||
|
totalHiddenSelected={0}
|
||||||
/>
|
/>
|
||||||
</Table.Container>
|
</Table.Container>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user