Compare commits

..

98 Commits

Author SHA1 Message Date
Oscar Zhou
b284d7094a fix(stack): filter out orphan stacks that have same name as normal stacks [EE-6791] (#11471) 2024-04-03 09:53:36 +13:00
LP B
7bb54bcbe6 fix(app): replace fields removed by Docker 25 and 26 (#11469)
* fix(app/volume): make optional Container and ContainerConfig fields removed in docker 26

* fix(app/image): use image.Size instead of image.VirtualSize removed in Docker 25
2024-03-29 13:57:18 +01:00
cmeng
b3c489366f fix(edge-stack): avoid reference of undefined EE-6914 (#11465) 2024-03-27 16:02:25 +13:00
cmeng
5eca761883 feat(version): bump to 2.20.1 EE-6933 (#11459) 2024-03-27 15:41:45 +13:00
andres-portainer
bea8acce1f fix(kubernetes): avoid a deadlock EE-6901 (#11446) 2024-03-25 14:19:33 -03:00
Matt Hook
6a3eda4bce fix(doclinks): fix help link paths [EE-6861] (#11417) 2024-03-19 11:46:55 +13:00
Matt Hook
889c36f64a fix(docs): fix all remaining webhook app links [EE-6861] (#11392) 2024-03-18 16:28:43 +13:00
Matt Hook
c8fb3adda3 fix(kube): fix edit application webhook link [EE-6861] (#11390) 2024-03-18 10:21:20 +13:00
cmeng
f15be1d92a fix(stack): prepopulate when creating template from stack EE-6853 (#11379) 2024-03-18 09:36:04 +13:00
Oscar Zhou
d9ae249ffe chore(template/git): sync frontend code from ee (#11343) 2024-03-18 08:55:26 +13:00
Matt Hook
04de06c07f fix(docs): make all doc links versioned [EE-6861] (#11381) 2024-03-15 16:57:42 +13:00
Matt Hook
59d53940fe fix(stacks): update swagger stacks doc description [EE-6860] (#11383) 2024-03-15 16:47:05 +13:00
cmeng
db16888379 fix(container): make blank string as valid value EE-6852 (#11372) 2024-03-15 09:01:42 +13:00
Prabhat Khera
8880876bcd fix(auth): make createAccessToken api backward compatible [EE-6818] (#11327)
* fix(auth): make createAccessToken api backward compatible [EE-6818]

* fix(api): api error message [EE-6818]

* fix messages
2024-03-14 09:02:25 +13:00
Ali
bfe5a49263 fix(app): only show special message when limits change for existing app resource limit [EE-6837] (#11368)
Co-authored-by: testa113 <testa113>
2024-03-14 08:45:53 +13:00
cmeng
6e11c10bab fix(csrf): disable csrf secure cookie EE-6787 (#11299) 2024-03-13 11:22:18 +13:00
LP B
cb9ab3b375 fix(app): views not loading when quickly navigating in app (#11279) 2024-03-12 15:16:19 +01:00
Chaim Lev-Ari
b13dac0f6d fix(docker): apply private uac to edge admin [EE-6788] (#11284) 2024-03-12 09:59:39 +02:00
cmeng
0144a98b3b fix(edge-stack): deploy button is disabled EE-6819 (#11354) 2024-03-12 17:19:45 +13:00
Prabhat Khera
64a08c59e9 address review commets (#11361) 2024-03-12 11:32:03 +13:00
Ali
1090c82beb fix(app): on create don't mention previous values [EE-6837] (#11351)
Co-authored-by: testa113 <testa113>
2024-03-11 16:43:45 +13:00
Prabhat Khera
6094dc115b fix(container): autocomplete off for create container form [EE-6761] (#11337)
* autocomplete off doe create container form

* address review commets

* remove auto complete off from forms
2024-03-11 13:38:59 +13:00
Prabhat Khera
30513695b5 fix(kube): stackname in daemonsets and statefulsets app [EE-6670] (#11353) 2024-03-11 10:04:55 +13:00
Chaim Lev-Ari
dd2be9fb1e refactor(tests): wrap tests explicitly with provider [EE-6686] (#11276) 2024-03-10 14:22:05 +02:00
Chaim Lev-Ari
e265b8b67c fix(kube/config): validate change window start [EE-6830] (#11328) 2024-03-10 09:42:29 +02:00
Matt Hook
cc1ce9412a fix(exec): improve alignment of help icon [EE-6816] (#11340) 2024-03-08 14:03:01 +13:00
Prabhat Khera
8eb8df2b30 fix(kube-stacks): change wordings [EE-6670] (#11335) 2024-03-08 12:15:27 +13:00
Ali
c0bd2dfdaf fix(matomo): stop oauth link event [EE-6779] (#11333) 2024-03-08 10:17:26 +13:00
Matt Hook
bf65a38d5a fix(exec): fix alignment and text size and alignment [EE-6816] (#11324) 2024-03-07 12:57:53 +13:00
cmeng
0ea21f2317 fix(menu): edge compute menu not clickable EE-6804 (#11320) 2024-03-07 12:11:59 +13:00
Prabhat Khera
b5f839a920 fix(stacks): make stackName kube stack specific field [EE-6670] (#11316)
* fix(stacks): make stackName kube stack specific field [EE-6670]

* fix wordings
2024-03-07 11:31:28 +13:00
Prabhat Khera
29025e7dd4 fix(UI): axios progress bar loading issue [EE-6781] (#11290) 2024-03-07 11:30:23 +13:00
Ali
692981b615 fix(time window): show errors for component [EE-6800] (#11318)
Co-authored-by: testa113 <testa113>
2024-03-07 09:03:26 +13:00
Chaim Lev-Ari
d6545b6af5 fix(kube/setup): add a11y labels [EE-6747] (#11308) 2024-03-06 14:57:03 +02:00
Matt Hook
6bbf62fe64 fix(contexthelp): remove extra slash from contexthelp docs link [EE-6780] (#11312) 2024-03-06 16:38:19 +13:00
Matt Hook
6b3ddf11d4 fix(helm): remove helm insights from the stack datatable [EE-6803] (#11313) 2024-03-06 16:36:48 +13:00
Dakota Walsh
77c9124e8a fix(datatable): title size EE-6774 (#11273) 2024-03-06 08:01:45 +13:00
Chaim Lev-Ari
2c3dcdd14e fix(docker/images): export image [EE-6807] (#11305) 2024-03-05 19:30:45 +02:00
matias-portainer
ec913b45d6 fix(edge/templates): get correct default value for selectType env vars EE-6796 (#11293) 2024-03-04 10:35:19 -03:00
Matt Hook
51c672af21 fix(kube): update doc links to match new menu structure [EE-6759] (#11266) 2024-03-01 15:37:32 +13:00
Matt Hook
ff178641be fix(help): add versioned doc links to support LTS/STS docs [EE-6780] (#11282) 2024-03-01 15:36:19 +13:00
cmeng
a43454076b fix(edge-stacks): take not-found stack as removed EE-6758 (#11249) 2024-03-01 11:50:27 +13:00
cmeng
a7eaa0f3fa fix(container): get old container info correctly EE-6716 (#11215) 2024-03-01 09:14:26 +13:00
cmeng
8ad11fc88f fix(stack): more space for add button EE-6773 (#11258) 2024-03-01 09:11:46 +13:00
Chaim Lev-Ari
43a95874f4 fix(auth): prevent unauthorized redirect on page load [EE-6777] (#11265) 2024-02-29 09:41:29 +02:00
Chaim Lev-Ari
b4f4c3212a feat(kube): add a11y props for smoke tests [EE-6747] (#11262) 2024-02-29 09:26:10 +02:00
Chaim Lev-Ari
d44f57ed6f fix(ci): prevent tests from running twice [EE-6728] (#11196) 2024-02-29 08:11:46 +02:00
Chaim Lev-Ari
eba08cdca0 fix(docker): hide write buttons for non authorized [EE-6775] (#11261) 2024-02-27 12:36:47 +02:00
Prabhat Khera
de3a3f88a0 fix(ui): autocomplete on edge custom template and stacks [EE-6761] (#11269) 2024-02-27 20:15:56 +13:00
Matt Hook
f6b2c879bc fix(kube): make app autorefresh and show system settings stay [EE-6771] (#11256) 2024-02-27 11:18:28 +13:00
Prabhat Khera
f5fbcd4d9d fix(stack): auto complete dropdown in docker stacks [EE-6761] (#11254) 2024-02-26 11:43:18 +13:00
Ali
f8b68a809f fix(app): parse nan in validation check [EE-6714] (#11247) 2024-02-26 09:20:59 +13:00
Oscar Zhou
6258c02353 fix(edge/template): validate app template env vars [EE-6743] (#11234) 2024-02-26 09:00:03 +13:00
Chaim Lev-Ari
0fd20277c1 fix(docker): prevent non admins from passing security settings [EE-6765] (#11239) 2024-02-25 11:57:19 +02:00
cmeng
988064a542 fix(stack): make web editor readonly for git template EE-6706 (#11183) 2024-02-23 13:28:20 +13:00
Matt Hook
380b23a9f5 fix(dependancies): update compose and runc [EE-6744] (#11243) 2024-02-23 11:48:49 +13:00
Prabhat Khera
158b43194c fix(ui): turn autocomplete off for git deployment [EE-6761] (#11241) 2024-02-23 08:44:00 +13:00
Ali
1bbe98379a fix(app): NaN validation for autoscaling [EE-6714] (#11238) 2024-02-22 17:36:41 +13:00
Matt Hook
8f9b265f5a fix(helm) tighten up helm requests [EE-6722] (#11233) 2024-02-22 11:35:01 +13:00
Ali
1cdd3fdfe2 fix(input): allow clearing number inputs [EE-6714] (#11187) 2024-02-21 10:43:28 +13:00
Ali
4e95139909 fix(inputlist): update warning style [EE-6737] (#11222) 2024-02-21 08:29:14 +13:00
Matt Hook
704d75596d fix(libhttp): capitalize http error responses for better display [EE-6698] (#11109) 2024-02-21 07:51:29 +13:00
Chaim Lev-Ari
a8938779bf fix(ui): check for authorization [EE-6733] (#11207) 2024-02-20 11:06:05 +02:00
Chaim Lev-Ari
bb6f4e026a fix(kube/apps): move namespace selector in apps view [EE-6612] (#11069) 2024-02-20 10:14:11 +02:00
Ali
b64166ff25 fix(app): remove insight from helm [EE-6693] (#11214)
Co-authored-by: testa113 <testa113>
2024-02-20 17:25:22 +13:00
Ali
bac1c28fa9 fix(app): set values in react autoscaling form section [EE-6740] (#11220) 2024-02-20 09:35:32 +13:00
Prabhat Khera
a17da6d2cd fix(git): update stack name for git stacks [EE-6670] (#11218) 2024-02-20 09:23:50 +13:00
Chaim Lev-Ari
24c2baf6cc feat(a11y): add labels and roles [EE-6717] (#11209) 2024-02-19 16:37:21 +02:00
Oscar Zhou
22b4d029fd fix(edge/template): custom template git fields not pre-filled [EE-6695] (#11113) 2024-02-19 08:39:16 +13:00
Ali
b126472ec7 fix(app): update app type when changing data access policy [EE-6719] (#11210)
Co-authored-by: testa113 <testa113>
2024-02-19 08:08:17 +13:00
Ali
a46fa3b2c4 fix(app): avoid duplicate env requests [EE-6727] (#11193)
Co-authored-by: testa113 <testa113>
2024-02-16 14:02:02 +13:00
Prabhat Khera
a374157d6f fix(ui): update search placeholder [EE-6667] (#11191)
* update search placeholder

* remove box selector description
2024-02-16 12:34:10 +13:00
Matt Hook
861ed662e2 fix(namespace): fix default namespace quota [EE-6700] (#11184) 2024-02-16 08:17:10 +13:00
Chaim Lev-Ari
99b89a8ec5 chore(eslint): add rule to check imports [EE-6730] (#11200) 2024-02-15 17:45:54 +02:00
Chaim Lev-Ari
95750c2339 fix(auth): export hasAuthorizations [EE-6595] (#11198) 2024-02-15 14:05:45 +02:00
Chaim Lev-Ari
165d6165dc feat(ui): restrict views by role [EE-6595] (#11071) 2024-02-15 13:29:55 +02:00
Chaim Lev-Ari
fe6ed55cab feat(edge/stacks): add app templates to deploy types [EE-6632] (#11070) 2024-02-15 09:00:57 +02:00
Chaim Lev-Ari
edea9e3481 feat(auth): add useIsEdgeAdmin hook [EE-6627] (#11101) 2024-02-14 19:50:26 -03:00
Ali
c08b5af85a fix(insight): split insight from input [EE-6693] (#11177)
Co-authored-by: testa113 <testa113>
2024-02-15 10:46:02 +13:00
Prabhat Khera
ed861044a7 Revert "fix(logs): add NOCOLOR option for use when exporting to greylog etc […" (#11178)
This reverts commit aca6d33548.
2024-02-15 06:26:22 +13:00
Chaim Lev-Ari
a83321ebe6 feat(ui): write tests [EE-6685] (#11082) 2024-02-14 17:25:32 +02:00
Ali
513cd9c9b3 fix(configs): correct 'external' display in tables [EE-6649] (#11111)
Co-authored-by: testa113 <testa113>
2024-02-14 11:48:05 +13:00
Ali
dc94bf141e fix(stacks): add app form stacks input [EE-6693] (#11105) 2024-02-14 09:01:02 +13:00
Dakota Walsh
24471a9ae1 fix(restore): add S3 teaser [EE-6675] (#11096) 2024-02-14 08:40:34 +13:00
Matt Hook
aca6d33548 fix(logs): add NOCOLOR option for use when exporting to greylog etc [EE-6696] (#11107) 2024-02-14 07:54:47 +13:00
Ali
ca77b85c65 fix(kube-owner): owner labels from resources created via manifest [EE-6647] (#11103)
Co-authored-by: testa113 <testa113>
2024-02-12 15:30:59 +13:00
Prabhat Khera
1fd4291630 fix(ui): stackname auto fill on create from manifest screen [EE-6688] (#11100)
* fix(ui): stackname auto fill on create from manifest screen [EE-6688]

* address review comment
2024-02-12 10:54:24 +13:00
Ali
08dd7f6d2a fix(auth): isAdmin redirect for wizard [EE-6669] (#11075)
Co-authored-by: testa113 <testa113>
2024-02-12 08:04:44 +13:00
Prabhat Khera
ce4b0e759c fix(ui): scroll issue [EE-6667 (#11085)
* Fix scroll issue

* fix minorissue

* address review comments

* add comment
2024-02-09 15:35:38 +13:00
Steven Kang
538e7a823b fix: pre-release build only after merging (#11098) 2024-02-09 15:26:39 +13:00
Matt Hook
956e8d3c59 fix(docs): fix swagger docs for webhook params [EE-6668] (#11089) 2024-02-09 14:44:29 +13:00
Prabhat Khera
1c5458f0d4 fix(kube): ingress path duplication issue [EE-6649] (#11087) 2024-02-09 07:49:57 +13:00
Prabhat Khera
f6085ffad7 fix stack name update issue (#11065) 2024-02-08 13:51:06 +13:00
Matt Hook
490bda2eaf fix(kube-apps): add helm insights, remove namespace insights panel [EE-6671] (#11078) 2024-02-08 11:18:48 +13:00
Prabhat Khera
d601d8eb7b fix(UI): some minor fixes [EE-6667] (#11062)
* minor tweeks for kubernetes settings

* address review comments
2024-02-06 12:17:35 +13:00
Steven Kang
b0564b9238 Pre-release as part of the CI (#11067)
* feat: add pre-release
* feat: add extension
* feat: fix typo
2024-02-05 18:29:12 +13:00
Prabhat Khera
8922585a70 keep labels on edit ingress, configmaps and secrets (#11063) 2024-02-05 16:30:31 +13:00
Ali
d7cf2284dc fix(r2a): don't set errors to undefined [EE-6665] (#11060)
Co-authored-by: testa113 <testa113>
2024-02-05 14:24:15 +13:00
1020 changed files with 9193 additions and 13933 deletions

View File

@@ -140,11 +140,9 @@ overrides:
'react/jsx-no-constructed-context-values': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off
- files:
- app/**/*.stories.*
rules:
'no-alert': off
'@typescript-eslint/no-restricted-imports': off
no-restricted-imports: off
'react/jsx-props-no-spreading': off

View File

@@ -93,8 +93,6 @@ body:
description: We only provide support for the most recent version of Portainer and the previous 3 versions. If you are on an older version of Portainer we recommend [upgrading first](https://docs.portainer.io/start/upgrade) in case your bug has already been fixed.
multiple: false
options:
- '2.20.1'
- '2.20.0'
- '2.19.4'
- '2.19.3'
- '2.19.2'

View File

@@ -26,7 +26,7 @@ func RestoreArchive(archive io.Reader, password string, filestorePath string, ga
if password != "" {
archive, err = decrypt(archive, password)
if err != nil {
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
return errors.Wrap(err, "failed to decrypt the archive")
}
}

View File

@@ -1,7 +1,6 @@
package chisel
import (
"context"
"net"
"net/http"
"testing"
@@ -29,17 +28,12 @@ func TestPingAgentPanic(t *testing.T) {
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0})
require.NoError(t, err)
srv := &http.Server{Handler: mux}
errCh := make(chan error)
go func() {
errCh <- srv.Serve(ln)
require.NoError(t, http.Serve(ln, mux))
}()
s.getTunnelDetails(endpointID)
s.tunnelDetailsMap[endpointID].Port = ln.Addr().(*net.TCPAddr).Port
require.Error(t, s.pingAgent(endpointID))
require.NoError(t, srv.Shutdown(context.Background()))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
}

View File

@@ -62,7 +62,7 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
MaxBatchDelay: kingpin.Flag("max-batch-delay", "Maximum delay before a batch starts").Duration(),
SecretKeyName: kingpin.Flag("secret-key-name", "Secret key name for encryption and will be used as /run/secrets/<secret-key-name>.").Default(defaultSecretKeyName).String(),
LogLevel: kingpin.Flag("log-level", "Set the minimum logging level to show").Default("INFO").Enum("DEBUG", "INFO", "WARN", "ERROR"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("PRETTY", "JSON"),
}
kingpin.Parse()

View File

@@ -42,13 +42,6 @@ func setLoggingMode(mode string) {
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
})
case "NOCOLOR":
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stderr,
TimeFormat: "2006/01/02 03:04PM",
FormatMessage: formatMessage,
NoColor: true,
})
case "JSON":
log.Logger = log.Output(os.Stderr)
}

View File

@@ -1,216 +1,52 @@
package crypto
import (
"bufio"
"bytes"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"errors"
"fmt"
"io"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/scrypt"
)
const (
// AES GCM settings
aesGcmHeader = "AES256-GCM" // The encrypted file header
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
// NOTE: has to go with what is considered to be a simplistic in that it omits any
// authentication of the encrypted data.
// Person with better knowledge is welcomed to improve it.
// sourced from https://golang.org/src/crypto/cipher/example_test.go
// Argon2 settings
// Recommded settings lower memory hardware according to current OWASP recommendations
// Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware
// https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
argon2MemoryCost = 12 * 1024
argon2TimeCost = 3
argon2Threads = 1
argon2KeyLength = 32
)
var emptySalt []byte = make([]byte, 0)
// AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
err := aesEncryptGCM(input, output, passphrase)
if err != nil {
return fmt.Errorf("error encrypting file: %w", err)
}
return nil
}
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
// Read file header to determine how it was encrypted
inputReader := bufio.NewReader(input)
header, err := inputReader.Peek(len(aesGcmHeader))
if err != nil {
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err)
}
if string(header) == aesGcmHeader {
reader, err := aesDecryptGCM(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting file: %w", err)
}
return reader, nil
}
// Use the previous decryption routine which has no header (to support older archives)
reader, err := aesDecryptOFB(inputReader, passphrase)
if err != nil {
return nil, fmt.Errorf("error decrypting legacy file backup: %w", err)
}
return reader, nil
}
// aesEncryptGCM reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key.
func aesEncryptGCM(input io.Reader, output io.Writer, passphrase []byte) error {
// Derive key using argon2 with a random salt
salt := make([]byte, 16) // 16 bytes salt
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
block, err := aes.NewCipher(key)
if err != nil {
return err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
// Generate nonce
nonce, err := NewRandomNonce(aesgcm.NonceSize())
if err != nil {
return err
}
// write the header
if _, err := output.Write([]byte(aesGcmHeader)); err != nil {
return err
}
// Write nonce and salt to the output file
if _, err := output.Write(salt); err != nil {
return err
}
if _, err := output.Write(nonce.Value()); err != nil {
return err
}
// Buffer for reading plaintext blocks
buf := make([]byte, aesGcmBlockSize) // Adjust buffer size as needed
ciphertext := make([]byte, len(buf)+aesgcm.Overhead())
// Encrypt plaintext in blocks
for {
n, err := io.ReadFull(input, buf)
if n == 0 {
break // end of plaintext input
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return err
}
// Seal encrypts the plaintext using the nonce returning the updated slice.
ciphertext = aesgcm.Seal(ciphertext[:0], nonce.Value(), buf[:n], nil)
_, err = output.Write(ciphertext)
if err != nil {
return err
}
nonce.Increment()
}
return nil
}
// aesDecryptGCM reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from.
func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) {
// Reader & verify header
header := make([]byte, len(aesGcmHeader))
if _, err := io.ReadFull(input, header); err != nil {
return nil, err
}
if string(header) != aesGcmHeader {
return nil, fmt.Errorf("invalid header")
}
// Read salt
salt := make([]byte, 16) // Salt size
if _, err := io.ReadFull(input, salt); err != nil {
return nil, err
}
key := argon2.IDKey(passphrase, salt, argon2TimeCost, argon2MemoryCost, argon2Threads, 32)
// Initialize AES cipher block
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
// Create GCM mode with the cipher block
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
// Read nonce from the input reader
nonce := NewNonce(aesgcm.NonceSize())
if err := nonce.Read(input); err != nil {
return nil, err
}
// Initialize a buffer to store decrypted data
buf := bytes.Buffer{}
plaintext := make([]byte, aesGcmBlockSize)
// Decrypt the ciphertext in blocks
for {
// Read a block of ciphertext from the input reader
ciphertextBlock := make([]byte, aesGcmBlockSize+aesgcm.Overhead()) // Adjust block size as needed
n, err := io.ReadFull(input, ciphertextBlock)
if n == 0 {
break // end of ciphertext
}
if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
return nil, err
}
// Decrypt the block of ciphertext
plaintext, err = aesgcm.Open(plaintext[:0], nonce.Value(), ciphertextBlock[:n], nil)
if err != nil {
return nil, err
}
_, err = buf.Write(plaintext)
if err != nil {
return nil, err
}
nonce.Increment()
}
return &buf, nil
}
// aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
// passphrase is used to generate an encryption key.
// note: This function used to decrypt files that were encrypted without a header i.e. old archives
func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
var emptySalt []byte = make([]byte, 0)
func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
// making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
if err != nil {
return err
}
block, err := aes.NewCipher(key)
if err != nil {
return err
}
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
writer := &cipher.StreamWriter{S: stream, W: output}
// Copy the input to the output, encrypting as we go.
if _, err := io.Copy(writer, input); err != nil {
return err
}
return nil
}
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from.
// passphrase is used to generate an encryption key.
func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) {
// making a 32 bytes key that would correspond to AES-256
// don't necessarily need a salt, so just kept in empty
key, err := scrypt.Key(passphrase, emptySalt, 32768, 8, 1, 32)
@@ -223,9 +59,11 @@ func aesDecryptOFB(input io.Reader, passphrase []byte) (io.Reader, error) {
return nil, err
}
// If the key is unique for each ciphertext, then it's ok to use a zero IV.
// If the key is unique for each ciphertext, then it's ok to use a zero
// IV.
var iv [aes.BlockSize]byte
stream := cipher.NewOFB(block, iv[:])
reader := &cipher.StreamReader{S: stream, R: input}
return reader, nil

View File

@@ -2,7 +2,6 @@ package crypto
import (
"io"
"math/rand"
"os"
"path/filepath"
"testing"
@@ -10,19 +9,7 @@ import (
"github.com/stretchr/testify/assert"
)
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randBytes(n int) []byte {
b := make([]byte, n)
for i := range b {
b[i] = letterBytes[rand.Intn(len(letterBytes))]
}
return b
}
func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
const passphrase = "passphrase"
tmpdir := t.TempDir()
var (
@@ -31,99 +18,17 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := randBytes(1024*1024*100 + 523)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath)
defer encryptedFileReader.Close()
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match")
}
func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
tmpdir := t.TempDir()
var (
originFilePath = filepath.Join(tmpdir, "origin2")
encryptedFilePath = filepath.Join(tmpdir, "encrypted2")
decryptedFilePath = filepath.Join(tmpdir, "decrypted2")
)
content := randBytes(500)
content := []byte("content")
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
defer originFile.Close()
encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close()
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
@@ -152,7 +57,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := randBytes(1024 * 50)
content := []byte("content")
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
@@ -191,7 +96,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
)
content := randBytes(1034)
content := []byte("content")
os.WriteFile(originFilePath, content, 0600)
originFile, _ := os.Open(originFilePath)
@@ -212,6 +117,11 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close()
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("garbage"))
assert.Nil(t, err, "Should allow to decrypt with wrong passphrase")
io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.NotEqual(t, content, decryptedContent, "Original and decrypted content should NOT match")
}

View File

@@ -1,61 +0,0 @@
package crypto
import (
"crypto/rand"
"errors"
"io"
)
type Nonce struct {
val []byte
}
func NewNonce(size int) *Nonce {
return &Nonce{val: make([]byte, size)}
}
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value
// This ensures there are plenty of nonce values availble before rolling over
// Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier
// https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
func NewRandomNonce(size int) (*Nonce, error) {
randomBytes := 1
if size <= randomBytes {
return nil, errors.New("nonce size must be greater than the number of random bytes")
}
randomPart := make([]byte, randomBytes)
if _, err := rand.Read(randomPart); err != nil {
return nil, err
}
zeroPart := make([]byte, size-randomBytes)
nonceVal := append(randomPart, zeroPart...)
return &Nonce{val: nonceVal}, nil
}
func (n *Nonce) Read(stream io.Reader) error {
_, err := io.ReadFull(stream, n.val)
return err
}
func (n *Nonce) Value() []byte {
return n.val
}
func (n *Nonce) Increment() error {
// Start incrementing from the least significant byte
for i := len(n.val) - 1; i >= 0; i-- {
// Increment the current byte
n.val[i]++
// Check for overflow
if n.val[i] != 0 {
// No overflow, nonce is successfully incremented
return nil
}
}
// If we reach here, it means the nonce has overflowed
return errors.New("nonce overflow")
}

View File

@@ -939,6 +939,6 @@
}
],
"version": {
"VERSION": "{\"SchemaVersion\":\"2.22.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
"VERSION": "{\"SchemaVersion\":\"2.20.1\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
}
}

View File

@@ -85,7 +85,7 @@ type Handler struct {
}
// @title PortainerCE API
// @version 2.22.0
// @version 2.20.1
// @description.markdown api-description.md
// @termsOfService

View File

@@ -2,7 +2,6 @@ package users
import (
"errors"
"fmt"
"net/http"
portainer "github.com/portainer/portainer/api"
@@ -21,6 +20,9 @@ type userAccessTokenCreatePayload struct {
}
func (payload *userAccessTokenCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Password) {
return errors.New("invalid password: cannot be empty")
}
if govalidator.IsNull(payload.Description) {
return errors.New("invalid description: cannot be empty")
}
@@ -42,7 +44,6 @@ type accessTokenResponse struct {
// @summary Generate an API key for a user
// @description Generates an API key for a user.
// @description Only the calling user can generate a token for themselves.
// @description Password is required only for internal authentication.
// @description **Access policy**: restricted
// @tags users
// @security jwt
@@ -93,21 +94,9 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
return httperror.InternalServerError("Unable to find a user with the specified identifier inside the database", err)
}
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.InternalServerError("Unable to determine the authentication method", err)
}
if internalAuth {
// Internal auth requires the password field and must not be empty
if govalidator.IsNull(payload.Password) {
return httperror.BadRequest("Invalid request payload", errors.New("invalid password: cannot be empty"))
}
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
if err != nil {
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
}
return httperror.Forbidden("Current password doesn't match", errors.New("Current password does not match the password provided. Please try again"))
}
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
@@ -118,18 +107,3 @@ func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Req
w.WriteHeader(http.StatusCreated)
return response.JSON(w, accessTokenResponse{rawAPIKey, *apiKey})
}
func (handler *Handler) usesInternalAuthentication(userid portainer.UserID) (bool, error) {
// userid 1 is the admin user and always uses internal auth
if userid == 1 {
return true, nil
}
// otherwise determine the auth method from the settings
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return false, fmt.Errorf("unable to retrieve the settings from the database: %w", err)
}
return settings.AuthenticationMethod == portainer.AuthenticationInternal, nil
}

View File

@@ -251,10 +251,6 @@ func (factory *ClientFactory) buildEdgeConfig(endpoint *portainer.Endpoint) (*re
}
signature, err := factory.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
config.Insecure = true
config.QPS = DefaultKubeClientQPS
config.Burst = DefaultKubeClientBurst

View File

@@ -1595,7 +1595,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "2.22.0"
APIVersion = "2.20.1"
// Edition is what this edition of Portainer is called
Edition = PortainerCE
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax

View File

@@ -138,14 +138,13 @@ func agentServer(t *testing.T) string {
Handler: h,
}
errCh := make(chan error)
go func() {
errCh <- s.Serve(l)
err := s.Serve(l)
require.ErrorIs(t, err, http.ErrServerClosed)
}()
t.Cleanup(func() {
require.NoError(t, s.Shutdown(context.Background()))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
s.Shutdown(context.Background())
})
return "http://" + l.Addr().String()

View File

@@ -1,6 +1,6 @@
<div class="form-group">
<label for="target_node" class="col-sm-1 control-label text-left">Node</label>
<div class="col-sm-11">
<select class="form-control" ng-model="$ctrl.model" ng-options="agent.NodeName as agent.NodeName for agent in $ctrl.agents" data-cy="target-node-c"></select>
<select class="form-control" ng-model="$ctrl.model" ng-options="agent.NodeName as agent.NodeName for agent in $ctrl.agents"></select>
</div>
</div>

View File

@@ -615,6 +615,24 @@ input[type='checkbox'] {
font-weight: 600;
}
/* json-tree override */
json-tree {
font-size: 13px;
color: var(--blue-5);
}
json-tree .key {
color: var(--blue-3);
padding-right: 5px;
}
json-tree .branch-preview {
font-style: normal;
font-size: 11px;
opacity: 0.5;
}
/* !json-tree override */
/* uib-progressbar override */
.progress-bar {
color: var(--text-progress-bar-color);
@@ -711,21 +729,3 @@ input[style*='background-image: url("data:image/png'] {
input:-webkit-autofill {
@apply caret-[--grey-25] th-highcontrast:caret-white th-dark:caret-white;
}
/*
rules for styling the progress bar on both chrome and firefox
first rule is for firefox and the second rule is for chrome
use the `.progress-filled` tailwind variant util to style the filled value of the progress bar,
and the usual styles to style the unfilled value.
see app/react/edge/edge-stacks/ListView/EdgeStacksDatatable/DeploymentCounter.tsx for an example
*/
progress {
appearance: none;
}
progress::-webkit-progress-bar {
background-color: transparent;
}

View File

@@ -42,10 +42,6 @@
z-index: unset;
}
.input-group-sm > .input-group-addon {
line-height: 1;
}
.text-danger {
color: var(--ui-error-9);
}
@@ -168,6 +164,17 @@ pre {
background-color: var(--bg-pre-color);
color: var(--text-pre-color);
}
json-tree .key {
color: var(--text-json-tree-color);
}
json-tree .leaf-value {
color: var(--text-json-tree-leaf-color);
}
json-tree .branch-preview {
color: var(--text-json-tree-branch-preview-color);
}
.progress {
background-color: var(--bg-progress-color);

View File

@@ -510,10 +510,11 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
var templates = {
name: 'docker.templates',
url: '/templates?template',
url: '/templates',
views: {
'content@': {
component: 'appTemplatesView',
templateUrl: '~Portainer/views/templates/templates.html',
controller: 'TemplatesController',
},
},
data: {
@@ -602,7 +603,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/registries',
views: {
'content@': {
component: 'environmentRegistriesView',
component: 'endpointRegistriesView',
},
},
data: {
@@ -615,7 +616,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
url: '/registries',
views: {
'content@': {
component: 'environmentRegistriesView',
component: 'endpointRegistriesView',
},
},
data: {

View File

@@ -5,8 +5,7 @@
<span>Name</span>
</td>
<td>
<select class="form-control" ng-model="$ctrl.state.editModel.name" disable-authorization="DockerContainerUpdate" data-cy="container-restart-policy-select">
>
<select class="form-control" ng-model="$ctrl.state.editModel.name" disable-authorization="DockerContainerUpdate">
<option value="no">None</option>
<option value="on-failure">On Failure</option>
<option value="always">Always</option>
@@ -20,7 +19,7 @@
<tr ng-if="$ctrl.state.editModel.name === 'on-failure'">
<td class="col-md-3">Maximum Retry Count</td>
<td colspan="2">
<input type="number" class="form-control" ng-model="$ctrl.state.editModel.maximumRetryCount" data-cy="container-restart-max-retry-input" />
<input type="number" class="form-control" ng-model="$ctrl.state.editModel.maximumRetryCount" />
</td>
</tr>
</table>

View File

@@ -1,6 +1,5 @@
<div class="input-group input-group-sm">
<select name="nodeAvailability" class="selectpicker form-control" ng-model="$ctrl.availability" ng-change="$ctrl.onChange()" data-cy="node-availability-select">
>
<select name="nodeAvailability" class="selectpicker form-control" ng-model="$ctrl.availability" ng-change="$ctrl.onChange()">
<option value="active">Active</option>
<option value="pause">Pause</option>
<option value="drain">Drain</option>

View File

@@ -4,18 +4,11 @@
<div ng-repeat="label in $ctrl.labels" class="mt-1">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input
type="text"
class="form-control"
ng-model="label.key"
placeholder="e.g. com.example.foo"
ng-change="$ctrl.updateLabel(label)"
data-cy="node-label-input_{{ $index }}"
/>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" ng-change="$ctrl.updateLabel(label)" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="$ctrl.updateLabel(label)" data-cy="node-label-value_{{ $index }}" />
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="$ctrl.updateLabel(label)" />
</div>
<button class="btn btn-light" type="button" ng-click="$ctrl.removeLabel($index)">
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>

View File

@@ -4,7 +4,6 @@
<label for="image_registry" class="control-label col-sm-3 col-lg-2 text-left" ng-class="$ctrl.labelClass"> Registry </label>
<div ng-class="$ctrl.inputClass" class="col-sm-8">
<select
data-cy="component-registrySelect"
ng-options="registry as registry.Name for registry in $ctrl.registries track by registry.Id"
ng-model="$ctrl.model.Registry"
id="image_registry"
@@ -20,7 +19,6 @@
<span class="input-group-addon" id="registry-name">{{ $ctrl.displayedRegistryURL() }}</span>
<input
type="text"
data-cy="component-imageInput"
class="form-control"
aria-describedby="registry-name"
uib-typeahead="image for image in $ctrl.availableImages | filter:$viewValue | limitTo:5"
@@ -29,6 +27,7 @@
placeholder="e.g. my-image:my-tag"
ng-change="$ctrl.onImageChange()"
required
data-cy="component-imageInput"
/>
<span ng-if="$ctrl.isDockerHubRegistry()" class="input-group-btn">
<a
@@ -52,15 +51,7 @@
</span>
<label for="image_name" ng-class="$ctrl.labelClass" class="control-label col-sm-3 col-lg-2 required text-left">Image </label>
<div ng-class="$ctrl.inputClass" class="col-sm-8">
<input
type="text"
class="form-control"
ng-model="$ctrl.model.Image"
name="image_name"
placeholder="e.g. registry:port/my-image:my-tag"
required
data-cy="component-imageInput"
/>
<input type="text" class="form-control" ng-model="$ctrl.model.Image" name="image_name" placeholder="e.g. registry:port/my-image:my-tag" required />
</div>
</div>
</div>

View File

@@ -19,7 +19,6 @@
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="macvlan-network-card-input"
class="form-control"
name="network_card"
ng-model="$ctrl.data.ParentNetworkCard"
@@ -70,7 +69,6 @@
ng-model="$ctrl.data.SelectedNetworkConfig"
name="config_network"
ng-required="$ctrl.requiredConfigSelection()"
data-cy="macvlanConfigNetworkSelector"
>
<option selected disabled hidden value="">Select a network</option>
</select>

View File

@@ -6,15 +6,7 @@
<div class="form-group col-md-12">
<label for="cifs_address" class="col-sm-2 col-md-1 control-label required text-left">Address</label>
<div class="col-sm-10 col-md-11">
<input
type="text"
class="form-control"
ng-model="$ctrl.data.serverAddress"
name="cifs_address"
placeholder="e.g. my.cifs-server.com OR xxx.xxx.xxx.xxx"
required
data-cy="cifs-address-input"
/>
<input type="text" class="form-control" ng-model="$ctrl.data.serverAddress" name="cifs_address" placeholder="e.g. my.cifs-server.com OR xxx.xxx.xxx.xxx" required />
</div>
</div>
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_address.$invalid">
@@ -29,7 +21,7 @@
<div class="form-group col-md-12">
<label for="cifs_share" class="col-sm-2 col-md-1 control-label required text-left">Share</label>
<div class="col-sm-10 col-md-11">
<input type="text" class="form-control" ng-model="$ctrl.data.share" name="cifs_share" placeholder="e.g. /myshare" required data-cy="cifs-share-input" />
<input type="text" class="form-control" ng-model="$ctrl.data.share" name="cifs_share" placeholder="e.g. /myshare" required />
</div>
</div>
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_share.$invalid">
@@ -44,14 +36,7 @@
<div class="form-group col-md-12">
<label for="cifs_version" class="col-sm-2 col-md-1 control-label text-left">CIFS Version</label>
<div class="col-sm-10 col-md-11">
<select
class="form-control"
ng-model="$ctrl.data.version"
name="cifs_version"
ng-options="version for version in $ctrl.data.versions"
required
data-cy="cifs-version-select"
></select>
<select class="form-control" ng-model="$ctrl.data.version" name="cifs_version" ng-options="version for version in $ctrl.data.versions" required></select>
</div>
</div>
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_version.$invalid">
@@ -66,7 +51,7 @@
<div class="form-group col-md-12">
<label for="cifs_username" class="col-sm-2 col-md-1 control-label required text-left">Username</label>
<div class="col-sm-10 col-md-11">
<input type="text" class="form-control" ng-model="$ctrl.data.username" name="cifs_username" required data-cy="cifs-username-input" />
<input type="text" class="form-control" ng-model="$ctrl.data.username" name="cifs_username" required />
</div>
</div>
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_username.$invalid">
@@ -81,7 +66,7 @@
<div class="form-group col-md-12">
<label for="cifs_password" class="col-sm-2 col-md-1 control-label text-left">Password</label>
<div class="col-sm-10 col-md-11">
<input type="text" class="form-control" ng-model="$ctrl.data.password" name="cifs_password" required data-cy="cifs-password-input" />
<input type="text" class="form-control" ng-model="$ctrl.data.password" name="cifs_password" required />
</div>
</div>
<div class="form-group col-md-12" ng-show="cifsInformationForm.password.$invalid">

View File

@@ -6,15 +6,7 @@
<div class="form-group col-md-12">
<label for="nfs_address" class="col-sm-2 col-md-1 control-label required text-left">Address</label>
<div class="col-sm-10 col-md-11">
<input
type="text"
class="form-control"
ng-model="$ctrl.data.serverAddress"
name="nfs_address"
placeholder="e.g. my.nfs-server.com OR xxx.xxx.xxx.xxx"
required
data-cy="nfs-address-input"
/>
<input type="text" class="form-control" ng-model="$ctrl.data.serverAddress" name="nfs_address" placeholder="e.g. my.nfs-server.com OR xxx.xxx.xxx.xxx" required />
</div>
</div>
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_address.$invalid">
@@ -29,14 +21,7 @@
<div class="form-group col-md-12">
<label for="nfs_version" class="col-sm-2 col-md-1 control-label text-left">NFS Version</label>
<div class="col-sm-10 col-md-11">
<select
class="form-control"
ng-model="$ctrl.data.version"
name="nfs_version"
ng-options="version for version in $ctrl.data.versions"
required
data-cy="nfs-version-select"
></select>
<select class="form-control" ng-model="$ctrl.data.version" name="nfs_version" ng-options="version for version in $ctrl.data.versions" required></select>
</div>
</div>
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_version.$invalid">
@@ -53,7 +38,6 @@
<div class="col-sm-10 col-md-11">
<input
type="text"
data-cy="nfs-mountpoint-input"
class="form-control"
ng-model="$ctrl.data.mountPoint"
name="nfs_mountpoint"
@@ -77,7 +61,7 @@
<portainer-tooltip message="'Comma separated list of options'"></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-11">
<input type="text" class="form-control" ng-model="$ctrl.data.options" name="nfs_options" placeholder="e.g. rw,noatime,tcp ..." required data-cy="nfs-options-input" />
<input type="text" class="form-control" ng-model="$ctrl.data.options" name="nfs_options" placeholder="e.g. rw,noatime,tcp ..." required />
</div>
</div>
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_options.$invalid">

View File

@@ -26,7 +26,6 @@ import { servicesModule } from './services';
import { networksModule } from './networks';
import { swarmModule } from './swarm';
import { volumesModule } from './volumes';
import { templatesModule } from './templates';
const ngModule = angular
.module('portainer.docker.react.components', [
@@ -35,7 +34,6 @@ const ngModule = angular
networksModule,
swarmModule,
volumesModule,
templatesModule,
])
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))

View File

@@ -1,19 +1,12 @@
import angular from 'angular';
import { SchemaOf } from 'yup';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { ServicesDatatable } from '@/react/docker/services/ListView/ServicesDatatable';
import { TasksDatatable } from '@/react/docker/services/ItemView/TasksDatatable';
import {
PortsMappingField,
portsMappingUtils,
PortsMappingValues,
} from '@/react/docker/services/ItemView/PortMappingField';
import { withFormValidation } from '@/react-tools/withFormValidation';
const ngModule = angular
export const servicesModule = angular
.module('portainer.docker.react.components.services', [])
.component(
'dockerServiceTasksDatatable',
@@ -32,14 +25,4 @@ const ngModule = angular
'onRefresh',
'titleIcon',
])
);
export const servicesModule = ngModule.name;
withFormValidation(
ngModule,
withUIRouter(withCurrentUser(PortsMappingField)),
'dockerServicePortsMappingField',
['disabled', 'readOnly', 'hasChanges', 'onReset', 'onSubmit'],
portsMappingUtils.validation as unknown as () => SchemaOf<PortsMappingValues>
);
).name;

View File

@@ -1,17 +0,0 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { StackFromCustomTemplateFormWidget } from '@/react/docker/templates/StackFromCustomTemplateFormWidget';
export const templatesModule = angular
.module('portainer.docker.react.components.templates', [])
.component(
'stackFromCustomTemplateFormWidget',
r2a(withUIRouter(withCurrentUser(StackFromCustomTemplateFormWidget)), [
'template',
'unselect',
])
).name;

View File

@@ -112,6 +112,24 @@ function ContainerServiceFactory($q, Container, $timeout) {
return deferred.promise;
};
service.createAndStartContainer = function (environmentId, configuration) {
var deferred = $q.defer();
var container;
service
.createContainer(environmentId, configuration)
.then(function success(data) {
container = data;
return service.startContainer(environmentId, container.Id);
})
.then(function success() {
deferred.resolve(container);
})
.catch(function error(err) {
deferred.reject(err);
});
return deferred.promise;
};
service.createExec = function (environmentId, execConfig) {
var deferred = $q.defer();

View File

@@ -92,6 +92,14 @@ angular.module('portainer.docker').factory('VolumeService', [
return $q.all(createVolumeQueries);
};
service.createXAutoGeneratedLocalVolumes = function (x) {
var createVolumeQueries = [];
for (var i = 0; i < x; i++) {
createVolumeQueries.push(service.createVolume({ Driver: 'local' }));
}
return $q.all(createVolumeQueries);
};
return service;
},
]);

View File

@@ -1,4 +1,5 @@
import angular from 'angular';
import { confirmDelete } from '@@/modals/confirm';
class ConfigsController {
/* @ngInject */
@@ -33,6 +34,10 @@ class ConfigsController {
}
async removeAction(selectedItems) {
const confirmed = await confirmDelete('Do you want to remove the selected config(s)?');
if (!confirmed) {
return null;
}
return this.$async(this.removeActionAsync, selectedItems);
}

View File

@@ -9,7 +9,7 @@
<div class="form-group">
<label for="config_name" class="col-sm-1 control-label text-left">Name</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="ctrl.formValues.Name" id="config_name" placeholder="e.g. myConfig" data-cy="config-name-input" />
<input type="text" class="form-control" ng-model="ctrl.formValues.Name" id="config_name" placeholder="e.g. myConfig" />
</div>
</div>
<!-- !name-input -->
@@ -37,11 +37,11 @@
<div ng-repeat="label in ctrl.formValues.Labels" class="mt-1">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo" data-cy="config-label-input_{{ $index }}" />
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo" />
</div>
<div class="input-group col-sm-6 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="config-label-value_{{ $index }}" />
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" />
<span class="input-group-btn">
<button class="btn btn-dangerlight" type="button" ng-click="ctrl.removeLabel($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>

View File

@@ -26,7 +26,7 @@
<pr-icon ng-if="imageOS == 'linux'" icon="'svg-linux'"></pr-icon>
<pr-icon ng-if="imageOS == 'windows'" icon="'layout-grid'"></pr-icon>
</span>
<select class="form-control" ng-model="formValues.command" id="command" data-cy="command-select">
<select class="form-control" ng-model="formValues.command" id="command">
<option value="ash" ng-if="imageOS == 'linux'">/bin/ash</option>
<option value="bash" ng-if="imageOS == 'linux'">/bin/bash</option>
<option value="sh" ng-if="imageOS == 'linux'">/bin/sh</option>
@@ -35,15 +35,7 @@
<option ng-repeat="command in containerCommands" value="{{ command.command }}">{{ command.title }}: {{ command.command }}</option>
</select>
</div>
<input
class="form-control"
ng-if="formValues.isCustomCommand"
type="text"
name="custom-command"
ng-model="formValues.customCommand"
placeholder="e.g. ps aux"
data-cy="custom-command"
/>
<input class="form-control" ng-if="formValues.isCustomCommand" type="text" name="custom-command" ng-model="formValues.customCommand" placeholder="e.g. ps aux" />
</div>
</div>
<!-- !command-list -->
@@ -61,7 +53,7 @@
<portainer-tooltip message="'Format is one of: user, user:group, uid or uid:gid'"></portainer-tooltip>
</label>
<div class="col-lg-11 col-sm-10">
<input class="form-control" type="text" ng-model="formValues.user" placeholder="root" data-cy="container-exec-user" />
<input class="form-control" type="text" ng-model="formValues.user" placeholder="root" />
</div>
</div>
<div class="form-group">
@@ -78,8 +70,8 @@
</div>
<div ng-if="state !== states.disconnected">
<label
>Exec into container as <code class="align-baseline !text-sm">{{ ::formValues.user || 'default user' }}</code> using command
<code class="align-baseline !text-sm">{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
>Exec into container as <code class="!text-sm align-baseline">{{ ::formValues.user || 'default user' }}</code> using command
<code class="!text-sm align-baseline">{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
<terminal-tooltip class="align-sub"> </terminal-tooltip>
</label>
<button type="button" class="btn btn-primary" ng-click="disconnect()">

View File

@@ -72,7 +72,7 @@
<rd-widget>
<rd-widget-header icon="box" title-text="Container status"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table" data-cy="container-status-table">
<table class="table">
<tbody>
<tr>
<td class="col-xs-6 col-sm-4 col-md-3 col-lg-3">ID</td>
@@ -88,7 +88,7 @@
</td>
<td ng-if="container.edit">
<form ng-submit="renameContainer()">
<input type="text" class="containerNameInput" ng-model="container.newContainerName" data-cy="containerNameInput" />
<input type="text" class="containerNameInput" ng-model="container.newContainerName" />
<a href="" ng-click="container.edit = false;">
<pr-icon icon="'x'"></pr-icon>
</a>
@@ -327,7 +327,7 @@
<rd-widget>
<rd-widget-header icon="database" title-text="Volumes"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table" data-cy="container-volumes-table">
<table class="table">
<thead>
<tr>
<th>Host/volume</th>

View File

@@ -27,7 +27,7 @@
<div class="form-group">
<label for="refreshRate" class="col-sm-3 col-md-2 col-lg-2 margin-sm-top control-label text-left"> Refresh rate </label>
<div class="col-sm-3 col-md-2">
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control" data-cy="docker-containers-stats-refresh-rate">
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control">
<option value="1">1s</option>
<option value="3">3s</option>
<option value="5">5s</option>

View File

@@ -40,15 +40,7 @@
<!-- name-input -->
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input
type="text"
class="form-control"
ng-model="item.Name"
ng-change="checkName($index)"
placeholder="e.g. my-image:my-tag"
auto-focus
data-cy="image-name-input"
/>
<input type="text" class="form-control" ng-model="item.Name" ng-change="checkName($index)" placeholder="e.g. my-image:my-tag" auto-focus />
<span class="input-group-addon" ng-if="!item.Valid">
<pr-icon icon="'x'" mode="'danger'"></pr-icon>
</span>
@@ -109,7 +101,9 @@
</span>
</div>
<button class="btn btn-sm btn-primary" ngf-select="selectAdditionalFiles($files)" ngf-multiple="true">Select files</button>
<span ng-repeat="item in formValues.AdditionalFiles track by $index" class="mx-2"> {{ item.name }} </span>
<span ng-repeat="item in formValues.AdditionalFiles track by $index" class="mx-2">
{{ item.name }}
</span>
</div>
</div>
</div>
@@ -139,7 +133,7 @@
<div class="form-group">
<label for="image_path" class="col-sm-2 control-label text-left">Dockerfile path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile" data-cy="image-path-input" />
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile" />
</div>
</div>
</div>
@@ -162,7 +156,6 @@
<div class="col-sm-10">
<input
type="text"
data-cy="image-url-input"
class="form-control"
ng-model="formValues.URL"
id="image_url"
@@ -179,7 +172,7 @@
<div class="form-group">
<label for="image_path" class="col-sm-2 control-label text-left">Dockerfile path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile" data-cy="image-path-input" />
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile" />
</div>
</div>
</div>

View File

@@ -9,7 +9,7 @@
<div class="form-group">
<label for="network_name" class="col-sm-2 col-lg-1 control-label text-left">Name</label>
<div class="col-sm-10 col-lg-11">
<input type="text" class="form-control" ng-model="config.Name" id="network_name" placeholder="e.g. myNetwork" data-cy="network-name-input" />
<input type="text" class="form-control" ng-model="config.Name" id="network_name" placeholder="e.g. myNetwork" />
</div>
</div>
<!-- !name-input -->
@@ -18,24 +18,10 @@
<div class="form-group">
<label for="network_driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
<div class="col-sm-10 col-lg-11">
<select
class="form-control"
ng-options="driver for driver in availableNetworkDrivers"
ng-model="config.Driver"
ng-if="availableNetworkDrivers.length > 0"
data-cy="network-driver-select"
>
<select class="form-control" ng-options="driver for driver in availableNetworkDrivers" ng-model="config.Driver" ng-if="availableNetworkDrivers.length > 0">
<option disabled hidden value="">Select a driver</option>
</select>
<input
type="text"
class="form-control"
ng-model="config.Driver"
id="network_driver"
placeholder="e.g. driverName"
ng-if="availableNetworkDrivers.length === 0"
data-cy="network-driver-input"
/>
<input type="text" class="form-control" ng-model="config.Driver" id="network_driver" placeholder="e.g. driverName" ng-if="availableNetworkDrivers.length === 0" />
</div>
</div>
<!-- !driver-input -->
@@ -52,17 +38,11 @@
<div ng-repeat="option in formValues.DriverOptions" class="mt-1">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input
type="text"
class="form-control"
ng-model="option.name"
placeholder="e.g. com.docker.network.bridge.enable_icc"
data-cy="network-driver-option-name-input"
/>
<input type="text" class="form-control" ng-model="option.name" placeholder="e.g. com.docker.network.bridge.enable_icc" />
</div>
<div class="input-group col-sm-6 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. true" data-cy="network-driver-option-value-input" />
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. true" />
<span class="input-group-btn">
<button class="btn btn-dangerlight" type="button" ng-click="removeDriverOption($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
@@ -84,25 +64,11 @@
<div class="form-group">
<label for="ipv4_network_subnet" class="col-sm-2 col-lg-1 control-label text-left">Subnet</label>
<div class="col-sm-4 col-lg-5">
<input
type="text"
class="form-control"
ng-model="formValues.IPV4.Subnet"
id="ipv4_network_subnet"
placeholder="e.g. 172.20.0.0/16"
data-cy="network-ipv4-subnet-input"
/>
<input type="text" class="form-control" ng-model="formValues.IPV4.Subnet" id="ipv4_network_subnet" placeholder="e.g. 172.20.0.0/16" />
</div>
<label for="ipv4_network_gateway" class="col-sm-2 col-lg-1 control-label text-left">Gateway</label>
<div class="col-sm-4 col-lg-5">
<input
type="text"
class="form-control"
ng-model="formValues.IPV4.Gateway"
id="ipv4_network_gateway"
placeholder="e.g. 172.20.10.11"
data-cy="network-ipv4-gateway-input"
/>
<input type="text" class="form-control" ng-model="formValues.IPV4.Gateway" id="ipv4_network_gateway" placeholder="e.g. 172.20.10.11" />
</div>
</div>
<!-- !subnet-gateway-inputs -->
@@ -110,14 +76,7 @@
<div class="form-group">
<label for="ipv4_network_iprange" class="col-sm-2 col-lg-1 control-label text-left">IP range</label>
<div class="col-sm-4 col-lg-5">
<input
type="text"
class="form-control"
ng-model="formValues.IPV4.IPRange"
id="ipv4_network_iprange"
placeholder="e.g. 172.20.10.128/25"
data-cy="network-ipv4-iprange-input"
/>
<input type="text" class="form-control" ng-model="formValues.IPV4.IPRange" id="ipv4_network_iprange" placeholder="e.g. 172.20.10.128/25" />
</div>
</div>
<div ng-repeat="auxAddress in formValues.IPV4.AuxiliaryAddresses track by $index" class="form-group">
@@ -125,7 +84,6 @@
<div class="col-sm-4 col-lg-5">
<input
type="text"
data-cy="network-ipv4-auxaddr-input"
class="form-control"
ng-model="formValues.IPV4.AuxiliaryAddresses[$index]"
ng-change="checkIPV4AuxiliaryAddress($index)"
@@ -153,25 +111,11 @@
<div class="form-group">
<label for="ipv6_network_subnet" class="col-sm-2 col-lg-1 control-label text-left">Subnet</label>
<div class="col-sm-4 col-lg-5">
<input
type="text"
class="form-control"
ng-model="formValues.IPV6.Subnet"
id="ipv6_network_subnet"
placeholder="e.g. 2001:db8::/48"
data-cy="network-ipv6-subnet-input"
/>
<input type="text" class="form-control" ng-model="formValues.IPV6.Subnet" id="ipv6_network_subnet" placeholder="e.g. 2001:db8::/48" />
</div>
<label for="ipv6_network_gateway" class="col-sm-2 col-lg-1 control-label text-left">Gateway</label>
<div class="col-sm-4 col-lg-5">
<input
type="text"
class="form-control"
ng-model="formValues.IPV6.Gateway"
id="ipv6_network_gateway"
placeholder="e.g. 2001:db8::1"
data-cy="network-ipv6-gateway-input"
/>
<input type="text" class="form-control" ng-model="formValues.IPV6.Gateway" id="ipv6_network_gateway" placeholder="e.g. 2001:db8::1" />
</div>
</div>
<!-- !subnet-gateway-inputs -->
@@ -179,14 +123,7 @@
<div class="form-group">
<label for="ipv6_network_iprange" class="col-sm-2 col-lg-1 control-label text-left">IP range</label>
<div class="col-sm-4 col-lg-5">
<input
type="text"
class="form-control"
ng-model="formValues.IPV6.IPRange"
id="ipv6_network_iprange"
placeholder="e.g. 2001:db8::/64"
data-cy="network-ipv6-iprange-input"
/>
<input type="text" class="form-control" ng-model="formValues.IPV6.IPRange" id="ipv6_network_iprange" placeholder="e.g. 2001:db8::/64" />
</div>
</div>
<div ng-repeat="auxAddress in formValues.IPV6.AuxiliaryAddresses track by $index" class="form-group">
@@ -194,7 +131,6 @@
<div class="col-sm-4 col-lg-5">
<input
type="text"
data-cy="network-ipv6-auxaddr-input"
class="form-control"
ng-model="formValues.IPV6.AuxiliaryAddresses[$index]"
ng-change="checkIPV6AuxiliaryAddress($index)"
@@ -224,11 +160,11 @@
<div ng-repeat="label in formValues.Labels" class="mt-1">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="network-label-key-input" />
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" />
</div>
<div class="input-group col-sm-6 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="network-label-value-input" />
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" />
<span class="input-group-btn">
<button class="btn btn-dangerlight" type="button" ng-click="removeLabel($index)"> <pr-icon icon="'trash-2'" size="'md'"></pr-icon> </button
></span>

View File

@@ -1,5 +1,6 @@
import _ from 'lodash-es';
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('NetworksController', [
'$q',
@@ -12,6 +13,10 @@ angular.module('portainer.docker').controller('NetworksController', [
'AgentService',
function ($q, $scope, $state, NetworkService, Notifications, HttpRequestHelper, endpoint, AgentService) {
$scope.removeAction = async function (selectedItems) {
const confirmed = await confirmDelete('Do you want to remove the selected network(s)?');
if (!confirmed) {
return null;
}
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (network) {
HttpRequestHelper.setPortainerAgentTargetHeader(network.NodeName);

View File

@@ -9,7 +9,7 @@
<div class="form-group">
<label for="secret_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.Name" id="secret_name" placeholder="e.g. mySecret" data-cy="createSecret-nameInput" />
<input type="text" class="form-control" ng-model="formValues.Name" id="secret_name" placeholder="e.g. mySecret" />
</div>
</div>
<!-- !name-input -->
@@ -17,7 +17,7 @@
<div class="form-group">
<label for="secret_data" class="col-sm-2 control-label text-left">Secret</label>
<div class="col-sm-10">
<textarea class="form-control" rows="5" ng-model="formValues.Data" ng-trim="false" data-cy="createSecret-secretDataInput"></textarea>
<textarea class="form-control" rows="5" ng-model="formValues.Data" ng-trim="false"></textarea>
</div>
</div>
<!-- !secret-data -->
@@ -45,11 +45,11 @@
<div ng-repeat="label in formValues.Labels" class="mt-1">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="createSecret-labelNameInput" />
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" />
</div>
<div class="input-group col-sm-6 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="createSecret-labelValueInput" />
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" />
<span class="input-group-btn">
<button class="btn btn-dangerlight" type="button" ng-click="removeLabel($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>

View File

@@ -1,3 +1,4 @@
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('SecretsController', [
'$scope',
'$state',
@@ -5,6 +6,10 @@ angular.module('portainer.docker').controller('SecretsController', [
'Notifications',
function ($scope, $state, SecretService, Notifications) {
$scope.removeAction = async function (selectedItems) {
const confirmed = await confirmDelete('Do you want to remove the selected secret(s)?');
if (!confirmed) {
return null;
}
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (secret) {
SecretService.remove(secret.Id)

View File

@@ -9,7 +9,7 @@
<div class="form-group">
<label for="service_name" class="col-sm-2 control-label text-left">Name</label>
<div class="col-sm-8">
<input type="text" class="form-control" ng-model="formValues.Name" id="service_name" placeholder="e.g. myService" data-cy="service-name-input" />
<input type="text" class="form-control" ng-model="formValues.Name" id="service_name" placeholder="e.g. myService" />
</div>
</div>
<!-- !name-input -->
@@ -42,7 +42,7 @@
<div>
<label class="control-label col-sm-2 text-left"> Replicas </label>
<div class="col-sm-8">
<input type="number" class="form-control" ng-model="formValues.Replicas" id="replicas" placeholder="e.g. 3" data-cy="docker-service-create-replica-count-input" />
<input type="number" class="form-control" ng-model="formValues.Replicas" id="replicas" placeholder="e.g. 3" />
</div>
</div>
</div>
@@ -59,7 +59,7 @@
<!-- host-port -->
<div class="input-group col-sm-3 input-group-sm">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="portBinding.PublishedPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)" data-cy="host-port-input" />
<input type="text" class="form-control" ng-model="portBinding.PublishedPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)" />
</div>
<!-- !host-port -->
<span style="margin: 0 10px 0 10px">
@@ -68,7 +68,7 @@
<!-- container-port -->
<div class="input-group col-sm-3 input-group-sm">
<span class="input-group-addon">container</span>
<input type="text" class="form-control" ng-model="portBinding.TargetPort" placeholder="e.g. 80" data-cy="container-port-input" />
<input type="text" class="form-control" ng-model="portBinding.TargetPort" placeholder="e.g. 80" />
</div>
<!-- !container-port -->
<!-- protocol-actions -->
@@ -159,14 +159,7 @@
<div class="form-group">
<label for="service_command" class="col-sm-2 col-lg-1 control-label text-left">Command</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
ng-model="formValues.Command"
id="service_command"
placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf"
data-cy="service-command-input"
/>
<input type="text" class="form-control" ng-model="formValues.Command" id="service_command" placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf" />
</div>
</div>
<!-- !command-input -->
@@ -174,14 +167,7 @@
<div class="form-group">
<label for="service_entrypoint" class="col-sm-2 col-lg-1 control-label text-left">Entrypoint</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
ng-model="formValues.EntryPoint"
id="service_entrypoint"
placeholder="e.g. /bin/sh -c"
data-cy="service-entrypoint-input"
/>
<input type="text" class="form-control" ng-model="formValues.EntryPoint" id="service_entrypoint" placeholder="e.g. /bin/sh -c" />
</div>
</div>
<!-- !entrypoint-input -->
@@ -189,11 +175,11 @@
<div class="form-group">
<label for="service_workingdir" class="col-sm-2 col-lg-1 control-label text-left">Working Dir</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="formValues.WorkingDir" id="service_workingdir" placeholder="e.g. /myapp" data-cy="service-workingdir-input" />
<input type="text" class="form-control" ng-model="formValues.WorkingDir" id="service_workingdir" placeholder="e.g. /myapp" />
</div>
<label for="service_user" class="col-sm-1 control-label text-left">User</label>
<div class="col-sm-4">
<input type="text" class="form-control" ng-model="formValues.User" id="service_user" placeholder="e.g. nginx" data-cy="service-user-input" />
<input type="text" class="form-control" ng-model="formValues.User" id="service_user" placeholder="e.g. nginx" />
</div>
</div>
<!-- !workdir-user-input -->
@@ -202,7 +188,7 @@
<div class="form-group">
<label for="log-driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
<div class="col-sm-4">
<select class="form-control" ng-model="formValues.LogDriverName" id="log-driver" data-cy="service-creation-logging-driver">
<select class="form-control" ng-model="formValues.LogDriverName" id="log-driver">
<option selected value="">Default logging driver</option>
<option ng-repeat="driver in availableLoggingDrivers" ng-value="driver">{{ driver }}</option>
<option value="none">none</option>
@@ -240,11 +226,11 @@
<div ng-repeat="opt in formValues.LogDriverOpts" class="mt-1">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">option</span>
<input type="text" class="form-control" ng-model="opt.name" placeholder="e.g. FOO" data-cy="service-creation-logging-driver-option" />
<input type="text" class="form-control" ng-model="opt.name" placeholder="e.g. FOO" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="opt.value" placeholder="e.g. bar" data-cy="service-creation-logging-driver-option-value" />
<input type="text" class="form-control" ng-model="opt.value" placeholder="e.g. bar" />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="removeLogDriverOpt($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
@@ -278,13 +264,7 @@
<div class="input-group col-sm-6">
<div class="input-group input-group-sm w-full">
<span class="input-group-addon">container</span>
<input
type="text"
class="form-control"
ng-model="volume.Target"
placeholder="e.g. /path/in/container"
data-cy="service-creation-volume-container-path"
/>
<input type="text" class="form-control" ng-model="volume.Target" placeholder="e.g. /path/in/container" />
</div>
<div class="small text-warning" ng-show="!volume.Target"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Target is required. </div>
</div>
@@ -315,7 +295,6 @@
class="form-control"
ng-model="volume.Source"
ng-options="vol as ((vol.Id|truncate:30) + ' - ' + (vol.Driver|truncate:30)) for vol in availableVolumes"
data-cy="volume-source-select"
>
<option selected disabled value="">Select a volume</option>
</select>
@@ -327,7 +306,7 @@
<div class="input-group input-group-sm col-sm-6" ng-if="volume.Type === 'bind'">
<div class="input-group input-group-sm w-full">
<span class="input-group-addon">host</span>
<input type="text" class="form-control" ng-model="volume.Source" placeholder="e.g. /path/on/host" data-cy="service-creation-volume-host-path" />
<input type="text" class="form-control" ng-model="volume.Source" placeholder="e.g. /path/on/host" />
</div>
<div class="small text-warning" ng-show="!volume.Source"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Source is required. </div>
</div>
@@ -358,7 +337,7 @@
<div class="form-group">
<label for="container_network" class="col-sm-2 col-lg-1 control-label text-left">Network</label>
<div class="col-sm-9">
<select class="form-control" ng-model="formValues.Network" data-cy="create-service-network-select">
<select class="form-control" ng-model="formValues.Network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks | orderBy: 'Name'" ng-value="net.Name">{{ net.Name }}</option>
</select>
@@ -377,7 +356,7 @@
<!-- network-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px">
<div ng-repeat="network in formValues.ExtraNetworks" style="margin-top: 2px">
<select class="form-control" ng-model="network.Name" data-cy="create-service-extra-network-select-{{ $index }}">
<select class="form-control" ng-model="network.Name">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
</select>
@@ -402,7 +381,7 @@
<div ng-repeat="variable in formValues.HostsEntries" style="margin-top: 2px">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. host:IP" data-cy="service-creation-hosts-entry" />
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. host:IP" />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="removeHostsEntry($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
@@ -442,11 +421,11 @@
<div ng-repeat="label in formValues.Labels" style="margin-top: 2px">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="service-creation-label" />
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="service-creation-label-value" />
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="removeLabel($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
@@ -469,11 +448,11 @@
<div ng-repeat="label in formValues.ContainerLabels" style="margin-top: 2px">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="service-creation-container-label" />
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="service-creation-container-label-value" />
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="removeContainerLabel($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>

View File

@@ -14,13 +14,7 @@
<div ng-repeat="config in formValues.Configs" style="margin-top: 2px">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">config</span>
<select
class="form-control"
ng-change="checkIfConfigDuplicated()"
ng-model="config.model"
ng-options="config.Name for config in availableConfigs | orderBy: 'Name'"
data-cy="docker-stack-configs-select"
>
<select class="form-control" ng-change="checkIfConfigDuplicated()" ng-model="config.model" ng-options="config.Name for config in availableConfigs | orderBy: 'Name'">
<option value="" selected="selected">Select a config</option>
</select>
</div>

View File

@@ -4,17 +4,10 @@
<div class="form-group">
<label for="memory-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> Memory reservation </label>
<div class="col-sm-3">
<slider
model="formValues.MemoryReservation"
floor="0"
ceil="state.sliderMaxMemory"
step="256"
ng-if="state.sliderMaxMemory"
data-cy="docker-services-create-memory-reservation-slider"
></slider>
<slider model="formValues.MemoryReservation" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></slider>
</div>
<div class="col-sm-2">
<input type="number" data-cy="docker-services-create-memory-reservation-input" min="0" class="form-control" ng-model="formValues.MemoryReservation" />
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryReservation" />
</div>
<div class="col-sm-4">
<p class="small text-muted" style="margin-top: 7px"> Minimum memory available on a node to run a task (<b>MB</b>) </p>
@@ -25,17 +18,10 @@
<div class="form-group">
<label for="memory-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> Memory limit </label>
<div class="col-sm-3">
<slider
model="formValues.MemoryLimit"
floor="0"
ceil="state.sliderMaxMemory"
step="256"
ng-if="state.sliderMaxMemory"
data-cy="docker-services-create-memory-limit-slider"
></slider>
<slider model="formValues.MemoryLimit" floor="0" ceil="state.sliderMaxMemory" step="256" ng-if="state.sliderMaxMemory"></slider>
</div>
<div class="col-sm-2">
<input type="number" data-cy="docker-services-create-memory-limit-input" min="0" class="form-control" ng-model="formValues.MemoryLimit" />
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryLimit" />
</div>
<div class="col-sm-4" style="margin-top: 7px">
<p class="small text-muted"> Maximum memory usage per task (<b>MB</b>) </p>
@@ -46,15 +32,7 @@
<div class="form-group">
<label for="cpu-reservation" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> CPU reservation </label>
<div class="col-sm-5">
<slider
model="formValues.CpuReservation"
floor="0"
ceil="state.sliderMaxCpu"
step="0.25"
precision="2"
ng-if="state.sliderMaxCpu"
data-cy="docker-services-create-cpu-reservation-slider"
></slider>
<slider model="formValues.CpuReservation" floor="0" ceil="state.sliderMaxCpu" step="0.25" precision="2" ng-if="state.sliderMaxCpu"></slider>
</div>
<div class="col-sm-4" style="margin-top: 20px">
<p class="small text-muted"> Minimum CPU available on a node to run a task </p>
@@ -65,15 +43,7 @@
<div class="form-group">
<label for="cpu-limit" class="col-sm-3 col-lg-2 control-label text-left" style="margin-top: 20px"> CPU limit </label>
<div class="col-sm-5">
<slider
model="formValues.CpuLimit"
floor="0"
ceil="state.sliderMaxCpu"
step="0.25"
precision="2"
ng-if="state.sliderMaxCpu"
data-cy="docker-services-create-cpu-limit-slider"
></slider>
<slider model="formValues.CpuLimit" floor="0" ceil="state.sliderMaxCpu" step="0.25" precision="2" ng-if="state.sliderMaxCpu"></slider>
</div>
<div class="col-sm-4" style="margin-top: 20px">
<p class="small text-muted"> Maximum CPU usage per task </p>
@@ -93,17 +63,17 @@
<div ng-repeat="constraint in formValues.PlacementConstraints" style="margin-top: 2px">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="constraint.key" placeholder="e.g. node.role" data-cy="docker-services-create-placement-constraint-name-{{ $index }}" />
<input type="text" class="form-control" ng-model="constraint.key" placeholder="e.g. node.role" />
</div>
<div class="input-group col-sm-1 input-group-sm">
<select name="constraintOperator" class="form-control" ng-model="constraint.operator" data-cy="docker-services-create-placement-constraint-operator-">
<select name="constraintOperator" class="form-control" ng-model="constraint.operator">
<option value="==">==</option>
<option value="!=">!=</option>
</select>
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="constraint.value" placeholder="e.g. manager" data-cy="docker-services-create-placement-constraint-value-{{ $index }}" />
<input type="text" class="form-control" ng-model="constraint.value" placeholder="e.g. manager" />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="removePlacementConstraint($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
@@ -124,23 +94,11 @@
<div ng-repeat="preference in formValues.PlacementPreferences" style="margin-top: 2px">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">strategy</span>
<input
type="text"
class="form-control"
ng-model="preference.strategy"
placeholder="e.g. spread"
data-cy="docker-services-create-placement-preference-strategy-{{ $index }}"
/>
<input type="text" class="form-control" ng-model="preference.strategy" placeholder="e.g. spread" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input
type="text"
class="form-control"
ng-model="preference.value"
placeholder="e.g. node.labels.datacenter"
data-cy="docker-services-create-placement-preference-value-{{ $index }}"
/>
<input type="text" class="form-control" ng-model="preference.value" placeholder="e.g. node.labels.datacenter" />
</div>
<button class="btn btn-dangerlight" type="button" ng-click="removePlacementPreference($index)">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>

View File

@@ -20,13 +20,7 @@
<div ng-repeat="secret in formValues.Secrets track by $index" style="margin-top: 4px">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">secret</span>
<select
class="form-control"
ng-model="secret.model"
ng-change="checkIfSecretDuplicated()"
ng-options="secret.Name for secret in availableSecrets | orderBy: 'Name'"
data-cy="docker-stack-secrets-select"
>
<select class="form-control" ng-model="secret.model" ng-change="checkIfSecretDuplicated()" ng-options="secret.Name for secret in availableSecrets | orderBy: 'Name'">
<option value="" selected="selected">Select a secret</option>
</select>
</div>

View File

@@ -4,7 +4,7 @@
<div class="form-group">
<label for="parallelism" class="col-sm-3 col-lg-2 control-label text-left">Update parallelism</label>
<div class="col-sm-4 col-lg-3">
<input type="number" data-cy="docker-service-update-parallelism-input" class="form-control" ng-model="formValues.Parallelism" id="parallelism" placeholder="e.g. 1" />
<input type="number" class="form-control" ng-model="formValues.Parallelism" id="parallelism" placeholder="e.g. 1" />
</div>
<div class="col-sm-5">
<p class="small text-muted"> Maximum number of tasks to be updated simultaneously (0 to update all at once). </p>
@@ -18,15 +18,7 @@
<portainer-tooltip message="'Supported format examples: 1h, 5m, 10s, 1000ms, 15us, 60ns.'"></portainer-tooltip>
</label>
<div class="col-sm-4 col-lg-3">
<input
type="text"
class="form-control"
ng-model="formValues.UpdateDelay"
id="update-delay"
placeholder="e.g. 1m"
ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i"
data-cy="docker-service-update-delay-input"
/>
<input type="text" class="form-control" ng-model="formValues.UpdateDelay" id="update-delay" placeholder="e.g. 1m" ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i" />
</div>
<div class="col-sm-5">
<p class="small text-muted"> Amount of time between updates expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 0s, 0 seconds. </p>
@@ -85,15 +77,7 @@
<portainer-tooltip message="'Supported format examples: 1h, 5m, 10s, 1000ms, 15us, 60ns.'"></portainer-tooltip>
</label>
<div class="col-sm-4 col-lg-3">
<input
type="text"
class="form-control"
ng-model="formValues.RestartDelay"
id="restart-delay"
placeholder="e.g. 1m"
ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i"
data-cy="docker-service-restart-delay-input"
/>
<input type="text" class="form-control" ng-model="formValues.RestartDelay" id="restart-delay" placeholder="e.g. 1m" ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i" />
</div>
<div class="col-sm-5">
<p class="small text-muted"> Delay between restart attempts expressed by a number followed by unit (ns|us|ms|s|m|h). Default value is 5s, 5 seconds. </p>
@@ -104,14 +88,7 @@
<div class="form-group">
<label for="restart-max-attempts" class="col-sm-3 col-lg-2 control-label text-left">Restart max attempts</label>
<div class="col-sm-4 col-lg-3">
<input
type="number"
data-cy="docker-service-restart-max-attempts-input"
class="form-control"
ng-model="formValues.RestartMaxAttempts"
id="restart-max-attempts"
placeholder="e.g. 0"
/>
<input type="number" class="form-control" ng-model="formValues.RestartMaxAttempts" id="restart-max-attempts" placeholder="e.g. 0" />
</div>
<div class="col-sm-5">
<p class="small text-muted"> Maximum attempts to restart a given task before giving up (default value is 0, which means unlimited). </p>
@@ -125,15 +102,7 @@
<portainer-tooltip message="'Supported format examples: 1h, 5m, 10s, 1000ms, 15us, 60ns.'"></portainer-tooltip>
</label>
<div class="col-sm-4 col-lg-3">
<input
type="text"
class="form-control"
ng-model="formValues.RestartWindow"
id="restart-window"
placeholder="e.g. 1m"
ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i"
data-cy="docker-service-restart-window-input"
/>
<input type="text" class="form-control" ng-model="formValues.RestartWindow" id="restart-window" placeholder="e.g. 1m" ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i" />
</div>
<div class="col-sm-5">
<p class="small text-muted">

View File

@@ -4,12 +4,7 @@
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
Add a config:
<select
class="form-control !h-[30px] !text-[13px]"
ng-options="config.Name for config in filterConfigs(configs) | orderBy: 'Name'"
ng-model="newConfig"
data-cy="service-configs-select"
>
<select class="form-control !h-[30px] !text-[13px]" ng-options="config.Name for config in filterConfigs(configs) | orderBy: 'Name'" ng-model="newConfig">
<option selected disabled hidden value="">Select a config</option>
</select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)"> <pr-icon icon="'plus'"></pr-icon> add config </a>
@@ -62,7 +57,7 @@
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConfigs'])">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConfigs'])">Reset changes</a></li>

View File

@@ -25,7 +25,6 @@
<div class="input-group input-group-sm">
<input
type="text"
data-cy="placement-constraint-key-input-{{ $index }}"
class="form-control"
ng-model="constraint.key"
placeholder="e.g. node.role"
@@ -44,7 +43,6 @@
ng-change="updatePlacementConstraint(service, constraint)"
ng-disabled="isUpdating"
disable-authorization="DockerServiceUpdate"
data-cy="placement-constraint-operator=selectoer"
>
<option value="==">==</option>
<option value="!=">!=</option>
@@ -55,7 +53,6 @@
<div class="input-group input-group-sm">
<input
type="text"
data-cy="placement-constraint-value-input-{{ $index }}"
class="form-control"
ng-model="constraint.value"
placeholder="e.g. manager"
@@ -79,7 +76,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceConstraints'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceConstraints'])">Reset changes</a></li>

View File

@@ -25,7 +25,6 @@
<span class="input-group-addon fit-text-size">name</span>
<input
type="text"
data-cy="container-label-key-{{ $index }}"
class="form-control"
ng-model="label.key"
placeholder="e.g. com.example.foo"
@@ -40,7 +39,6 @@
<span class="input-group-addon fit-text-size">value</span>
<input
type="text"
data-cy="container-label-value_{{ $index }}"
class="form-control"
ng-model="label.value"
placeholder="e.g. bar"
@@ -66,7 +64,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceContainerLabels'])">Reset changes</a></li>

View File

@@ -11,7 +11,9 @@
<p>There are no environment variables for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0">
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
<div class="form-group">
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
</div>
</rd-widget-body>
<rd-widget-footer authorization="DockerServiceUpdate">
<div class="btn-toolbar" role="toolbar">
@@ -25,7 +27,7 @@
Apply changes
</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['EnvironmentVariables'])">Reset changes</a></li>

View File

@@ -25,7 +25,6 @@
<input
type="text"
class="form-control"
data-cy="hosts-entry-hostname-input-{{ $index }}"
ng-model="entry.hostname"
placeholder="e.g. example.com"
ng-change="updateHostsEntry(service, entry)"
@@ -38,7 +37,6 @@
<div class="input-group input-group-sm">
<input
type="text"
data-cy="hosts-entry-ip-input-{{ $index }}"
class="form-control"
ng-model="entry.ip"
placeholder="e.g. 10.0.1.1"
@@ -62,7 +60,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Hosts'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Hosts'])">Reset changes</a></li>

View File

@@ -24,7 +24,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Image'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Image'])">Reset changes</a></li>

View File

@@ -4,13 +4,7 @@
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
Driver:
<select
class="form-control !h-[30px] !text-[13px]"
ng-model="service.LogDriverName"
ng-change="updateLogDriverName(service)"
ng-disabled="isUpdating"
data-cy="logging-driver-selector"
>
<select class="form-control !h-[30px] !text-[13px]" ng-model="service.LogDriverName" ng-change="updateLogDriverName(service)" ng-disabled="isUpdating">
<option selected value="">Default logging driver</option>
<option ng-repeat="driver in availableLoggingDrivers" ng-value="driver">{{ driver }}</option>
<option value="none">none</option>
@@ -31,14 +25,7 @@
<td class="w-1/2">
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input
type="text"
data-cy="service-logging-driver-option-name-input-{{ $index }}"
class="form-control"
ng-model="option.key"
ng-disabled="option.added || isUpdating"
placeholder="e.g. FOO"
/>
<input type="text" class="form-control" ng-model="option.key" ng-disabled="option.added || isUpdating" placeholder="e.g. FOO" />
</div>
</td>
<td>
@@ -46,7 +33,6 @@
<span class="input-group-addon fit-text-size">value</span>
<input
type="text"
data-cy="service-logging-driver-option-value-input-{{ $index }}"
class="form-control"
ng-model="option.value"
ng-change="updateLogDriverOpt(service, option)"
@@ -75,7 +61,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['LogDriverName', 'LogDriverOpts'])">Reset changes</a></li>

View File

@@ -24,7 +24,6 @@
<td class="!pt-6 !align-top" ng-if="isAdmin || allowBindMounts">
<select
name="mountType"
data-cy="mount-type-selector"
class="form-control !h-[30px] !text-[13px]"
ng-model="mount.Type"
ng-change="onChangeMountType(service, mount)"
@@ -44,13 +43,11 @@
ng-options="vol.Id as ((vol.Id|truncate:30) + ' - ' + (vol.Driver|truncate:30)) for vol in availableVolumes"
ng-if="mount.Type === 'volume'"
disable-authorization="DockerServiceUpdate"
data-cy="volume-selector"
>
<option selected disabled hidden value="">Select a volume</option>
</select>
<input
type="text"
data-cy="bind-mount-source-input-{{ index }}"
class="form-control !h-[30px] !text-[13px]"
name=""
ng-model="mount.Source"
@@ -67,7 +64,6 @@
<td class="!pb-0 !pt-6 !align-top">
<input
type="text"
data-cy="mount-target-input-{{ index }}"
class="form-control mb-6 !h-[30px] !text-[13px]"
ng-model="mount.Target"
placeholder="e.g. /tmp/portainer/data"
@@ -100,7 +96,7 @@
Apply changes
</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceMounts'])">Reset changes</a></li>

View File

@@ -29,7 +29,6 @@
ng-options="net.Id as net.Name for net in filterNetworks(swarmNetworks, network)"
disable-authorization="DockerServiceUpdate"
style="width: initial; min-width: 50%"
data-cy="network-selector_{{ network.Name }}"
>
<option disabled value="" selected>Select a network</option>
</select>
@@ -38,7 +37,9 @@
<td>
<a ui-sref="docker.networks.network({id: network.Id})">{{ network.Id }}</a>
</td>
<td> {{ network.Addr }} </td>
<td>
{{ network.Addr }}
</td>
<td ng-if="network.Editable" authorization="DockerServiceUpdate">
<span class="input-group-btn">
<button class="btn btn-sm btn-dangerlight" type="button" ng-click="removeNetwork(service, $index)" ng-disabled="isUpdating">
@@ -58,7 +59,7 @@
Apply changes
</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Networks'])">Reset changes</a></li>

View File

@@ -24,7 +24,6 @@
<div class="input-group input-group-sm">
<input
type="text"
data-cy="placement-preference-strategy-input-{{ $index }}"
class="form-control"
ng-model="preference.strategy"
placeholder="e.g. node.role"
@@ -38,7 +37,6 @@
<div class="input-group input-group-sm">
<input
type="text"
data-cy="placement-preference-value-input-{{ $index }}"
class="form-control"
ng-model="preference.value"
placeholder="e.g. manager"
@@ -62,7 +60,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServicePreferences'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServicePreferences'])">Reset changes</a></li>

View File

@@ -0,0 +1,108 @@
<div>
<rd-widget>
<rd-widget-header icon="list" title-text="Published ports">
<div class="nopadding" authorization="DockerServiceUpdate">
<a class="btn btn-secondary btn-sm pull-right" ng-click="isUpdating ||addPublishedPort(service)" ng-disabled="isUpdating">
<pr-icon icon="'plus'"></pr-icon> port mapping
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="!service.Ports || service.Ports.length === 0">
<p>This service has no ports published.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.Ports && service.Ports.length > 0" classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Host port</th>
<th>Container port</th>
<th>Protocol</th>
<th>Publish mode</th>
<th authorization="DockerServiceUpdate">Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="portBinding in service.Ports">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon !leading-none">host</span>
<input
type="number"
class="form-control"
ng-model="portBinding.PublishedPort"
placeholder="e.g. 8080"
ng-change="updatePublishedPort(service, mapping)"
ng-disabled="isUpdating"
disable-authorization="DockerServiceUpdate"
/>
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon !leading-none">container</span>
<input
type="number"
class="form-control"
ng-model="portBinding.TargetPort"
placeholder="e.g. 80"
ng-change="updatePublishedPort(service, mapping)"
ng-disabled="isUpdating"
disable-authorization="DockerServiceUpdate"
/>
</div>
</td>
<td>
<div class="input-group input-group-sm">
<select
class="selectpicker form-control !rounded"
ng-model="portBinding.Protocol"
ng-change="updatePublishedPort(service, mapping)"
ng-disabled="isUpdating"
disable-authorization="DockerServiceUpdate"
>
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
</div>
</td>
<td>
<div class="input-group input-group-sm">
<select
class="selectpicker form-control !rounded"
ng-model="portBinding.PublishMode"
ng-change="updatePublishedPort(service, mapping)"
ng-disabled="isUpdating"
disable-authorization="DockerServiceUpdate"
>
<option value="ingress">ingress</option>
<option value="host">host</option>
</select>
</div>
</td>
<td authorization="DockerServiceUpdate">
<span class="input-group-btn">
<button class="btn btn-sm btn-dangerlight" type="button" ng-click="removePortPublishedBinding(service, $index)" ng-disabled="isUpdating">
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
</button>
</span>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer authorization="DockerServiceUpdate">
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Ports'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Ports'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>

View File

@@ -10,7 +10,6 @@
<input
class="input-sm"
type="number"
data-cy="docker-service-memory-reservation-input"
step="0.125"
min="0"
ng-model="service.ReservationMemoryBytes"
@@ -28,7 +27,6 @@
<input
class="input-sm"
type="number"
data-cy="docker-service-memory-limit-input"
step="0.125"
min="0"
ng-model="service.LimitMemoryBytes"
@@ -46,7 +44,6 @@
</td>
<td>
<slider
data-cy="docker-service-cpu-reservation-slider"
model="service.ReservationNanoCPUs"
floor="0"
ceil="state.sliderMaxCpu"
@@ -67,7 +64,6 @@
</td>
<td>
<slider
data-cy="docker-service-cpu-limit-slider"
model="service.LimitNanoCPUs"
floor="0"
ceil="state.sliderMaxCpu"
@@ -96,7 +92,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['LimitNanoCPUs', 'LimitMemoryBytes', 'ReservationNanoCPUs', 'ReservationMemoryBytes'])">Reset changes</a></li>

View File

@@ -10,7 +10,6 @@
<div class="input-group input-group-sm">
<select
class="selectpicker form-control !rounded"
data-cy="docker-service-restart-condition-select"
ng-model="service.RestartCondition"
ng-change="updateServiceAttribute(service, 'RestartCondition')"
disable-authorization="DockerServiceUpdate"
@@ -31,7 +30,6 @@
<input
class="input-sm"
type="text"
data-cy="docker-service-restart-delay-input"
ng-model="service.RestartDelay"
ng-change="updateServiceAttribute(service, 'RestartDelay')"
ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i"
@@ -50,7 +48,6 @@
<input
class="input-sm"
type="number"
data-cy="docker-service-restart-max-attempts-input"
ng-model="service.RestartMaxAttempts"
ng-change="updateServiceAttribute(service, 'RestartMaxAttempts')"
disable-authorization="DockerServiceUpdate"
@@ -66,7 +63,6 @@
<input
class="input-sm"
type="text"
data-cy="docker-service-restart-window-input"
ng-model="service.RestartWindow"
ng-change="updateServiceAttribute(service, 'RestartWindow')"
ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i"
@@ -93,7 +89,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])">Reset changes</a></li>

View File

@@ -4,12 +4,7 @@
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px" authorization="DockerServiceUpdate">
Add a secret:
<select
class="form-control !h-[30px] !text-[13px]"
ng-options="secret.Name for secret in secrets | orderBy: 'Name'"
ng-model="state.addSecret.secret"
data-cy="service-secrets-select"
>
<select class="form-control !h-[30px] !text-[13px]" ng-options="secret.Name for secret in secrets | orderBy: 'Name'" ng-model="state.addSecret.secret">
<option selected disabled hidden value="">Select a secret</option>
</select>
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.3 && state.addSecret.override">
@@ -59,7 +54,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceSecrets'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceSecrets'])">Reset changes</a></li>

View File

@@ -23,7 +23,6 @@
<span class="input-group-addon fit-text-size">name</span>
<input
type="text"
data-cy="service-label-key-{{ $index }}"
class="form-control"
ng-model="label.key"
placeholder="e.g. com.example.foo"
@@ -38,7 +37,6 @@
<span class="input-group-addon fit-text-size">value</span>
<input
type="text"
data-cy="service-label-value_{{ $index }}"
class="form-control"
ng-model="label.value"
placeholder="e.g. bar"
@@ -62,7 +60,7 @@
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServiceLabels'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServiceLabels'])">Reset changes</a></li>

View File

@@ -10,7 +10,6 @@
<input
class="input-sm"
type="number"
data-cy="docker-service-update-parallelism-input"
ng-model="service.UpdateParallelism"
ng-change="updateServiceAttribute(service, 'UpdateParallelism')"
disable-authorization="DockerServiceUpdate"
@@ -26,7 +25,6 @@
<input
class="input-sm"
type="text"
data-cy="docker-service-update-delay-input"
ng-model="service.UpdateDelay"
ng-change="updateServiceAttribute(service, 'UpdateDelay')"
ng-pattern="/^([0-9]+)(h|m|s|ms|us|ns)$/i"
@@ -45,7 +43,6 @@
<input
type="radio"
name="failure_action"
data-cy="update-failure-action-continue"
ng-model="service.UpdateFailureAction"
value="continue"
ng-change="updateServiceAttribute(service, 'UpdateFailureAction')"
@@ -57,7 +54,6 @@
<input
type="radio"
name="failure_action"
data-cy="update-failure-action-pause"
ng-model="service.UpdateFailureAction"
value="pause"
ng-change="updateServiceAttribute(service, 'UpdateFailureAction')"
@@ -79,7 +75,6 @@
<input
type="radio"
name="updateconfig_order"
data-cy="update-order-start-first"
ng-model="service.UpdateOrder"
value="start-first"
ng-change="updateServiceAttribute(service, 'UpdateOrder')"
@@ -91,7 +86,6 @@
<input
type="radio"
name="updateconfig_order"
data-cy="update-order-stop-first"
ng-model="service.UpdateOrder"
value="stop-first"
ng-change="updateServiceAttribute(service, 'UpdateOrder')"
@@ -119,7 +113,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])">Reset changes</a></li>

View File

@@ -18,20 +18,17 @@
<tr>
<td class="w-1/5">Name</td>
<td ng-if="applicationState.endpoint.apiVersion <= 1.24">
<input
type="text"
class="form-control"
ng-model="service.Name"
ng-change="updateServiceAttribute(service, 'Name')"
ng-disabled="isUpdating"
data-cy="docker-service-edit-name"
/>
<input type="text" class="form-control" ng-model="service.Name" ng-change="updateServiceAttribute(service, 'Name')" ng-disabled="isUpdating" />
</td>
<td ng-if="applicationState.endpoint.apiVersion >= 1.25">
{{ service.Name }}
</td>
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
</tr>
<tr>
<td>ID</td>
<td> {{ service.Id }} </td>
<td>
{{ service.Id }}
</td>
</tr>
<tr ng-if="service.CreatedAt">
<td>Created at</td>
@@ -56,7 +53,6 @@
<input
class="input-sm"
type="number"
data-cy="docker-service-edit-replicas-input"
ng-model="service.Replicas"
ng-change="updateServiceAttribute(service, 'Replicas')"
disable-authorization="DockerServiceUpdate"
@@ -168,7 +164,7 @@
>Apply changes</button
>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<pr-icon icon="'chevron-down'"></pr-icon>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
@@ -235,17 +231,7 @@
<div class="col-lg-12 col-md-12 col-xs-12">
<h3 id="service-network-specs">Networks &amp; ports</h3>
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
<docker-service-ports-mapping-field
id="service-published-ports"
class="block padding-top"
values="formValues.ports"
on-change="(onChangePorts)"
has-changes="hasChanges(service, ['Ports'])"
on-reset="(onResetPorts)"
on-submit="(onSubmit)"
></docker-service-ports-mapping-field>
<div id="service-published-ports" class="padding-top" ng-include="'app/docker/views/services/edit/includes/ports.html'"></div>
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
</div>
</div>

View File

@@ -9,6 +9,7 @@ require('./includes/logging.html');
require('./includes/mounts.html');
require('./includes/networks.html');
require('./includes/placementPreferences.html');
require('./includes/ports.html');
require('./includes/resources.html');
require('./includes/restart.html');
require('./includes/secrets.html');
@@ -26,7 +27,6 @@ import { confirm, confirmDelete } from '@@/modals/confirm';
import { ModalType } from '@@/modals';
import { buildConfirmButton } from '@@/modals/utils';
import { convertServiceToConfig } from '@/react/docker/services/common/convertServiceToConfig';
import { portsMappingUtils } from '@/react/docker/services/ItemView/PortMappingField';
angular.module('portainer.docker').controller('ServiceController', [
'$q',
@@ -108,7 +108,6 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.formValues = {
RegistryModel: new PorImageRegistryModel(),
ports: [],
};
$scope.tasks = [];
@@ -545,8 +544,12 @@ angular.module('portainer.docker').controller('ServiceController', [
}
}
if ($scope.hasChanges(service, ['Ports'])) {
service.Ports = portsMappingUtils.toRequest($scope.formValues.ports);
if (service.Ports) {
service.Ports.forEach(function (binding) {
if (binding.PublishedPort === null || binding.PublishedPort === '') {
delete binding.PublishedPort;
}
});
}
config.EndpointSpec = {
@@ -711,25 +714,6 @@ angular.module('portainer.docker').controller('ServiceController', [
service.StopGracePeriod = service.StopGracePeriod ? ServiceHelper.translateNanosToHumanDuration(service.StopGracePeriod) : '';
}
$scope.onChangePorts = function (ports) {
$scope.$evalAsync(() => {
$scope.formValues.ports = ports;
updateServiceArray($scope.service, 'Ports');
});
};
$scope.onResetPorts = function (all = false) {
$scope.$evalAsync(() => {
$scope.formValues.ports = portsMappingUtils.toViewModel($scope.service.Model.Spec.EndpointSpec.Ports);
$scope.cancelChanges($scope.service, all ? undefined : ['Ports']);
});
};
$scope.onSubmit = function () {
$scope.updateService($scope.service);
};
function initView() {
var apiVersion = $scope.applicationState.endpoint.apiVersion;
var agentProxy = $scope.applicationState.endpoint.mode.agentProxy;
@@ -743,8 +727,6 @@ angular.module('portainer.docker').controller('ServiceController', [
$scope.lastVersion = service.Version;
}
$scope.formValues.ports = portsMappingUtils.toViewModel(service.Model.Spec.EndpointSpec.Ports);
transformResources(service);
translateServiceArrays(service);
transformDurations(service);

View File

@@ -57,7 +57,7 @@
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" size="'sm'" style="display: none"></pr-icon>
</label>
<div class="col-sm-2">
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control" data-cy="swarm-refreshRate-select">
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control">
<option value="5">5s</option>
<option value="10">10s</option>
<option value="30">30s</option>
@@ -97,7 +97,9 @@
<div class="node_labels" ng-if="node.Labels.length > 0 && state.DisplayNodeLabels">
<div>Labels</div>
<div class="node_label" ng-repeat="label in node.Labels">
<span class="label_key"> {{ label.key }} </span>
<span class="label_key">
{{ label.key }}
</span>
<span class="label_value" ng-if="label.value"> = {{ label.value }} </span>
</div>
</div>

View File

@@ -9,7 +9,7 @@
<div class="form-group">
<label for="volume_name" class="col-sm-2 col-md-1 control-label text-left">Name</label>
<div class="col-sm-10 col-md-11">
<input type="text" class="form-control" ng-model="formValues.Name" id="volume_name" placeholder="e.g. myVolume" data-cy="volume-name-input" />
<input type="text" class="form-control" ng-model="formValues.Name" id="volume_name" placeholder="e.g. myVolume" />
</div>
</div>
<!-- !name-input -->
@@ -18,24 +18,10 @@
<div class="form-group">
<label for="volume_driver" class="col-sm-2 col-md-1 control-label text-left">Driver</label>
<div class="col-sm-10 col-md-11">
<select
class="form-control"
ng-options="driver for driver in availableVolumeDrivers"
ng-model="formValues.Driver"
ng-if="availableVolumeDrivers.length > 0"
data-cy="volume-driver-select"
>
<select class="form-control" ng-options="driver for driver in availableVolumeDrivers" ng-model="formValues.Driver" ng-if="availableVolumeDrivers.length > 0">
<option disabled hidden value="">Select a driver</option>
</select>
<input
type="text"
class="form-control"
ng-model="formValues.Driver"
id="volume_driver"
placeholder="e.g. driverName"
ng-if="availableVolumeDrivers.length === 0"
data-cy="volume-driver-input"
/>
<input type="text" class="form-control" ng-model="formValues.Driver" id="volume_driver" placeholder="e.g. driverName" ng-if="availableVolumeDrivers.length === 0" />
</div>
</div>
<!-- !driver-input -->
@@ -55,11 +41,11 @@
<div ng-repeat="option in formValues.DriverOptions" style="margin-top: 2px">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="option.name" placeholder="e.g. mountpoint" data-cy="driver-option-name-input" />
<input type="text" class="form-control" ng-model="option.name" placeholder="e.g. mountpoint" />
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. /path/on/host" data-cy="driver-option-value-input" />
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. /path/on/host" />
</div>
<button class="btn btn-sm btn-light" type="button" ng-click="removeDriverOption($index)">
<pr-icon icon="'trash-2'" class-name="'icon-secondary icon-md'"></pr-icon>

View File

@@ -1,3 +1,5 @@
import { confirmDelete } from '@@/modals/confirm';
angular.module('portainer.docker').controller('VolumesController', [
'$q',
'$scope',
@@ -11,24 +13,28 @@ angular.module('portainer.docker').controller('VolumesController', [
'endpoint',
function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, Authentication, endpoint) {
$scope.removeAction = function (selectedItems) {
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (volume) {
HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName);
VolumeService.remove(volume)
.then(function success() {
Notifications.success('Volume successfully removed', volume.Id);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove volume');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
confirmDelete('Do you want to remove the selected volume(s)?').then((confirmed) => {
if (confirmed) {
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (volume) {
HttpRequestHelper.setPortainerAgentTargetHeader(volume.NodeName);
VolumeService.remove(volume)
.then(function success() {
Notifications.success('Volume successfully removed', volume.Id);
var index = $scope.volumes.indexOf(volume);
$scope.volumes.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove volume');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
}
});
};

View File

@@ -154,7 +154,7 @@ angular
url: '/templates?template',
views: {
'content@': {
component: 'appTemplatesView',
component: 'edgeAppTemplatesView',
},
},
data: {

View File

@@ -6,7 +6,6 @@
<div class="col-sm-10">
<input
type="text"
data-cy="edgejob-name-input"
class="form-control"
ng-model="$ctrl.model.Name"
ng-pattern="/^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/"
@@ -44,9 +43,7 @@
<div class="form-group">
<label for="recurring" class="col-sm-2 control-label text-left">Recurring Edge job</label>
<div class="col-sm-10">
<label class="switch"
><input type="checkbox" name="recurring" ng-model="$ctrl.model.Recurring" data-cy="recurring-edge-job-checkbox" /><span class="slider round"></span
></label>
<label class="switch"><input type="checkbox" name="recurring" ng-model="$ctrl.model.Recurring" /><span class="slider round"></span></label>
</div>
</div>
<!-- not-recurring -->
@@ -54,7 +51,7 @@
<div class="form-group">
<label for="edgejob_cron" class="col-sm-2 control-label text-left">Schedule date</label>
<div class="col-sm-10">
<input class="form-control" moment-picker ng-model="$ctrl.formValues.datetime" format="YYYY-MM-DD HH:mm" data-cy="edge-job-date-time-picker" />
<input class="form-control" moment-picker ng-model="$ctrl.formValues.datetime" format="YYYY-MM-DD HH:mm" />
</div>
<div class="col-sm-12 small text-muted mt-2.5"> Time should be set according to the chosen environments' timezone. </div>
<div ng-show="edgeJobForm.datepicker.$invalid">
@@ -76,7 +73,6 @@
<div class="col-sm-10">
<select
id="edgejob_value"
data-cy="edge-job-time-select"
name="edgejob_value"
class="form-control"
ng-model="$ctrl.formValues.scheduleValue"
@@ -105,7 +101,6 @@
<div class="col-sm-10">
<input
type="text"
data-cy="edge-job-cron-input"
class="form-control"
ng-model="$ctrl.model.CronExpression"
id="edgejob_cron"
@@ -198,7 +193,9 @@
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
<span ng-show="$ctrl.actionInProgress">In progress...</span>
</button>
<span class="text-danger space-left" ng-if="$ctrl.state.formValidationError"> {{ $ctrl.state.formValidationError }} </span>
<span class="text-danger space-left" ng-if="$ctrl.state.formValidationError">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
<!-- !actions -->

View File

@@ -0,0 +1,3 @@
.edge-job-results-datatable thead th {
width: 50%;
}

View File

@@ -0,0 +1,70 @@
<div class="datatable edge-job-results-datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle vertical-center">
<span><pr-icon icon="$ctrl.titleIcon"></pr-icon></span>
{{ $ctrl.titleText }}
</div>
<div class="searchBar">
<span><pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon></span>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
</div>
</div>
<div class="table-responsive">
<table class="table-hover table-filters nowrap-cells table">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Endpoint')">
Environment
<span><pr-icon icon="'arrow-down'" ng-if="$ctrl.state.orderBy === 'Endpoint' && !$ctrl.state.reverseOrder"></pr-icon></span>
<span><pr-icon icon="'arrow-up'" ng-if="$ctrl.state.orderBy === 'Endpoint' && $ctrl.state.reverseOrder"></pr-icon></span>
</a>
</th>
<th> Actions </th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
>
<td>
{{ item.Endpoint.Name }}
</td>
<td>
<button ng-if="item.LogsStatus === 0 || item.LogsStatus === 1" class="btn btn-sm btn-primary" ng-click="$ctrl.collectLogs(item.EndpointId)"> Retrieve logs </button>
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onDownloadLogsClick(item.EndpointId)"> Download logs </button>
<button ng-if="item.LogsStatus === 3" class="btn btn-sm btn-primary" ng-click="$ctrl.onClearLogsClick(item.EndpointId)"> Clear logs </button>
<span ng-if="item.LogsStatus === 2"> Logs marked for collection, please wait until the logs are available. </span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="9" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="9" class="text-muted text-center">No result available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span class="mr-1"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,30 @@
import angular from 'angular';
import _ from 'lodash-es';
export class EdgeJobResultsDatatableController {
/* @ngInject */
constructor($controller, $scope, $state) {
this.$state = $state;
angular.extend(this, $controller('GenericDatatableController', { $scope }));
}
collectLogs(...args) {
this.settings.repeater.autoRefresh = true;
this.settings.repeater.refreshRate = '5';
this.onSettingsRepeaterChange();
this.onCollectLogsClick(...args);
}
$onChanges({ dataset }) {
if (dataset && dataset.currentValue) {
this.onDatasetChange(dataset.currentValue);
}
}
onDatasetChange(dataset) {
const anyCollecting = _.some(dataset, (item) => item.LogsStatus === 2);
this.settings.repeater.autoRefresh = anyCollecting;
this.settings.repeater.refreshRate = '5';
this.onSettingsRepeaterChange();
}
}

View File

@@ -0,0 +1,21 @@
import angular from 'angular';
import { EdgeJobResultsDatatableController } from './edgeJobResultsDatatableController';
import './edgeJobResultsDatatable.css';
angular.module('portainer.edge').component('edgeJobResultsDatatable', {
templateUrl: './edgeJobResultsDatatable.html',
controller: EdgeJobResultsDatatableController,
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
onDownloadLogsClick: '<',
onCollectLogsClick: '<',
onClearLogsClick: '<',
refreshCallback: '<',
},
});

View File

@@ -0,0 +1,108 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
</div>
{{ $ctrl.titleText }}
</div>
<div class="searchBar vertical-center">
<pr-icon icon="'search'" class-name="'icon'"></pr-icon>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<pr-icon icon="'trash-2'"></pr-icon>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="edge.jobs.new"> <pr-icon icon="'plus'"></pr-icon>Add Edge job </button>
</div>
</div>
<div class="table-responsive">
<table class="table-hover nowrap-cells table">
<thead>
<tr>
<th>
<div class="vertical-center">
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<table-column-header
col-title="'Name'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Name'"
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header>
</div>
</th>
<th>
<table-column-header
col-title="'CronExpression'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'CronExpression'"
is-sorted-desc="$ctrl.state.orderBy === 'CronExpression' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('CronExpression')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Created'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Created'"
is-sorted-desc="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Created')"
></table-column-header>
</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
ng-class="{ active: item.Checked }"
>
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="edge.jobs.job({id: item.Id})">{{ item.Name }}</a>
</td>
<td>
{{ item.CronExpression }}
</td>
<td>{{ item.Created | getisodatefromtimestamp }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-muted text-center">No Edge job available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span class="mr-1"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,15 @@
import angular from 'angular';
angular.module('portainer.edge').component('edgeJobsDatatable', {
templateUrl: './edgeJobsDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<',
},
});

View File

@@ -4,13 +4,13 @@
<div class="col-sm-9 col-lg-10">
<input
type="text"
data-cy="edgeGroupCreate-groupNameInput"
class="form-control"
id="group_name"
name="group_name"
ng-model="$ctrl.model.Name"
required
auto-focus
data-cy="edgeGroupCreate-groupNameInput"
placeholder="e.g. mygroup"
/>
<div class="help-block" ng-show="EdgeGroupForm.group_name.$invalid">

View File

@@ -1,18 +0,0 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ResultsDatatable } from '@/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable';
export const edgeJobsModule = angular
.module('portainer.edge.react.components.edge-jobs', [])
.component(
'edgeJobResultsDatatable',
r2a(withUIRouter(ResultsDatatable), [
'dataset',
'onClearLogs',
'onCollectLogs',
'onDownloadLogs',
'onRefresh',
])
).name;

View File

@@ -15,10 +15,8 @@ import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/Asso
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
import { edgeJobsModule } from './edge-jobs';
const ngModule = angular
.module('portainer.edge.react.components', [edgeJobsModule])
.module('portainer.edge.react.components', [])
.component(
'edgeStackEnvironmentsDatatable',
r2a(withUIRouter(withReactQuery(EnvironmentsDatatable)), [])

View File

@@ -9,10 +9,9 @@ import { ListView as EdgeStacksListView } from '@/react/edge/edge-stacks/ListVie
import { ListView as EdgeGroupsListView } from '@/react/edge/edge-groups/ListView';
import { templatesModule } from './templates';
import { jobsModule } from './jobs';
export const viewsModule = angular
.module('portainer.edge.react.views', [templatesModule, jobsModule])
.module('portainer.edge.react.views', [templatesModule])
.component(
'waitingRoomView',
r2a(withUIRouter(withReactQuery(withCurrentUser(WaitingRoomView))), [])

View File

@@ -1,13 +0,0 @@
import angular from 'angular';
import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/edge/edge-jobs/ListView';
export const jobsModule = angular
.module('portainer.edge.react.views.jobs', [])
.component(
'edgeJobsView',
r2a(withUIRouter(withCurrentUser(ListView)), [])
).name;

View File

@@ -4,10 +4,25 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { ListView } from '@/react/edge/templates/custom-templates/ListView';
import { AppTemplatesView } from '@/react/edge/templates/AppTemplatesView';
import { CreateView } from '@/react/portainer/templates/custom-templates/CreateView';
import { EditView } from '@/react/portainer/templates/custom-templates/EditView';
export const templatesModule = angular
.module('portainer.edge.react.views.templates', [])
.module('portainer.app.react.components.templates', [])
.component(
'edgeAppTemplatesView',
r2a(withCurrentUser(withUIRouter(AppTemplatesView)), [])
)
.component(
'edgeCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(ListView)), [])
)
.component(
'createCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(CreateView)), [])
)
.component(
'editCustomTemplatesView',
r2a(withCurrentUser(withUIRouter(EditView)), [])
).name;

View File

@@ -30,13 +30,17 @@
</uib-tab-heading>
<edge-job-results-datatable
class="mt-4 block"
ng-if="$ctrl.results"
title-text="Results"
title-icon="list"
dataset="$ctrl.results"
on-refresh="($ctrl.refresh)"
on-download-logs="($ctrl.downloadLogs)"
on-collect-logs="($ctrl.collectLogs)"
on-clear-logs="($ctrl.clearLogs)"
table-key="edge-job-results"
order-by="Status"
reverse-order="true"
refresh-callback="$ctrl.refresh"
on-download-logs-click="($ctrl.downloadLogs)"
on-collect-logs-click="($ctrl.collectLogs)"
on-clear-logs-click="($ctrl.clearLogs)"
></edge-job-results-datatable>
</uib-tab>
</uib-tabset>

View File

@@ -86,14 +86,8 @@ export class EdgeJobController {
async collectLogsAsync(endpointId) {
try {
await this.EdgeJobService.collectLogs(this.edgeJob.Id, endpointId);
this.results = this.results.map((result) =>
result.EndpointId === endpointId
? {
...result,
LogsStatus: 2,
}
: result
);
const result = _.find(this.results, (result) => result.EndpointId === endpointId);
result.LogsStatus = 2;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to collect logs');
}
@@ -105,14 +99,8 @@ export class EdgeJobController {
async clearLogsAsync(endpointId) {
try {
await this.EdgeJobService.clearLogs(this.edgeJob.Id, endpointId);
this.results = this.results.map((result) =>
result.EndpointId === endpointId
? {
...result,
LogsStatus: 1,
}
: result
);
const result = _.find(this.results, (result) => result.EndpointId === endpointId);
result.LogsStatus = 1;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to clear logs');
}

View File

@@ -0,0 +1,20 @@
<page-header title="'Edge Jobs'" breadcrumbs="['Edge Jobs']" reload="true"> </page-header>
<information-panel title-text="Information">
<span class="small">
<p class="text-muted">Edge Jobs requires Docker Standalone and a cron implementation that reads jobs from <code>/etc/cron.d</code></p>
</span>
</information-panel>
<div class="row">
<div class="col-sm-12">
<edge-jobs-datatable
title-text="Edge jobs"
title-icon="clock"
dataset="$ctrl.edgeJobs"
table-key="edgeJobs"
order-by="Name"
remove-action="$ctrl.removeAction"
></edge-jobs-datatable>
</div>
</div>

View File

@@ -0,0 +1,53 @@
import _ from 'lodash-es';
import { confirmDelete } from '@@/modals/confirm';
export class EdgeJobsViewController {
/* @ngInject */
constructor($async, $state, EdgeJobService, Notifications) {
this.$async = $async;
this.$state = $state;
this.EdgeJobService = EdgeJobService;
this.Notifications = Notifications;
this.removeAction = this.removeAction.bind(this);
this.deleteJobsAsync = this.deleteJobsAsync.bind(this);
this.deleteJobs = this.deleteJobs.bind(this);
}
removeAction(selectedItems) {
confirmDelete('Do you want to remove the selected Edge job(s)?').then((confirmed) => {
if (!confirmed) {
return;
}
this.deleteJobs(selectedItems);
});
}
deleteJobs(edgeJobs) {
return this.$async(this.deleteJobsAsync, edgeJobs);
}
async deleteJobsAsync(edgeJobs) {
for (let edgeJob of edgeJobs) {
try {
await this.EdgeJobService.remove(edgeJob.Id);
this.Notifications.success('Edge job successfully removed', edgeJob.Name);
_.remove(this.edgeJobs, edgeJob);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to remove Edge job ' + edgeJob.Name);
}
}
this.$state.reload();
}
async $onInit() {
try {
const edgeJobs = await this.EdgeJobService.edgeJobs();
this.edgeJobs = edgeJobs;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Edge jobs');
this.edgeJobs = [];
}
}
}

View File

@@ -0,0 +1,7 @@
import angular from 'angular';
import { EdgeJobsViewController } from './edgeJobsViewController';
angular.module('portainer.edge').component('edgeJobsView', {
templateUrl: './edgeJobsView.html',
controller: EdgeJobsViewController,
});

View File

@@ -17,7 +17,7 @@ import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/Te
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/portainer/templates/app-templates/DeployFormWidget/EnvVarsFieldset';
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
export default class CreateEdgeStackViewController {
/* @ngInject */

View File

@@ -11,7 +11,6 @@
<div class="col-sm-11">
<input
type="text"
data-cy="edgeStackCreate-nameInput"
class="form-control"
ng-model="$ctrl.formValues.Name"
id="stack_name"
@@ -20,6 +19,7 @@
placeholder="e.g. mystack"
auto-focus
required
data-cy="edgeStackCreate-nameInput"
/>
<div class="help-block" ng-show="$ctrl.form.$invalid">
<div class="small text-warning">
@@ -90,7 +90,9 @@
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px"> {{ $ctrl.state.formValidationError }} </span>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
<!-- !actions -->

View File

@@ -518,7 +518,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
url: '/registries',
views: {
'content@': {
component: 'environmentRegistriesView',
component: 'endpointRegistriesView',
},
},
data: {

View File

@@ -0,0 +1,12 @@
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
export default class {
$onInit() {
const secrets = (this.configurations || [])
.filter((config) => config.Data && config.Kind === KubernetesConfigurationKinds.SECRET)
.flatMap((config) => Object.entries(config.Data))
.map(([key, value]) => ({ key, value }));
this.state = { secrets };
}
}

View File

@@ -0,0 +1,10 @@
<div class="col-xs-12 !px-0 !py-1 text-[13px]"> Secrets </div>
<table style="width: 50%">
<tbody>
<tr>
<td>
<sensitive-details ng-repeat="secret in $ctrl.state.secrets" key="{{ secret.key }}" value="{{ secret.value }}"> </sensitive-details>
</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,10 @@
import angular from 'angular';
import controller from './applications-datatable-details.controller';
angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatableDetails', {
templateUrl: './applications-datatable-details.html',
controller,
bindings: {
configurations: '<',
},
});

View File

@@ -0,0 +1,22 @@
.secondary-heading {
background-color: transparent;
}
.secondary-body {
background-color: transparent;
}
.datatable-wide {
width: 55px;
}
.published-url-container {
display: grid;
grid-template-columns: 1fr 1fr 3fr;
padding-top: 10px;
padding-bottom: 5px;
}
.publish-url-link {
width: min-content;
}

View File

@@ -0,0 +1,385 @@
<div class="datatable">
<!-- toolbar header actions and settings -->
<div ng-if="$ctrl.isPrimary">
<div class="toolBar">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="'box'"></pr-icon>
</div>
Applications
</div>
<!-- use row reverse to make the left most items wrap first to the right side in the next line -->
<div class="inline-flex flex-row-reverse flex-wrap !gap-x-5 gap-y-3">
<div class="settings" data-cy="k8sApp-tableSettings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle aria-label="Settings">
<pr-icon icon="'more-vertical'" class-name="'!mr-0 !h-4'"></pr-icon>
</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Table settings </div>
<div class="menuContent">
<div>
<div class="md-checkbox" ng-if="$ctrl.isAdmin">
<input id="applications_setting_show_system" type="checkbox" ng-model="$ctrl.settings.showSystem" ng-change="$ctrl.onSettingsShowSystemChange()" />
<label for="applications_setting_show_system">Show system resources</label>
</div>
<div class="md-checkbox">
<input
id="setting_auto_refresh"
type="checkbox"
ng-model="$ctrl.settings.repeater.autoRefresh"
ng-change="$ctrl.onSettingsRepeaterChange()"
data-cy="k8sApp-autoRefreshCheckbox"
/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate"> Refresh rate </label>
<select
id="settings_refresh_rate"
ng-model="$ctrl.settings.repeater.refreshRate"
ng-change="$ctrl.onSettingsRepeaterChange()"
class="small-select"
data-cy="k8sApp-refreshRateDropdown"
>
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.settings.open = false;" data-cy="k8sApp-tableSettingsCloseButton">Close</a>
</div>
</div>
</div>
</span>
</div>
<div class="actionBar !mr-0 !gap-3">
<button
ng-if="$ctrl.isPrimary"
type="button"
class="btn btn-sm btn-dangerlight vertical-center !ml-0 h-fit"
ng-disabled="$ctrl.state.selectedItemCount === 0"
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
data-cy="k8sApp-removeAppButton"
>
<pr-icon icon="'trash-2'"></pr-icon>
Remove
</button>
<button
ng-if="$ctrl.isPrimary"
hide-deployment-option="form"
type="button"
class="btn btn-sm btn-secondary vertical-center !ml-0 h-fit"
ui-sref="kubernetes.applications.new"
data-cy="k8sApp-addApplicationButton"
>
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Add with form
</button>
<button
ng-if="$ctrl.isPrimary"
type="button"
class="btn btn-sm btn-primary vertical-center !ml-0 h-fit"
ui-sref="kubernetes.deploy({ referrer: 'kubernetes.applications' })"
data-cy="k8sApp-deployFromManifestButton"
>
<pr-icon icon="'plus'" class-name="'!h-3'"></pr-icon>Create from manifest
</button>
</div>
<div class="searchBar">
<pr-icon icon="'search'" class-name="'searchIcon'"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search..."
auto-focus
ng-model-options="{ debounce: 300 }"
data-cy="k8sApp-searchApplicationsInput"
/>
</div>
</div>
</div>
<div class="toolBar !pt-0">
<div class="w-full">
<div class="form-group float-right !h-[30px] min-w-[140px] mr-2">
<div class="input-group">
<span class="input-group-addon">
<div className="flex items-center gap-1">
<pr-icon icon="'filter'" size="'sm'"></pr-icon>
Namespace
</div>
</span>
<select
class="form-control !h-[30px] !py-1"
ng-model="$ctrl.state.namespace"
ng-change="$ctrl.onChangeNamespace()"
data-cy="component-namespaceSelect"
ng-options="o.Value as (o.Name + (o.IsSystem ? ' - system' : '')) for o in $ctrl.state.namespaces"
>
</select>
</div>
</div>
<div class="space-y-2">
<div class="flex flex-row" authorization="K8sAccessSystemNamespaces" ng-if="$ctrl.isAdmin && !$ctrl.settings.showSystem">
<span class="small vertical-center text-xs">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span class="text-muted">System resources are hidden, this can be changed in the table settings.</span>
</span>
</div>
<div class="w-fit">
<helm-insights-box></helm-insights-box>
</div>
</div>
</div>
</div>
</div>
<!-- data table content -->
<div ng-class="{ 'table-responsive': $ctrl.isPrimary, 'inner-datatable': !$ctrl.isPrimary }">
<table class="table-hover table-filters nowrap-cells table" data-cy="k8sApp-appTable">
<thead ng-class="{ 'secondary-heading': !$ctrl.isPrimary }">
<tr role="row">
<th role="columnheader" class="datatable-wide dropdown">
<div ng-if="$ctrl.isPrimary" class="no-wrap flex min-w-max">
<span class="md-checkbox vertical-center">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" data-cy="k8sApp-selectAllCheckbox" />
<label for="select_all"></label>
</span>
<div class="vertical-center cursor-pointer" ng-click="$ctrl.expandAll()">
<pr-icon ng-if="$ctrl.state.expandAll" icon="'chevron-down'"></pr-icon>
<pr-icon ng-if="!$ctrl.state.expandAll" icon="'chevron-right'"></pr-icon>
</div>
</div>
</th>
<th>
<table-column-header
col-title="'Name'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Name'"
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header>
</th>
<th ng-if="!$ctrl.hideStacksFunctionality">
<table-column-header
col-title="'Stack'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'StackName'"
is-sorted-desc="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('StackName')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Namespace'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'ResourcePool'"
is-sorted-desc="$ctrl.state.orderBy === 'ResourcePool' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('ResourcePool')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Image'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Image'"
is-sorted-desc="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Image')"
></table-column-header>
</th>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
<div class="no-wrap flex flex-row gap-2">
<table-column-header
col-title="'Application Type'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'ApplicationType'"
is-sorted-desc="$ctrl.state.orderBy === 'ApplicationType' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('ApplicationType')"
></table-column-header>
<div class="no-wrap flex flex-row items-center gap-1" uib-dropdown-toggle>
<span class="table-filter">Filters</span>
<pr-icon ng-if="!$ctrl.filters.state.enabled" icon="'filter'"></pr-icon>
<pr-icon ng-if="$ctrl.filters.state.enabled" icon="'check'"></pr-icon>
</div>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Filter by application type </div>
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
<input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
<label for="filter_state_{{ $index }}">{{ filter.type }}</label>
</div>
</div>
<div>
<a type="button" class="btn btn-sm btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div>
</div>
</div>
</div>
</th>
<th>
<table-column-header col-title="'Status'" can-sort="false"></table-column-header>
<!-- Status -->
</th>
<th>
<table-column-header col-title="'Published'" can-sort="false"></table-column-header>
<!-- Published -->
</th>
<th>
<table-column-header
col-title="'Created'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'CreationDate'"
is-sorted-desc="$ctrl.state.orderBy === 'CreationDate' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('CreationDate')"
></table-column-header>
</th>
</tr>
</thead>
<tbody>
<tr
ng-show="!$ctrl.isAppsLoading"
ng-click="$ctrl.expandItem(item, !$ctrl.isItemExpanded(item))"
dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.applyFilters | filter:$ctrl.state.textFilter | filter:$ctrl.isDisplayed | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit: $ctrl.tableKey))"
ng-class="{ active: item.Checked, interactive: $ctrl.isExpandable(item), 'secondary-body': !$ctrl.isPrimary }"
pagination-id="$ctrl.tableKey"
>
<td class="h-[50px]">
<span ng-if="$ctrl.isPrimary" class="md-checkbox vertical-center">
<input
id="select_{{ $index }}"
type="checkbox"
ng-model="item.Checked"
ng-click="$ctrl.selectItem(item, $event); $event.stopPropagation()"
ng-disabled="$ctrl.isSystemNamespace(item)"
/>
<label for="select_{{ $index }}"></label>
</span>
<div ng-if="$ctrl.isExpandable(item)" class="vertical-center">
<pr-icon ng-if="$ctrl.isItemExpanded(item)" icon="'chevron-down'"></pr-icon>
<pr-icon ng-if="!$ctrl.isItemExpanded(item)" icon="'chevron-right'"></pr-icon>
</div>
</td>
<td>
<a
ng-if="item.KubernetesApplications"
ui-sref="kubernetes.helm({ name: item.Name, namespace: item.ResourcePool })"
ng-click="$event.stopPropagation()"
class="hyperlink"
>{{ item.Name }}
</a>
<a
ng-if="!item.KubernetesApplications"
ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool, 'resource-type': item.ApplicationType })"
ng-click="$event.stopPropagation()"
class="hyperlink"
>{{ item.Name }}
</a>
<span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td>
<td ng-if="!$ctrl.hideStacksFunctionality">{{ item.StackName || '-' }}</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })" ng-click="$event.stopPropagation()">{{ item.ResourcePool }}</a>
</td>
<td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
>
<td>{{ item.ApplicationType }}</td>
<td ng-if="item.ApplicationType !== $ctrl.KubernetesApplicationTypes.Pod">
<status-indicator ok="(item.TotalPodsCount > 0 && item.TotalPodsCount === item.RunningPodsCount) || item.Status === 'Ready'"></status-indicator>
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.Replicated">Replicated</span>
<span ng-if="item.DeploymentType === $ctrl.KubernetesApplicationDeploymentTypes.Global">Global</span>
<span ng-if="item.RunningPodsCount >= 0 && item.TotalPodsCount >= 0"
><code>{{ item.RunningPodsCount }}</code> / <code>{{ item.TotalPodsCount }}</code></span
>
<span ng-if="item.KubernetesApplications">{{ item.Status }}</span>
</td>
<td ng-if="item.ApplicationType === $ctrl.KubernetesApplicationTypes.Pod">
{{ item.Pods[0].Status }}
</td>
<td>
<span>
{{ item.Services.length === 0 ? 'No' : 'Yes' }}
</span>
</td>
<td>{{ item.CreationDate | getisodate }} {{ item.ApplicationOwner ? 'by ' + item.ApplicationOwner : '' }}</td>
</tr>
<tr dir-paginate-end ng-show="$ctrl.isExpandable(item) && $ctrl.isItemExpanded(item)" ng-class="{ 'secondary-body': $ctrl.isPrimary && !item.KubernetesApplications }">
<td></td>
<td colspan="8" class="datatable-padding-vertical">
<span ng-if="item.KubernetesApplications">
<kubernetes-applications-datatable
dataset="item.KubernetesApplications"
table-key="{{ item.Id }}_table"
settings-key="{{ $ctrl.tableKey }}"
order-by="Name"
remove-action="$ctrl.removeAction"
refresh-callback="$ctrl.refreshCallback"
on-publishing-mode-click="($ctrl.onPublishingModeClick)"
is-primary="false"
hide-stacks-functionality="$ctrl.hideStacksFunctionality"
>
</kubernetes-applications-datatable>
</span>
<span ng-if="!item.KubernetesApplications">
<div class="published-url-container">
<div>
<div class="text-muted"> Published URL(s) </div>
</div>
<div>
<div ng-repeat="url in $ctrl.getPublishedUrls(item)">
<a ng-href="{{ url }}" target="_blank" class="publish-url-link vertical-center">
<pr-icon icon="'external-link'"></pr-icon>
{{ url }}
</a>
</div>
</div>
</div>
<kubernetes-applications-datatable-details
ng-if="$ctrl.hasConfigurationSecrets(item)"
configurations="item.Configurations"
></kubernetes-applications-datatable-details>
</span>
</td>
</tr>
<tr ng-if="$ctrl.isAppsLoading">
<td colspan="8" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0 && !$ctrl.isAppsLoading">
<td colspan="8" class="text-muted text-center">No application available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer pl-5" ng-if="$ctrl.isPrimary && $ctrl.dataset">
<div class="infoBar !ml-0" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span class="mr-1"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5" pagination-id="$ctrl.tableKey"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,27 @@
import './applicationsDatatable.css';
angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatable', {
templateUrl: './applicationsDatatable.html',
controller: 'KubernetesApplicationsDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
settingsKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<',
refreshCallback: '<',
onPublishingModeClick: '<',
isPrimary: '<',
namespaces: '<',
namespace: '<',
onChangeNamespaceDropdown: '<',
isAppsLoading: '<',
isSystemResources: '<',
isVisible: '<',
setSystemResources: '<',
hideStacksFunctionality: '<',
},
});

View File

@@ -0,0 +1,246 @@
import _ from 'lodash-es';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import KubernetesNamespaceHelper from 'Kubernetes/helpers/namespaceHelper';
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
import { KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models/appConstants';
angular.module('portainer.kubernetes').controller('KubernetesApplicationsDatatableController', [
'$scope',
'$controller',
'DatatableService',
'Authentication',
function ($scope, $controller, DatatableService, Authentication) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
const ctrl = this;
this.settings = Object.assign(this.settings, {
showSystem: false,
});
this.state = Object.assign(this.state, {
expandAll: false,
expandedItems: [],
namespace: '',
namespaces: [],
});
this.filters = {
state: {
open: false,
enabled: false,
values: [],
},
};
this.expandAll = function () {
this.state.expandAll = !this.state.expandAll;
this.state.filteredDataSet.forEach((item) => this.expandItem(item, this.state.expandAll));
};
this.isItemExpanded = function (item) {
return this.state.expandedItems.includes(item.Id);
};
this.isExpandable = function (item) {
return item.KubernetesApplications || this.hasConfigurationSecrets(item) || !!this.getPublishedUrls(item).length;
};
this.expandItem = function (item, expanded) {
// collapse item
if (!expanded) {
this.state.expandedItems = this.state.expandedItems.filter((id) => id !== item.Id);
// expanded item
} else if (expanded && !this.state.expandedItems.includes(item.Id)) {
this.state.expandedItems = [...this.state.expandedItems, item.Id];
}
DatatableService.setDataTableExpandedItems(this.tableKey, this.state.expandedItems);
};
this.expandItems = function (storedExpandedItems) {
this.state.expandedItems = storedExpandedItems;
if (this.state.expandedItems.length === this.dataset.length) {
this.state.expandAll = true;
}
};
this.onDataRefresh = function () {
const storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey);
if (storedExpandedItems !== null) {
this.expandItems(storedExpandedItems);
}
};
this.onSettingsShowSystemChange = function () {
this.updateNamespace();
this.setSystemResources(this.settings.showSystem);
DatatableService.setDataTableSettings(this.tableKey, this.settings);
};
this.isExternalApplication = function (item) {
return KubernetesApplicationHelper.isExternalApplication(item);
};
this.isSystemNamespace = function (item) {
// if all charts in a helm app/release are in the system namespace
if (item.KubernetesApplications && item.KubernetesApplications.length > 0) {
return item.KubernetesApplications.some((app) => KubernetesNamespaceHelper.isSystemNamespace(app.ResourcePool));
}
return KubernetesNamespaceHelper.isSystemNamespace(item.ResourcePool);
};
this.isDisplayed = function (item) {
return !ctrl.isSystemNamespace(item) || ctrl.settings.showSystem;
};
this.getPublishedUrls = function (item) {
// Map all ingress rules in published ports to their respective URLs
const ingressUrls = item.PublishedPorts.flatMap((pp) => pp.IngressRules)
.filter(({ Host, IP }) => Host || IP)
.map(({ Host, IP, Path, TLS }) => {
let scheme = TLS && TLS.filter((tls) => tls.hosts && tls.hosts.includes(Host)).length > 0 ? 'https' : 'http';
return `${scheme}://${Host || IP}${Path}`;
});
// Map all load balancer service ports to ip address
let loadBalancerURLs = [];
if (item.LoadBalancerIPAddress) {
loadBalancerURLs = item.PublishedPorts.map((pp) => `http://${item.LoadBalancerIPAddress}:${pp.Port}`);
}
// combine ingress urls
const publishedUrls = [...ingressUrls, ...loadBalancerURLs];
// Return the first URL - priority given to ingress urls, then services (load balancers)
return publishedUrls.length > 0 ? publishedUrls : '';
};
this.hasConfigurationSecrets = function (item) {
return item.Configurations && item.Configurations.some((config) => config.Data && config.Kind === KubernetesConfigurationKinds.SECRET);
};
/**
* Do not allow applications in system namespaces to be selected
*/
this.allowSelection = function (item) {
return !this.isSystemNamespace(item);
};
this.applyFilters = function (item) {
return ctrl.filters.state.values.some((filter) => item.ApplicationType === filter.type && filter.display);
};
this.onStateFilterChange = function () {
this.filters.state.enabled = this.filters.state.values.some((filter) => !filter.display);
};
this.prepareTableFromDataset = function () {
const availableTypeFilters = this.dataset.map((item) => ({ type: item.ApplicationType, display: true }));
this.filters.state.values = _.uniqBy(availableTypeFilters, 'type');
};
this.onChangeNamespace = function () {
this.onChangeNamespaceDropdown(this.state.namespace);
};
this.updateNamespace = function () {
if (this.namespaces && this.settingsLoaded) {
const allNamespacesOption = { Name: 'All namespaces', Value: '', IsSystem: false };
const visibleNamespaceOptions = this.namespaces
.filter((ns) => {
if (!this.settings.showSystem && ns.IsSystem) {
return false;
}
return true;
})
.map((ns) => ({ Name: ns.Name, Value: ns.Name, IsSystem: ns.IsSystem }));
this.state.namespaces = [allNamespacesOption, ...visibleNamespaceOptions];
if (this.state.namespace && !this.state.namespaces.find((ns) => ns.Name === this.state.namespace)) {
if (this.state.namespaces.length > 1) {
let defaultNS = this.state.namespaces.find((ns) => ns.Name === 'default');
defaultNS = defaultNS || this.state.namespaces[1];
this.state.namespace = defaultNS.Value;
} else {
this.state.namespace = this.state.namespaces[0].Value;
}
}
}
};
this.$onChanges = function (changes) {
if (this.settingsLoaded) {
// when the table is visible, sync the show system setting with the stack show system setting
if (changes.isVisible && changes.isVisible.currentValue) {
const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks');
if (storedStacksSettings && storedStacksSettings.state) {
this.settings.showSystem = storedStacksSettings.state.showSystemResources;
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
}
} else if (typeof this.isSystemResources !== 'undefined') {
this.settings.showSystem = this.isSystemResources;
DatatableService.setDataTableSettings(this.settingsKey, this.settings);
}
this.state.namespace = this.namespace;
this.updateNamespace();
this.prepareTableFromDataset();
}
};
this.$onInit = function () {
this.isAdmin = Authentication.isAdmin();
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.KubernetesApplicationTypes = KubernetesApplicationTypes;
this.setDefaults();
this.prepareTableFromDataset();
this.state.orderBy = this.orderBy;
const storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
const textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
const storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
const storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey);
if (storedExpandedItems !== null) {
this.expandItems(storedExpandedItems);
}
const storedSettings = DatatableService.getDataTableSettings(this.settingsKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
// make show system in sync with the stack show system settings
const storedStacksSettings = DatatableService.getDataTableSettings('kubernetes.applications.stacks');
if (storedStacksSettings && storedStacksSettings.state) {
this.settings.showSystem = storedStacksSettings.state.showSystemResources || this.settings.showSystem;
}
this.setSystemResources && this.setSystemResources(this.settings.showSystem);
}
this.settingsLoaded = true;
// Set the default selected namespace
if (!this.state.namespace) {
this.state.namespace = this.namespace;
}
this.updateNamespace();
this.onSettingsRepeaterChange();
};
},
]);

View File

@@ -10,13 +10,13 @@
<pr-icon icon="'search'" class="vertical-center"></pr-icon>
<input
type="text"
data-cy="k8sConfigDetail-eventsTableSearchInput"
class="searchInput ml-1"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search for an event..."
auto-focus
ng-model-options="{ debounce: 300 }"
data-cy="k8sConfigDetail-eventsTableSearchInput"
/>
</div>
<div class="settings">
@@ -28,24 +28,12 @@
<div class="menuContent">
<div>
<div class="md-checkbox">
<input
id="setting_auto_refresh"
type="checkbox"
ng-model="$ctrl.settings.repeater.autoRefresh"
ng-change="$ctrl.onSettingsRepeaterChange()"
data-cy="k8sConfigDetail-eventsTableAutoRefreshCheckbox"
/>
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate"> Refresh rate </label>
<select
id="settings_refresh_rate"
ng-model="$ctrl.settings.repeater.refreshRate"
ng-change="$ctrl.onSettingsRepeaterChange()"
class="small-select"
data-cy="k8sConfigDetail-eventsTableRefreshRateSelect"
>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>

View File

@@ -0,0 +1,131 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar !gap-3">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
</div>
{{ $ctrl.titleText }}
</div>
<div class="searchBar vertical-center !mr-0 min-w-[280px]">
<pr-icon icon="'search'" class="vertical-center"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search for an application..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div ng-if="$ctrl.refreshCallback" class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle aria-label="Settings">
<pr-icon icon="'more-vertical'" class-name="'!mr-0 !h-4'"></pr-icon>
</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Table settings </div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate"> Refresh rate </label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<pr-icon id="refreshRateChange" icon="'check'" mode="'success'" style="display: none"></pr-icon>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table-hover nowrap-cells table">
<thead>
<tr>
<th>
<table-column-header
col-title="'Name'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Name'"
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Stack'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'StackName'"
is-sorted-desc="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('StackName')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Image'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Image'"
is-sorted-desc="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Image')"
></table-column-header>
</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
>
<td
><a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a></td
>
<td>{{ item.StackName || '-' }}</td>
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-muted text-center">No application available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@@ -0,0 +1,13 @@
angular.module('portainer.kubernetes').component('kubernetesIntegratedApplicationsDatatable', {
templateUrl: './integratedApplicationsDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
refreshCallback: '<',
},
});

View File

@@ -0,0 +1,168 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle flex">
<div class="widget-icon space-right">
<pr-icon icon="'svg-laptopcode'"></pr-icon>
</div>
<span class="vertical-center">
{{ $ctrl.titleText }}
</span>
</div>
<div class="searchBar min-w-[260px]">
<pr-icon icon="'search'" class="vertical-center" class-name="'searchIcon'"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search for an application..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="settings vertical-center">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><pr-icon icon="'more-vertical'"></pr-icon></span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader"> Table settings </div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()" />
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate"> Refresh rate </label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<pr-icon id="refreshRateChange" style="display: none" icon="'check'" mode="'success'"></pr-icon>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div>
<div class="table-responsive">
<table class="table-hover nowrap-cells table">
<thead>
<tr>
<th>
<table-column-header
col-title="'Name'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Name'"
is-sorted-desc="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Stack'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'StackName'"
is-sorted-desc="$ctrl.state.orderBy === 'StackName' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('StackName')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Namespace'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Namespace'"
is-sorted-desc="$ctrl.state.orderBy === 'Namespace' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Namespace')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Image'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Image'"
is-sorted-desc="$ctrl.state.orderBy === 'Image' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Image')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'CPU reservation'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'CPU'"
is-sorted-desc="$ctrl.state.orderBy === 'CPU' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('CPU')"
></table-column-header>
</th>
<th>
<table-column-header
col-title="'Memory reservation'"
can-sort="true"
is-sorted="$ctrl.state.orderBy === 'Memory'"
is-sorted-desc="$ctrl.state.orderBy === 'Memory' && $ctrl.state.reverseOrder"
ng-click="$ctrl.changeOrderBy('Memory')"
></table-column-header>
</th>
</tr>
</thead>
<tbody>
<tr
dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))"
>
<td>
<a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a>
<span style="margin-left: 5px" class="label label-info image-tag" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span style="margin-left: 5px" class="label label-primary image-tag" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td>
<td>{{ item.StackName || '-' }}</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
</td>
<td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
>
<td>{{ item.CPU | kubernetesApplicationCPUValue }}</td>
<td>{{ item.Memory | humansize }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="6" class="text-muted text-center">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="6" class="text-muted text-center">No stack available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0"> {{ $ctrl.state.selectedItemCount }} item(s) selected </div>
<div class="paginationControls">
<form class="form-inline vertical-center">
<span class="limitSelector">
<span style="margin-right: 5px"> Items per page </span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()" data-cy="component-paginationSelect">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

Some files were not shown because too many files have changed in this diff Show More