Compare commits
65 Commits
test-versi
...
2.19.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f42ba0254 | ||
|
|
6f81fcc169 | ||
|
|
46949508a4 | ||
|
|
034157be9a | ||
|
|
011a1ce720 | ||
|
|
a4922eb693 | ||
|
|
8c77c5ffbe | ||
|
|
a062c36ff5 | ||
|
|
122fd835dc | ||
|
|
f7ff07833f | ||
|
|
8010167006 | ||
|
|
4c79e9ef6b | ||
|
|
88ea0cb64f | ||
|
|
5f50f20a7a | ||
|
|
bbc26682dd | ||
|
|
f74704fca4 | ||
|
|
9b52bd50d9 | ||
|
|
04073f0d1f | ||
|
|
c035e4a778 | ||
|
|
7abed624d9 | ||
|
|
1e24451cc9 | ||
|
|
adcfcdd6e3 | ||
|
|
e6e3810fa4 | ||
|
|
5e20854f86 | ||
|
|
69f3670ce5 | ||
|
|
f24555c6c9 | ||
|
|
1c79f10ae8 | ||
|
|
dc76900a28 | ||
|
|
74eeb9da06 | ||
|
|
77120abf33 | ||
|
|
dffdf6783c | ||
|
|
55236129ea | ||
|
|
d54dd47b21 | ||
|
|
360969c93e | ||
|
|
3ea6d2b9d9 | ||
|
|
577a36e04e | ||
|
|
6aa978d5e9 | ||
|
|
0b8d72bfd4 | ||
|
|
faa1387110 | ||
|
|
f5cc245c63 | ||
|
|
20c6965ce0 | ||
|
|
53679f9381 | ||
|
|
e1951baac0 | ||
|
|
187ec2aa9a | ||
|
|
125db4f0de | ||
|
|
59be96e9e8 | ||
|
|
d3420f39c1 | ||
|
|
004c86578d | ||
|
|
b3d404b378 | ||
|
|
82faf20c68 | ||
|
|
18e40cd973 | ||
|
|
9c4d512a4c | ||
|
|
ce5c38f841 | ||
|
|
dbb79a181e | ||
|
|
2177c27dc4 | ||
|
|
bfdd72d644 | ||
|
|
998bf481f7 | ||
|
|
c97ef40cc0 | ||
|
|
cbae7bdf82 | ||
|
|
f4ec4d6175 | ||
|
|
ec39d5a88e | ||
|
|
d0d9c2a93b | ||
|
|
73010efd8d | ||
|
|
88de50649f | ||
|
|
fc89066846 |
@@ -1,9 +1,6 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
@@ -18,13 +15,3 @@ type APIKeyService interface {
|
||||
DeleteAPIKey(apiKeyID portainer.APIKeyID) error
|
||||
InvalidateUserKeyCache(userId portainer.UserID) bool
|
||||
}
|
||||
|
||||
// generateRandomKey generates a random key of specified length
|
||||
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
|
||||
func generateRandomKey(length int) []byte {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return nil
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package apikey
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/internal/securecookie"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -33,7 +34,7 @@ func Test_generateRandomKey(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := generateRandomKey(tt.wantLenth)
|
||||
got := securecookie.GenerateRandomKey(tt.wantLenth)
|
||||
is.Equal(tt.wantLenth, len(got))
|
||||
})
|
||||
}
|
||||
@@ -41,7 +42,7 @@ func Test_generateRandomKey(t *testing.T) {
|
||||
t.Run("Generated keys are unique", func(t *testing.T) {
|
||||
keys := make(map[string]bool)
|
||||
for i := 0; i < 100; i++ {
|
||||
key := generateRandomKey(8)
|
||||
key := securecookie.GenerateRandomKey(8)
|
||||
_, ok := keys[string(key)]
|
||||
is.False(ok)
|
||||
keys[string(key)] = true
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/securecookie"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -39,7 +40,7 @@ func (a *apiKeyService) HashRaw(rawKey string) []byte {
|
||||
// GenerateApiKey generates a raw API key for a user (for one-time display).
|
||||
// The generated API key is stored in the cache and database.
|
||||
func (a *apiKeyService) GenerateApiKey(user portainer.User, description string) (string, *portainer.APIKey, error) {
|
||||
randKey := generateRandomKey(32)
|
||||
randKey := securecookie.GenerateRandomKey(32)
|
||||
encodedRawAPIKey := base64.StdEncoding.EncodeToString(randKey)
|
||||
prefixedAPIKey := portainerAPIKeyPrefix + encodedRawAPIKey
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ var filesToBackup = []string{
|
||||
"portainer.key",
|
||||
"portainer.pub",
|
||||
"tls",
|
||||
"chisel",
|
||||
}
|
||||
|
||||
// Creates a tar.gz system archive and encrypts it if password is not empty. Returns a path to the archive file.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/datastore/migrator"
|
||||
"github.com/portainer/portainer/api/demo"
|
||||
"github.com/portainer/portainer/api/docker"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
@@ -119,11 +120,15 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
log.Fatal().Err(err).Msg("failed generating instance id")
|
||||
}
|
||||
|
||||
migratorInstance := migrator.NewMigrator(&migrator.MigratorParameters{})
|
||||
migratorCount := migratorInstance.GetMigratorCountOfCurrentAPIVersion()
|
||||
|
||||
// from MigrateData
|
||||
v := models.Version{
|
||||
SchemaVersion: portainer.APIVersion,
|
||||
Edition: int(portainer.PortainerCE),
|
||||
InstanceID: instanceId.String(),
|
||||
MigratorCount: migratorCount,
|
||||
}
|
||||
store.VersionService.UpdateVersion(&v)
|
||||
|
||||
@@ -152,6 +157,16 @@ func initDataStore(flags *portainer.CLIFlags, secretKey []byte, fileService port
|
||||
return store
|
||||
}
|
||||
|
||||
// checkDBSchemaServerVersionMatch checks if the server version matches the db scehma version
|
||||
func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersion string, serverEdition int) bool {
|
||||
v, err := dbStore.Version().Version()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||
}
|
||||
|
||||
func initComposeStackManager(composeDeployer libstack.Deployer, proxyManager *proxy.Manager) portainer.ComposeStackManager {
|
||||
composeWrapper, err := exec.NewComposeStackManager(composeDeployer, proxyManager)
|
||||
if err != nil {
|
||||
@@ -383,6 +398,11 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("")
|
||||
}
|
||||
|
||||
// check if the db schema version matches with server version
|
||||
if !checkDBSchemaServerVersionMatch(dataStore, portainer.APIVersion, int(portainer.Edition)) {
|
||||
log.Fatal().Msg("The database schema version does not align with the server version. Please consider reverting to the previous server version or addressing the database migration issue.")
|
||||
}
|
||||
|
||||
instanceID, err := dataStore.Version().InstanceID()
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed getting instance id")
|
||||
|
||||
@@ -50,10 +50,10 @@ func (store *Store) MigrateData() error {
|
||||
if err != nil {
|
||||
err = errors.Wrap(err, "failed to migrate database")
|
||||
|
||||
log.Warn().Msg("migration failed, restoring database to previous version")
|
||||
err = store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to restore database")
|
||||
log.Warn().Err(err).Msg("migration failed, restoring database to previous version")
|
||||
restorErr := store.restoreWithOptions(&BackupOptions{BackupPath: backupPath})
|
||||
if restorErr != nil {
|
||||
return errors.Wrap(restorErr, "failed to restore database")
|
||||
}
|
||||
|
||||
log.Info().Msg("database restored to previous version")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package datastore
|
||||
|
||||
import (
|
||||
portaineree "github.com/portainer/portainer/api"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/database/models"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
)
|
||||
@@ -72,7 +72,7 @@ func dbVersionToSemanticVersion(dbVersion int) string {
|
||||
func (store *Store) getOrMigrateLegacyVersion() (*models.Version, error) {
|
||||
// Very old versions of portainer did not have a version bucket, lets set some defaults
|
||||
dbVersion := 24
|
||||
edition := int(portaineree.PortainerCE)
|
||||
edition := int(portainer.PortainerCE)
|
||||
instanceId := ""
|
||||
|
||||
// If we already have a version key, we don't need to migrate
|
||||
|
||||
@@ -115,10 +115,16 @@ func (m *Migrator) updateEdgeStackStatusForDB100() error {
|
||||
}
|
||||
|
||||
if environmentStatus.Details.Ok {
|
||||
statusArray = append(statusArray, portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusRunning,
|
||||
Time: time.Now().Unix(),
|
||||
})
|
||||
statusArray = append(statusArray,
|
||||
portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusDeploymentReceived,
|
||||
Time: time.Now().Unix(),
|
||||
},
|
||||
portainer.EdgeStackDeploymentStatus{
|
||||
Type: portainer.EdgeStackStatusRunning,
|
||||
Time: time.Now().Unix(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if environmentStatus.Details.ImagesPulled {
|
||||
|
||||
@@ -148,6 +148,17 @@ func (m *Migrator) LatestMigrations() Migrations {
|
||||
return m.migrations[len(m.migrations)-1]
|
||||
}
|
||||
|
||||
func (m *Migrator) GetMigratorCountOfCurrentAPIVersion() int {
|
||||
migratorCount := 0
|
||||
latestMigrations := m.LatestMigrations()
|
||||
|
||||
if latestMigrations.Version.Equal(semver.MustParse(portainer.APIVersion)) {
|
||||
migratorCount = len(latestMigrations.MigrationFuncs)
|
||||
}
|
||||
|
||||
return migratorCount
|
||||
}
|
||||
|
||||
// !NOTE: Migration funtions should ideally be idempotent.
|
||||
// ! Which simply means the function can run over the same data many times but only transform it once.
|
||||
// ! In practice this really just means an extra check or two to ensure we're not destroying valid data.
|
||||
|
||||
@@ -944,6 +944,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.0\",\"MigratorCount\":3,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.19.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -57,20 +57,20 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
|
||||
func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithVersion(dockerClientVersion),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
}
|
||||
|
||||
func CreateClientFromEnv() (*client.Client, error) {
|
||||
return client.NewClientWithOpts(
|
||||
client.FromEnv,
|
||||
client.WithVersion(dockerClientVersion),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
}
|
||||
|
||||
func CreateSimpleClient() (*client.Client, error) {
|
||||
return client.NewClientWithOpts(
|
||||
client.WithVersion(dockerClientVersion),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*cli
|
||||
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithVersion(dockerClientVersion),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
)
|
||||
}
|
||||
@@ -116,7 +116,7 @@ func createEdgeClient(endpoint *portainer.Endpoint, signatureService portainer.D
|
||||
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpointURL),
|
||||
client.WithVersion(dockerClientVersion),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
client.WithHTTPHeaders(headers),
|
||||
)
|
||||
@@ -144,7 +144,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
|
||||
|
||||
return client.NewClientWithOpts(
|
||||
client.WithHost(endpoint.URL),
|
||||
client.WithVersion(dockerClientVersion),
|
||||
client.WithAPIVersionNegotiation(),
|
||||
client.WithHTTPClient(httpCli),
|
||||
client.WithHTTPHeaders(headers),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// SwarmStackManager represents a service for managing stacks.
|
||||
@@ -64,16 +65,35 @@ func (manager *SwarmStackManager) Login(registries []portainer.Registry, endpoin
|
||||
if registry.Authentication {
|
||||
err = registryutils.EnsureRegTokenValid(manager.dataStore, ®istry)
|
||||
if err != nil {
|
||||
return err
|
||||
log.
|
||||
Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to validate registry token. Skip logging with this registry.")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
username, password, err := registryutils.GetRegEffectiveCredential(®istry)
|
||||
if err != nil {
|
||||
return err
|
||||
log.
|
||||
Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to get effective credential. Skip logging with this registry.")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
registryArgs := append(args, "login", "--username", username, "--password", password, registry.URL)
|
||||
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
|
||||
err = runCommandAndCaptureStdErr(command, registryArgs, nil, "")
|
||||
if err != nil {
|
||||
log.
|
||||
Warn().
|
||||
Err(err).
|
||||
Str("RegistryName", registry.Name).
|
||||
Msg("Failed to login.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ require (
|
||||
github.com/google/go-cmp v0.5.9
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/hashicorp/golang-lru v0.5.4
|
||||
github.com/joho/godotenv v1.4.0
|
||||
|
||||
@@ -203,8 +203,6 @@ github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH
|
||||
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
|
||||
@@ -3,6 +3,7 @@ package customtemplates
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -472,3 +473,29 @@ func (handler *Handler) createCustomTemplateFromFileUpload(r *http.Request) (*po
|
||||
|
||||
return customTemplate, nil
|
||||
}
|
||||
|
||||
// @id CustomTemplateCreate
|
||||
// @summary Create a custom template
|
||||
// @description Create a custom template.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags custom_templates
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json,multipart/form-data
|
||||
// @produce json
|
||||
// @param method query string true "method for creating template" Enums(string, file, repository)
|
||||
// @param body body object true "for body documentation see the relevant /custom_templates/{method} endpoint"
|
||||
// @success 200 {object} portainer.CustomTemplate
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /custom_templates [post]
|
||||
func deprecatedCustomTemplateCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("/custom_templates/create/%s", method)
|
||||
return url, nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,7 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
|
||||
h.Handle("/custom_templates/create/{method}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/custom_templates", middlewares.Deprecated(h, deprecatedCustomTemplateCreateUrlParser)).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/custom_templates",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.customTemplateList))).Methods(http.MethodGet)
|
||||
h.Handle("/custom_templates/{id}",
|
||||
|
||||
@@ -2,6 +2,7 @@ package edgejobs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -287,3 +288,26 @@ func (handler *Handler) addAndPersistEdgeJob(tx dataservices.DataStoreTx, edgeJo
|
||||
|
||||
return tx.EdgeJob().CreateWithID(edgeJob.ID, edgeJob)
|
||||
}
|
||||
|
||||
// @id EdgeJobCreate
|
||||
// @summary Create an EdgeJob
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_jobs
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param method query string true "Creation Method" Enums(file, string)
|
||||
// @param body body object true "for body documentation see the relevant /edge_jobs/create/{method} endpoint"
|
||||
// @success 200 {object} portainer.EdgeGroup
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @failure 500
|
||||
// @deprecated
|
||||
// @router /edge_jobs [post]
|
||||
func deprecatedEdgeJobCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/edge_jobs/create/%s", method), nil
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/portainer/libhttp/response"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@@ -29,6 +30,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
h.Handle("/edge_jobs",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_jobs",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeJobCreateUrlParser)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_jobs/create/{method}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeJobCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_jobs/{id}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
@@ -18,6 +19,7 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
|
||||
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
@@ -60,3 +62,26 @@ func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method str
|
||||
|
||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
}
|
||||
|
||||
// @id EdgeStackCreate
|
||||
// @summary Create an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param method query string true "Creation Method" Enums(file,string,repository)
|
||||
// @param body body object true "for body documentation see the relevant /edge_stacks/create/{method} endpoint"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @deprecated
|
||||
// @router /edge_stacks [post]
|
||||
func deprecatedEdgeStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/edge_stacks/create/%s", method), nil
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
|
||||
h.Handle("/edge_stacks/create/{method}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(middlewares.Deprecated(h, deprecatedEdgeStackCreateUrlParser)))).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_stacks/{id}",
|
||||
|
||||
@@ -50,7 +50,7 @@ func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentTyp
|
||||
entryPoint = stack.ManifestPath
|
||||
}
|
||||
|
||||
_, err := handler.FileService.StoreEdgeStackFileFromBytesByVersion(stackFolder, entryPoint, stack.Version, config)
|
||||
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
|
||||
}
|
||||
|
||||
@@ -294,7 +294,7 @@ func shouldReloadTLSConfiguration(endpoint *portainer.Endpoint, payload *endpoin
|
||||
// When updating Docker API environment, as long as TLS is true and TLSSkipVerify is false,
|
||||
// we assume that new TLS files have been uploaded and we need to reload the TLS configuration.
|
||||
if endpoint.Type != portainer.DockerEnvironment ||
|
||||
!strings.HasPrefix(*payload.URL, "tcp://") ||
|
||||
(payload.URL != nil && !strings.HasPrefix(*payload.URL, "tcp://")) ||
|
||||
payload.TLS == nil || !*payload.TLS {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type EnvironmentsQuery struct {
|
||||
edgeCheckInPassedSeconds int
|
||||
edgeStackId portainer.EdgeStackID
|
||||
edgeStackStatus *portainer.EdgeStackStatusType
|
||||
excludeIds []portainer.EndpointID
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
@@ -69,6 +70,11 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
excludeIDs, err := getNumberArrayQueryParameter[portainer.EndpointID](r, "excludeIds")
|
||||
if err != nil {
|
||||
return EnvironmentsQuery{}, err
|
||||
}
|
||||
|
||||
agentVersions := getArrayQueryParameter(r, "agentVersions")
|
||||
|
||||
name, _ := request.RetrieveQueryParameter(r, "name", true)
|
||||
@@ -97,6 +103,7 @@ func parseQuery(r *http.Request) (EnvironmentsQuery, error) {
|
||||
types: endpointTypes,
|
||||
tagIds: tagIDs,
|
||||
endpointIds: endpointIDs,
|
||||
excludeIds: excludeIDs,
|
||||
tagsPartialMatch: tagsPartialMatch,
|
||||
groupIds: groupIDs,
|
||||
status: status,
|
||||
@@ -118,6 +125,12 @@ func (handler *Handler) filterEndpointsByQuery(filteredEndpoints []portainer.End
|
||||
filteredEndpoints = filteredEndpointsByIds(filteredEndpoints, query.endpointIds)
|
||||
}
|
||||
|
||||
if len(query.excludeIds) > 0 {
|
||||
filteredEndpoints = filter(filteredEndpoints, func(endpoint portainer.Endpoint) bool {
|
||||
return !slices.Contains(query.excludeIds, endpoint.ID)
|
||||
})
|
||||
}
|
||||
|
||||
if len(query.groupIds) > 0 {
|
||||
filteredEndpoints = filterEndpointsByGroupIDs(filteredEndpoints, query.groupIds)
|
||||
}
|
||||
@@ -208,9 +221,12 @@ func endpointStatusInStackMatchesFilter(edgeStackStatus map[portainer.EndpointID
|
||||
status, ok := edgeStackStatus[envId]
|
||||
|
||||
// consider that if the env has no status in the stack it is in Pending state
|
||||
// workaround because Stack.Status[EnvId].Details.Pending is never set to True in the codebase
|
||||
if !ok && statusFilter == portainer.EdgeStackStatusPending {
|
||||
return true
|
||||
if statusFilter == portainer.EdgeStackStatusPending {
|
||||
return !ok || len(status.Status) == 0
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return slices.ContainsFunc(status.Status, func(s portainer.EdgeStackDeploymentStatus) bool {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/internal/slices"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
@@ -124,6 +125,28 @@ func Test_Filter_edgeFilter(t *testing.T) {
|
||||
runTests(tests, t, handler, endpoints)
|
||||
}
|
||||
|
||||
func Test_Filter_excludeIDs(t *testing.T) {
|
||||
ids := []portainer.EndpointID{1, 2, 3, 4, 5, 6, 7, 8, 9}
|
||||
|
||||
environments := slices.Map(ids, func(id portainer.EndpointID) portainer.Endpoint {
|
||||
return portainer.Endpoint{ID: id, GroupID: 1, Type: portainer.DockerEnvironment}
|
||||
})
|
||||
|
||||
handler := setupFilterTest(t, environments)
|
||||
|
||||
tests := []filterTest{
|
||||
{
|
||||
title: "should exclude IDs 2,5,8",
|
||||
expected: []portainer.EndpointID{1, 3, 4, 6, 7, 9},
|
||||
query: EnvironmentsQuery{
|
||||
excludeIds: []portainer.EndpointID{2, 5, 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
runTests(tests, t, handler, environments)
|
||||
}
|
||||
|
||||
func runTests(tests []filterTest, t *testing.T, handler *Handler, endpoints []portainer.Endpoint) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.title, func(t *testing.T) {
|
||||
|
||||
@@ -84,7 +84,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.19.0
|
||||
// @version 2.19.1
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackbuilders"
|
||||
@@ -176,6 +177,14 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
|
||||
handler.KubernetesDeployer,
|
||||
user)
|
||||
|
||||
// Refresh ECR registry secret if needed
|
||||
// RefreshEcrSecret method checks if the namespace has any ECR registry
|
||||
// otherwise return nil
|
||||
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err == nil {
|
||||
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, payload.Namespace)
|
||||
}
|
||||
|
||||
stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder)
|
||||
_, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint)
|
||||
if httpErr != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
dockerclient "github.com/portainer/portainer/api/docker/client"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/authorization"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
@@ -58,6 +59,8 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
|
||||
h.Handle("/stacks/create/{type}/{method}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/stacks",
|
||||
bouncer.AuthenticatedAccess(middlewares.Deprecated(h, deprecatedStackCreateUrlParser))).Methods(http.MethodPost) // Deprecated
|
||||
h.Handle("/stacks",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackList))).Methods(http.MethodGet)
|
||||
h.Handle("/stacks/{id}",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -139,3 +140,53 @@ func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *port
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func getStackTypeFromQueryParameter(r *http.Request) (string, error) {
|
||||
stackType, err := request.RetrieveNumericQueryParameter(r, "type", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
switch stackType {
|
||||
case 1:
|
||||
return "swarm", nil
|
||||
case 2:
|
||||
return "standalone", nil
|
||||
case 3:
|
||||
return "kubernetes", nil
|
||||
}
|
||||
|
||||
return "", errors.New(request.ErrInvalidQueryParameter)
|
||||
}
|
||||
|
||||
// @id StackCreate
|
||||
// @summary Deploy a new stack
|
||||
// @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json,multipart/form-data
|
||||
// @produce json
|
||||
// @param type query int true "Stack deployment type. Possible values: 1 (Swarm stack), 2 (Compose stack) or 3 (Kubernetes stack)." Enums(1,2,3)
|
||||
// @param method query string true "Stack deployment method. Possible values: file, string, repository or url." Enums(string, file, repository, url)
|
||||
// @param endpointId query int true "Identifier of the environment(endpoint) that will be used to deploy the stack"
|
||||
// @param body body object true "for body documentation see the relevant /stacks/create/{type}/{method} endpoint"
|
||||
// @success 200 {object} portainer.Stack
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 500 "Server error"
|
||||
// @deprecated
|
||||
// @router /stacks [post]
|
||||
func deprecatedStackCreateUrlParser(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError) {
|
||||
method, err := request.RetrieveQueryParameter(r, "method", false)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: method. Valid values are: file or string", err)
|
||||
}
|
||||
|
||||
stackType, err := getStackTypeFromQueryParameter(r)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid query parameter: type", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("/stacks/create/%s/%s", stackType, method), nil
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
return handler.StackDeployer.UndeployRemoteSwarmStack(stack, endpoint)
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St
|
||||
if stack.Type == portainer.DockerComposeStack {
|
||||
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
return handler.StackDeployer.UndeployRemoteComposeStack(stack, endpoint)
|
||||
}
|
||||
|
||||
|
||||
@@ -117,7 +117,7 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
|
||||
stack.AutoUpdate.JobID = jobID
|
||||
}
|
||||
|
||||
err = handler.startStack(stack, endpoint)
|
||||
err = handler.startStack(stack, endpoint, securityContext)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to start stack", err)
|
||||
}
|
||||
@@ -136,12 +136,16 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
|
||||
func (handler *Handler) startStack(
|
||||
stack *portainer.Stack,
|
||||
endpoint *portainer.Endpoint,
|
||||
securityContext *security.RestrictedRequestContext,
|
||||
) error {
|
||||
switch stack.Type {
|
||||
case portainer.DockerComposeStack:
|
||||
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
return handler.StackDeployer.StartRemoteComposeStack(stack, endpoint)
|
||||
}
|
||||
|
||||
@@ -149,11 +153,23 @@ func (handler *Handler) startStack(stack *portainer.Stack, endpoint *portainer.E
|
||||
case portainer.DockerSwarmStack:
|
||||
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
return handler.StackDeployer.StartRemoteSwarmStack(stack, endpoint)
|
||||
}
|
||||
|
||||
return handler.SwarmStackManager.Deploy(stack, true, true, endpoint)
|
||||
user, err := handler.DataStore.User().Read(securityContext.UserID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to load user information from the database: %w", err)
|
||||
}
|
||||
|
||||
registries, err := handler.DataStore.Registry().ReadAll()
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retrieve registries from the database: %w", err)
|
||||
}
|
||||
|
||||
filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID)
|
||||
|
||||
return handler.StackDeployer.DeploySwarmStack(stack, endpoint, filteredRegistries, true, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -125,7 +125,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
|
||||
case portainer.DockerComposeStack:
|
||||
stack.Name = handler.ComposeStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
return handler.StackDeployer.StopRemoteComposeStack(stack, endpoint)
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ func (handler *Handler) stopStack(stack *portainer.Stack, endpoint *portainer.En
|
||||
case portainer.DockerSwarmStack:
|
||||
stack.Name = handler.SwarmStackManager.NormalizeStackName(stack.Name)
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
return handler.StackDeployer.StopRemoteSwarmStack(stack, endpoint)
|
||||
}
|
||||
|
||||
|
||||
@@ -198,6 +198,11 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
|
||||
|
||||
stack.Env = payload.Env
|
||||
|
||||
if stack.GitConfig != nil {
|
||||
// detach from git
|
||||
stack.GitConfig = nil
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
@@ -263,6 +268,11 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
|
||||
|
||||
stack.Env = payload.Env
|
||||
|
||||
if stack.GitConfig != nil {
|
||||
// detach from git
|
||||
stack.GitConfig = nil
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
_, err = handler.FileService.UpdateStoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||
if err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/registryutils"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
|
||||
@@ -113,6 +114,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
|
||||
return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err)
|
||||
}
|
||||
|
||||
// Refresh ECR registry secret if needed
|
||||
// RefreshEcrSecret method checks if the namespace has any ECR registry
|
||||
// otherwise return nil
|
||||
cli, err := handler.KubernetesClientFactory.GetKubeClient(endpoint)
|
||||
if err == nil {
|
||||
registryutils.RefreshEcrSecret(cli, endpoint, handler.DataStore, stack.Namespace)
|
||||
}
|
||||
|
||||
//use temp dir as the stack project path for deployment
|
||||
//so if the deployment failed, the original file won't be over-written
|
||||
stack.ProjectPath = tempFileDir
|
||||
|
||||
@@ -22,6 +22,7 @@ var (
|
||||
errAdminCannotRemoveSelf = errors.New("Cannot remove your own user account. Contact another administrator")
|
||||
errCannotRemoveLastLocalAdmin = errors.New("Cannot remove the last local administrator account")
|
||||
errCryptoHashFailure = errors.New("Unable to hash data")
|
||||
errWrongPassword = errors.New("Wrong password")
|
||||
)
|
||||
|
||||
func hideFields(user *portainer.User) {
|
||||
|
||||
@@ -21,9 +21,10 @@ type themePayload struct {
|
||||
}
|
||||
|
||||
type userUpdatePayload struct {
|
||||
Username string `validate:"required" example:"bob"`
|
||||
Password string `validate:"required" example:"cg9Wgky3"`
|
||||
Theme *themePayload
|
||||
Username string `validate:"required" example:"bob"`
|
||||
Password string `validate:"required" example:"cg9Wgky3"`
|
||||
NewPassword string `validate:"required" example:"asfj2emv"`
|
||||
Theme *themePayload
|
||||
|
||||
// User role (1 for administrator account and 2 for regular account)
|
||||
Role int `validate:"required" enums:"1,2" example:"2"`
|
||||
@@ -37,12 +38,14 @@ func (payload *userUpdatePayload) Validate(r *http.Request) error {
|
||||
if payload.Role != 0 && payload.Role != 1 && payload.Role != 2 {
|
||||
return errors.New("invalid role value. Value must be one of: 1 (administrator) or 2 (regular user)")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id UserUpdate
|
||||
// @summary Update a user
|
||||
// @description Update user details. A regular user account can only update his details.
|
||||
// @description A regular user account cannot change their username or role.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags users
|
||||
// @security ApiKeyAuth
|
||||
@@ -95,6 +98,10 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
}
|
||||
|
||||
if payload.Username != "" && payload.Username != user.Username {
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
return httperror.Forbidden("Permission denied. Unable to update username", httperrors.ErrResourceAccessDenied)
|
||||
}
|
||||
|
||||
sameNameUser, err := handler.DataStore.User().UserByUsername(payload.Username)
|
||||
if err != nil && !handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.InternalServerError("Unable to retrieve users from the database", err)
|
||||
@@ -106,8 +113,28 @@ func (handler *Handler) userUpdate(w http.ResponseWriter, r *http.Request) *http
|
||||
user.Username = payload.Username
|
||||
}
|
||||
|
||||
if payload.Password != "" {
|
||||
user.Password, err = handler.CryptoService.Hash(payload.Password)
|
||||
if payload.Password != "" && payload.NewPassword == "" {
|
||||
if tokenData.Role == portainer.AdministratorRole {
|
||||
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password as an admin, you only need 'newPassword' in your request"))
|
||||
}
|
||||
|
||||
return httperror.BadRequest("Existing password field specified without new password field.", errors.New("To change the password, you must include both 'password' and 'newPassword' in your request"))
|
||||
}
|
||||
|
||||
if payload.NewPassword != "" {
|
||||
// Non-admins need to supply the previous password
|
||||
if tokenData.Role != portainer.AdministratorRole {
|
||||
err := handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
if err != nil {
|
||||
return httperror.Forbidden("Current password doesn't match. Password left unchanged", errors.New("Current password does not match the password provided. Please try again"))
|
||||
}
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to hash user password", errCryptoHashFailure)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ func (handler *Handler) userUpdatePassword(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
if !handler.passwordStrengthChecker.Check(payload.NewPassword) {
|
||||
return httperror.BadRequest("Password does not meet the requirements", nil)
|
||||
return httperror.BadRequest("Password does not meet the minimum strength requirements", nil)
|
||||
}
|
||||
|
||||
user.Password, err = handler.CryptoService.Hash(payload.NewPassword)
|
||||
|
||||
25
api/http/middlewares/deprecated.go
Normal file
25
api/http/middlewares/deprecated.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// deprecate api route
|
||||
func Deprecated(router http.Handler, urlBuilder func(w http.ResponseWriter, r *http.Request) (string, *httperror.HandlerError)) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
newUrl, err := urlBuilder(w, r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, err.StatusCode, err.Error(), err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Warn().Msgf("This api is deprecated. Use %s instead", newUrl)
|
||||
|
||||
redirectedRequest := r.Clone(r.Context())
|
||||
redirectedRequest.URL.Path = newUrl
|
||||
router.ServeHTTP(w, redirectedRequest)
|
||||
})
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
func (transport *baseTransport) proxyDeploymentsRequest(request *http.Request, namespace, requestPath string) (*http.Response, error) {
|
||||
switch request.Method {
|
||||
case http.MethodPost, http.MethodPatch:
|
||||
case http.MethodPost, http.MethodPatch, http.MethodPut:
|
||||
transport.refreshRegistry(request, namespace)
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.Endpoint
|
||||
endpointGroup := getAssociatedGroup(&endpoint, groups)
|
||||
|
||||
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
|
||||
endpoint.UserAccessPolicies = nil
|
||||
endpoints[n] = endpoint
|
||||
n++
|
||||
}
|
||||
|
||||
16
api/internal/securecookie/securecookie.go
Normal file
16
api/internal/securecookie/securecookie.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package securecookie
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
)
|
||||
|
||||
// GenerateRandomKey generates a random key of specified length
|
||||
// source: https://github.com/gorilla/securecookie/blob/master/securecookie.go#L515
|
||||
func GenerateRandomKey(length int) []byte {
|
||||
k := make([]byte, length)
|
||||
if _, err := io.ReadFull(rand.Reader, k); err != nil {
|
||||
return nil
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -63,3 +63,12 @@ func RemoveIndex[T any](s []T, index int) []T {
|
||||
s[index] = s[len(s)-1]
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
|
||||
// Map applies the given function to each element of the slice and returns a new slice with the results
|
||||
func Map[T, U any](s []T, f func(T) U) []U {
|
||||
result := make([]U, len(s))
|
||||
for i, v := range s {
|
||||
result[i] = f(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -90,7 +90,10 @@ func (service *service) upgradeDocker(licenseKey, version, envType string) error
|
||||
}
|
||||
|
||||
func (service *service) checkImageForDocker(ctx context.Context, image string, skipPullImage bool) error {
|
||||
cli, err := client.NewClientWithOpts(client.FromEnv)
|
||||
cli, err := client.NewClientWithOpts(
|
||||
client.FromEnv,
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create docker client")
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/gorilla/securecookie"
|
||||
"github.com/portainer/portainer/api/internal/securecookie"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -154,17 +155,29 @@ func (factory *ClientFactory) createCachedAdminKubeClient(endpoint *portainer.En
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateClient returns a pointer to a new Clientset instance
|
||||
// CreateClient returns a pointer to a new Clientset instance.
|
||||
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
switch endpoint.Type {
|
||||
case portainer.KubernetesLocalEnvironment:
|
||||
return buildLocalClient()
|
||||
case portainer.AgentOnKubernetesEnvironment:
|
||||
return factory.buildAgentClient(endpoint)
|
||||
case portainer.EdgeAgentOnKubernetesEnvironment:
|
||||
return factory.buildEdgeClient(endpoint)
|
||||
case portainer.KubernetesLocalEnvironment, portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnKubernetesEnvironment:
|
||||
c, err := factory.CreateConfig(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kubernetes.NewForConfig(c)
|
||||
}
|
||||
return nil, errors.New("unsupported environment type")
|
||||
}
|
||||
|
||||
// CreateConfig returns a pointer to a new kubeconfig ready to create a client.
|
||||
func (factory *ClientFactory) CreateConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
|
||||
switch endpoint.Type {
|
||||
case portainer.KubernetesLocalEnvironment:
|
||||
return buildLocalConfig()
|
||||
case portainer.AgentOnKubernetesEnvironment:
|
||||
return factory.buildAgentConfig(endpoint)
|
||||
case portainer.EdgeAgentOnKubernetesEnvironment:
|
||||
return factory.buildEdgeConfig(endpoint)
|
||||
}
|
||||
return nil, errors.New("unsupported environment type")
|
||||
}
|
||||
|
||||
@@ -184,20 +197,64 @@ func (rt *agentHeaderRoundTripper) RoundTrip(req *http.Request) (*http.Response,
|
||||
return rt.roundTripper.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) buildAgentClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
|
||||
func (factory *ClientFactory) buildAgentConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
|
||||
var clientURL strings.Builder
|
||||
if !strings.HasPrefix(endpoint.URL, "http") {
|
||||
clientURL.WriteString("https://")
|
||||
}
|
||||
clientURL.WriteString(endpoint.URL)
|
||||
clientURL.WriteString("/kubernetes")
|
||||
|
||||
return factory.createRemoteClient(endpointURL)
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config, err := clientcmd.BuildConfigFromFlags(clientURL.String(), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Insecure = true
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &agentHeaderRoundTripper{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) buildEdgeClient(endpoint *portainer.Endpoint) (*kubernetes.Clientset, error) {
|
||||
func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*rest.Config, error) {
|
||||
tunnel, err := factory.reverseTunnelService.GetActiveTunnel(endpoint)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed activating tunnel")
|
||||
}
|
||||
endpointURL := fmt.Sprintf("http://127.0.0.1:%d/kubernetes", tunnel.Port)
|
||||
|
||||
return factory.createRemoteClient(endpointURL)
|
||||
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
config.Insecure = true
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &agentHeaderRoundTripper{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernetes.Clientset, error) {
|
||||
@@ -227,34 +284,14 @@ func (factory *ClientFactory) createRemoteClient(endpointURL string) (*kubernete
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) CreateRemoteMetricsClient(endpoint *portainer.Endpoint) (*metricsv.Clientset, error) {
|
||||
endpointURL := fmt.Sprintf("https://%s/kubernetes", endpoint.URL)
|
||||
|
||||
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
|
||||
config, err := factory.CreateConfig(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to create metrics KubeConfig")
|
||||
}
|
||||
|
||||
config, err := clientcmd.BuildConfigFromFlags(endpointURL, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
config.Insecure = true
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
config.Wrap(func(rt http.RoundTripper) http.RoundTripper {
|
||||
return &agentHeaderRoundTripper{
|
||||
signatureHeader: signature,
|
||||
publicKeyHeader: factory.signatureService.EncodedPublicKey(),
|
||||
roundTripper: rt,
|
||||
}
|
||||
})
|
||||
|
||||
return metricsv.NewForConfig(config)
|
||||
}
|
||||
|
||||
func buildLocalClient() (*kubernetes.Clientset, error) {
|
||||
func buildLocalConfig() (*rest.Config, error) {
|
||||
config, err := rest.InClusterConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -263,7 +300,7 @@ func buildLocalClient() (*kubernetes.Clientset, error) {
|
||||
config.QPS = DefaultKubeClientQPS
|
||||
config.Burst = DefaultKubeClientBurst
|
||||
|
||||
return kubernetes.NewForConfig(config)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func (factory *ClientFactory) MigrateEndpointIngresses(e *portainer.Endpoint) error {
|
||||
|
||||
@@ -301,6 +301,8 @@ type (
|
||||
|
||||
// StackDeploymentInfo records the information of a deployed stack
|
||||
StackDeploymentInfo struct {
|
||||
// Version is the version of the stack and also is the deployed version in edge agent
|
||||
Version int `json:"Version"`
|
||||
// FileVersion is the version of the stack file, used to detect changes
|
||||
FileVersion int `json:"FileVersion"`
|
||||
// ConfigHash is the commit hash of the git repository used for deploying the stack
|
||||
@@ -1557,7 +1559,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.19.0"
|
||||
APIVersion = "2.19.1"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -17,6 +17,18 @@ type Scheduler struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type PermanentError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func NewPermanentError(err error) *PermanentError {
|
||||
return &PermanentError{err: err}
|
||||
}
|
||||
|
||||
func (e *PermanentError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func NewScheduler(ctx context.Context) *Scheduler {
|
||||
crontab := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)))
|
||||
crontab.Start()
|
||||
@@ -84,14 +96,24 @@ func (s *Scheduler) StopJob(jobID string) error {
|
||||
func (s *Scheduler) StartJobEvery(duration time.Duration, job func() error) string {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
j := cron.FuncJob(func() {
|
||||
if err := job(); err != nil {
|
||||
log.Debug().Msg("job returned an error")
|
||||
cancel()
|
||||
jobFn := cron.FuncJob(func() {
|
||||
err := job()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var permErr *PermanentError
|
||||
if errors.As(err, &permErr) {
|
||||
log.Error().Err(permErr).Msg("job returned a permanent error, it will be stopped")
|
||||
cancel()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error().Err(err).Msg("job returned an error, it will be rescheduled")
|
||||
})
|
||||
|
||||
entryID := s.crontab.Schedule(cron.Every(duration), j)
|
||||
entryID := s.crontab.Schedule(cron.Every(duration), jobFn)
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeJobs[entryID] = cancel
|
||||
|
||||
@@ -49,7 +49,7 @@ func Test_JobCanBeStopped(t *testing.T) {
|
||||
assert.False(t, workDone, "job shouldn't had a chance to run")
|
||||
}
|
||||
|
||||
func Test_JobShouldStop_UponError(t *testing.T) {
|
||||
func Test_JobShouldStop_UponPermError(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer s.Shutdown()
|
||||
|
||||
@@ -58,7 +58,7 @@ func Test_JobShouldStop_UponError(t *testing.T) {
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
acc++
|
||||
close(ch)
|
||||
return fmt.Errorf("failed")
|
||||
return NewPermanentError(fmt.Errorf("failed"))
|
||||
})
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
@@ -66,6 +66,28 @@ func Test_JobShouldStop_UponError(t *testing.T) {
|
||||
assert.Equal(t, 1, acc, "job stop after the first run because it returns an error")
|
||||
}
|
||||
|
||||
func Test_JobShouldNotStop_UponError(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
defer s.Shutdown()
|
||||
|
||||
var acc int
|
||||
ch := make(chan struct{})
|
||||
s.StartJobEvery(jobInterval, func() error {
|
||||
acc++
|
||||
|
||||
if acc == 2 {
|
||||
close(ch)
|
||||
return NewPermanentError(fmt.Errorf("failed"))
|
||||
}
|
||||
|
||||
return errors.New("non-permanent error")
|
||||
})
|
||||
|
||||
<-time.After(3 * jobInterval)
|
||||
<-ch
|
||||
assert.Equal(t, 2, acc)
|
||||
}
|
||||
|
||||
func Test_CanTerminateAllJobs_ByShuttingDownScheduler(t *testing.T) {
|
||||
s := NewScheduler(context.Background())
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/git/update"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -29,7 +30,9 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
log.Debug().Int("stack_id", int(stackID)).Msg("redeploying stack")
|
||||
|
||||
stack, err := datastore.Stack().Read(stackID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(errors.WithMessagef(err, "failed to get the stack %v", stackID))
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to get the stack %v", stackID)
|
||||
}
|
||||
|
||||
@@ -38,7 +41,15 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
}
|
||||
|
||||
endpoint, err := datastore.Endpoint().Endpoint(stack.EndpointID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(
|
||||
errors.WithMessagef(err,
|
||||
"failed to find the environment %v associated to the stack %v",
|
||||
stack.EndpointID,
|
||||
stack.ID,
|
||||
),
|
||||
)
|
||||
} else if err != nil {
|
||||
return errors.WithMessagef(err, "failed to find the environment %v associated to the stack %v", stack.EndpointID, stack.ID)
|
||||
}
|
||||
|
||||
@@ -78,14 +89,16 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
}
|
||||
|
||||
registries, err := getUserRegistries(datastore, user, endpoint.ID)
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return scheduler.NewPermanentError(err)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch stack.Type {
|
||||
case portainer.DockerComposeStack:
|
||||
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = deployer.DeployRemoteComposeStack(stack, endpoint, registries, true, false)
|
||||
} else {
|
||||
err = deployer.DeployComposeStack(stack, endpoint, registries, true, false)
|
||||
@@ -95,7 +108,7 @@ func RedeployWhenChanged(stackID portainer.StackID, deployer StackDeployer, data
|
||||
return errors.WithMessagef(err, "failed to deploy a docker compose stack %v", stackID)
|
||||
}
|
||||
case portainer.DockerSwarmStack:
|
||||
if stackutils.IsGitStack(stack) {
|
||||
if stackutils.IsRelativePathStack(stack) {
|
||||
err = deployer.DeployRemoteSwarmStack(stack, endpoint, registries, true, true)
|
||||
} else {
|
||||
err = deployer.DeploySwarmStack(stack, endpoint, registries, true, true)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package deployments
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"github.com/docker/docker/api/types"
|
||||
"github.com/docker/docker/api/types/container"
|
||||
"github.com/docker/docker/api/types/swarm"
|
||||
"github.com/docker/docker/pkg/stdcopy"
|
||||
"github.com/pkg/errors"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
@@ -184,16 +187,18 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
case <-statusCh:
|
||||
}
|
||||
|
||||
stdErr := &bytes.Buffer{}
|
||||
|
||||
out, err := cli.ContainerLogs(ctx, unpackerContainer.ID, types.ContainerLogsOptions{ShowStdout: true, ShowStderr: true})
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("unable to get logs from unpacker container")
|
||||
} else {
|
||||
outputBytes, err := io.ReadAll(out)
|
||||
_, err = stdcopy.StdCopy(io.Discard, stdErr, out)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("unable to parse logs from unpacker container")
|
||||
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
|
||||
} else {
|
||||
log.Info().
|
||||
Str("output", string(outputBytes)).
|
||||
Str("output", stdErr.String()).
|
||||
Msg("Stack deployment output")
|
||||
}
|
||||
}
|
||||
@@ -204,6 +209,26 @@ func (d *stackDeployer) remoteStack(stack *portainer.Stack, endpoint *portainer.
|
||||
}
|
||||
|
||||
if status.State.ExitCode != 0 {
|
||||
dec := json.NewDecoder(stdErr)
|
||||
for {
|
||||
errorStruct := struct {
|
||||
Level string
|
||||
Error string
|
||||
}{}
|
||||
|
||||
if err := dec.Decode(&errorStruct); errors.Is(err, io.EOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
log.Warn().Err(err).Msg("unable to parse logs from unpacker container")
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if errorStruct.Level == "error" {
|
||||
return fmt.Errorf("an error occurred while running unpacker container with exit code %d: %s", status.State.ExitCode, errorStruct.Error)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("an error occurred while running unpacker container with exit code %d", status.State.ExitCode)
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ func (config *ComposeStackDeploymentConfig) Deploy() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if stackutils.IsGitStack(config.stack) {
|
||||
if stackutils.IsRelativePathStack(config.stack) {
|
||||
return config.StackDeployer.DeployRemoteComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate)
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ func (config *SwarmStackDeploymentConfig) Deploy() error {
|
||||
}
|
||||
}
|
||||
|
||||
if stackutils.IsGitStack(config.stack) {
|
||||
if stackutils.IsRelativePathStack(config.stack) {
|
||||
return config.StackDeployer.DeployRemoteSwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,3 +47,10 @@ func SanitizeLabel(value string) string {
|
||||
func IsGitStack(stack *portainer.Stack) bool {
|
||||
return stack.GitConfig != nil && len(stack.GitConfig.URL) != 0
|
||||
}
|
||||
|
||||
// IsRelativePathStack checks if the stack is a git stack or not
|
||||
func IsRelativePathStack(stack *portainer.Stack) bool {
|
||||
// Always return false in CE
|
||||
// This function is only for code consistency with EE
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import _ from 'lodash-es';
|
||||
import { PorImageRegistryModel } from 'Docker/models/porImageRegistry';
|
||||
import { confirmImageExport } from '@/react/docker/images/common/ConfirmExportModal';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
||||
angular.module('portainer.docker').controller('ImageController', [
|
||||
'$async',
|
||||
@@ -120,30 +121,42 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||
}
|
||||
|
||||
$scope.removeTag = function (repository) {
|
||||
ImageService.deleteImage(repository, false)
|
||||
.then(function success() {
|
||||
if ($scope.image.RepoTags.length === 1) {
|
||||
Notifications.success('Image successfully deleted', repository);
|
||||
$state.go('docker.images', {}, { reload: true });
|
||||
} else {
|
||||
Notifications.success('Tag successfully deleted', repository);
|
||||
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove image');
|
||||
});
|
||||
return $async(async () => {
|
||||
if (!(await confirmDelete('Are you sure you want to delete this tag?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageService.deleteImage(repository, false)
|
||||
.then(function success() {
|
||||
if ($scope.image.RepoTags.length === 1) {
|
||||
Notifications.success('Image successfully deleted', repository);
|
||||
$state.go('docker.images', {}, { reload: true });
|
||||
} else {
|
||||
Notifications.success('Tag successfully deleted', repository);
|
||||
$state.go('docker.images.image', { id: $transition$.params().id }, { reload: true });
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove image');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeImage = function (id) {
|
||||
ImageService.deleteImage(id, false)
|
||||
.then(function success() {
|
||||
Notifications.success('Image successfully deleted', id);
|
||||
$state.go('docker.images', {}, { reload: true });
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove image');
|
||||
});
|
||||
return $async(async () => {
|
||||
if (!(await confirmDelete('Deleting this image will also delete all associated tags. Are you sure you want to delete this image?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
ImageService.deleteImage(id, false)
|
||||
.then(function success() {
|
||||
Notifications.success('Image successfully deleted', id);
|
||||
$state.go('docker.images', {}, { reload: true });
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to remove image');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
function exportImage(image) {
|
||||
|
||||
@@ -57,7 +57,8 @@ angular.module('portainer.docker').controller('ImagesController', [
|
||||
function confirmImageForceRemoval() {
|
||||
return confirmDestructive({
|
||||
title: 'Are you sure?',
|
||||
message: 'Forcing the removal of the image will remove the image even if it has multiple tags or if it is used by stopped containers.',
|
||||
message:
|
||||
"Forcing removal of an image will remove it even if it's used by stopped containers, and delete all associated tags. Are you sure you want to remove the selected image(s)?",
|
||||
confirmButton: buildConfirmButton('Remove the image', 'danger'),
|
||||
});
|
||||
}
|
||||
@@ -65,7 +66,7 @@ angular.module('portainer.docker').controller('ImagesController', [
|
||||
function confirmRegularRemove() {
|
||||
return confirmDestructive({
|
||||
title: 'Are you sure?',
|
||||
message: 'Removing the image will remove all tags associated to that image. Are you sure you want to remove the image?',
|
||||
message: 'Removing an image will also delete all associated tags. Are you sure you want to remove the selected image(s)?',
|
||||
confirmButton: buildConfirmButton('Remove the image', 'danger'),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ angular
|
||||
component: 'editEdgeStackView',
|
||||
},
|
||||
},
|
||||
params: {
|
||||
status: {
|
||||
dynamic: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const edgeJobs = {
|
||||
|
||||
@@ -92,7 +92,6 @@ export const componentsModule = angular
|
||||
'query',
|
||||
'title',
|
||||
'data-cy',
|
||||
'hideEnvironmentIds',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
||||
export class EdgeGroupsController {
|
||||
/* @ngInject */
|
||||
@@ -26,6 +27,10 @@ export class EdgeGroupsController {
|
||||
}
|
||||
|
||||
async removeActionAsync(selectedItems) {
|
||||
if (!(await confirmDelete('Do you want to remove the selected Edge Group(s)?'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let item of selectedItems) {
|
||||
try {
|
||||
await this.EdgeGroupService.remove(item.Id);
|
||||
|
||||
@@ -15,7 +15,7 @@ export class EdgeJobsViewController {
|
||||
}
|
||||
|
||||
removeAction(selectedItems) {
|
||||
confirmDelete('Do you want to remove the selected edge job(s)?').then((confirmed) => {
|
||||
confirmDelete('Do you want to remove the selected Edge job(s)?').then((confirmed) => {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Switch to advanced mode to copy and paste multiple key/values
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple">
|
||||
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'configmap'">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Generate a configuration entry per line, use YAML format
|
||||
Generate a ConfigMap entry per line, use YAML format
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center" ng-if="!$ctrl.formValues.IsSimple && $ctrl.type === 'secret'">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
Generate a Secret entry per line, use YAML format
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,5 +8,6 @@ angular.module('portainer.kubernetes').component('kubernetesConfigurationData',
|
||||
isValid: '=',
|
||||
isCreation: '=',
|
||||
isEditorDirty: '=',
|
||||
type: '<',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
ApplicationSummaryWidget,
|
||||
ApplicationDetailsWidget,
|
||||
} from '@/react/kubernetes/applications/DetailsView';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { withFormValidation } from '@/react-tools/withFormValidation';
|
||||
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||
|
||||
@@ -104,7 +103,7 @@ export const ngModule = angular
|
||||
.component(
|
||||
'applicationDetailsWidget',
|
||||
r2a(
|
||||
withUIRouter(withReactQuery(withUserProvider(ApplicationDetailsWidget))),
|
||||
withUIRouter(withReactQuery(withCurrentUser(ApplicationDetailsWidget))),
|
||||
[]
|
||||
)
|
||||
);
|
||||
|
||||
@@ -352,7 +352,7 @@
|
||||
<!-- #region CONFIGMAPS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center">
|
||||
<label class="control-label !pt-0 text-left">ConfigMap</label>
|
||||
<label class="control-label !pt-0 text-left">ConfigMaps</label>
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.ConfigMaps.length">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
@@ -503,7 +503,7 @@
|
||||
<!-- #region SECRETS -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 vertical-center pt-2.5">
|
||||
<label class="control-label !pt-0 text-left">Secret</label>
|
||||
<label class="control-label !pt-0 text-left">Secrets</label>
|
||||
</div>
|
||||
<div class="col-sm-12 small text-muted vertical-center" style="margin-top: 15px" ng-if="ctrl.formValues.Secrets.length">
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
|
||||
@@ -26,95 +26,93 @@
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
|
||||
<span class="small text-warning vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
|
||||
configured.
|
||||
</span>
|
||||
</information-panel>
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'info'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> About statistics </span>
|
||||
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve container metrics">
|
||||
<span class="small text-warning vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Portainer was unable to retrieve any metrics associated to that container. Please contact your administrator to ensure that the Kubernetes metrics feature is properly
|
||||
configured.
|
||||
</span>
|
||||
</information-panel>
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'info'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> About statistics </span>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-warning">
|
||||
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<span>
|
||||
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-warning">
|
||||
This view displays real-time statistics about the container <b>{{ ctrl.state.transition.containerName | trimcontainername }}</b
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Network stats are unavailable for this container.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'svg-memory'"></pr-icon>
|
||||
<span>
|
||||
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'"></pr-icon>
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group" ng-if="ctrl.state.networkStatsUnavailable">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
|
||||
Network stats are unavailable for this container.
|
||||
</span>
|
||||
</div>
|
||||
<span class="vertical-center"> Memory usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'cpu'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> CPU usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'svg-memory'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> Memory usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 col-sm-12" ng-if="!ctrl.state.networkStatsUnavailable">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'cpu'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> CPU usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-container" style="position: relative">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@ class KubernetesApplicationStatsController {
|
||||
this.ChartService = ChartService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.initCharts = this.initCharts.bind(this);
|
||||
}
|
||||
|
||||
changeUpdateRepeater() {
|
||||
@@ -68,17 +69,26 @@ class KubernetesApplicationStatsController {
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
const cpuChartCtx = $('#cpuChart');
|
||||
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
|
||||
this.cpuChart = cpuChart;
|
||||
|
||||
const memoryChartCtx = $('#memoryChart');
|
||||
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
this.memoryChart = memoryChart;
|
||||
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
this.setUpdateRepeater();
|
||||
let i = 0;
|
||||
const findCharts = setInterval(() => {
|
||||
let cpuChartCtx = $('#cpuChart');
|
||||
let memoryChartCtx = $('#memoryChart');
|
||||
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
|
||||
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
|
||||
this.cpuChart = cpuChart;
|
||||
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
this.memoryChart = memoryChart;
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
this.setUpdateRepeater();
|
||||
clearInterval(findCharts);
|
||||
return;
|
||||
}
|
||||
i++;
|
||||
if (i >= 10) {
|
||||
clearInterval(findCharts);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getStats() {
|
||||
|
||||
@@ -15,86 +15,84 @@
|
||||
|
||||
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
|
||||
|
||||
<div ng-if="ctrl.state.viewReady">
|
||||
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
|
||||
<span class="small text-muted vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
|
||||
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
|
||||
</span>
|
||||
</information-panel>
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'info'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> About statistics </span>
|
||||
<information-panel ng-if="!ctrl.state.getMetrics" title-text="Unable to retrieve node metrics">
|
||||
<span class="small text-muted vertical-center">
|
||||
<pr-icon icon="'alert-triangle'" mode="'primary'"></pr-icon>
|
||||
Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured.
|
||||
</span>
|
||||
</information-panel>
|
||||
<div class="row" ng-if="ctrl.state.getMetrics">
|
||||
<div class="col-md-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'info'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> About statistics </span>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<span>
|
||||
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
This view displays real-time statistics about the node <b>{{ ctrl.state.transition.nodeName }}</b
|
||||
>.
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="ctrl.state.getMetrics">
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'svg-memory'"></pr-icon>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
|
||||
<div class="col-sm-3 col-md-2">
|
||||
<select id="refreshRate" ng-model="ctrl.state.refreshRate" ng-change="ctrl.changeUpdateRepeater()" class="form-control">
|
||||
<option value="30">30s</option>
|
||||
<option value="60">60s</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="vertical-center"> Memory usage </span>
|
||||
<span>
|
||||
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-node" style="position: relative">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'cpu'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> CPU usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-node" style="position: relative">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="ctrl.state.getMetrics">
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'svg-memory'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> Memory usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-node" style="position: relative">
|
||||
<canvas id="memoryChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
<div class="col-lg-6 col-md-12 col-sm-12">
|
||||
<rd-widget>
|
||||
<div class="toolBar px-5 pt-5">
|
||||
<div class="toolBarTitle flex">
|
||||
<div class="widget-icon space-right">
|
||||
<pr-icon icon="'cpu'"></pr-icon>
|
||||
</div>
|
||||
<span class="vertical-center"> CPU usage </span>
|
||||
</div>
|
||||
</div>
|
||||
<rd-widget-body>
|
||||
<div class="chart-node" style="position: relative">
|
||||
<canvas id="cpuChart" width="770" height="300"></canvas>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ class KubernetesNodeStatsController {
|
||||
this.ChartService = ChartService;
|
||||
|
||||
this.onInit = this.onInit.bind(this);
|
||||
this.initCharts = this.initCharts.bind(this);
|
||||
}
|
||||
|
||||
changeUpdateRepeater() {
|
||||
@@ -63,17 +64,20 @@ class KubernetesNodeStatsController {
|
||||
}
|
||||
|
||||
initCharts() {
|
||||
const cpuChartCtx = $('#cpuChart');
|
||||
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
|
||||
this.cpuChart = cpuChart;
|
||||
|
||||
const memoryChartCtx = $('#memoryChart');
|
||||
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
this.memoryChart = memoryChart;
|
||||
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
this.setUpdateRepeater();
|
||||
const findCharts = setInterval(() => {
|
||||
let cpuChartCtx = $('#cpuChart');
|
||||
let memoryChartCtx = $('#memoryChart');
|
||||
if (cpuChartCtx.length !== 0 && memoryChartCtx.length !== 0) {
|
||||
const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx);
|
||||
this.cpuChart = cpuChart;
|
||||
const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx);
|
||||
this.memoryChart = memoryChart;
|
||||
this.updateCPUChart();
|
||||
this.updateMemoryChart();
|
||||
this.setUpdateRepeater();
|
||||
clearInterval(findCharts);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
getStats() {
|
||||
@@ -84,7 +88,7 @@ class KubernetesNodeStatsController {
|
||||
const memory = filesizeParser(stats.usage.memory);
|
||||
const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu);
|
||||
this.stats = {
|
||||
read: stats.creationTimestamp,
|
||||
read: stats.metadata.creationTimestamp,
|
||||
MemoryUsage: memory,
|
||||
CPUUsage: (cpu / this.nodeCPU) * 100,
|
||||
};
|
||||
@@ -118,12 +122,6 @@ class KubernetesNodeStatsController {
|
||||
this.nodeCPU = node.CPU || 1;
|
||||
|
||||
await this.getStats();
|
||||
|
||||
if (this.state.getMetrics) {
|
||||
this.$document.ready(() => {
|
||||
this.initCharts();
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.state.getMetrics = false;
|
||||
}
|
||||
@@ -132,6 +130,11 @@ class KubernetesNodeStatsController {
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve node stats');
|
||||
} finally {
|
||||
this.state.viewReady = true;
|
||||
if (this.state.getMetrics) {
|
||||
this.$document.ready(() => {
|
||||
this.initCharts();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="true"
|
||||
type="'configmap'"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="false"
|
||||
type="'configmap'"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
|
||||
<span>
|
||||
More information about types of secret can be found in the official
|
||||
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">kubernetes documentation</a>.
|
||||
<a class="hyperlink" href="https://kubernetes.io/docs/concepts/configuration/secret/#secret-types" target="_blank">Kubernetes documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -186,6 +186,7 @@
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="true"
|
||||
type="'secret'"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
is-valid="ctrl.state.isDataValid"
|
||||
on-change-validation="ctrl.isFormValid()"
|
||||
is-creation="false"
|
||||
type="'secret'"
|
||||
is-editor-dirty="ctrl.state.isEditorDirty"
|
||||
></kubernetes-configuration-data>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal" name="kubernetesClusterSetupForm">
|
||||
<div class="col-sm-12 form-section-title"> Networking </div>
|
||||
<div class="col-sm-12 form-section-title"> Networking - Services </div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 text-muted small">
|
||||
@@ -41,6 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Networking - Ingresses </div>
|
||||
|
||||
<ingress-class-datatable
|
||||
on-change-controllers="(ctrl.onChangeControllers)"
|
||||
allow-none-ingress-class="ctrl.formValues.AllowNoneIngressClass"
|
||||
@@ -51,47 +53,58 @@
|
||||
view="'cluster'"
|
||||
></ingress-class-datatable>
|
||||
|
||||
<label htmlFor="foldingButtonIngControllerSettings" class="col-sm-12 form-section-title flex cursor-pointer items-center">
|
||||
<button
|
||||
id="foldingButtonIngControllerSettings"
|
||||
type="button"
|
||||
class="mx-2 !ml-0 inline-flex w-2 items-center justify-center border-0 bg-transparent"
|
||||
ng-click="ctrl.toggleAdvancedIngSettings($event)"
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.AllowNoneIngressClass"
|
||||
name="'allowNoIngressClass'"
|
||||
label="'Allow ingress class to be set to "none"'"
|
||||
tooltip="'This allows users setting up ingresses to select "none" as the ingress class.'"
|
||||
on-change="(ctrl.onToggleAllowNoneIngressClass)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8'"
|
||||
>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
|
||||
name="'ingressAvailabilityPerNamespace'"
|
||||
label="'Configure ingress controller availability per namespace'"
|
||||
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
|
||||
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8'"
|
||||
>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.RestrictStandardUserIngressW"
|
||||
name="'restrictStandardUserIngressW'"
|
||||
label="'Only allow admins to deploy ingresses'"
|
||||
feature-id="ctrl.limitedFeatureIngressDeploy"
|
||||
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
|
||||
on-change="(ctrl.onToggleRestrictStandardUserIngressW)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8 text-muted'"
|
||||
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
|
||||
disabled="!ctrl.isRBACEnabled"
|
||||
>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 !inline-flex gap-1 !align-top">
|
||||
<div class="icon icon-sm"><pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon></div>
|
||||
<div class="text-muted small"
|
||||
>You may set up ingress defaults (hostnames and annotations) via Create/Edit ingress. Users may then select them via the hostname dropdown in Create/Edit
|
||||
application.</div
|
||||
>
|
||||
<pr-icon ng-if="!ctrl.state.isIngToggleSectionExpanded" icon="'chevron-right'"></pr-icon>
|
||||
<pr-icon ng-if="ctrl.state.isIngToggleSectionExpanded" icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
More settings
|
||||
</label>
|
||||
<div ng-if="ctrl.state.isIngToggleSectionExpanded" class="ml-4">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.AllowNoneIngressClass"
|
||||
name="'allowNoIngressClass'"
|
||||
label="'Allow ingress class to be set to "none"'"
|
||||
tooltip="'This allows users setting up ingresses to select "none" as the ingress class.'"
|
||||
on-change="(ctrl.onToggleAllowNoneIngressClass)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8'"
|
||||
>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<por-switch-field
|
||||
checked="ctrl.formValues.IngressAvailabilityPerNamespace"
|
||||
name="'ingressAvailabilityPerNamespace'"
|
||||
label="'Configure ingress controller availability per namespace'"
|
||||
tooltip="'This allows an administrator to configure, in each namespace, which ingress controllers will be available for users to select when setting up ingresses for applications.'"
|
||||
on-change="(ctrl.onToggleIngressAvailabilityPerNamespace)"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8'"
|
||||
>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- auto update window -->
|
||||
@@ -161,19 +174,6 @@
|
||||
>
|
||||
</por-switch-field>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-12 mt-5">
|
||||
<por-switch-field
|
||||
name="'restrictStandardUserIngressW'"
|
||||
label="'Only allow admins to deploy ingresses'"
|
||||
tooltip="'Enforces only allowing admins to deploy ingresses (and disallows standard users from doing so).'"
|
||||
label-class="'col-sm-5 col-lg-4 px-0 !m-0'"
|
||||
switch-class="'col-sm-8 text-muted'"
|
||||
data-cy="kubeSetup-restrictStandardUserIngressWToggle"
|
||||
feature-id="ctrl.limitedFeatureIngressDeploy"
|
||||
disabled="!ctrl.isRBACEnabled"
|
||||
></por-switch-field>
|
||||
</div>
|
||||
</div>
|
||||
<!-- #endregion -->
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ class KubernetesConfigureController {
|
||||
await getMetricsForAllNodes(this.endpoint.Id);
|
||||
this.state.metrics.isServerRunning = true;
|
||||
this.state.metrics.pending = false;
|
||||
this.state.metrics.userClick = false;
|
||||
this.state.metrics.userClick = true;
|
||||
this.formValues.UseServerMetrics = true;
|
||||
} catch (_) {
|
||||
this.state.metrics.isServerRunning = false;
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
import { updateIngressControllerClassMap, getIngressControllerClassMap } from '@/react/kubernetes/cluster/ingressClass/utils';
|
||||
import { confirmUpdate } from '@@/modals/confirm';
|
||||
import { confirmUpdateNamespace } from '@/react/kubernetes/namespaces/ItemView/ConfirmUpdateNamespace';
|
||||
import { getMetricsForAllNodes, getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
|
||||
import { getMetricsForAllPods } from '@/react/kubernetes/services/service.ts';
|
||||
|
||||
class KubernetesResourcePoolController {
|
||||
/* #region CONSTRUCTOR */
|
||||
@@ -36,7 +36,8 @@ class KubernetesResourcePoolController {
|
||||
KubernetesApplicationService,
|
||||
KubernetesIngressService,
|
||||
KubernetesVolumeService,
|
||||
KubernetesNamespaceService
|
||||
KubernetesNamespaceService,
|
||||
KubernetesNodeService
|
||||
) {
|
||||
Object.assign(this, {
|
||||
$async,
|
||||
@@ -54,6 +55,7 @@ class KubernetesResourcePoolController {
|
||||
KubernetesIngressService,
|
||||
KubernetesVolumeService,
|
||||
KubernetesNamespaceService,
|
||||
KubernetesNodeService,
|
||||
});
|
||||
|
||||
this.IngressClassTypes = KubernetesIngressClassTypes;
|
||||
@@ -366,7 +368,7 @@ class KubernetesResourcePoolController {
|
||||
|
||||
const name = this.$state.params.id;
|
||||
|
||||
const [nodes, pools] = await Promise.all([getMetricsForAllNodes, this.KubernetesResourcePoolService.get('', { getQuota: true })]);
|
||||
const [nodes, pools] = await Promise.all([this.KubernetesNodeService.get(), this.KubernetesResourcePoolService.get('', { getQuota: true })]);
|
||||
|
||||
this.ingressControllers = [];
|
||||
if (this.state.ingressAvailabilityPerNamespace) {
|
||||
|
||||
@@ -20,7 +20,7 @@ const { CREATE, UPDATE, DELETE } = KubernetesResourceActions;
|
||||
* Get summary of Kubernetes resources to be created, updated or deleted
|
||||
* @param {KubernetesApplicationFormValues} formValues
|
||||
*/
|
||||
export default function (formValues, oldFormValues = {}) {
|
||||
export function getApplicationResources(formValues, oldFormValues = {}) {
|
||||
if (oldFormValues instanceof KubernetesApplicationFormValues) {
|
||||
const resourceSummary = getUpdatedApplicationResources(oldFormValues, formValues);
|
||||
return resourceSummary;
|
||||
@@ -139,9 +139,9 @@ function getUpdatedApplicationResources(oldFormValues, newFormValues) {
|
||||
}
|
||||
|
||||
// Ingress
|
||||
const oldIngresses = KubernetesIngressConverter.applicationFormValuesToIngresses(oldFormValues, oldService.Name);
|
||||
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
|
||||
const oldServicePorts = oldFormValues.Services.flatMap((service) => service.Ports);
|
||||
const oldIngresses = generateNewIngressesFromFormPaths(oldFormValues.OriginalIngresses, oldServicePorts, oldServicePorts);
|
||||
const newServicePorts = newFormValues.Services.flatMap((service) => service.Ports);
|
||||
const newIngresses = generateNewIngressesFromFormPaths(newFormValues.OriginalIngresses, newServicePorts, oldServicePorts);
|
||||
resources.push(...getIngressUpdateSummary(oldIngresses, newIngresses));
|
||||
} else if (!oldService && newService) {
|
||||
@@ -190,7 +190,7 @@ function getApplicationResourceType(app) {
|
||||
function getIngressUpdateSummary(oldIngresses, newIngresses) {
|
||||
const ingressesSummaries = newIngresses
|
||||
.map((newIng) => {
|
||||
const oldIng = _.find(oldIngresses, { Name: newIng.Name });
|
||||
const oldIng = oldIngresses.find((oldIng) => oldIng.Name === newIng.Name);
|
||||
return getIngressUpdateResourceSummary(oldIng, newIng);
|
||||
})
|
||||
.filter((s) => s); // remove nulls
|
||||
|
||||
@@ -3,7 +3,7 @@ import { KubernetesConfigurationFormValues } from 'Kubernetes/models/configurati
|
||||
import { KubernetesResourcePoolFormValues } from 'Kubernetes/models/resource-pool/formValues';
|
||||
import { KubernetesApplicationFormValues } from 'Kubernetes/models/application/formValues';
|
||||
import { KubernetesResourceActions, KubernetesResourceTypes } from 'Kubernetes/models/resource-types/models';
|
||||
import getApplicationResources from './resources/applicationResources';
|
||||
import { getApplicationResources } from './resources/applicationResources';
|
||||
import getNamespaceResources from './resources/namespaceResources';
|
||||
import getConfigurationResources from './resources/configurationResources';
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.ser
|
||||
|
||||
class PorAccessManagementController {
|
||||
/* @ngInject */
|
||||
constructor($scope, Notifications, AccessService, RoleService) {
|
||||
Object.assign(this, { $scope, Notifications, AccessService, RoleService });
|
||||
constructor($scope, $state, Notifications, AccessService, RoleService) {
|
||||
Object.assign(this, { $scope, $state, Notifications, AccessService, RoleService });
|
||||
|
||||
this.limitedToBE = false;
|
||||
this.$state = $state;
|
||||
|
||||
this.unauthorizeAccess = this.unauthorizeAccess.bind(this);
|
||||
this.updateAction = this.updateAction.bind(this);
|
||||
@@ -105,6 +106,7 @@ class PorAccessManagementController {
|
||||
this.availableUsersAndTeams = _.orderBy(data.availableUsersAndTeams, 'Name', 'asc');
|
||||
this.authorizedUsersAndTeams = data.authorizedUsersAndTeams;
|
||||
} catch (err) {
|
||||
this.$state.go('portainer.home');
|
||||
this.availableUsersAndTeams = [];
|
||||
this.authorizedUsersAndTeams = [];
|
||||
this.Notifications.error('Failure', err, 'Unable to retrieve accesses');
|
||||
|
||||
@@ -14,14 +14,19 @@
|
||||
{{ $ctrl.model.Title }}
|
||||
</span>
|
||||
<div class="space-left blocklist-item-subtitle inline-flex items-center">
|
||||
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
|
||||
<span ng-if="!$ctrl.model.Platform"> & </span>
|
||||
<pr-icon
|
||||
icon="'svg-microsoft'"
|
||||
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
|
||||
class-name="'[&>*]:flex [&>*]:items-center'"
|
||||
size="'lg'"
|
||||
></pr-icon>
|
||||
<div ng-if="$ctrl.typeLabel !== 'manifest'" class="vertical-center gap-1">
|
||||
<pr-icon ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform" icon="'svg-linux'" class="mr-1"></pr-icon>
|
||||
<pr-icon
|
||||
icon="'svg-microsoft'"
|
||||
ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"
|
||||
class-name="'[&>*]:flex [&>*]:items-center'"
|
||||
size="'lg'"
|
||||
></pr-icon>
|
||||
</div>
|
||||
<!-- currently only kubernetes uses the typeLabel of 'manifest' -->
|
||||
<div ng-if="$ctrl.typeLabel === 'manifest'" class="vertical-center">
|
||||
<pr-icon icon="'svg-kubernetes'" size="'lg'" class="align-bottom" class-name="'[&>*]:flex [&>*]:items-center'"></pr-icon>
|
||||
</div>
|
||||
{{ $ctrl.typeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,8 +54,8 @@ export function UserService($q, Users, TeamService, TeamMembershipService) {
|
||||
return Users.remove({ id: id }).$promise;
|
||||
};
|
||||
|
||||
service.updateUser = function (id, { password, role, username }) {
|
||||
return Users.update({ id }, { password, role, username }).$promise;
|
||||
service.updateUser = function (id, { newPassword, role, username }) {
|
||||
return Users.update({ id }, { newPassword, role, username }).$promise;
|
||||
};
|
||||
|
||||
service.updateUserPassword = function (id, currentPassword, newPassword) {
|
||||
|
||||
@@ -41,7 +41,7 @@ export default class LdapSettingsBaseDnBuilderController {
|
||||
}
|
||||
|
||||
getOUValues(dn, domainSuffix = '') {
|
||||
const regex = /(\w+)=(\w*),?/;
|
||||
const regex = /(\w+)=([a-zA-Z0-9_ ]*),?/;
|
||||
let ouValues = [];
|
||||
let left = dn;
|
||||
let match = left.match(regex);
|
||||
@@ -49,7 +49,7 @@ export default class LdapSettingsBaseDnBuilderController {
|
||||
const [, type, value] = match;
|
||||
ouValues.push({ type, value });
|
||||
left = left.replace(regex, '');
|
||||
match = left.match(/(\w+)=(\w+),?/);
|
||||
match = left.match(regex);
|
||||
}
|
||||
return ouValues;
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ angular.module('portainer.app').controller('UserController', [
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
UserService.updateUser($scope.user.Id, { password: $scope.formValues.newPassword })
|
||||
UserService.updateUser($scope.user.Id, { newPassword: $scope.formValues.newPassword })
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Password successfully updated');
|
||||
|
||||
|
||||
@@ -117,8 +117,8 @@ export function createMockEnvironment(): Environment {
|
||||
StartTime: '',
|
||||
},
|
||||
StatusMessage: {
|
||||
Detail: '',
|
||||
Summary: '',
|
||||
detail: '',
|
||||
summary: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { semverCompare } from './utils';
|
||||
import { semverCompare } from './semver-utils';
|
||||
|
||||
describe('semverCompare', () => {
|
||||
test('sort array', () => {
|
||||
27
app/react/common/semver-utils.ts
Normal file
27
app/react/common/semver-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Compares two semver strings.
|
||||
*
|
||||
* returns:
|
||||
* - `-1` if `a < b`
|
||||
* - `0` if `a == b`
|
||||
* - `1` if `a > b`
|
||||
*/
|
||||
export function semverCompare(a: string, b: string) {
|
||||
if (a.startsWith(`${b}-`)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (b.startsWith(`${a}-`)) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.localeCompare(b, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'case',
|
||||
caseFirst: 'upper',
|
||||
});
|
||||
}
|
||||
|
||||
export function isVersionSmaller(a: string, b: string) {
|
||||
return semverCompare(a, b) < 0;
|
||||
}
|
||||
19
app/react/components/InlineLoader/InlineLoader.stories.tsx
Normal file
19
app/react/components/InlineLoader/InlineLoader.stories.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Story, Meta } from '@storybook/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
|
||||
import { InlineLoader, Props } from './InlineLoader';
|
||||
|
||||
export default {
|
||||
title: 'Components/InlineLoader',
|
||||
component: InlineLoader,
|
||||
} as Meta;
|
||||
|
||||
function Template({ className, children }: PropsWithChildren<Props>) {
|
||||
return <InlineLoader className={className}>{children}</InlineLoader>;
|
||||
}
|
||||
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
className: 'test-class',
|
||||
children: 'Loading...',
|
||||
};
|
||||
23
app/react/components/InlineLoader/InlineLoader.tsx
Normal file
23
app/react/components/InlineLoader/InlineLoader.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
export type Props = {
|
||||
className: string;
|
||||
};
|
||||
|
||||
export function InlineLoader({
|
||||
children,
|
||||
className,
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx('text-muted flex items-center gap-2 text-sm', className)}
|
||||
>
|
||||
<Icon icon={Loader2} className="animate-spin-slow" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
app/react/components/InlineLoader/index.ts
Normal file
1
app/react/components/InlineLoader/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { InlineLoader } from './InlineLoader';
|
||||
@@ -247,6 +247,12 @@ const docURLs = [
|
||||
locationRegex: /#!\/edge\/jobs/,
|
||||
examples: ['#!/edge/jobs', '#!/edge/jobs/new'],
|
||||
},
|
||||
{
|
||||
desc: 'Edge Compute / Edge Configurations',
|
||||
docURL: 'https://docs.portainer.io/user/edge/configurations',
|
||||
locationRegex: /#!\/edge\/configurations/,
|
||||
examples: ['#!/edge/configurations', '#!/edge/configurations/new'],
|
||||
},
|
||||
{
|
||||
desc: 'Nomad / Dashboard',
|
||||
docURL: 'https://docs.portainer.io/user/nomad/dashboard',
|
||||
|
||||
@@ -8,29 +8,22 @@ interface Props {
|
||||
boundaryLinks?: boolean;
|
||||
currentPage: number;
|
||||
directionLinks?: boolean;
|
||||
itemsPerPage: number;
|
||||
onPageChange(page: number): void;
|
||||
totalCount: number;
|
||||
pageCount: number;
|
||||
maxSize: number;
|
||||
isInputVisible?: boolean;
|
||||
}
|
||||
|
||||
export function PageSelector({
|
||||
currentPage,
|
||||
totalCount,
|
||||
itemsPerPage,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
maxSize = 5,
|
||||
directionLinks = true,
|
||||
boundaryLinks = false,
|
||||
isInputVisible = false,
|
||||
}: Props) {
|
||||
const pages = generatePagesArray(
|
||||
currentPage,
|
||||
totalCount,
|
||||
itemsPerPage,
|
||||
maxSize
|
||||
);
|
||||
const pages = generatePagesArray(currentPage, pageCount, maxSize);
|
||||
const last = pages[pages.length - 1];
|
||||
|
||||
if (pages.length <= 1) {
|
||||
@@ -42,7 +35,7 @@ export function PageSelector({
|
||||
{isInputVisible && (
|
||||
<PageInput
|
||||
onChange={(page) => onPageChange(page)}
|
||||
totalPages={Math.ceil(totalCount / itemsPerPage)}
|
||||
totalPages={pageCount}
|
||||
/>
|
||||
)}
|
||||
<ul className="pagination">
|
||||
|
||||
@@ -9,7 +9,7 @@ interface Props {
|
||||
page: number;
|
||||
pageLimit: number;
|
||||
showAll?: boolean;
|
||||
totalCount: number;
|
||||
pageCount: number;
|
||||
isPageInputVisible?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export function PaginationControls({
|
||||
onPageLimitChange,
|
||||
showAll,
|
||||
onPageChange,
|
||||
totalCount,
|
||||
pageCount,
|
||||
isPageInputVisible,
|
||||
className,
|
||||
}: Props) {
|
||||
@@ -38,8 +38,7 @@ export function PaginationControls({
|
||||
maxSize={5}
|
||||
onPageChange={onPageChange}
|
||||
currentPage={page}
|
||||
itemsPerPage={pageLimit}
|
||||
totalCount={totalCount}
|
||||
pageCount={pageCount}
|
||||
isInputVisible={isPageInputVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -12,12 +12,10 @@ export /**
|
||||
*/
|
||||
function generatePagesArray(
|
||||
currentPage: number,
|
||||
collectionLength: number,
|
||||
rowsPerPage: number,
|
||||
totalPages: number,
|
||||
paginationRange: number
|
||||
): (number | '...')[] {
|
||||
const pages: (number | '...')[] = [];
|
||||
const totalPages = Math.ceil(collectionLength / rowsPerPage);
|
||||
const halfWay = Math.ceil(paginationRange / 2);
|
||||
|
||||
let position;
|
||||
|
||||
@@ -17,7 +17,7 @@ interface WidgetProps {
|
||||
}
|
||||
|
||||
const meta: Meta<WidgetProps> = {
|
||||
title: 'Widget',
|
||||
title: 'Components/Widget',
|
||||
component: Widget,
|
||||
args: {
|
||||
loading: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
TableState,
|
||||
useReactTable,
|
||||
Row,
|
||||
Column,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
getFilteredRowModel,
|
||||
@@ -33,6 +34,20 @@ import { createSelectColumn } from './select-column';
|
||||
import { TableRow } from './TableRow';
|
||||
import { type TableState as GlobalTableState } from './useTableState';
|
||||
|
||||
export type PaginationProps =
|
||||
| {
|
||||
isServerSidePagination?: false;
|
||||
totalCount?: never;
|
||||
page?: never;
|
||||
onPageChange?: never;
|
||||
}
|
||||
| {
|
||||
isServerSidePagination: true;
|
||||
totalCount: number;
|
||||
page: number;
|
||||
onPageChange(page: number): void;
|
||||
};
|
||||
|
||||
export interface Props<
|
||||
D extends Record<string, unknown>,
|
||||
TMeta extends TableMeta<D> = TableMeta<D>
|
||||
@@ -49,12 +64,8 @@ export interface Props<
|
||||
titleIcon?: IconProps['icon'];
|
||||
initialTableState?: Partial<TableState>;
|
||||
isLoading?: boolean;
|
||||
totalCount?: number;
|
||||
description?: ReactNode;
|
||||
pageCount?: number;
|
||||
highlightedItemId?: string;
|
||||
onPageChange?(page: number): void;
|
||||
|
||||
settingsManager: GlobalTableState<BasicTableSettings>;
|
||||
renderRow?(row: Row<D>, highlightedItemId?: string): ReactNode;
|
||||
getRowCanExpand?(row: Row<D>): boolean;
|
||||
@@ -78,10 +89,7 @@ export function Datatable<
|
||||
emptyContentLabel,
|
||||
initialTableState = {},
|
||||
isLoading,
|
||||
totalCount = dataset.length,
|
||||
description,
|
||||
pageCount,
|
||||
onPageChange = () => null,
|
||||
settingsManager: settings,
|
||||
renderRow = defaultRenderRow,
|
||||
highlightedItemId,
|
||||
@@ -89,8 +97,16 @@ export function Datatable<
|
||||
getRowCanExpand,
|
||||
'data-cy': dataCy,
|
||||
meta,
|
||||
}: Props<D, TMeta>) {
|
||||
const isServerSidePagination = typeof pageCount !== 'undefined';
|
||||
onPageChange = () => {},
|
||||
page,
|
||||
totalCount = dataset.length,
|
||||
isServerSidePagination = false,
|
||||
}: Props<D, TMeta> & PaginationProps) {
|
||||
const pageCount = useMemo(
|
||||
() => Math.ceil(totalCount / settings.pageSize),
|
||||
[settings.pageSize, totalCount]
|
||||
);
|
||||
|
||||
const enableRowSelection = getIsSelectionEnabled(
|
||||
disableSelect,
|
||||
isRowSelectable
|
||||
@@ -107,6 +123,7 @@ export function Datatable<
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: settings.pageSize,
|
||||
pageIndex: page || 0,
|
||||
},
|
||||
sorting: settings.sortBy ? [settings.sortBy] : [],
|
||||
globalFilter: settings.search,
|
||||
@@ -116,6 +133,7 @@ export function Datatable<
|
||||
defaultColumn: {
|
||||
enableColumnFilter: false,
|
||||
enableHiding: true,
|
||||
sortingFn: 'alphanumeric',
|
||||
},
|
||||
enableRowSelection,
|
||||
autoResetExpanded: false,
|
||||
@@ -123,14 +141,18 @@ export function Datatable<
|
||||
getRowId,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
getFacetedMinMaxValues: getFacetedMinMaxValues(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
getRowCanExpand,
|
||||
...(isServerSidePagination ? { manualPagination: true, pageCount } : {}),
|
||||
getColumnCanGlobalFilter,
|
||||
...(isServerSidePagination
|
||||
? { manualPagination: true, pageCount }
|
||||
: {
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
}),
|
||||
meta,
|
||||
});
|
||||
|
||||
@@ -158,6 +180,7 @@ export function Datatable<
|
||||
renderTableActions={() => renderTableActions(selectedItems)}
|
||||
renderTableSettings={() => renderTableSettings(tableInstance)}
|
||||
/>
|
||||
|
||||
<DatatableContent<D>
|
||||
tableInstance={tableInstance}
|
||||
renderRow={(row) => renderRow(row, highlightedItemId)}
|
||||
@@ -170,9 +193,9 @@ export function Datatable<
|
||||
<DatatableFooter
|
||||
onPageChange={handlePageChange}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
page={tableState.pagination.pageIndex}
|
||||
page={typeof page === 'number' ? page : tableState.pagination.pageIndex}
|
||||
pageSize={tableState.pagination.pageSize}
|
||||
totalCount={totalCount}
|
||||
pageCount={tableInstance.getPageCount()}
|
||||
totalSelected={selectedItems.length}
|
||||
/>
|
||||
</Table.Container>
|
||||
@@ -258,3 +281,10 @@ function globalFilterFn<D>(
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getColumnCanGlobalFilter<D>(column: Column<D, unknown>): boolean {
|
||||
if (column.id === 'select') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ interface Props {
|
||||
pageSize: number;
|
||||
page: number;
|
||||
onPageChange(page: number): void;
|
||||
totalCount: number;
|
||||
pageCount: number;
|
||||
onPageSizeChange(pageSize: number): void;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export function DatatableFooter({
|
||||
pageSize,
|
||||
page,
|
||||
onPageChange,
|
||||
totalCount,
|
||||
pageCount,
|
||||
onPageSizeChange,
|
||||
}: Props) {
|
||||
return (
|
||||
@@ -28,7 +28,7 @@ export function DatatableFooter({
|
||||
pageLimit={pageSize}
|
||||
page={page + 1}
|
||||
onPageChange={(page) => onPageChange(page - 1)}
|
||||
totalCount={totalCount}
|
||||
pageCount={pageCount}
|
||||
onPageLimitChange={onPageSizeChange}
|
||||
/>
|
||||
</Table.Footer>
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Row } from '@tanstack/react-table';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ExpandableDatatableTableRow } from './ExpandableDatatableRow';
|
||||
import { Datatable, Props as DatatableProps } from './Datatable';
|
||||
import {
|
||||
Datatable,
|
||||
Props as DatatableProps,
|
||||
PaginationProps,
|
||||
} from './Datatable';
|
||||
|
||||
interface Props<D extends Record<string, unknown>>
|
||||
extends Omit<DatatableProps<D>, 'renderRow' | 'expandable'> {
|
||||
@@ -15,7 +19,7 @@ export function ExpandableDatatable<D extends Record<string, unknown>>({
|
||||
getRowCanExpand = () => true,
|
||||
expandOnRowClick,
|
||||
...props
|
||||
}: Props<D>) {
|
||||
}: Props<D> & PaginationProps) {
|
||||
return (
|
||||
<Datatable<D>
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
|
||||
@@ -17,7 +17,6 @@ export function buildNameColumn<T extends Record<string, unknown>>(
|
||||
cell,
|
||||
enableSorting: true,
|
||||
enableHiding: false,
|
||||
sortingFn: 'text',
|
||||
};
|
||||
|
||||
function createCell<T extends Record<string, unknown>>() {
|
||||
|
||||
@@ -31,6 +31,22 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.portainer-selector-root .portainer-selector__group-heading {
|
||||
text-transform: none !important;
|
||||
font-size: 85% !important;
|
||||
}
|
||||
|
||||
.input-group .portainer-selector-root:last-child .portainer-selector__control {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.input-group .portainer-selector-root:not(:first-child):not(:last-child) .portainer-selector__control {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* input style */
|
||||
.portainer-selector-root .portainer-selector__control {
|
||||
border-color: var(--border-form-control-color);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from 'clsx';
|
||||
import { ComponentProps } from 'react';
|
||||
import uuid from 'uuid';
|
||||
|
||||
import { FeatureId } from '@/react/portainer/feature-flags/enums';
|
||||
|
||||
@@ -30,7 +31,7 @@ export function SwitchField({
|
||||
checked,
|
||||
label,
|
||||
index,
|
||||
name,
|
||||
name = uuid(),
|
||||
labelClass,
|
||||
fieldClass,
|
||||
dataCy,
|
||||
@@ -43,15 +44,16 @@ export function SwitchField({
|
||||
const toggleName = name ? `toggle_${name}` : '';
|
||||
|
||||
return (
|
||||
<label className={clsx(styles.root, fieldClass)}>
|
||||
<span
|
||||
<div className={clsx(styles.root, fieldClass)}>
|
||||
<label
|
||||
className={clsx('space-right control-label !p-0 text-left', labelClass)}
|
||||
htmlFor={toggleName}
|
||||
>
|
||||
{label}
|
||||
{tooltip && (
|
||||
<Tooltip message={tooltip} setHtmlMessage={setTooltipHtmlMessage} />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
<Switch
|
||||
className={clsx('space-right', switchClass)}
|
||||
name={toggleName}
|
||||
@@ -63,6 +65,6 @@ export function SwitchField({
|
||||
featureId={featureId}
|
||||
dataCy={dataCy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,6 @@ export const size = columnHelper.accessor('VirtualSize', {
|
||||
header: 'Size',
|
||||
cell: ({ getValue }) => {
|
||||
const value = getValue();
|
||||
return humanize(value);
|
||||
return humanize(value) || '-';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -28,6 +28,7 @@ export function AssociatedEdgeEnvironmentsSelector({
|
||||
emptyContentLabel="No environment available"
|
||||
query={{
|
||||
types: EdgeTypes,
|
||||
excludeIds: value,
|
||||
}}
|
||||
onClickRow={(env) => {
|
||||
if (!value.includes(env.Id)) {
|
||||
@@ -35,7 +36,6 @@ export function AssociatedEdgeEnvironmentsSelector({
|
||||
}
|
||||
}}
|
||||
data-cy="edgeGroupCreate-availableEndpoints"
|
||||
hideEnvironmentIds={value}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2">
|
||||
|
||||
@@ -3,10 +3,7 @@ import { truncate } from 'lodash';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useEnvironmentList } from '@/react/portainer/environments/queries';
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentId,
|
||||
} from '@/react/portainer/environments/types';
|
||||
import { Environment } from '@/react/portainer/environments/types';
|
||||
import { useGroups } from '@/react/portainer/environments/environment-groups/queries';
|
||||
import { useTags } from '@/portainer/tags/queries';
|
||||
import { EnvironmentsQueryParams } from '@/react/portainer/environments/environment.service';
|
||||
@@ -47,19 +44,17 @@ export function EdgeGroupAssociationTable({
|
||||
emptyContentLabel,
|
||||
onClickRow,
|
||||
'data-cy': dataCy,
|
||||
hideEnvironmentIds = [],
|
||||
}: {
|
||||
title: string;
|
||||
query: EnvironmentsQueryParams;
|
||||
emptyContentLabel: string;
|
||||
onClickRow: (env: Environment) => void;
|
||||
hideEnvironmentIds?: EnvironmentId[];
|
||||
} & AutomationTestingProps) {
|
||||
const tableState = useTableStateWithoutStorage('Name');
|
||||
const [page, setPage] = useState(1);
|
||||
const [page, setPage] = useState(0);
|
||||
const environmentsQuery = useEnvironmentList({
|
||||
pageLimit: tableState.pageSize,
|
||||
page,
|
||||
page: page + 1,
|
||||
search: tableState.search,
|
||||
sort: tableState.sortBy.id as 'Group' | 'Name',
|
||||
order: tableState.sortBy.desc ? 'desc' : 'asc',
|
||||
@@ -74,25 +69,17 @@ export function EdgeGroupAssociationTable({
|
||||
|
||||
const environments: Array<DecoratedEnvironment> = useMemo(
|
||||
() =>
|
||||
environmentsQuery.environments
|
||||
.filter((e) => !hideEnvironmentIds.includes(e.Id))
|
||||
.map((env) => ({
|
||||
...env,
|
||||
Group:
|
||||
groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
|
||||
Tags: env.TagIds.map(
|
||||
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
|
||||
),
|
||||
})),
|
||||
[
|
||||
environmentsQuery.environments,
|
||||
groupsQuery.data,
|
||||
hideEnvironmentIds,
|
||||
tagsQuery.data,
|
||||
]
|
||||
environmentsQuery.environments.map((env) => ({
|
||||
...env,
|
||||
Group: groupsQuery.data?.find((g) => g.Id === env.GroupId)?.Name || '',
|
||||
Tags: env.TagIds.map(
|
||||
(tagId) => tagsQuery.data?.find((t) => t.ID === tagId)?.Name || ''
|
||||
),
|
||||
})),
|
||||
[environmentsQuery.environments, groupsQuery.data, tagsQuery.data]
|
||||
);
|
||||
|
||||
const totalCount = environmentsQuery.totalCount - hideEnvironmentIds.length;
|
||||
const { totalCount } = environmentsQuery;
|
||||
|
||||
return (
|
||||
<Datatable<DecoratedEnvironment>
|
||||
@@ -100,8 +87,10 @@ export function EdgeGroupAssociationTable({
|
||||
columns={columns}
|
||||
settingsManager={tableState}
|
||||
dataset={environments}
|
||||
isServerSidePagination
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
pageCount={Math.ceil(totalCount / tableState.pageSize)}
|
||||
totalCount={totalCount}
|
||||
renderRow={(row) => (
|
||||
<TableRow<DecoratedEnvironment>
|
||||
cells={row.getVisibleCells()}
|
||||
@@ -111,7 +100,6 @@ export function EdgeGroupAssociationTable({
|
||||
emptyContentLabel={emptyContentLabel}
|
||||
data-cy={dataCy}
|
||||
disableSelect
|
||||
totalCount={totalCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user