Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 730e05f40c |
@@ -94,14 +94,8 @@ body:
|
||||
description: We only provide support for current versions of Portainer as per the lifecycle policy linked above. If you are on an older version of Portainer we recommend [updating first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
|
||||
multiple: false
|
||||
options:
|
||||
- '2.29.2'
|
||||
- '2.29.1'
|
||||
- '2.29.0'
|
||||
- '2.28.1'
|
||||
- '2.28.0'
|
||||
- '2.27.6'
|
||||
- '2.27.5'
|
||||
- '2.27.4'
|
||||
- '2.27.3'
|
||||
- '2.27.2'
|
||||
- '2.27.1'
|
||||
|
||||
@@ -12,7 +12,6 @@ linters:
|
||||
- copyloopvar
|
||||
- intrange
|
||||
- perfsprint
|
||||
- ineffassign
|
||||
|
||||
linters-settings:
|
||||
depguard:
|
||||
|
||||
+50
-5
@@ -2,6 +2,7 @@ package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -11,6 +12,50 @@ import (
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
||||
func UnzipArchive(archiveData []byte, dest string) error {
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, zipFile := range zipReader.File {
|
||||
err := extractFileFromArchive(zipFile, dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractFileFromArchive(file *zip.File, dest string) error {
|
||||
f, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fpath := filepath.Join(dest, file.Name)
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return outFile.Close()
|
||||
}
|
||||
|
||||
// UnzipFile will decompress a zip archive, moving all files and folders
|
||||
// within the zip file (parameter 1) to an output directory (parameter 2).
|
||||
func UnzipFile(src string, dest string) error {
|
||||
@@ -31,11 +76,11 @@ func UnzipFile(src string, dest string) error {
|
||||
if f.FileInfo().IsDir() {
|
||||
// Make Folder
|
||||
os.MkdirAll(p, os.ModePerm)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := unzipFile(f, p); err != nil {
|
||||
err = unzipFile(f, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -48,20 +93,20 @@ func unzipFile(f *zip.File, p string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(p), os.ModePerm); err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't make a path %s", p)
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't create file %s", p)
|
||||
}
|
||||
defer outFile.Close()
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't open zip file %s in the archive", f.Name)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
if _, err = io.Copy(outFile, rc); err != nil {
|
||||
_, err = io.Copy(outFile, rc)
|
||||
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "unzipFile: can't copy an archived file content")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"gopkg.in/alecthomas/kingpin.v2"
|
||||
)
|
||||
|
||||
type pairListBool []portainer.Pair
|
||||
|
||||
// Set implementation for a list of portainer.Pair
|
||||
func (l *pairListBool) Set(value string) error {
|
||||
p := new(portainer.Pair)
|
||||
|
||||
// default to true. example setting=true is equivalent to setting
|
||||
parts := strings.SplitN(value, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
p.Name = parts[0]
|
||||
p.Value = "true"
|
||||
} else {
|
||||
p.Name = parts[0]
|
||||
p.Value = parts[1]
|
||||
}
|
||||
|
||||
*l = append(*l, *p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// String implementation for a list of pair
|
||||
func (l *pairListBool) String() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsCumulative implementation for a list of pair
|
||||
func (l *pairListBool) IsCumulative() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func BoolPairs(s kingpin.Settings) (target *[]portainer.Pair) {
|
||||
target = new([]portainer.Pair)
|
||||
s.SetValue((*pairListBool)(target))
|
||||
return
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package logs
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
func ConfigureLogger() {
|
||||
func configureLogger() {
|
||||
zerolog.ErrorStackFieldName = "stack_trace"
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
|
||||
@@ -21,7 +21,7 @@ func ConfigureLogger() {
|
||||
log.Logger = log.Logger.With().Caller().Stack().Logger()
|
||||
}
|
||||
|
||||
func SetLoggingLevel(level string) {
|
||||
func setLoggingLevel(level string) {
|
||||
switch level {
|
||||
case "ERROR":
|
||||
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
|
||||
@@ -34,7 +34,7 @@ func SetLoggingLevel(level string) {
|
||||
}
|
||||
}
|
||||
|
||||
func SetLoggingMode(mode string) {
|
||||
func setLoggingMode(mode string) {
|
||||
switch mode {
|
||||
case "PRETTY":
|
||||
log.Logger = log.Output(zerolog.ConsoleWriter{
|
||||
@@ -39,7 +39,6 @@ import (
|
||||
"github.com/portainer/portainer/api/kubernetes"
|
||||
kubecli "github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/api/ldap"
|
||||
"github.com/portainer/portainer/api/logs"
|
||||
"github.com/portainer/portainer/api/oauth"
|
||||
"github.com/portainer/portainer/api/pendingactions"
|
||||
"github.com/portainer/portainer/api/pendingactions/actions"
|
||||
@@ -167,8 +166,8 @@ func checkDBSchemaServerVersionMatch(dbStore dataservices.DataStore, serverVersi
|
||||
return v.SchemaVersion == serverVersion && v.Edition == serverEdition
|
||||
}
|
||||
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
func initKubernetesDeployer(kubernetesTokenCacheManager *kubeproxy.TokenCacheManager, kubernetesClientFactory *kubecli.ClientFactory, dataStore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, assetsPath string) portainer.KubernetesDeployer {
|
||||
return exec.NewKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, assetsPath)
|
||||
}
|
||||
|
||||
func initHelmPackageManager() (libhelmtypes.HelmPackageManager, error) {
|
||||
@@ -423,7 +422,7 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
log.Fatal().Err(err).Msg("failed initializing swarm stack manager")
|
||||
}
|
||||
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager)
|
||||
kubernetesDeployer := initKubernetesDeployer(kubernetesTokenCacheManager, kubernetesClientFactory, dataStore, reverseTunnelService, signatureService, proxyManager, *flags.Assets)
|
||||
|
||||
pendingActionsService := pendingactions.NewService(dataStore, kubernetesClientFactory)
|
||||
pendingActionsService.RegisterHandler(actions.CleanNAPWithOverridePolicies, handlers.NewHandlerCleanNAPWithOverridePolicies(authorizationService, dataStore))
|
||||
@@ -582,13 +581,13 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server {
|
||||
}
|
||||
|
||||
func main() {
|
||||
logs.ConfigureLogger()
|
||||
logs.SetLoggingMode("PRETTY")
|
||||
configureLogger()
|
||||
setLoggingMode("PRETTY")
|
||||
|
||||
flags := initCLI()
|
||||
|
||||
logs.SetLoggingLevel(*flags.LogLevel)
|
||||
logs.SetLoggingMode(*flags.LogMode)
|
||||
setLoggingLevel(*flags.LogLevel)
|
||||
setLoggingMode(*flags.LogMode)
|
||||
|
||||
for {
|
||||
server := buildServer(flags)
|
||||
|
||||
@@ -18,7 +18,8 @@ func (m *Migrator) updateResourceControlsToDBVersion22() error {
|
||||
for _, resourceControl := range legacyResourceControls {
|
||||
resourceControl.AdministratorsOnly = false
|
||||
|
||||
if err := m.resourceControlService.Update(resourceControl.ID, &resourceControl); err != nil {
|
||||
err := m.resourceControlService.Update(resourceControl.ID, &resourceControl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -41,8 +42,8 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
|
||||
|
||||
for _, user := range legacyUsers {
|
||||
user.PortainerAuthorizations = authorization.DefaultPortainerAuthorizations()
|
||||
|
||||
if err := m.userService.Update(user.ID, &user); err != nil {
|
||||
err = m.userService.Update(user.ID, &user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -51,47 +52,38 @@ func (m *Migrator) updateUsersAndRolesToDBVersion22() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endpointAdministratorRole.Priority = 1
|
||||
endpointAdministratorRole.Authorizations = authorization.DefaultEndpointAuthorizationsForEndpointAdministratorRole()
|
||||
|
||||
if err := m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole); err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.roleService.Update(endpointAdministratorRole.ID, endpointAdministratorRole)
|
||||
|
||||
helpDeskRole, err := m.roleService.Read(portainer.RoleID(2))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
helpDeskRole.Priority = 2
|
||||
helpDeskRole.Authorizations = authorization.DefaultEndpointAuthorizationsForHelpDeskRole(settings.AllowVolumeBrowserForRegularUsers)
|
||||
|
||||
if err := m.roleService.Update(helpDeskRole.ID, helpDeskRole); err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.roleService.Update(helpDeskRole.ID, helpDeskRole)
|
||||
|
||||
standardUserRole, err := m.roleService.Read(portainer.RoleID(3))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
standardUserRole.Priority = 3
|
||||
standardUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForStandardUserRole(settings.AllowVolumeBrowserForRegularUsers)
|
||||
|
||||
if err := m.roleService.Update(standardUserRole.ID, standardUserRole); err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.roleService.Update(standardUserRole.ID, standardUserRole)
|
||||
|
||||
readOnlyUserRole, err := m.roleService.Read(portainer.RoleID(4))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
readOnlyUserRole.Priority = 4
|
||||
readOnlyUserRole.Authorizations = authorization.DefaultEndpointAuthorizationsForReadOnlyUserRole(settings.AllowVolumeBrowserForRegularUsers)
|
||||
|
||||
if err := m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole); err != nil {
|
||||
err = m.roleService.Update(readOnlyUserRole.ID, readOnlyUserRole)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -610,7 +610,7 @@
|
||||
"RequiredPasswordLength": 12
|
||||
},
|
||||
"KubeconfigExpiry": "0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.30.0",
|
||||
"KubectlShellImage": "portainer/kubectl-shell:2.29.0",
|
||||
"LDAPSettings": {
|
||||
"AnonymousMode": true,
|
||||
"AutoCreateUsers": true,
|
||||
@@ -943,7 +943,7 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.30.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.29.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
},
|
||||
"webhooks": null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
var validate *validator.Validate
|
||||
|
||||
func ValidateLDAPSettings(ldp *portainer.LDAPSettings) error {
|
||||
validate = validator.New()
|
||||
registerValidationMethods(validate)
|
||||
|
||||
return validate.Struct(ldp)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
func TestValidateLDAPSettings(t *testing.T) {
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ldap portainer.LDAPSettings
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Empty LDAP Settings",
|
||||
ldap: portainer.LDAPSettings{},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "With URL",
|
||||
ldap: portainer.LDAPSettings{
|
||||
AnonymousMode: true,
|
||||
URL: "192.168.0.1:323",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "Validate URL and URLs",
|
||||
ldap: portainer.LDAPSettings{
|
||||
AnonymousMode: true,
|
||||
URL: "192.168.0.1:323",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "validate client ldap",
|
||||
ldap: portainer.LDAPSettings{
|
||||
AnonymousMode: false,
|
||||
ReaderDN: "CN=LDAP API Service Account",
|
||||
Password: "Qu**dfUUU**",
|
||||
URL: "aukdc15.pgc.co:389",
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
TLSSkipVerify: false,
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateLDAPSettings(&tt.ldap)
|
||||
if (err == nil) == tt.wantErr {
|
||||
t.Errorf("No error expected but got %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
func registerValidationMethods(v *validator.Validate) {
|
||||
v.RegisterValidation("validate_bool", ValidateBool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation methods below are being used for custom validation
|
||||
*/
|
||||
func ValidateBool(fl validator.FieldLevel) bool {
|
||||
_, ok := fl.Field().Interface().(bool)
|
||||
return ok
|
||||
}
|
||||
@@ -73,6 +73,19 @@ func createLocalClient(endpoint *portainer.Endpoint) (*client.Client, error) {
|
||||
)
|
||||
}
|
||||
|
||||
func CreateClientFromEnv() (*client.Client, error) {
|
||||
return client.NewClientWithOpts(
|
||||
client.FromEnv,
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
}
|
||||
|
||||
func CreateSimpleClient() (*client.Client, error) {
|
||||
return client.NewClientWithOpts(
|
||||
client.WithAPIVersionNegotiation(),
|
||||
)
|
||||
}
|
||||
|
||||
func createTCPClient(endpoint *portainer.Endpoint, timeout *time.Duration) (*client.Client, error) {
|
||||
httpCli, err := httpClient(endpoint, timeout)
|
||||
if err != nil {
|
||||
|
||||
@@ -38,10 +38,10 @@ func NewClientWithRegistry(registryClient *RegistryClient, clientFactory *docker
|
||||
func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
|
||||
ctx, cancel := c.timeoutContext()
|
||||
defer cancel()
|
||||
|
||||
// Docker references with both a tag and digest are currently not supported
|
||||
if image.Tag != "" && image.Digest != "" {
|
||||
if err := image.TrimDigest(); err != nil {
|
||||
err := image.trimDigest()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (c *DigestClient) RemoteDigest(image Image) (digest.Digest, error) {
|
||||
// Retrieve remote digest through HEAD request
|
||||
rmDigest, err := docker.GetDigest(ctx, sysCtx, rmRef)
|
||||
if err != nil {
|
||||
// Fallback to public registry for hub
|
||||
// fallback to public registry for hub
|
||||
if image.HubLink != "" {
|
||||
rmDigest, err = docker.GetDigest(ctx, c.sysCtx, rmRef)
|
||||
if err == nil {
|
||||
@@ -131,7 +131,8 @@ func ParseRepoDigests(repoDigests []string) []digest.Digest {
|
||||
func ParseRepoTags(repoTags []string) []*Image {
|
||||
images := make([]*Image, 0)
|
||||
for _, repoTag := range repoTags {
|
||||
if image := ParseRepoTag(repoTag); image != nil {
|
||||
image := ParseRepoTag(repoTag)
|
||||
if image != nil {
|
||||
images = append(images, image)
|
||||
}
|
||||
}
|
||||
@@ -146,7 +147,7 @@ func ParseRepoDigest(repoDigest string) digest.Digest {
|
||||
|
||||
d, err := digest.Parse(strings.Split(repoDigest, "@")[1])
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Str("digest", repoDigest).Msg("skip invalid repo item")
|
||||
log.Warn().Msgf("Skip invalid repo digest item: %s [error: %v]", repoDigest, err)
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ type Image struct {
|
||||
Digest digest.Digest
|
||||
HubLink string
|
||||
named reference.Named
|
||||
Opts ParseImageOptions `json:"-"`
|
||||
opts ParseImageOptions
|
||||
}
|
||||
|
||||
// ParseImageOptions holds image options for parsing.
|
||||
@@ -43,10 +43,9 @@ func (i *Image) Name() string {
|
||||
// FullName return the real full name may include Tag or Digest of the image, Tag first.
|
||||
func (i *Image) FullName() string {
|
||||
if i.Tag == "" {
|
||||
return i.Name() + "@" + i.Digest.String()
|
||||
return fmt.Sprintf("%s@%s", i.Name(), i.Digest)
|
||||
}
|
||||
|
||||
return i.Name() + ":" + i.Tag
|
||||
return fmt.Sprintf("%s:%s", i.Name(), i.Tag)
|
||||
}
|
||||
|
||||
// String returns the string representation of an image, including Tag and Digest if existed.
|
||||
@@ -67,25 +66,22 @@ func (i *Image) Reference() string {
|
||||
func (i *Image) WithDigest(digest digest.Digest) (err error) {
|
||||
i.Digest = digest
|
||||
i.named, err = reference.WithDigest(i.named, digest)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *Image) WithTag(tag string) (err error) {
|
||||
i.Tag = tag
|
||||
i.named, err = reference.WithTag(i.named, tag)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (i *Image) TrimDigest() error {
|
||||
func (i *Image) trimDigest() error {
|
||||
i.Digest = ""
|
||||
named, err := ParseImage(ParseImageOptions{Name: i.FullName()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
i.named = &named
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -96,12 +92,11 @@ func ParseImage(parseOpts ParseImageOptions) (Image, error) {
|
||||
if err != nil {
|
||||
return Image{}, errors.Wrapf(err, "parsing image %s failed", parseOpts.Name)
|
||||
}
|
||||
|
||||
// Add the latest lag if they did not provide one.
|
||||
named = reference.TagNameOnly(named)
|
||||
|
||||
i := Image{
|
||||
Opts: parseOpts,
|
||||
opts: parseOpts,
|
||||
named: named,
|
||||
Domain: reference.Domain(named),
|
||||
Path: reference.Path(named),
|
||||
@@ -127,16 +122,15 @@ func ParseImage(parseOpts ParseImageOptions) (Image, error) {
|
||||
}
|
||||
|
||||
func (i *Image) hubLink() (string, error) {
|
||||
if i.Opts.HubTpl != "" {
|
||||
if i.opts.HubTpl != "" {
|
||||
var out bytes.Buffer
|
||||
tmpl, err := template.New("tmpl").
|
||||
Option("missingkey=error").
|
||||
Parse(i.Opts.HubTpl)
|
||||
Parse(i.opts.HubTpl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
err = tmpl.Execute(&out, i)
|
||||
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
@@ -148,7 +142,6 @@ func (i *Image) hubLink() (string, error) {
|
||||
prefix = "_"
|
||||
path = strings.Replace(i.Path, "library/", "", 1)
|
||||
}
|
||||
|
||||
return "https://hub.docker.com/" + prefix + "/" + path, nil
|
||||
case "docker.bintray.io", "jfrog-docker-reg2.bintray.io":
|
||||
return "https://bintray.com/jfrog/reg2/" + strings.ReplaceAll(i.Path, "/", "%3A"), nil
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestImageParser(t *testing.T) {
|
||||
})
|
||||
is.NoError(err, "")
|
||||
is.Equal("docker.io/portainer/portainer-ee:latest", image.FullName())
|
||||
is.Equal("portainer/portainer-ee", image.Opts.Name)
|
||||
is.Equal("portainer/portainer-ee", image.opts.Name)
|
||||
is.Equal("latest", image.Tag)
|
||||
is.Equal("portainer/portainer-ee", image.Path)
|
||||
is.Equal("docker.io", image.Domain)
|
||||
@@ -32,7 +32,7 @@ func TestImageParser(t *testing.T) {
|
||||
})
|
||||
is.NoError(err, "")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
|
||||
is.Equal("", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
@@ -49,7 +49,7 @@ func TestImageParser(t *testing.T) {
|
||||
})
|
||||
is.NoError(err, "")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
@@ -71,7 +71,7 @@ func TestUpdateParsedImage(t *testing.T) {
|
||||
is.NoError(err, "")
|
||||
_ = image.WithTag("v0.0.31")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.31", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
|
||||
is.Equal("v0.0.31", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
@@ -89,7 +89,7 @@ func TestUpdateParsedImage(t *testing.T) {
|
||||
is.NoError(err, "")
|
||||
_ = image.WithDigest("sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b3")
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
@@ -105,9 +105,9 @@ func TestUpdateParsedImage(t *testing.T) {
|
||||
Name: "gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2",
|
||||
})
|
||||
is.NoError(err, "")
|
||||
_ = image.TrimDigest()
|
||||
_ = image.trimDigest()
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30", image.FullName())
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.Opts.Name)
|
||||
is.Equal("gcr.io/k8s-minikube/kicbase:v0.0.30@sha256:02c921df998f95e849058af14de7045efc3954d90320967418a0d1f182bbc0b2", image.opts.Name)
|
||||
is.Equal("v0.0.30", image.Tag)
|
||||
is.Equal("k8s-minikube/kicbase", image.Path)
|
||||
is.Equal("gcr.io", image.Domain)
|
||||
|
||||
@@ -29,7 +29,7 @@ func (c *RegistryClient) RegistryAuth(image Image) (string, string, error) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
|
||||
registry, err := findBestMatchRegistry(image.opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -59,7 +59,7 @@ func (c *RegistryClient) EncodedRegistryAuth(image Image) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
registry, err := findBestMatchRegistry(image.Opts.Name, registries)
|
||||
registry, err := findBestMatchRegistry(image.opts.Name, registries)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -12,15 +12,3 @@ type kubernetesMockDeployer struct {
|
||||
func NewKubernetesDeployer() *kubernetesMockDeployer {
|
||||
return &kubernetesMockDeployer{}
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (deployer *kubernetesMockDeployer) Restart(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
@@ -10,17 +15,13 @@ import (
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory/kubernetes"
|
||||
"github.com/portainer/portainer/api/kubernetes/cli"
|
||||
"github.com/portainer/portainer/pkg/libkubectl"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultServerURL = "https://kubernetes.default.svc"
|
||||
)
|
||||
|
||||
// KubernetesDeployer represents a service to deploy resources inside a Kubernetes environment(endpoint).
|
||||
type KubernetesDeployer struct {
|
||||
binaryPath string
|
||||
dataStore dataservices.DataStore
|
||||
reverseTunnelService portainer.ReverseTunnelService
|
||||
signatureService portainer.DigitalSignatureService
|
||||
@@ -30,8 +31,9 @@ type KubernetesDeployer struct {
|
||||
}
|
||||
|
||||
// NewKubernetesDeployer initializes a new KubernetesDeployer service.
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager) *KubernetesDeployer {
|
||||
func NewKubernetesDeployer(kubernetesTokenCacheManager *kubernetes.TokenCacheManager, kubernetesClientFactory *cli.ClientFactory, datastore dataservices.DataStore, reverseTunnelService portainer.ReverseTunnelService, signatureService portainer.DigitalSignatureService, proxyManager *proxy.Manager, binaryPath string) *KubernetesDeployer {
|
||||
return &KubernetesDeployer{
|
||||
binaryPath: binaryPath,
|
||||
dataStore: datastore,
|
||||
reverseTunnelService: reverseTunnelService,
|
||||
signatureService: signatureService,
|
||||
@@ -76,56 +78,63 @@ func (deployer *KubernetesDeployer) getToken(userID portainer.UserID, endpoint *
|
||||
}
|
||||
|
||||
// Deploy upserts Kubernetes resources defined in manifest(s)
|
||||
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
return deployer.command("apply", userID, endpoint, resources, namespace)
|
||||
func (deployer *KubernetesDeployer) Deploy(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return deployer.command("apply", userID, endpoint, manifestFiles, namespace)
|
||||
}
|
||||
|
||||
// Remove deletes Kubernetes resources defined in manifest(s)
|
||||
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
return deployer.command("delete", userID, endpoint, resources, namespace)
|
||||
func (deployer *KubernetesDeployer) Remove(userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
return deployer.command("delete", userID, endpoint, manifestFiles, namespace)
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, resources []string, namespace string) (string, error) {
|
||||
func (deployer *KubernetesDeployer) command(operation string, userID portainer.UserID, endpoint *portainer.Endpoint, manifestFiles []string, namespace string) (string, error) {
|
||||
token, err := deployer.getToken(userID, endpoint, endpoint.Type == portainer.KubernetesLocalEnvironment)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed generating a user token")
|
||||
}
|
||||
|
||||
serverURL := defaultServerURL
|
||||
command := path.Join(deployer.binaryPath, "kubectl")
|
||||
if runtime.GOOS == "windows" {
|
||||
command = path.Join(deployer.binaryPath, "kubectl.exe")
|
||||
}
|
||||
|
||||
args := []string{"--token", token}
|
||||
if namespace != "" {
|
||||
args = append(args, "--namespace", namespace)
|
||||
}
|
||||
|
||||
if endpoint.Type == portainer.AgentOnKubernetesEnvironment || endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
url, proxy, err := deployer.getAgentURL(endpoint)
|
||||
if err != nil {
|
||||
return "", errors.WithMessage(err, "failed generating endpoint URL")
|
||||
}
|
||||
|
||||
defer proxy.Close()
|
||||
|
||||
serverURL = url
|
||||
args = append(args, "--server", url)
|
||||
args = append(args, "--insecure-skip-tls-verify")
|
||||
}
|
||||
|
||||
client, err := libkubectl.NewClient(&libkubectl.ClientAccess{
|
||||
Token: token,
|
||||
ServerUrl: serverURL,
|
||||
}, namespace, "", true)
|
||||
if operation == "delete" {
|
||||
args = append(args, "--ignore-not-found=true")
|
||||
}
|
||||
|
||||
args = append(args, operation)
|
||||
for _, path := range manifestFiles {
|
||||
args = append(args, "-f", strings.TrimSpace(path))
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd := exec.Command(command, args...)
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Env = append(cmd.Env, "POD_NAMESPACE=default")
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "failed to create kubectl client")
|
||||
return "", errors.Wrapf(err, "failed to execute kubectl command: %q", stderr.String())
|
||||
}
|
||||
|
||||
operations := map[string]func(context.Context, []string) (string, error){
|
||||
"apply": client.Apply,
|
||||
"delete": client.Delete,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
if !ok {
|
||||
return "", errors.Errorf("unsupported operation: %s", operation)
|
||||
}
|
||||
|
||||
output, err := operationFunc(context.Background(), resources)
|
||||
if err != nil {
|
||||
return "", errors.Wrapf(err, "failed to execute kubectl %s command", operation)
|
||||
}
|
||||
|
||||
return output, nil
|
||||
return string(output), nil
|
||||
}
|
||||
|
||||
func (deployer *KubernetesDeployer) getAgentURL(endpoint *portainer.Endpoint) (string, *factory.ProxyServer, error) {
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
type mockKubectlClient struct {
|
||||
applyFunc func(ctx context.Context, files []string) error
|
||||
deleteFunc func(ctx context.Context, files []string) error
|
||||
rolloutRestartFunc func(ctx context.Context, resources []string) error
|
||||
}
|
||||
|
||||
func (m *mockKubectlClient) Apply(ctx context.Context, files []string) error {
|
||||
if m.applyFunc != nil {
|
||||
return m.applyFunc(ctx, files)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKubectlClient) Delete(ctx context.Context, files []string) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(ctx, files)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockKubectlClient) RolloutRestart(ctx context.Context, resources []string) error {
|
||||
if m.rolloutRestartFunc != nil {
|
||||
return m.rolloutRestartFunc(ctx, resources)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func testExecuteKubectlOperation(client *mockKubectlClient, operation string, manifestFiles []string) error {
|
||||
operations := map[string]func(context.Context, []string) error{
|
||||
"apply": client.Apply,
|
||||
"delete": client.Delete,
|
||||
"rollout-restart": client.RolloutRestart,
|
||||
}
|
||||
|
||||
operationFunc, ok := operations[operation]
|
||||
if !ok {
|
||||
return fmt.Errorf("unsupported operation: %s", operation)
|
||||
}
|
||||
|
||||
if err := operationFunc(context.Background(), manifestFiles); err != nil {
|
||||
return fmt.Errorf("failed to execute kubectl %s command: %w", operation, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Apply_Success(t *testing.T) {
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
applyFunc: func(ctx context.Context, files []string) error {
|
||||
called = true
|
||||
assert.Equal(t, []string{"manifest1.yaml", "manifest2.yaml"}, files)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
manifests := []string{"manifest1.yaml", "manifest2.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "apply", manifests)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Apply_Error(t *testing.T) {
|
||||
expectedErr := errors.New("kubectl apply failed")
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
applyFunc: func(ctx context.Context, files []string) error {
|
||||
called = true
|
||||
assert.Equal(t, []string{"error.yaml"}, files)
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
manifests := []string{"error.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "apply", manifests)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), expectedErr.Error())
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Delete_Success(t *testing.T) {
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
deleteFunc: func(ctx context.Context, files []string) error {
|
||||
called = true
|
||||
assert.Equal(t, []string{"manifest1.yaml"}, files)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
manifests := []string{"manifest1.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "delete", manifests)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_Delete_Error(t *testing.T) {
|
||||
expectedErr := errors.New("kubectl delete failed")
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
deleteFunc: func(ctx context.Context, files []string) error {
|
||||
called = true
|
||||
assert.Equal(t, []string{"error.yaml"}, files)
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
manifests := []string{"error.yaml"}
|
||||
err := testExecuteKubectlOperation(mockClient, "delete", manifests)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), expectedErr.Error())
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_RolloutRestart_Success(t *testing.T) {
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
rolloutRestartFunc: func(ctx context.Context, resources []string) error {
|
||||
called = true
|
||||
assert.Equal(t, []string{"deployment/nginx"}, resources)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
resources := []string{"deployment/nginx"}
|
||||
err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_RolloutRestart_Error(t *testing.T) {
|
||||
expectedErr := errors.New("kubectl rollout restart failed")
|
||||
called := false
|
||||
mockClient := &mockKubectlClient{
|
||||
rolloutRestartFunc: func(ctx context.Context, resources []string) error {
|
||||
called = true
|
||||
assert.Equal(t, []string{"deployment/error"}, resources)
|
||||
return expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
resources := []string{"deployment/error"}
|
||||
err := testExecuteKubectlOperation(mockClient, "rollout-restart", resources)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), expectedErr.Error())
|
||||
assert.True(t, called)
|
||||
}
|
||||
|
||||
func TestExecuteKubectlOperation_UnsupportedOperation(t *testing.T) {
|
||||
mockClient := &mockKubectlClient{}
|
||||
|
||||
err := testExecuteKubectlOperation(mockClient, "unsupported", []string{})
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported operation")
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package update
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
)
|
||||
|
||||
func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error {
|
||||
@@ -17,7 +17,7 @@ func ValidateAutoUpdateSettings(autoUpdate *portainer.AutoUpdateSettings) error
|
||||
return httperrors.NewInvalidPayloadError("Webhook or Interval must be provided")
|
||||
}
|
||||
|
||||
if autoUpdate.Webhook != "" && !validate.IsUUID(autoUpdate.Webhook) {
|
||||
if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) {
|
||||
return httperrors.NewInvalidPayloadError("invalid Webhook format")
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -1,17 +1,19 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"github.com/asaskevich/govalidator"
|
||||
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
)
|
||||
|
||||
func ValidateRepoConfig(repoConfig *gittypes.RepoConfig) error {
|
||||
if len(repoConfig.URL) == 0 || !validate.IsURL(repoConfig.URL) {
|
||||
if len(repoConfig.URL) == 0 || !govalidator.IsURL(repoConfig.URL) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
return ValidateRepoAuthentication(repoConfig.Authentication)
|
||||
|
||||
}
|
||||
|
||||
func ValidateRepoAuthentication(auth *gittypes.GitAuthentication) error {
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -228,7 +228,7 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
|
||||
if len(payload.Description) == 0 {
|
||||
return errors.New("Invalid custom template description")
|
||||
}
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && (len(payload.RepositoryUsername) == 0 || len(payload.RepositoryPassword) == 0) {
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
)
|
||||
|
||||
type customTemplateUpdatePayload struct {
|
||||
@@ -169,7 +170,7 @@ func (handler *Handler) customTemplateUpdate(w http.ResponseWriter, r *http.Requ
|
||||
customTemplate.EdgeTemplate = payload.EdgeTemplate
|
||||
|
||||
if payload.RepositoryURL != "" {
|
||||
if !validate.IsURL(payload.RepositoryURL) {
|
||||
if !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return httperror.BadRequest("Invalid repository URL. Must correspond to a valid URL format", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
)
|
||||
|
||||
type edgeJobBasePayload struct {
|
||||
@@ -52,7 +53,7 @@ func (payload *edgeJobCreateFromFileContentPayload) Validate(r *http.Request) er
|
||||
return errors.New("invalid Edge job name")
|
||||
}
|
||||
|
||||
if !validate.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
|
||||
if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]*$`) {
|
||||
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
||||
}
|
||||
|
||||
@@ -135,7 +136,7 @@ func (payload *edgeJobCreateFromFilePayload) Validate(r *http.Request) error {
|
||||
return errors.New("invalid Edge job name")
|
||||
}
|
||||
|
||||
if !validate.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
||||
if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
||||
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
||||
}
|
||||
payload.Name = name
|
||||
|
||||
@@ -14,7 +14,8 @@ import (
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
)
|
||||
|
||||
type edgeJobUpdatePayload struct {
|
||||
@@ -27,7 +28,7 @@ type edgeJobUpdatePayload struct {
|
||||
}
|
||||
|
||||
func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error {
|
||||
if payload.Name != nil && !validate.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
||||
if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
|
||||
return errors.New("invalid Edge job name format. Allowed characters are: [a-zA-Z0-9_.-]")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -59,7 +59,7 @@ func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) erro
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
)
|
||||
|
||||
type fileResponse struct {
|
||||
@@ -28,7 +29,7 @@ type repositoryFilePreviewPayload struct {
|
||||
}
|
||||
|
||||
func (payload *repositoryFilePreviewPayload) Validate(r *http.Request) error {
|
||||
if len(payload.Repository) == 0 || !validate.IsURL(payload.Repository) {
|
||||
if len(payload.Repository) == 0 || !govalidator.IsURL(payload.Repository) {
|
||||
return errors.New("invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.30.0
|
||||
// @version 2.29.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -62,10 +62,6 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
h.Handle("/{id}/kubernetes/helm/{release}/history",
|
||||
httperror.LoggerHandler(h.helmGetHistory)).Methods(http.MethodGet)
|
||||
|
||||
// `helm rollback [RELEASE_NAME] [REVISION]`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}/rollback",
|
||||
httperror.LoggerHandler(h.helmRollback)).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -26,8 +26,6 @@ type installChartPayload struct {
|
||||
Chart string `json:"chart"`
|
||||
Repo string `json:"repo"`
|
||||
Values string `json:"values"`
|
||||
Version string `json:"version"`
|
||||
Atomic bool `json:"atomic"`
|
||||
}
|
||||
|
||||
var errChartNameInvalid = errors.New("invalid chart name. " +
|
||||
@@ -103,10 +101,8 @@ func (handler *Handler) installChart(r *http.Request, p installChartPayload) (*r
|
||||
installOpts := options.InstallOptions{
|
||||
Name: p.Name,
|
||||
Chart: p.Chart,
|
||||
Version: p.Version,
|
||||
Namespace: p.Namespace,
|
||||
Repo: p.Repo,
|
||||
Atomic: p.Atomic,
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
}
|
||||
|
||||
@@ -196,7 +192,7 @@ func (handler *Handler) updateHelmAppManifest(r *http.Request, manifest []byte,
|
||||
g := new(errgroup.Group)
|
||||
for _, resource := range yamlResources {
|
||||
g.Go(func() error {
|
||||
tmpfile, err := os.CreateTemp("", "helm-manifest-*.yaml")
|
||||
tmpfile, err := os.CreateTemp("", "helm-manifest-*")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create a tmp helm manifest file")
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package helm
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libhelm/options"
|
||||
_ "github.com/portainer/portainer/pkg/libhelm/release"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id HelmRollback
|
||||
// @summary Rollback a helm release
|
||||
// @description Rollback a helm release to a previous revision
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags helm
|
||||
// @security ApiKeyAuth || jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment(Endpoint) identifier"
|
||||
// @param release path string true "Helm release name"
|
||||
// @param namespace query string false "specify an optional namespace"
|
||||
// @param revision query int false "specify the revision to rollback to (defaults to previous revision if not specified)"
|
||||
// @param wait query boolean false "wait for resources to be ready (default: false)"
|
||||
// @param waitForJobs query boolean false "wait for jobs to complete before marking the release as successful (default: false)"
|
||||
// @param recreate query boolean false "performs pods restart for the resource if applicable (default: true)"
|
||||
// @param force query boolean false "force resource update through delete/recreate if needed (default: false)"
|
||||
// @param timeout query int false "time to wait for any individual Kubernetes operation in seconds (default: 300)"
|
||||
// @success 200 {object} release.Release "Success"
|
||||
// @failure 400 "Invalid request payload, such as missing required fields or fields not meeting validation criteria."
|
||||
// @failure 401 "Unauthorized access - the user is not authenticated or does not have the necessary permissions. Ensure that you have provided a valid API key or JWT token, and that you have the required permissions."
|
||||
// @failure 403 "Permission denied - the user is authenticated but does not have the necessary permissions to access the requested resource or perform the specified operation. Check your user roles and permissions."
|
||||
// @failure 404 "Unable to find an environment with the specified identifier or release name."
|
||||
// @failure 500 "Server error occurred while attempting to rollback the release."
|
||||
// @router /endpoints/{id}/kubernetes/helm/{release}/rollback [post]
|
||||
func (handler *Handler) helmRollback(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
release, err := request.RetrieveRouteVariableValue(r, "release")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("No release specified", err)
|
||||
}
|
||||
|
||||
clusterAccess, httperr := handler.getHelmClusterAccess(r)
|
||||
if httperr != nil {
|
||||
return httperr
|
||||
}
|
||||
|
||||
// build the rollback options
|
||||
rollbackOpts := options.RollbackOptions{
|
||||
KubernetesClusterAccess: clusterAccess,
|
||||
Name: release,
|
||||
// Set default values
|
||||
Recreate: true, // Default to recreate pods (restart)
|
||||
Timeout: 5 * time.Minute, // Default timeout of 5 minutes
|
||||
}
|
||||
|
||||
namespace, _ := request.RetrieveQueryParameter(r, "namespace", true)
|
||||
// optional namespace. The library defaults to "default"
|
||||
if namespace != "" {
|
||||
rollbackOpts.Namespace = namespace
|
||||
}
|
||||
|
||||
revision, _ := request.RetrieveNumericQueryParameter(r, "revision", true)
|
||||
// optional revision. If not specified, it will rollback to the previous revision
|
||||
if revision > 0 {
|
||||
rollbackOpts.Version = revision
|
||||
}
|
||||
|
||||
// Default for wait is false, only set to true if explicitly requested
|
||||
wait, err := request.RetrieveBooleanQueryParameter(r, "wait", true)
|
||||
if err == nil {
|
||||
rollbackOpts.Wait = wait
|
||||
}
|
||||
|
||||
// Default for waitForJobs is false, only set to true if explicitly requested
|
||||
waitForJobs, err := request.RetrieveBooleanQueryParameter(r, "waitForJobs", true)
|
||||
if err == nil {
|
||||
rollbackOpts.WaitForJobs = waitForJobs
|
||||
}
|
||||
|
||||
// Default for recreate is true (set above), override if specified
|
||||
recreate, err := request.RetrieveBooleanQueryParameter(r, "recreate", true)
|
||||
if err == nil {
|
||||
rollbackOpts.Recreate = recreate
|
||||
}
|
||||
|
||||
// Default for force is false, only set to true if explicitly requested
|
||||
force, err := request.RetrieveBooleanQueryParameter(r, "force", true)
|
||||
if err == nil {
|
||||
rollbackOpts.Force = force
|
||||
}
|
||||
|
||||
timeout, _ := request.RetrieveNumericQueryParameter(r, "timeout", true)
|
||||
// Override default timeout if specified
|
||||
if timeout > 0 {
|
||||
rollbackOpts.Timeout = time.Duration(timeout) * time.Second
|
||||
}
|
||||
|
||||
releaseInfo, err := handler.helmPackageManager.Rollback(rollbackOpts)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Failed to rollback helm release", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, releaseInfo)
|
||||
}
|
||||
@@ -36,14 +36,11 @@ func deprecatedNamespaceParser(w http.ResponseWriter, r *http.Request) (string,
|
||||
|
||||
// Restore the original body for further use
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return "", httperror.InternalServerError("Unable to read request body", err)
|
||||
}
|
||||
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
|
||||
payload := models.K8sNamespaceDetails{}
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return "", httperror.BadRequest("Invalid request. Unable to parse namespace payload", err)
|
||||
}
|
||||
namespaceName := payload.Name
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
@@ -62,15 +62,15 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
return errors.New("Invalid authentication method value. Value must be one of: 1 (internal), 2 (LDAP/AD) or 3 (OAuth)")
|
||||
}
|
||||
|
||||
if payload.LogoURL != nil && *payload.LogoURL != "" && !validate.IsURL(*payload.LogoURL) {
|
||||
if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) {
|
||||
return errors.New("Invalid logo URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !validate.IsURL(*payload.TemplatesURL) {
|
||||
if payload.TemplatesURL != nil && *payload.TemplatesURL != "" && !govalidator.IsURL(*payload.TemplatesURL) {
|
||||
return errors.New("Invalid external templates URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !validate.IsURL(*payload.HelmRepositoryURL) {
|
||||
if payload.HelmRepositoryURL != nil && *payload.HelmRepositoryURL != "" && !govalidator.IsURL(*payload.HelmRepositoryURL) {
|
||||
return errors.New("Invalid Helm repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -205,7 +205,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
|
||||
if len(payload.Name) == 0 {
|
||||
return errors.New("Invalid stack name")
|
||||
}
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
|
||||
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -96,7 +96,7 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
|
||||
}
|
||||
|
||||
func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
|
||||
}
|
||||
|
||||
func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error {
|
||||
if len(payload.ManifestURL) == 0 || !validate.IsURL(payload.ManifestURL) {
|
||||
if len(payload.ManifestURL) == 0 || !govalidator.IsURL(payload.ManifestURL) {
|
||||
return errors.New("Invalid manifest URL")
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
valid "github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
@@ -142,7 +142,7 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
|
||||
if len(payload.SwarmID) == 0 {
|
||||
return errors.New("Invalid Swarm ID")
|
||||
}
|
||||
if len(payload.RepositoryURL) == 0 || !valid.IsURL(payload.RepositoryURL) {
|
||||
if len(payload.RepositoryURL) == 0 || !govalidator.IsURL(payload.RepositoryURL) {
|
||||
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
|
||||
|
||||
@@ -11,7 +11,8 @@ import (
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
)
|
||||
|
||||
type userAccessTokenCreatePayload struct {
|
||||
@@ -23,10 +24,10 @@ func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
|
||||
if len(payload.Description) == 0 {
|
||||
return errors.New("invalid description: cannot be empty")
|
||||
}
|
||||
if validate.HasWhitespaceOnly(payload.Description) {
|
||||
if govalidator.HasWhitespaceOnly(payload.Description) {
|
||||
return errors.New("invalid description: cannot contain only whitespaces")
|
||||
}
|
||||
if validate.MinStringLength(payload.Description, 128) {
|
||||
if govalidator.MinStringLength(payload.Description, "128") {
|
||||
return errors.New("invalid description: cannot be longer than 128 characters")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/portainer/portainer/api/ws"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func (handler *Handler) websocketAttach(w http.ResponseWriter, r *http.Request)
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: id", err)
|
||||
}
|
||||
if !validate.IsHexadecimal(attachID) {
|
||||
if !govalidator.IsHexadecimal(attachID) {
|
||||
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"github.com/portainer/portainer/api/ws"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/segmentio/encoding/json"
|
||||
)
|
||||
@@ -42,7 +42,7 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: id", err)
|
||||
}
|
||||
if !validate.IsHexadecimal(execID) {
|
||||
if !govalidator.IsHexadecimal(execID) {
|
||||
return httperror.BadRequest("Invalid query parameter: id (must be hexadecimal identifier)", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
)
|
||||
|
||||
var (
|
||||
// Idempotent (safe) methods as defined by RFC7231 section 4.2.2.
|
||||
safeMethods = []string{"GET", "HEAD", "OPTIONS", "TRACE"}
|
||||
)
|
||||
|
||||
type plainTextHTTPRequestHandler struct {
|
||||
next http.Handler
|
||||
}
|
||||
|
||||
func (h *plainTextHTTPRequestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if slices.Contains(safeMethods, r.Method) {
|
||||
h.next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
req := r
|
||||
// If original request was HTTPS (via proxy), keep CSRF checks.
|
||||
if xfproto := r.Header.Get("X-Forwarded-Proto"); xfproto != "https" {
|
||||
req = csrf.PlaintextHTTPRequest(r)
|
||||
}
|
||||
|
||||
h.next.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
func PlaintextHTTPRequest(next http.Handler) http.Handler {
|
||||
return &plainTextHTTPRequestHandler{next: next}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func WithItem[TId ~int, TObject any](getter ItemGetter[TId, TObject], idParam st
|
||||
}
|
||||
}
|
||||
|
||||
func FetchItem[T any](request *http.Request, contextKey ItemContextKey) (*T, error) {
|
||||
func FetchItem[T any](request *http.Request, contextKey string) (*T, error) {
|
||||
contextData := request.Context().Value(contextKey)
|
||||
if contextData == nil {
|
||||
return nil, errors.New("unable to find item in request context")
|
||||
|
||||
@@ -52,7 +52,7 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
|
||||
endpointURL.Scheme = "https"
|
||||
}
|
||||
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
|
||||
proxy.Transport = agent.NewTransport(factory.signatureService, httpTransport)
|
||||
|
||||
@@ -63,7 +63,8 @@ func (factory *ProxyFactory) NewAgentProxy(endpoint *portainer.Endpoint) (*Proxy
|
||||
Port: 0,
|
||||
}
|
||||
|
||||
if err := proxyServer.start(); err != nil {
|
||||
err = proxyServer.start()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed starting proxy server")
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func newAzureProxy(endpoint *portainer.Endpoint, dataStore dataservices.DataStor
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy.Transport = azure.NewTransport(&endpoint.AzureCredentials, dataStore, endpoint)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ func (factory *ProxyFactory) newDockerHTTPProxy(endpoint *portainer.Endpoint) (h
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy.Transport = dockerTransport
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ func newGitlabProxy(uri string) (http.Handler, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(url)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(url)
|
||||
proxy.Transport = gitlab.NewTransport()
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (factory *ProxyFactory) newKubernetesLocalProxy(endpoint *portainer.Endpoin
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy.Transport = transport
|
||||
|
||||
return proxy, nil
|
||||
@@ -73,7 +73,7 @@ func (factory *ProxyFactory) newKubernetesEdgeHTTPProxy(endpoint *portainer.Endp
|
||||
}
|
||||
|
||||
endpointURL.Scheme = "http"
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(endpointURL)
|
||||
proxy.Transport = kubernetes.NewEdgeTransport(factory.dataStore, factory.signatureService, factory.reverseTunnelService, endpoint, tokenManager, factory.kubernetesClientFactory)
|
||||
|
||||
return proxy, nil
|
||||
@@ -104,7 +104,7 @@ func (factory *ProxyFactory) newKubernetesAgentHTTPSProxy(endpoint *portainer.En
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := NewSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
|
||||
proxy.Transport = kubernetes.NewAgentTransport(factory.signatureService, tlsConfig, tokenManager, endpoint, factory.kubernetesClientFactory, factory.dataStore)
|
||||
|
||||
return proxy, nil
|
||||
|
||||
@@ -10,14 +10,9 @@ import (
|
||||
// newSingleHostReverseProxyWithHostHeader is based on NewSingleHostReverseProxy
|
||||
// from golang.org/src/net/http/httputil/reverseproxy.go and merely sets the Host
|
||||
// HTTP header, which NewSingleHostReverseProxy deliberately preserves.
|
||||
func NewSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
|
||||
return &httputil.ReverseProxy{Director: createDirector(target)}
|
||||
}
|
||||
|
||||
func createDirector(target *url.URL) func(*http.Request) {
|
||||
sensitiveHeaders := []string{"Cookie", "X-Csrf-Token"}
|
||||
func newSingleHostReverseProxyWithHostHeader(target *url.URL) *httputil.ReverseProxy {
|
||||
targetQuery := target.RawQuery
|
||||
return func(req *http.Request) {
|
||||
director := func(req *http.Request) {
|
||||
req.URL.Scheme = target.Scheme
|
||||
req.URL.Host = target.Host
|
||||
req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
|
||||
@@ -31,11 +26,8 @@ func createDirector(target *url.URL) func(*http.Request) {
|
||||
// explicitly disable User-Agent so it's not set to default value
|
||||
req.Header.Set("User-Agent", "")
|
||||
}
|
||||
|
||||
for _, header := range sensitiveHeaders {
|
||||
delete(req.Header, header)
|
||||
}
|
||||
}
|
||||
return &httputil.ReverseProxy{Director: director}
|
||||
}
|
||||
|
||||
// singleJoiningSlash from golang.org/src/net/http/httputil/reverseproxy.go
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
package factory
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
func Test_createDirector(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
target *url.URL
|
||||
req *http.Request
|
||||
expectedReq *http.Request
|
||||
}{
|
||||
{
|
||||
name: "base case",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "no User-Agent",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json"},
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": ""},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Sensitive Headers",
|
||||
target: createURL(t, "https://portainer.io/api/docker?a=5&b=6"),
|
||||
req: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://agent-portainer.io/test?c=7",
|
||||
map[string]string{
|
||||
"Accept-Encoding": "gzip",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "something",
|
||||
"Cookie": "junk",
|
||||
"X-Csrf-Token": "junk",
|
||||
},
|
||||
),
|
||||
expectedReq: createRequest(
|
||||
t,
|
||||
"GET",
|
||||
"https://portainer.io/api/docker/test?a=5&b=6&c=7",
|
||||
map[string]string{"Accept-Encoding": "gzip", "Accept": "application/json", "User-Agent": "something"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
director := createDirector(tc.target)
|
||||
director(tc.req)
|
||||
|
||||
if diff := cmp.Diff(tc.req, tc.expectedReq, cmp.Comparer(compareRequests)); diff != "" {
|
||||
t.Fatalf("requests are different: \n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createURL(t *testing.T, urlString string) *url.URL {
|
||||
parsedURL, err := url.Parse(urlString)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create url: %s", err)
|
||||
}
|
||||
|
||||
return parsedURL
|
||||
}
|
||||
|
||||
func createRequest(t *testing.T, method, url string, headers map[string]string) *http.Request {
|
||||
req, err := http.NewRequest(method, url, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create http request: %s", err)
|
||||
} else {
|
||||
for k, v := range headers {
|
||||
req.Header.Add(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func compareRequests(a, b *http.Request) bool {
|
||||
methodEqual := a.Method == b.Method
|
||||
urlEqual := cmp.Diff(a.URL, b.URL) == ""
|
||||
hostEqual := a.Host == b.Host
|
||||
protoEqual := a.Proto == b.Proto && a.ProtoMajor == b.ProtoMajor && a.ProtoMinor == b.ProtoMinor
|
||||
headersEqual := cmp.Diff(a.Header, b.Header) == ""
|
||||
|
||||
return methodEqual && urlEqual && hostEqual && protoEqual && headersEqual
|
||||
}
|
||||
@@ -344,7 +344,6 @@ func Test_apiKeyLookup(t *testing.T) {
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
token, err := bouncer.apiKeyLookup(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
|
||||
is.Equal(expectedToken, token)
|
||||
@@ -359,7 +358,6 @@ func Test_apiKeyLookup(t *testing.T) {
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
token, err := bouncer.apiKeyLookup(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
|
||||
is.Equal(expectedToken, token)
|
||||
@@ -374,7 +372,6 @@ func Test_apiKeyLookup(t *testing.T) {
|
||||
req.Header.Add("x-api-key", rawAPIKey)
|
||||
|
||||
token, err := bouncer.apiKeyLookup(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedToken := &portainer.TokenData{ID: user.ID, Username: user.Username, Role: portainer.StandardUserRole}
|
||||
is.Equal(expectedToken, token)
|
||||
|
||||
+1
-1
@@ -349,7 +349,7 @@ func (server *Server) Start() error {
|
||||
log.Info().Str("bind_address", server.BindAddress).Msg("starting HTTP server")
|
||||
httpServer := &http.Server{
|
||||
Addr: server.BindAddress,
|
||||
Handler: middlewares.PlaintextHTTPRequest(handler),
|
||||
Handler: handler,
|
||||
ErrorLog: errorLogger,
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func NewKubeClientFromClientset(cli *kubernetes.Clientset) *KubeClient {
|
||||
return &KubeClient{
|
||||
cli: cli,
|
||||
instanceID: "",
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientFactory returns a new instance of a ClientFactory
|
||||
func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService, dataStore dataservices.DataStore, instanceID, addrHTTPS, userSessionTimeout string) (*ClientFactory, error) {
|
||||
if userSessionTimeout == "" {
|
||||
|
||||
+1
-1
@@ -1638,7 +1638,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.30.0"
|
||||
APIVersion = "2.29.0"
|
||||
// Support annotation for the API version ("STS" for Short-Term Support or "LTS" for Long-Term Support)
|
||||
APIVersionSupport = "STS"
|
||||
// Edition is what this edition of Portainer is called
|
||||
|
||||
@@ -45,6 +45,11 @@ func SanitizeLabel(value string) string {
|
||||
return strings.Trim(onlyAllowedCharacterString, ".-_")
|
||||
}
|
||||
|
||||
// IsGitStack checks if the stack is a git stack or not
|
||||
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
|
||||
|
||||
@@ -32,10 +32,6 @@ body {
|
||||
color: var(--text-body-color) !important;
|
||||
}
|
||||
|
||||
.bg-widget-color {
|
||||
background: var(--bg-widget-color);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#page-wrapper,
|
||||
@@ -228,7 +224,7 @@ input[type='checkbox'] {
|
||||
|
||||
.blocklist-item--selected {
|
||||
background-color: var(--bg-blocklist-item-selected-color);
|
||||
border-color: var(--border-blocklist-item-selected-color);
|
||||
border: 2px solid var(--border-blocklist-item-selected-color);
|
||||
color: var(--text-blocklist-item-selected-color);
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -20,7 +20,9 @@
|
||||
}
|
||||
|
||||
.vertical-center {
|
||||
@apply inline-flex items-center gap-1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
|
||||
@@ -268,7 +268,7 @@
|
||||
--bg-body-color: var(--grey-2);
|
||||
--bg-btn-default-color: var(--grey-3);
|
||||
--bg-blocklist-hover-color: var(--ui-gray-iron-10);
|
||||
--bg-blocklist-item-selected-color: var(--ui-gray-iron-10);
|
||||
--bg-blocklist-item-selected-color: var(--grey-3);
|
||||
--bg-card-color: var(--grey-1);
|
||||
--bg-checkbox-border-color: var(--grey-8);
|
||||
--bg-code-color: var(--grey-2);
|
||||
@@ -388,7 +388,7 @@
|
||||
--border-navtabs-color: var(--grey-38);
|
||||
--border-pre-color: var(--grey-3);
|
||||
--border-blocklist: var(--ui-gray-9);
|
||||
--border-blocklist-item-selected-color: var(--grey-31);
|
||||
--border-blocklist-item-selected-color: var(--grey-38);
|
||||
--border-pagination-span-color: var(--grey-1);
|
||||
--border-pagination-hover-color: var(--grey-3);
|
||||
--border-panel-color: var(--grey-2);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="col-sm-12" ng-if="ctrl.formValues.displayCodeEditor">
|
||||
<web-editor-form
|
||||
identifier="config-creation-editor"
|
||||
text-tip="Define or paste the content of your config here"
|
||||
placeholder="Define or paste the content of your config here"
|
||||
yml="false"
|
||||
on-change="(ctrl.editorUpdate)"
|
||||
value="ctrl.formValues.ConfigContent"
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<div class="col-sm-12">
|
||||
<code-editor
|
||||
identifier="image-build-editor"
|
||||
text-tip="Define or paste the content of your Dockerfile here"
|
||||
placeholder="Define or paste the content of your Dockerfile here"
|
||||
docker-file="true"
|
||||
on-change="(editorUpdate)"
|
||||
></code-editor>
|
||||
|
||||
@@ -145,7 +145,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
|
||||
|
||||
const helmApplication = {
|
||||
name: 'kubernetes.helm',
|
||||
url: '/helm/:namespace/:name?revision&tab',
|
||||
url: '/helm/:namespace/:name',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'kubernetesHelmApplicationView',
|
||||
|
||||
+1
-1
@@ -165,7 +165,7 @@
|
||||
value="$ctrl.formValues.DataYaml"
|
||||
on-change="($ctrl.editorUpdate)"
|
||||
yml="true"
|
||||
text-tip="Define or paste key-value pairs, one pair per line"
|
||||
placeholder="Define or paste key-value pairs, one pair per line"
|
||||
>
|
||||
</web-editor-form>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<react-code-editor
|
||||
id="$ctrl.identifier"
|
||||
text-tip="$ctrl.textTip"
|
||||
placeholder="$ctrl.placeholder"
|
||||
type="$ctrl.type"
|
||||
readonly="$ctrl.readOnly"
|
||||
on-change="($ctrl.handleChange)"
|
||||
|
||||
@@ -5,7 +5,7 @@ angular.module('portainer.app').component('codeEditor', {
|
||||
controller,
|
||||
bindings: {
|
||||
identifier: '@',
|
||||
textTip: '@',
|
||||
placeholder: '@',
|
||||
yml: '<',
|
||||
dockerFile: '<',
|
||||
shell: '<',
|
||||
|
||||
@@ -6,7 +6,7 @@ export const webEditorForm = {
|
||||
|
||||
bindings: {
|
||||
identifier: '@',
|
||||
textTip: '@',
|
||||
placeholder: '@',
|
||||
yml: '<',
|
||||
value: '<',
|
||||
readOnly: '<',
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<div class="col-sm-12 col-lg-12">
|
||||
<code-editor
|
||||
identifier="{{ $ctrl.identifier }}"
|
||||
text-tip="{{ $ctrl.textTip }}"
|
||||
placeholder="{{ $ctrl.placeholder }}"
|
||||
read-only="$ctrl.readOnly"
|
||||
yml="$ctrl.yml"
|
||||
value="$ctrl.value"
|
||||
|
||||
@@ -223,7 +223,7 @@ export const ngModule = angular
|
||||
'reactCodeEditor',
|
||||
r2a(CodeEditor, [
|
||||
'id',
|
||||
'textTip',
|
||||
'placeholder',
|
||||
'type',
|
||||
'readonly',
|
||||
'onChange',
|
||||
@@ -233,8 +233,6 @@ export const ngModule = angular
|
||||
'versions',
|
||||
'onVersionChange',
|
||||
'schema',
|
||||
'fileName',
|
||||
'placeholder',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
on-change="(onChangeFileContent)"
|
||||
ng-required="true"
|
||||
yml="true"
|
||||
text-tip="Define or paste the content of your docker compose file here"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
read-only="state.isEditorReadOnly"
|
||||
schema="dockerComposeSchema"
|
||||
>
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
<code-editor
|
||||
read-only="orphaned"
|
||||
identifier="stack-editor"
|
||||
text-tip="Define or paste the content of your docker compose file here"
|
||||
placeholder="Define or paste the content of your docker compose file here"
|
||||
yml="true"
|
||||
on-change="(editorUpdate)"
|
||||
value="stackFileContent"
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Format a date to a human-readable string based on the user's locale.
|
||||
*/
|
||||
export function localizeDate(date: Date) {
|
||||
return date
|
||||
.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
})
|
||||
.replace('am', 'AM')
|
||||
.replace('pm', 'PM');
|
||||
}
|
||||
@@ -6,36 +6,36 @@ import { Icon } from '@@/Icon';
|
||||
|
||||
type AlertType = 'success' | 'error' | 'info' | 'warn';
|
||||
|
||||
export const alertSettings: Record<
|
||||
const alertSettings: Record<
|
||||
AlertType,
|
||||
{ container: string; header: string; body: string; icon: ReactNode }
|
||||
> = {
|
||||
success: {
|
||||
container:
|
||||
'border-green-4 bg-green-2 th-dark:bg-green-10 th-dark:border-green-8 th-highcontrast:bg-green-10 th-highcontrast:border-green-8',
|
||||
header: 'text-green-8 th-dark:text-white th-highcontrast:text-white',
|
||||
body: 'text-green-7 th-dark:text-white th-highcontrast:text-white',
|
||||
'border-green-4 bg-green-2 th-dark:bg-green-3 th-dark:border-green-5',
|
||||
header: 'text-green-8',
|
||||
body: 'text-green-7',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
error: {
|
||||
container:
|
||||
'border-error-4 bg-error-2 th-dark:bg-error-10 th-dark:border-error-8 th-highcontrast:bg-error-10 th-highcontrast:border-error-8',
|
||||
header: 'text-error-8 th-dark:text-white th-highcontrast:text-white',
|
||||
body: 'text-error-7 th-dark:text-white th-highcontrast:text-white',
|
||||
'border-error-4 bg-error-2 th-dark:bg-error-3 th-dark:border-error-5',
|
||||
header: 'text-error-8',
|
||||
body: 'text-error-7',
|
||||
icon: XCircle,
|
||||
},
|
||||
info: {
|
||||
container:
|
||||
'border-blue-4 bg-blue-2 th-dark:bg-blue-10 th-dark:border-blue-8 th-highcontrast:bg-blue-10 th-highcontrast:border-blue-8',
|
||||
header: 'text-blue-8 th-dark:text-white th-highcontrast:text-white',
|
||||
body: 'text-blue-7 th-dark:text-white th-highcontrast:text-white',
|
||||
'border-blue-4 bg-blue-2 th-dark:bg-blue-3 th-dark:border-blue-5',
|
||||
header: 'text-blue-8',
|
||||
body: 'text-blue-7',
|
||||
icon: AlertCircle,
|
||||
},
|
||||
warn: {
|
||||
container:
|
||||
'border-warning-4 bg-warning-2 th-dark:bg-warning-10 th-dark:border-warning-8 th-highcontrast:bg-warning-10 th-highcontrast:border-warning-8',
|
||||
header: 'text-warning-8 th-dark:text-white th-highcontrast:text-white',
|
||||
body: 'text-warning-7 th-dark:text-white th-highcontrast:text-white',
|
||||
'border-warning-4 bg-warning-2 th-dark:bg-warning-3 th-dark:border-warning-5',
|
||||
header: 'text-warning-8',
|
||||
body: 'text-warning-7',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
};
|
||||
@@ -76,18 +76,12 @@ export function Alert({
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertContainer({
|
||||
function AlertContainer({
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<{ className?: string }>) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'border rounded-lg border-solid [&_ul]:ps-8',
|
||||
'p-3',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={clsx('rounded-md border-2 border-solid', 'p-3', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,6 @@ export default {
|
||||
'dangerSecondary',
|
||||
'warnSecondary',
|
||||
'infoSecondary',
|
||||
'muted',
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -36,7 +35,6 @@ function Template({ type = 'success' }: Props) {
|
||||
dangerSecondary: 'dangerSecondary badge',
|
||||
warnSecondary: 'warnSecondary badge',
|
||||
infoSecondary: 'infoSecondary badge',
|
||||
muted: 'muted badge',
|
||||
};
|
||||
return <Badge type={type}>{message[type]}</Badge>;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ export type BadgeType =
|
||||
| 'successSecondary'
|
||||
| 'dangerSecondary'
|
||||
| 'warnSecondary'
|
||||
| 'infoSecondary'
|
||||
| 'muted';
|
||||
| 'infoSecondary';
|
||||
|
||||
// the classes are typed in full because tailwind doesn't render the interpolated classes
|
||||
const typeClasses: Record<BadgeType, string> = {
|
||||
@@ -55,11 +54,6 @@ const typeClasses: Record<BadgeType, string> = {
|
||||
'th-dark:text-blue-3 th-dark:bg-blue-9',
|
||||
'th-highcontrast:text-blue-3 th-highcontrast:bg-blue-9'
|
||||
),
|
||||
muted: clsx(
|
||||
'text-gray-9 bg-gray-3',
|
||||
'th-dark:text-gray-3 th-dark:bg-gray-9',
|
||||
'th-highcontrast:text-gray-3 th-highcontrast:bg-gray-9'
|
||||
),
|
||||
};
|
||||
|
||||
export interface Props {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { localizeDate } from '@/react/common/date-utils';
|
||||
|
||||
import { Badge } from '@@/Badge';
|
||||
|
||||
import { BlocklistItem } from './BlocklistItem';
|
||||
|
||||
const meta: Meta<typeof BlocklistItem> = {
|
||||
title: 'Components/Blocklist/BlocklistItem',
|
||||
component: BlocklistItem,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="blocklist">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BlocklistItem>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Default Blocklist Item',
|
||||
},
|
||||
};
|
||||
|
||||
export const Selected: Story = {
|
||||
args: {
|
||||
children: 'Selected Blocklist Item',
|
||||
isSelected: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const AsDiv: Story = {
|
||||
args: {
|
||||
children: 'Blocklist Item as div',
|
||||
as: 'div',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomContent: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex flex-wrap gap-1 justify-between">
|
||||
<Badge type="success">Deployed</Badge>
|
||||
<span className="text-xs text-muted">Revision #4</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 justify-between">
|
||||
<span className="text-xs text-muted">my-app-1.0.0</span>
|
||||
<span className="text-xs text-muted">
|
||||
{localizeDate(new Date('2000-01-01'))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleItems: Story = {
|
||||
render: () => (
|
||||
<div className="blocklist">
|
||||
<BlocklistItem>First Item</BlocklistItem>
|
||||
<BlocklistItem isSelected>Second Item (Selected)</BlocklistItem>
|
||||
<BlocklistItem>Third Item</BlocklistItem>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -10,7 +10,7 @@ export function Card({ className, children }: PropsWithChildren<Props>) {
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'rounded-lg border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
|
||||
'rounded border border-solid border-gray-5 bg-gray-neutral-3 p-5 th-highcontrast:border-white th-highcontrast:bg-black th-dark:border-legacy-grey-3 th-dark:bg-gray-iron-11'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
+2
-44
@@ -47,33 +47,17 @@
|
||||
|
||||
.root :global(.cm-editor .cm-gutters) {
|
||||
border-right: 0px;
|
||||
@apply bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black;
|
||||
}
|
||||
|
||||
.root :global(.cm-merge-b) {
|
||||
@apply border-0 border-l border-solid border-l-gray-5 th-dark:border-l-gray-7 th-highcontrast:border-l-gray-2;
|
||||
}
|
||||
|
||||
.root :global(.cm-editor .cm-gutters .cm-lineNumbers .cm-gutterElement) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.codeEditor :global(.cm-editor),
|
||||
.codeEditor :global(.cm-editor .cm-scroller) {
|
||||
.root :global(.cm-editor),
|
||||
.root :global(.cm-editor .cm-scroller) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* code mirror merge side-by-side editor */
|
||||
.root :global(.cm-merge-a),
|
||||
.root :global(.cm-merge-a .cm-scroller) {
|
||||
@apply !rounded-r-none;
|
||||
}
|
||||
|
||||
.root :global(.cm-merge-b),
|
||||
.root :global(.cm-merge-b .cm-scroller) {
|
||||
@apply !rounded-l-none;
|
||||
}
|
||||
|
||||
/* Search Panel */
|
||||
/* Ideally we would use a react component for that, but this is the easy solution for onw */
|
||||
|
||||
@@ -178,29 +162,3 @@
|
||||
.root :global(.cm-activeLineGutter) {
|
||||
@apply bg-inherit;
|
||||
}
|
||||
|
||||
/* Collapsed lines gutter styles for all themes */
|
||||
.root :global(.cm-editor .cm-collapsedLines) {
|
||||
/* inherit bg, instead of using styles from library */
|
||||
background: inherit;
|
||||
@apply bg-blue-2 th-dark:bg-blue-10 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
|
||||
}
|
||||
.root :global(.cm-editor .cm-collapsedLines):hover {
|
||||
@apply bg-blue-3 th-dark:bg-blue-9 th-highcontrast:bg-white th-dark:text-white th-highcontrast:text-black;
|
||||
}
|
||||
|
||||
.root :global(.cm-editor .cm-collapsedLines:before) {
|
||||
content: '↧ Expand all';
|
||||
background: var(--bg-tooltip-color);
|
||||
color: var(--text-tooltip-color);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
margin-left: 4px;
|
||||
}
|
||||
/* override the default content */
|
||||
.root :global(.cm-editor .cm-collapsedLines:after) {
|
||||
content: '';
|
||||
}
|
||||
+3
-26
@@ -1,21 +1,9 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Extension } from '@codemirror/state';
|
||||
|
||||
import { CodeEditor } from './CodeEditor';
|
||||
|
||||
const mockExtension: Extension = { extension: [] };
|
||||
vi.mock('yaml-schema', () => ({
|
||||
// yamlSchema has 5 return values (all extensions)
|
||||
yamlSchema: () => [
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
mockExtension,
|
||||
],
|
||||
yamlCompletion: () => () => ({}),
|
||||
}));
|
||||
vi.mock('yaml-schema', () => ({}));
|
||||
|
||||
const defaultProps = {
|
||||
id: 'test-editor',
|
||||
@@ -36,7 +24,7 @@ test('should render with basic props', () => {
|
||||
test('should display placeholder when provided', async () => {
|
||||
const placeholder = 'Enter your code here';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} textTip={placeholder} />
|
||||
<CodeEditor {...defaultProps} placeholder={placeholder} />
|
||||
);
|
||||
|
||||
const placeholderText = await findByText(placeholder);
|
||||
@@ -56,7 +44,7 @@ test('should show copy button and copy content', async () => {
|
||||
clipboard: mockClipboard,
|
||||
});
|
||||
|
||||
const copyButton = await findByText('Copy');
|
||||
const copyButton = await findByText('Copy to clipboard');
|
||||
expect(copyButton).toBeVisible();
|
||||
|
||||
await userEvent.click(copyButton);
|
||||
@@ -125,14 +113,3 @@ test('should apply custom height', async () => {
|
||||
const editor = (await findByRole('textbox')).parentElement?.parentElement;
|
||||
expect(editor).toHaveStyle({ height: customHeight });
|
||||
});
|
||||
|
||||
test('should render with file name header when provided', async () => {
|
||||
const fileName = 'example.yaml';
|
||||
const testValue = 'file content';
|
||||
const { findByText } = render(
|
||||
<CodeEditor {...defaultProps} fileName={fileName} value={testValue} />
|
||||
);
|
||||
|
||||
expect(await findByText(fileName)).toBeInTheDocument();
|
||||
expect(await findByText(testValue)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import CodeMirror, {
|
||||
keymap,
|
||||
oneDarkHighlightStyle,
|
||||
} from '@uiw/react-codemirror';
|
||||
import {
|
||||
StreamLanguage,
|
||||
LanguageSupport,
|
||||
syntaxHighlighting,
|
||||
indentService,
|
||||
} from '@codemirror/language';
|
||||
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { lintKeymap, lintGutter } from '@codemirror/lint';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { yamlCompletion, yamlSchema } from 'yaml-schema';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
import { useDebounce } from '../hooks/useDebounce';
|
||||
|
||||
import styles from './CodeEditor.module.css';
|
||||
import { TextTip } from './Tip/TextTip';
|
||||
import { StackVersionSelector } from './StackVersionSelector';
|
||||
|
||||
type Type = 'yaml' | 'shell' | 'dockerfile';
|
||||
interface Props extends AutomationTestingProps {
|
||||
id: string;
|
||||
placeholder?: string;
|
||||
type?: Type;
|
||||
readonly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
value: string;
|
||||
height?: string;
|
||||
versions?: number[];
|
||||
onVersionChange?: (version: number) => void;
|
||||
schema?: JSONSchema7;
|
||||
}
|
||||
|
||||
const theme = createTheme({
|
||||
theme: 'light',
|
||||
settings: {
|
||||
background: 'var(--bg-codemirror-color)',
|
||||
foreground: 'var(--text-codemirror-color)',
|
||||
caret: 'var(--border-codemirror-cursor-color)',
|
||||
selection: 'var(--bg-codemirror-selected-color)',
|
||||
selectionMatch: 'var(--bg-codemirror-selected-color)',
|
||||
gutterBackground: 'var(--bg-codemirror-gutters-color)',
|
||||
},
|
||||
styles: [
|
||||
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
|
||||
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
|
||||
{
|
||||
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
|
||||
color: 'var(--text-cm-string-color)',
|
||||
},
|
||||
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
|
||||
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
|
||||
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
|
||||
{
|
||||
tag: highlightTags.variableName,
|
||||
color: 'var(--text-cm-variable-name-color)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Custom indentation service for YAML
|
||||
const yamlIndentExtension = indentService.of((context, pos) => {
|
||||
const prevLine = context.lineAt(pos, -1);
|
||||
|
||||
// Default to same as previous line
|
||||
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
||||
|
||||
// If previous line ends with a colon, increase indent
|
||||
if (/:\s*$/.test(prevLine.text)) {
|
||||
return prevIndent + 2; // Indent 2 spaces after a colon
|
||||
}
|
||||
|
||||
return prevIndent;
|
||||
});
|
||||
|
||||
// Create enhanced YAML language with custom indentation (from @codemirror/legacy-modes/mode/yaml)
|
||||
const yamlLanguageLegacy = new LanguageSupport(StreamLanguage.define(yaml), [
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
]);
|
||||
|
||||
const dockerFileLanguage = new LanguageSupport(
|
||||
StreamLanguage.define(dockerFile)
|
||||
);
|
||||
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
||||
|
||||
const docTypeExtensionMap: Record<Type, LanguageSupport> = {
|
||||
yaml: yamlLanguageLegacy,
|
||||
dockerfile: dockerFileLanguage,
|
||||
shell: shellLanguage,
|
||||
};
|
||||
|
||||
function schemaValidationExtensions(schema: JSONSchema7) {
|
||||
// skip the hover extension because fields like 'networks' display as 'null' with no description when using the default hover
|
||||
// skip the completion extension in favor of custom completion
|
||||
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
||||
return [
|
||||
yaml,
|
||||
linter,
|
||||
autocompletion({
|
||||
icons: false,
|
||||
activateOnTypingDelay: 300,
|
||||
selectOnOpen: true,
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
(ctx) => {
|
||||
const getCompletions = yamlCompletion();
|
||||
const completions = getCompletions(ctx);
|
||||
if (Array.isArray(completions)) {
|
||||
return null;
|
||||
}
|
||||
return completions;
|
||||
},
|
||||
],
|
||||
}),
|
||||
stateExtensions,
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||
];
|
||||
}
|
||||
|
||||
export function CodeEditor({
|
||||
id,
|
||||
onChange = () => {},
|
||||
placeholder,
|
||||
readonly,
|
||||
value,
|
||||
versions,
|
||||
onVersionChange,
|
||||
height = '500px',
|
||||
type,
|
||||
schema,
|
||||
'data-cy': dataCy,
|
||||
}: Props) {
|
||||
const [isRollback, setIsRollback] = useState(false);
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
if (!type || !docTypeExtensionMap[type]) {
|
||||
return [];
|
||||
}
|
||||
// YAML-specific schema validation
|
||||
if (schema && type === 'yaml') {
|
||||
return schemaValidationExtensions(schema);
|
||||
}
|
||||
// Default language support
|
||||
return [docTypeExtensionMap[type]];
|
||||
}, [type, schema]);
|
||||
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
if (versions && versions.length > 1) {
|
||||
setIsRollback(version < versions[0]);
|
||||
}
|
||||
onVersionChange?.(version);
|
||||
},
|
||||
[onVersionChange, versions]
|
||||
);
|
||||
|
||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{!!placeholder && <TextTip color="blue">{placeholder}</TextTip>}
|
||||
</div>
|
||||
|
||||
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
|
||||
<CopyButton
|
||||
data-cy={`copy-code-button-${id}`}
|
||||
fadeDelay={2500}
|
||||
copyText={value}
|
||||
color="link"
|
||||
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
||||
indicatorPosition="left"
|
||||
>
|
||||
Copy to clipboard
|
||||
</CopyButton>
|
||||
</div>
|
||||
</div>
|
||||
{versions && (
|
||||
<div className="mt-2 flex">
|
||||
<div className="ml-auto mr-2">
|
||||
<StackVersionSelector
|
||||
versions={versions}
|
||||
onChange={handleVersionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CodeMirror
|
||||
className={styles.root}
|
||||
theme={theme}
|
||||
value={debouncedValue}
|
||||
onChange={debouncedOnChange}
|
||||
readOnly={readonly || isRollback}
|
||||
id={id}
|
||||
extensions={extensions}
|
||||
height={height}
|
||||
basicSetup={{
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: !!schema,
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { createTheme } from '@uiw/codemirror-themes';
|
||||
import { tags as highlightTags } from '@lezer/highlight';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { TextTip } from '../Tip/TextTip';
|
||||
import { StackVersionSelector } from '../StackVersionSelector';
|
||||
|
||||
import styles from './CodeEditor.module.css';
|
||||
import {
|
||||
useCodeEditorExtensions,
|
||||
CodeEditorType,
|
||||
} from './useCodeEditorExtensions';
|
||||
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
|
||||
|
||||
interface Props extends AutomationTestingProps {
|
||||
id: string;
|
||||
textTip?: string;
|
||||
type?: CodeEditorType;
|
||||
readonly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
value: string;
|
||||
height?: string;
|
||||
versions?: number[];
|
||||
onVersionChange?: (version: number) => void;
|
||||
schema?: JSONSchema7;
|
||||
fileName?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const theme = createTheme({
|
||||
theme: 'light',
|
||||
settings: {
|
||||
background: 'var(--bg-codemirror-color)',
|
||||
foreground: 'var(--text-codemirror-color)',
|
||||
caret: 'var(--border-codemirror-cursor-color)',
|
||||
selection: 'var(--bg-codemirror-selected-color)',
|
||||
selectionMatch: 'var(--bg-codemirror-selected-color)',
|
||||
},
|
||||
styles: [
|
||||
{ tag: highlightTags.atom, color: 'var(--text-cm-default-color)' },
|
||||
{ tag: highlightTags.meta, color: 'var(--text-cm-meta-color)' },
|
||||
{
|
||||
tag: [highlightTags.string, highlightTags.special(highlightTags.brace)],
|
||||
color: 'var(--text-cm-string-color)',
|
||||
},
|
||||
{ tag: highlightTags.number, color: 'var(--text-cm-number-color)' },
|
||||
{ tag: highlightTags.keyword, color: 'var(--text-cm-keyword-color)' },
|
||||
{ tag: highlightTags.comment, color: 'var(--text-cm-comment-color)' },
|
||||
{
|
||||
tag: highlightTags.variableName,
|
||||
color: 'var(--text-cm-variable-name-color)',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export function CodeEditor({
|
||||
id,
|
||||
onChange = () => {},
|
||||
textTip,
|
||||
readonly,
|
||||
value,
|
||||
versions,
|
||||
onVersionChange,
|
||||
height = '500px',
|
||||
type,
|
||||
schema,
|
||||
'data-cy': dataCy,
|
||||
fileName,
|
||||
placeholder,
|
||||
}: Props) {
|
||||
const [isRollback, setIsRollback] = useState(false);
|
||||
|
||||
const extensions = useCodeEditorExtensions(type, schema);
|
||||
|
||||
const handleVersionChange = useCallback(
|
||||
(version: number) => {
|
||||
if (versions && versions.length > 1) {
|
||||
setIsRollback(version < versions[0]);
|
||||
}
|
||||
onVersionChange?.(version);
|
||||
},
|
||||
[onVersionChange, versions]
|
||||
);
|
||||
|
||||
const [debouncedValue, debouncedOnChange] = useDebounce(value, onChange);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-2 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{!!textTip && <TextTip color="blue">{textTip}</TextTip>}
|
||||
</div>
|
||||
{/* the copy button is in the file name header, when fileName is provided */}
|
||||
{!fileName && (
|
||||
<div className="flex-2 ml-auto mr-2 flex items-center gap-x-2">
|
||||
<CopyButton
|
||||
data-cy={`copy-code-button-${id}`}
|
||||
fadeDelay={2500}
|
||||
copyText={value}
|
||||
color="link"
|
||||
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
||||
indicatorPosition="left"
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{versions && (
|
||||
<div className="mt-2 flex">
|
||||
<div className="ml-auto mr-2">
|
||||
<StackVersionSelector
|
||||
versions={versions}
|
||||
onChange={handleVersionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2">
|
||||
{fileName && (
|
||||
<FileNameHeaderRow>
|
||||
<FileNameHeader
|
||||
fileName={fileName}
|
||||
copyText={value}
|
||||
data-cy={`copy-code-button-${id}`}
|
||||
/>
|
||||
</FileNameHeaderRow>
|
||||
)}
|
||||
<CodeMirror
|
||||
className={clsx(styles.root, styles.codeEditor)}
|
||||
theme={theme}
|
||||
value={debouncedValue}
|
||||
onChange={debouncedOnChange}
|
||||
readOnly={readonly || isRollback}
|
||||
id={id}
|
||||
extensions={extensions}
|
||||
height={height}
|
||||
basicSetup={{
|
||||
highlightSelectionMatches: false,
|
||||
autocompletion: !!schema,
|
||||
}}
|
||||
data-cy={dataCy}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import { render } from '@testing-library/react';
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
|
||||
import { DiffViewer } from './DiffViewer';
|
||||
|
||||
// Mock CodeMirror
|
||||
vi.mock('@uiw/react-codemirror', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-cy="mock-editor" />,
|
||||
oneDarkHighlightStyle: {},
|
||||
keymap: {
|
||||
of: () => ({}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock react-codemirror-merge
|
||||
vi.mock('react-codemirror-merge', () => {
|
||||
function CodeMirrorMerge({ children }: { children: React.ReactNode }) {
|
||||
return <div data-cy="mock-code-mirror-merge">{children}</div>;
|
||||
}
|
||||
function Original({ value }: { value: string }) {
|
||||
return <div data-cy="mock-original">{value}</div>;
|
||||
}
|
||||
function Modified({ value }: { value: string }) {
|
||||
return <div data-cy="mock-modified">{value}</div>;
|
||||
}
|
||||
|
||||
CodeMirrorMerge.Original = Original;
|
||||
CodeMirrorMerge.Modified = Modified;
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: CodeMirrorMerge,
|
||||
CodeMirrorMerge,
|
||||
};
|
||||
});
|
||||
|
||||
describe('DiffViewer', () => {
|
||||
beforeEach(() => {
|
||||
// Clear any mocks or state before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render with basic props', () => {
|
||||
const { getByText } = render(
|
||||
<DiffViewer
|
||||
originalCode="Original text"
|
||||
newCode="New text"
|
||||
id="test-diff-viewer"
|
||||
data-cy="test-diff-viewer"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check if the component renders with the expected content
|
||||
expect(getByText('Original text')).toBeInTheDocument();
|
||||
expect(getByText('New text')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with file name headers when provided', () => {
|
||||
const { getByText } = render(
|
||||
<DiffViewer
|
||||
originalCode="Original text"
|
||||
newCode="New text"
|
||||
id="test-diff-viewer"
|
||||
data-cy="test-diff-viewer"
|
||||
fileNames={{
|
||||
original: 'Original File',
|
||||
modified: 'Modified File',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Look for elements with the expected class structure
|
||||
const headerOriginal = getByText('Original File');
|
||||
const headerModified = getByText('Modified File');
|
||||
expect(headerOriginal).toBeInTheDocument();
|
||||
expect(headerModified).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply custom height when provided', () => {
|
||||
const customHeight = '800px';
|
||||
const { container } = render(
|
||||
<DiffViewer
|
||||
originalCode="Original text"
|
||||
newCode="New text"
|
||||
id="test-diff-viewer"
|
||||
data-cy="test-diff-viewer"
|
||||
height={customHeight}
|
||||
/>
|
||||
);
|
||||
|
||||
// Find the element with the style containing the height
|
||||
const divWithStyle = container.querySelector('[style*="height"]');
|
||||
expect(divWithStyle).toBeInTheDocument();
|
||||
|
||||
// Check that the style contains the expected height
|
||||
expect(divWithStyle?.getAttribute('style')).toContain(
|
||||
`height: ${customHeight}`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import CodeMirrorMerge from 'react-codemirror-merge';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { FileNameHeader, FileNameHeaderRow } from './FileNameHeader';
|
||||
import styles from './CodeEditor.module.css';
|
||||
import {
|
||||
CodeEditorType,
|
||||
useCodeEditorExtensions,
|
||||
} from './useCodeEditorExtensions';
|
||||
import { theme } from './CodeEditor';
|
||||
|
||||
const { Original } = CodeMirrorMerge;
|
||||
const { Modified } = CodeMirrorMerge;
|
||||
|
||||
type Props = {
|
||||
originalCode: string;
|
||||
newCode: string;
|
||||
id: string;
|
||||
type?: CodeEditorType;
|
||||
placeholder?: string;
|
||||
height?: string;
|
||||
fileNames?: {
|
||||
original: string;
|
||||
modified: string;
|
||||
};
|
||||
className?: string;
|
||||
} & AutomationTestingProps;
|
||||
|
||||
const defaultCollapseUnchanged = {
|
||||
margin: 10,
|
||||
minSize: 10,
|
||||
};
|
||||
|
||||
export function DiffViewer({
|
||||
originalCode,
|
||||
newCode,
|
||||
id,
|
||||
'data-cy': dataCy,
|
||||
type,
|
||||
placeholder = 'No values found',
|
||||
|
||||
height = '500px',
|
||||
fileNames,
|
||||
className,
|
||||
}: Props) {
|
||||
const extensions = useCodeEditorExtensions(type);
|
||||
const hasFileNames = !!fileNames?.original && !!fileNames?.modified;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden rounded-lg border border-solid border-gray-5 th-dark:border-gray-7 th-highcontrast:border-gray-2',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{hasFileNames && (
|
||||
<DiffFileNameHeaders
|
||||
originalCopyText={originalCode}
|
||||
modifiedCopyText={newCode}
|
||||
originalFileName={fileNames.original}
|
||||
modifiedFileName={fileNames.modified}
|
||||
/>
|
||||
)}
|
||||
{/* additional div, so that the scroll gutter doesn't overlap with the rounded border, and always show scrollbar, so that the file name headers align */}
|
||||
<div
|
||||
style={
|
||||
{
|
||||
// tailwind doesn't like dynamic class names, so use a custom css variable for the height
|
||||
// https://v3.tailwindcss.com/docs/content-configuration#dynamic-class-names
|
||||
'--editor-min-height': height,
|
||||
height,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="h-full [scrollbar-gutter:stable] overflow-y-scroll"
|
||||
>
|
||||
<CodeMirrorMerge
|
||||
theme={theme}
|
||||
className={clsx(
|
||||
styles.root,
|
||||
// to give similar sizing to CodeEditor
|
||||
'[&_.cm-content]:!min-h-[var(--editor-min-height)] [&_.cm-gutters]:!min-h-[var(--editor-min-height)] [&_.cm-editor>.cm-scroller]:!min-h-[var(--editor-min-height)]'
|
||||
)}
|
||||
id={id}
|
||||
data-cy={dataCy}
|
||||
collapseUnchanged={defaultCollapseUnchanged}
|
||||
>
|
||||
<Original
|
||||
value={originalCode}
|
||||
extensions={extensions}
|
||||
readOnly
|
||||
editable={false}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<Modified
|
||||
value={newCode}
|
||||
extensions={extensions}
|
||||
readOnly
|
||||
editable={false}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</CodeMirrorMerge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffFileNameHeaders({
|
||||
originalCopyText,
|
||||
modifiedCopyText,
|
||||
originalFileName,
|
||||
modifiedFileName,
|
||||
}: {
|
||||
originalCopyText: string;
|
||||
modifiedCopyText: string;
|
||||
originalFileName: string;
|
||||
modifiedFileName: string;
|
||||
}) {
|
||||
return (
|
||||
<FileNameHeaderRow>
|
||||
<div className="w-1/2">
|
||||
<FileNameHeader
|
||||
fileName={originalFileName}
|
||||
copyText={originalCopyText}
|
||||
data-cy="original"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px bg-gray-5 th-dark:bg-gray-7 th-highcontrast:bg-gray-2" />
|
||||
<div className="flex-1">
|
||||
<FileNameHeader
|
||||
fileName={modifiedFileName}
|
||||
copyText={modifiedCopyText}
|
||||
data-cy="modified"
|
||||
/>
|
||||
</div>
|
||||
</FileNameHeaderRow>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { CopyButton } from '@@/buttons/CopyButton';
|
||||
|
||||
type FileNameHeaderProps = {
|
||||
fileName: string;
|
||||
copyText: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
} & AutomationTestingProps;
|
||||
|
||||
/**
|
||||
* FileNameHeaderRow: Outer container for file name headers (single or multiple columns).
|
||||
* Use this to wrap one or more <FileNameHeader> components (and optional dividers).
|
||||
*/
|
||||
export function FileNameHeaderRow({
|
||||
children,
|
||||
className,
|
||||
style,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex w-full text-sm text-muted border-0 border-b border-solid border-b-gray-5 th-dark:border-b-gray-7 th-highcontrast:border-b-gray-2 bg-gray-2 th-dark:bg-gray-10 th-highcontrast:bg-black [scrollbar-gutter:stable] overflow-auto',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FileNameHeader: Renders a file name with a copy button, styled for use above a code editor or diff viewer.
|
||||
* Should be used inside FileNameHeaderRow.
|
||||
*/
|
||||
export function FileNameHeader({
|
||||
fileName,
|
||||
copyText,
|
||||
className = '',
|
||||
style,
|
||||
'data-cy': dataCy,
|
||||
}: FileNameHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full overflow-auto flex justify-between items-center gap-x-2 px-4 py-1 text-sm text-muted',
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{fileName}
|
||||
<CopyButton
|
||||
data-cy={dataCy}
|
||||
copyText={copyText}
|
||||
color="link"
|
||||
className="!pr-0 !text-sm !font-medium hover:no-underline focus:no-underline"
|
||||
indicatorPosition="left"
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './CodeEditor';
|
||||
@@ -1,91 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
StreamLanguage,
|
||||
LanguageSupport,
|
||||
syntaxHighlighting,
|
||||
indentService,
|
||||
} from '@codemirror/language';
|
||||
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
||||
import { shell } from '@codemirror/legacy-modes/mode/shell';
|
||||
import {
|
||||
oneDarkHighlightStyle,
|
||||
keymap,
|
||||
Extension,
|
||||
} from '@uiw/react-codemirror';
|
||||
import type { JSONSchema7 } from 'json-schema';
|
||||
import { lintKeymap, lintGutter } from '@codemirror/lint';
|
||||
import { defaultKeymap } from '@codemirror/commands';
|
||||
import { autocompletion, completionKeymap } from '@codemirror/autocomplete';
|
||||
import { yamlCompletion, yamlSchema } from 'yaml-schema';
|
||||
import { compact } from 'lodash';
|
||||
import { lineNumbers } from '@codemirror/view';
|
||||
|
||||
export type CodeEditorType = 'yaml' | 'shell' | 'dockerfile';
|
||||
|
||||
// Custom indentation service for YAML
|
||||
const yamlIndentExtension = indentService.of((context, pos) => {
|
||||
const prevLine = context.lineAt(pos, -1);
|
||||
const prevIndent = /^\s*/.exec(prevLine.text)?.[0].length || 0;
|
||||
if (/:\s*$/.test(prevLine.text)) {
|
||||
return prevIndent + 2;
|
||||
}
|
||||
return prevIndent;
|
||||
});
|
||||
|
||||
const dockerFileLanguage = new LanguageSupport(
|
||||
StreamLanguage.define(dockerFile)
|
||||
);
|
||||
const shellLanguage = new LanguageSupport(StreamLanguage.define(shell));
|
||||
|
||||
function yamlLanguage(schema?: JSONSchema7) {
|
||||
const [yaml, linter, , , stateExtensions] = yamlSchema(schema);
|
||||
|
||||
return compact([
|
||||
yaml,
|
||||
linter,
|
||||
stateExtensions,
|
||||
yamlIndentExtension,
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
// explicitly setting lineNumbers() as an extension ensures that the gutter order is the same between the diff viewer and the code editor
|
||||
lineNumbers(),
|
||||
lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...completionKeymap, ...lintKeymap]),
|
||||
// only show completions when a schema is provided
|
||||
!!schema &&
|
||||
autocompletion({
|
||||
icons: false,
|
||||
activateOnTypingDelay: 300,
|
||||
selectOnOpen: true,
|
||||
activateOnTyping: true,
|
||||
override: [
|
||||
(ctx) => {
|
||||
const getCompletions = yamlCompletion();
|
||||
const completions = getCompletions(ctx);
|
||||
if (Array.isArray(completions)) {
|
||||
return null;
|
||||
}
|
||||
completions.validFor = /^\w*$/;
|
||||
return completions;
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function useCodeEditorExtensions(
|
||||
type?: CodeEditorType,
|
||||
schema?: JSONSchema7
|
||||
): Extension[] {
|
||||
return useMemo(() => {
|
||||
switch (type) {
|
||||
case 'dockerfile':
|
||||
return [dockerFileLanguage];
|
||||
case 'shell':
|
||||
return [shellLanguage];
|
||||
case 'yaml':
|
||||
return yamlLanguage(schema);
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}, [type, schema]);
|
||||
}
|
||||
@@ -13,21 +13,21 @@ export type Props = {
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
xs: 'text-xs gap-1',
|
||||
sm: 'text-sm gap-2',
|
||||
md: 'text-md gap-2',
|
||||
xs: 'text-xs',
|
||||
sm: 'text-sm',
|
||||
md: 'text-md',
|
||||
};
|
||||
|
||||
export function InlineLoader({ children, className, size = 'sm' }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'text-muted flex items-center',
|
||||
'text-muted flex items-center gap-2',
|
||||
className,
|
||||
sizeStyles[size]
|
||||
)}
|
||||
>
|
||||
<Icon icon={Loader2} className="animate-spin-slow flex-none" />
|
||||
<Icon icon={Loader2} className="animate-spin-slow" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// allow custom labels
|
||||
export interface RadioGroupOption<TValue> {
|
||||
value: TValue;
|
||||
label: ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
import { Option } from '@@/form-components/PortainerSelect';
|
||||
|
||||
interface Props<T extends string | number> {
|
||||
options: Array<RadioGroupOption<T>> | ReadonlyArray<RadioGroupOption<T>>;
|
||||
options: Array<Option<T>> | ReadonlyArray<Option<T>>;
|
||||
selectedOption: T;
|
||||
name: string;
|
||||
onOptionChange: (value: T) => void;
|
||||
groupClassName?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
export function RadioGroup<T extends string | number = string>({
|
||||
@@ -21,18 +12,13 @@ export function RadioGroup<T extends string | number = string>({
|
||||
selectedOption,
|
||||
name,
|
||||
onOptionChange,
|
||||
groupClassName,
|
||||
itemClassName,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className={groupClassName ?? 'flex flex-wrap gap-x-2 gap-y-1'}>
|
||||
<div className="flex flex-wrap gap-x-2 gap-y-1">
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className={
|
||||
itemClassName ??
|
||||
'col-sm-3 col-lg-2 control-label !p-0 text-left font-normal'
|
||||
}
|
||||
className="col-sm-3 col-lg-2 control-label !p-0 text-left font-normal"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -42,7 +28,6 @@ export function RadioGroup<T extends string | number = string>({
|
||||
onChange={() => onOptionChange(option.value)}
|
||||
style={{ margin: '0 4px 0 0' }}
|
||||
data-cy={`radio-${option.value}`}
|
||||
disabled={option.disabled}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import {
|
||||
ComponentPropsWithoutRef,
|
||||
forwardRef,
|
||||
ElementRef,
|
||||
PropsWithChildren,
|
||||
} from 'react';
|
||||
import * as SheetPrimitive from '@radix-ui/react-dialog';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import clsx from 'clsx';
|
||||
import { RefreshCw, X } from 'lucide-react';
|
||||
|
||||
import { Button } from './buttons';
|
||||
|
||||
// modified from shadcn sheet component
|
||||
const Sheet = SheetPrimitive.Root;
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger;
|
||||
|
||||
const SheetClose = SheetPrimitive.Close;
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal;
|
||||
|
||||
const SheetDescription = SheetPrimitive.Description;
|
||||
|
||||
type SheetTitleProps = {
|
||||
title: string;
|
||||
onReload?(): Promise<void> | void;
|
||||
};
|
||||
|
||||
// similar to the PageHeader component with simplified props and no breadcrumbs
|
||||
function SheetHeader({
|
||||
onReload,
|
||||
title,
|
||||
children,
|
||||
}: PropsWithChildren<SheetTitleProps>) {
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-sm-12 pt-3 flex gap-2 justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SheetPrimitive.DialogTitle className="m-0 text-2xl font-medium text-gray-11 th-highcontrast:text-white th-dark:text-white">
|
||||
{title}
|
||||
</SheetPrimitive.DialogTitle>
|
||||
{onReload ? (
|
||||
<Button
|
||||
color="none"
|
||||
size="large"
|
||||
onClick={onReload}
|
||||
className="m-0 p-0 focus:text-inherit"
|
||||
title="Refresh drawer content"
|
||||
data-cy="sheet-refreshButton"
|
||||
>
|
||||
<RefreshCw className="icon" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SheetOverlay = forwardRef<
|
||||
ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={clsx(
|
||||
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'fixed gap-4 bg-widget-color p-5 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'inset-y-0 left-0 h-full w-[70vw] lg:w-[50vw] border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left max-w-2xl',
|
||||
right:
|
||||
'inset-y-0 right-0 h-full w-[70vw] lg:w-[50vw] border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right max-w-2xl',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: 'right',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
interface SheetContentProps
|
||||
extends ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const SheetContent = forwardRef<
|
||||
ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
side = 'right',
|
||||
className,
|
||||
children,
|
||||
title,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={clsx(sheetVariants({ side }), className)}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
>
|
||||
{title ? <SheetHeader title={title} /> : null}
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
asChild
|
||||
className="absolute close-button right-9 top-8 disabled:pointer-events-none"
|
||||
>
|
||||
<Button
|
||||
icon={X}
|
||||
color="none"
|
||||
className="btn-only-icon"
|
||||
size="medium"
|
||||
data-cy="sheet-closeButton"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
);
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
};
|
||||
@@ -74,7 +74,6 @@ export function WebEditorForm({
|
||||
children,
|
||||
error,
|
||||
schema,
|
||||
textTip,
|
||||
...props
|
||||
}: PropsWithChildren<Props>) {
|
||||
return (
|
||||
@@ -100,7 +99,6 @@ export function WebEditorForm({
|
||||
id={id}
|
||||
type="yaml"
|
||||
schema={schema as JSONSchema7}
|
||||
textTip={textTip}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
export function WidgetIcon({ icon }: { icon: ReactNode }) {
|
||||
return (
|
||||
<div className="text-lg inline-flex items-center rounded-full bg-blue-3 text-blue-8 th-dark:bg-gray-9 th-dark:text-blue-3 p-2">
|
||||
<Icon icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import clsx from 'clsx';
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
|
||||
import { WidgetIcon } from './WidgetIcon';
|
||||
import { Icon } from '@/react/components/Icon';
|
||||
|
||||
import { useWidgetContext } from './Widget';
|
||||
|
||||
interface Props {
|
||||
title: ReactNode;
|
||||
@@ -15,12 +17,16 @@ export function WidgetTitle({
|
||||
className,
|
||||
children,
|
||||
}: PropsWithChildren<Props>) {
|
||||
useWidgetContext();
|
||||
|
||||
return (
|
||||
<div className="widget-header">
|
||||
<div className="row">
|
||||
<span className={clsx('pull-left vertical-center', className)}>
|
||||
<WidgetIcon icon={icon} />
|
||||
<h2 className="text-base m-0 ml-1">{title}</h2>
|
||||
<div className="widget-icon">
|
||||
<Icon icon={icon} className="space-right" />
|
||||
</div>
|
||||
<h2 className="text-base m-0">{title}</h2>
|
||||
</span>
|
||||
<span className={clsx('pull-right', className)}>{children}</span>
|
||||
</div>
|
||||
|
||||
@@ -26,13 +26,13 @@ function Template({
|
||||
|
||||
export const Primary: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
Primary.args = {
|
||||
children: 'Copy',
|
||||
children: 'Copy to clipboard',
|
||||
copyText: 'this will be copied to clipboard',
|
||||
};
|
||||
|
||||
export const NoCopyText: Story<PropsWithChildren<Props>> = Template.bind({});
|
||||
NoCopyText.args = {
|
||||
children: 'Copy without copied text',
|
||||
children: 'Copy to clipboard without copied text',
|
||||
copyText: 'clipboard override',
|
||||
displayText: '',
|
||||
};
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
|
||||
import { Datatable, defaultGlobalFilterFn, Props } from './Datatable';
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
TableSettingsWithRefreshable,
|
||||
RefreshableTableSettings,
|
||||
} from './types';
|
||||
import { useTableState } from './useTableState';
|
||||
|
||||
@@ -29,14 +30,13 @@ const mockColumns = [
|
||||
];
|
||||
|
||||
// mock table settings / state
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
RefreshableTableSettings {}
|
||||
function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettingsWithRefreshable>(
|
||||
storageKey,
|
||||
'name',
|
||||
(set) => ({
|
||||
...refreshableSettings(set),
|
||||
})
|
||||
);
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
const storageKey = 'test-table';
|
||||
const settingsStore = createStore(storageKey);
|
||||
|
||||
@@ -58,7 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||
getRowId?(row: D): string;
|
||||
isRowSelectable?(row: Row<D>): boolean;
|
||||
emptyContentLabel?: string;
|
||||
title?: React.ReactNode;
|
||||
title?: string;
|
||||
titleIcon?: IconProps['icon'];
|
||||
titleId?: string;
|
||||
initialTableState?: Partial<TableState>;
|
||||
@@ -71,8 +71,6 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
|
||||
noWidget?: boolean;
|
||||
extendTableOptions?: (options: TableOptions<D>) => TableOptions<D>;
|
||||
includeSearch?: boolean;
|
||||
ariaLabel?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function Datatable<D extends DefaultType>({
|
||||
@@ -102,8 +100,6 @@ export function Datatable<D extends DefaultType>({
|
||||
isServerSidePagination = false,
|
||||
extendTableOptions = (value) => value,
|
||||
includeSearch,
|
||||
ariaLabel,
|
||||
id,
|
||||
}: Props<D> & PaginationProps) {
|
||||
const pageCount = useMemo(
|
||||
() => Math.ceil(totalCount / settings.pageSize),
|
||||
@@ -185,14 +181,9 @@ export function Datatable<D extends DefaultType>({
|
||||
() => _.difference(selectedItems, filteredItems),
|
||||
[selectedItems, filteredItems]
|
||||
);
|
||||
const { titleAriaLabel, contentAriaLabel } = getAriaLabels(
|
||||
ariaLabel,
|
||||
title,
|
||||
titleId
|
||||
);
|
||||
|
||||
return (
|
||||
<Table.Container noWidget={noWidget} aria-label={titleAriaLabel} id={id}>
|
||||
<Table.Container noWidget={noWidget} aria-label={title}>
|
||||
<DatatableHeader
|
||||
onSearchChange={handleSearchBarChange}
|
||||
searchValue={settings.search}
|
||||
@@ -213,7 +204,7 @@ export function Datatable<D extends DefaultType>({
|
||||
isLoading={isLoading}
|
||||
onSortChange={handleSortChange}
|
||||
data-cy={dataCy}
|
||||
aria-label={contentAriaLabel}
|
||||
aria-label={`${title} table`}
|
||||
/>
|
||||
|
||||
<DatatableFooter
|
||||
@@ -248,23 +239,6 @@ export function Datatable<D extends DefaultType>({
|
||||
}
|
||||
}
|
||||
|
||||
function getAriaLabels(
|
||||
titleAriaLabel?: string,
|
||||
title?: ReactNode,
|
||||
titleId?: string
|
||||
) {
|
||||
if (titleAriaLabel) {
|
||||
return { titleAriaLabel, contentAriaLabel: `${titleAriaLabel} table` };
|
||||
}
|
||||
if (typeof title === 'string') {
|
||||
return { titleAriaLabel: title, contentAriaLabel: `${title} table` };
|
||||
}
|
||||
if (titleId) {
|
||||
return { titleAriaLabel: titleId, contentAriaLabel: `${titleId} table` };
|
||||
}
|
||||
return { titleAriaLabel: 'table', contentAriaLabel: 'table' };
|
||||
}
|
||||
|
||||
function defaultRenderRow<D extends DefaultType>(
|
||||
row: Row<D>,
|
||||
highlightedItemId?: string
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SearchBar } from './SearchBar';
|
||||
import { Table } from './Table';
|
||||
|
||||
type Props = {
|
||||
title?: React.ReactNode;
|
||||
title?: string;
|
||||
titleIcon?: IconProps['icon'];
|
||||
searchValue: string;
|
||||
onSearchChange(value: string): void;
|
||||
@@ -52,7 +52,7 @@ export function DatatableHeader({
|
||||
return (
|
||||
<Table.Title
|
||||
id={titleId}
|
||||
label={title}
|
||||
label={title ?? ''}
|
||||
icon={titleIcon}
|
||||
description={description}
|
||||
data-cy={dataCy}
|
||||
|
||||
@@ -6,14 +6,12 @@ interface Props {
|
||||
// workaround to remove the widget, ideally we should have a different component to wrap the table with a widget
|
||||
noWidget?: boolean;
|
||||
'aria-label'?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export function TableContainer({
|
||||
children,
|
||||
noWidget = false,
|
||||
'aria-label': ariaLabel,
|
||||
id,
|
||||
}: PropsWithChildren<Props>) {
|
||||
if (noWidget) {
|
||||
return (
|
||||
@@ -27,7 +25,7 @@ export function TableContainer({
|
||||
<div className="row">
|
||||
<div className="col-sm-12">
|
||||
<div className="datatable">
|
||||
<Widget aria-label={ariaLabel} id={id}>
|
||||
<Widget aria-label={ariaLabel}>
|
||||
<WidgetBody className="no-padding">{children}</WidgetBody>
|
||||
</Widget>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Icon } from '@@/Icon';
|
||||
|
||||
interface Props {
|
||||
icon?: ReactNode | ComponentType<unknown>;
|
||||
label: React.ReactNode;
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
|
||||
@@ -99,10 +99,6 @@ export interface BasicTableSettings
|
||||
extends SortableTableSettings,
|
||||
PaginationTableSettings {}
|
||||
|
||||
export interface TableSettingsWithRefreshable
|
||||
extends BasicTableSettings,
|
||||
RefreshableTableSettings {}
|
||||
|
||||
export function createPersistedStore<T extends BasicTableSettings>(
|
||||
storageKey: string,
|
||||
initialSortBy?: string | { id: string; desc: boolean },
|
||||
|
||||
@@ -43,7 +43,7 @@ export function AdvancedMode({
|
||||
id="environment-variables-editor"
|
||||
value={editorValue}
|
||||
onChange={handleEditorChange}
|
||||
textTip="e.g. key=value"
|
||||
placeholder="e.g. key=value"
|
||||
data-cy={dataCy}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -45,9 +45,8 @@ export function FormSection({
|
||||
|
||||
{title}
|
||||
</FormSectionTitle>
|
||||
{/* col-sm-12 in the title has a 'float: left' style - 'clear-both' makes sure it doesn't get in the way of the next div */}
|
||||
{/* https://stackoverflow.com/questions/7759837/put-divs-below-floatleft-divs */}
|
||||
{isExpanded && <div className="clear-both">{children}</div>}
|
||||
|
||||
{isExpanded && children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,14 @@
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--bg-modal-content-color);
|
||||
padding: 20px;
|
||||
|
||||
position: relative;
|
||||
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
border-radius: 6px;
|
||||
|
||||
outline: 0;
|
||||
|
||||
box-shadow: 0 5px 15px rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function Modal({
|
||||
isOpen
|
||||
className={clsx(
|
||||
styles.overlay,
|
||||
'flex items-center justify-center z-50'
|
||||
'z-50 flex items-center justify-center'
|
||||
)}
|
||||
onDismiss={onDismiss}
|
||||
role="dialog"
|
||||
@@ -56,13 +56,7 @@ export function Modal({
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.modalContent,
|
||||
'relative overflow-y-auto p-5 rounded-lg',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={clsx(styles.modalContent, 'relative', className)}>
|
||||
{children}
|
||||
{onDismiss && <CloseButton onClose={onDismiss} />}
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import {
|
||||
BasicTableSettings,
|
||||
createPersistedStore,
|
||||
refreshableSettings,
|
||||
TableSettingsWithRefreshable,
|
||||
RefreshableTableSettings,
|
||||
} from '@@/datatables/types';
|
||||
|
||||
export interface TableSettings
|
||||
extends BasicTableSettings,
|
||||
RefreshableTableSettings {}
|
||||
|
||||
export function createStore(storageKey: string) {
|
||||
return createPersistedStore<TableSettingsWithRefreshable>(
|
||||
storageKey,
|
||||
'name',
|
||||
(set) => ({
|
||||
...refreshableSettings(set),
|
||||
})
|
||||
);
|
||||
return createPersistedStore<TableSettings>(storageKey, 'name', (set) => ({
|
||||
...refreshableSettings(set),
|
||||
}));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user