Compare commits
21 Commits
feature/EE
...
fix/ee-350
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3b017370 | ||
|
|
92219e61e7 | ||
|
|
24ae8aca08 | ||
|
|
b0ee674214 | ||
|
|
55ccfa1dc9 | ||
|
|
81e6d22d6c | ||
|
|
0cd2a4558b | ||
|
|
e1ddf37a44 | ||
|
|
89359a21ce | ||
|
|
69baa279d4 | ||
|
|
33861a834b | ||
|
|
dd4d126934 | ||
|
|
7275d23e4b | ||
|
|
46e8ae68cf | ||
|
|
bea155cde7 | ||
|
|
aaa04d055e | ||
|
|
3b2616ea2e | ||
|
|
d2e873e6c7 | ||
|
|
ab9a823f46 | ||
|
|
5eccda1afc | ||
|
|
83b2060aec |
@@ -790,6 +790,7 @@
|
||||
"IsComposeFormat": false,
|
||||
"Name": "alpine",
|
||||
"Namespace": "",
|
||||
"Option": null,
|
||||
"ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/2",
|
||||
"ResourceControl": null,
|
||||
"Status": 1,
|
||||
@@ -812,6 +813,7 @@
|
||||
"IsComposeFormat": false,
|
||||
"Name": "redis",
|
||||
"Namespace": "",
|
||||
"Option": null,
|
||||
"ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/5",
|
||||
"ResourceControl": null,
|
||||
"Status": 1,
|
||||
@@ -834,6 +836,7 @@
|
||||
"IsComposeFormat": false,
|
||||
"Name": "nginx",
|
||||
"Namespace": "",
|
||||
"Option": null,
|
||||
"ProjectPath": "/home/prabhat/portainer/data/ce1.25/compose/6",
|
||||
"ResourceControl": null,
|
||||
"Status": 1,
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
libstack "github.com/portainer/docker-compose-wrapper"
|
||||
"github.com/portainer/docker-compose-wrapper/compose"
|
||||
|
||||
"github.com/docker/cli/cli/compose/loader"
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/http/proxy"
|
||||
"github.com/portainer/portainer/api/http/proxy/factory"
|
||||
@@ -56,13 +54,13 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
envFilePath, err := createEnvFile(stack)
|
||||
envFile, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
}
|
||||
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFilePath, forceRereate)
|
||||
err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile, forceRereate)
|
||||
return errors.Wrap(err, "failed to deploy a stack")
|
||||
}
|
||||
|
||||
@@ -76,12 +74,14 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S
|
||||
defer proxy.Close()
|
||||
}
|
||||
|
||||
if err := updateNetworkEnvFile(stack); err != nil {
|
||||
return err
|
||||
envFile, err := createEnvFile(stack)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create env file")
|
||||
}
|
||||
|
||||
filePaths := stackutils.GetStackFilePaths(stack)
|
||||
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths)
|
||||
|
||||
err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile)
|
||||
return errors.Wrap(err, "failed to remove a stack")
|
||||
}
|
||||
|
||||
@@ -103,200 +103,42 @@ func (manager *ComposeStackManager) fetchEndpointProxy(endpoint *portainer.Endpo
|
||||
return fmt.Sprintf("tcp://127.0.0.1:%d", proxy.Port), proxy, nil
|
||||
}
|
||||
|
||||
// createEnvFile creates a file that would hold both "in-place" and default environment variables.
|
||||
// It will return the name of the file if the stack has "in-place" env vars, otherwise empty string.
|
||||
func createEnvFile(stack *portainer.Stack) (string, error) {
|
||||
// workaround for EE-1862. It will have to be removed when
|
||||
// docker/compose upgraded to v2.x.
|
||||
if err := createNetworkEnvFile(stack); err != nil {
|
||||
return "", errors.Wrap(err, "failed to create network env file")
|
||||
}
|
||||
|
||||
if stack.Env == nil || len(stack.Env) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
envFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
|
||||
envfile, err := os.OpenFile(envFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer envfile.Close()
|
||||
|
||||
copyDefaultEnvFile(stack, envfile)
|
||||
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
envfile.Close()
|
||||
|
||||
return "stack.env", nil
|
||||
}
|
||||
|
||||
func fileNotExist(filePath string) bool {
|
||||
if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func updateNetworkEnvFile(stack *portainer.Stack) error {
|
||||
envFilePath := path.Join(stack.ProjectPath, ".env")
|
||||
stackFilePath := path.Join(stack.ProjectPath, "stack.env")
|
||||
if fileNotExist(envFilePath) {
|
||||
if fileNotExist(stackFilePath) {
|
||||
return nil
|
||||
}
|
||||
|
||||
flags := os.O_WRONLY | os.O_SYNC | os.O_CREATE
|
||||
envFile, err := os.OpenFile(envFilePath, flags, 0666)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer envFile.Close()
|
||||
|
||||
stackFile, err := os.Open(stackFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer stackFile.Close()
|
||||
|
||||
_, err = io.Copy(envFile, stackFile)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createNetworkEnvFile(stack *portainer.Stack) error {
|
||||
networkNameSet := NewStringSet()
|
||||
|
||||
for _, filePath := range stackutils.GetStackFilePaths(stack) {
|
||||
networkNames, err := extractNetworkNames(filePath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to extract network name")
|
||||
}
|
||||
|
||||
if networkNames == nil || networkNames.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
networkNameSet.Union(networkNames)
|
||||
}
|
||||
|
||||
for _, s := range networkNameSet.List() {
|
||||
if _, ok := os.LookupEnv(s); ok {
|
||||
networkNameSet.Remove(s)
|
||||
}
|
||||
}
|
||||
|
||||
if networkNameSet.Len() == 0 && stack.Env == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
envfile, err := os.OpenFile(path.Join(stack.ProjectPath, ".env"),
|
||||
os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
// copyDefaultEnvFile copies the default .env file if it exists to the provided writer
|
||||
func copyDefaultEnvFile(stack *portainer.Stack, w io.Writer) {
|
||||
defaultEnvFile, err := os.Open(path.Join(path.Join(stack.ProjectPath, path.Dir(stack.EntryPoint)), ".env"))
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to open env file")
|
||||
// If cannot open a default file, then don't need to copy it.
|
||||
// We could as well stat it and check if it exists, but this is more efficient.
|
||||
return
|
||||
}
|
||||
|
||||
defer envfile.Close()
|
||||
defer defaultEnvFile.Close()
|
||||
|
||||
var scanEnvSettingFunc = func(name string) (string, bool) {
|
||||
if stack.Env != nil {
|
||||
for _, v := range stack.Env {
|
||||
if name == v.Name {
|
||||
return v.Value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
if _, err = io.Copy(w, defaultEnvFile); err == nil {
|
||||
io.WriteString(w, "\n")
|
||||
}
|
||||
|
||||
for _, s := range networkNameSet.List() {
|
||||
if _, ok := scanEnvSettingFunc(s); !ok {
|
||||
stack.Env = append(stack.Env, portainer.Pair{
|
||||
Name: s,
|
||||
Value: "None",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if stack.Env != nil {
|
||||
for _, v := range stack.Env {
|
||||
envfile.WriteString(
|
||||
fmt.Sprintf("%s=%s\n", v.Name, v.Value))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractNetworkNames(filePath string) (StringSet, error) {
|
||||
if info, err := os.Stat(filePath); errors.Is(err,
|
||||
os.ErrNotExist) || info.IsDir() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
stackFileContent, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open yaml file")
|
||||
}
|
||||
|
||||
config, err := loader.ParseYAML(stackFileContent)
|
||||
if err != nil {
|
||||
// invalid stack file
|
||||
return nil, errors.Wrap(err, "invalid stack file")
|
||||
}
|
||||
|
||||
var version string
|
||||
if _, ok := config["version"]; ok {
|
||||
version, _ = config["version"].(string)
|
||||
}
|
||||
|
||||
var networks map[string]interface{}
|
||||
if value, ok := config["networks"]; ok {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if networks, ok = value.(map[string]interface{}); !ok {
|
||||
return nil, nil
|
||||
}
|
||||
} else {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
networkContent, err := loader.LoadNetworks(networks, version)
|
||||
if err != nil {
|
||||
return nil, nil // skip the error
|
||||
}
|
||||
|
||||
re := regexp.MustCompile(`^\$\{?([^\}]+)\}?$`)
|
||||
networkNames := NewStringSet()
|
||||
|
||||
for _, v := range networkContent {
|
||||
matched := re.FindAllStringSubmatch(v.Name, -1)
|
||||
if matched != nil && matched[0] != nil {
|
||||
if strings.Contains(matched[0][1], ":-") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(matched[0][1], "?") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(matched[0][1], "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
networkNames.Add(matched[0][1])
|
||||
}
|
||||
}
|
||||
|
||||
if networkNames.Len() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return networkNames, nil
|
||||
// If couldn't copy the .env file, then ignore the error and try to continue
|
||||
}
|
||||
|
||||
@@ -65,56 +65,22 @@ func Test_createEnvFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createNetworkEnvFile(t *testing.T) {
|
||||
func Test_createEnvFile_mergesDefultAndInplaceEnvVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
buf := []byte(`
|
||||
version: '3.6'
|
||||
services:
|
||||
nginx-example:
|
||||
image: nginx:latest
|
||||
networks:
|
||||
default:
|
||||
name: ${test}
|
||||
driver: bridge
|
||||
`)
|
||||
if err := ioutil.WriteFile(path.Join(dir,
|
||||
"docker-compose.yml"), buf, 0644); err != nil {
|
||||
t.Fatalf("Failed to create yaml file: %s", err)
|
||||
}
|
||||
|
||||
stackWithoutEnv := &portainer.Stack{
|
||||
os.WriteFile(path.Join(dir, ".env"), []byte("VAR1=VAL1\nVAR2=VAL2\n"), 0600)
|
||||
stack := &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Env: []portainer.Pair{},
|
||||
}
|
||||
|
||||
if err := createNetworkEnvFile(stackWithoutEnv); err != nil {
|
||||
t.Fatalf("Failed to create network env file: %s", err)
|
||||
}
|
||||
|
||||
content, err := ioutil.ReadFile(path.Join(dir, ".env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read network env file: %s", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "test=None\n", string(content))
|
||||
|
||||
stackWithEnv := &portainer.Stack{
|
||||
ProjectPath: dir,
|
||||
EntryPoint: "docker-compose.yml",
|
||||
Env: []portainer.Pair{
|
||||
{Name: "test", Value: "test-value"},
|
||||
{Name: "VAR1", Value: "NEW_VAL1"},
|
||||
{Name: "VAR3", Value: "VAL3"},
|
||||
},
|
||||
}
|
||||
result, err := createEnvFile(stack)
|
||||
assert.Equal(t, "stack.env", result)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, path.Join(dir, "stack.env"))
|
||||
f, _ := os.Open(path.Join(dir, "stack.env"))
|
||||
content, _ := ioutil.ReadAll(f)
|
||||
|
||||
if err := createNetworkEnvFile(stackWithEnv); err != nil {
|
||||
t.Fatalf("Failed to create network env file: %s", err)
|
||||
}
|
||||
|
||||
content, err = ioutil.ReadFile(path.Join(dir, ".env"))
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read network env file: %s", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "test=test-value\n", string(content))
|
||||
assert.Equal(t, []byte("VAR1=VAL1\nVAR2=VAL2\n\nVAR1=NEW_VAL1\nVAR3=VAL3\n"), content)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ require (
|
||||
github.com/coreos/go-semver v0.3.0
|
||||
github.com/dchest/uniuri v0.0.0-20160212164326-8902c56451e9
|
||||
github.com/docker/cli v20.10.9+incompatible
|
||||
github.com/docker/docker v20.10.9+incompatible
|
||||
github.com/docker/docker v20.10.16+incompatible
|
||||
github.com/fvbommel/sortorder v1.0.2
|
||||
github.com/fxamacker/cbor/v2 v2.3.0
|
||||
github.com/g07cha/defender v0.0.0-20180505193036-5665c627c814
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
github.com/koding/websocketproxy v0.0.0-20181220232114-7ed82d81a28c
|
||||
github.com/orcaman/concurrent-map v0.0.0-20190826125027-8c72a8bb44f6
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220703222411-e3cf664b39c6
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108
|
||||
github.com/portainer/libhttp v0.0.0-20211208103139-07a5f798eb3f
|
||||
|
||||
@@ -332,6 +332,8 @@ github.com/docker/distribution v2.8.0+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc
|
||||
github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v20.10.9+incompatible h1:JlsVnETOjM2RLQa0Cc1XCIspUdXW3Zenq9P54uXBm6k=
|
||||
github.com/docker/docker v20.10.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v20.10.16+incompatible h1:2Db6ZR/+FUR3hqPMwnogOPHFn405crbpxvWzKovETOQ=
|
||||
github.com/docker/docker v20.10.16+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
|
||||
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
|
||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||
@@ -807,10 +809,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e h1:gW1Ooaj7RZ9YkwHxesnNEyOB5nUD71FlZ7cdb5h63vw=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220526210722-e1574867298e/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410 h1:LjxLd8UGR8ae73ov/vLrt/0jedj/nh98XnONkr8DJj8=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220531190153-c597b853e410/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220703222411-e3cf664b39c6 h1:6VQZsYaJGfEq1LSKiNQ8HIW3olB04MpnW6HTnLnpMSQ=
|
||||
github.com/portainer/docker-compose-wrapper v0.0.0-20220703222411-e3cf664b39c6/go.mod h1:WxDlJWZxCnicdLCPnLNEv7/gRhjeIVuCGmsv+iOPH3c=
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a h1:qY8TbocN75n5PDl16o0uVr5MevtM5IhdwSelXEd4nFM=
|
||||
github.com/portainer/libcrypto v0.0.0-20210422035235-c652195c5c3a/go.mod h1:n54EEIq+MM0NNtqLeCby8ljL+l275VpolXO0ibHegLE=
|
||||
github.com/portainer/libhelm v0.0.0-20210929000907-825e93d62108 h1:5e8KAnDa2G3cEHK7aV/ue8lOaoQwBZUzoALslwWkR04=
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
type stackGitUpdatePayload struct {
|
||||
AutoUpdate *portainer.StackAutoUpdate
|
||||
Env []portainer.Pair
|
||||
Prune bool
|
||||
RepositoryReferenceName string
|
||||
RepositoryAuthentication bool
|
||||
RepositoryUsername string
|
||||
@@ -131,6 +132,12 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
|
||||
stack.UpdatedBy = user.Username
|
||||
stack.UpdateDate = time.Now().Unix()
|
||||
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
stack.Option = &portainer.StackOption{
|
||||
Prune: payload.Prune,
|
||||
}
|
||||
}
|
||||
|
||||
if payload.RepositoryAuthentication {
|
||||
password := payload.RepositoryPassword
|
||||
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
|
||||
|
||||
@@ -24,6 +24,7 @@ type stackGitRedployPayload struct {
|
||||
RepositoryUsername string
|
||||
RepositoryPassword string
|
||||
Env []portainer.Pair
|
||||
Prune bool
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
@@ -118,6 +119,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
|
||||
stack.Env = payload.Env
|
||||
if stack.Type == portainer.DockerSwarmStack {
|
||||
stack.Option = &portainer.StackOption{
|
||||
Prune: payload.Prune,
|
||||
}
|
||||
}
|
||||
|
||||
backupProjectPath := fmt.Sprintf("%s-old", stack.ProjectPath)
|
||||
err = filesystem.MoveDirectory(stack.ProjectPath, backupProjectPath)
|
||||
@@ -187,7 +193,11 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
|
||||
switch stack.Type {
|
||||
case portainer.DockerSwarmStack:
|
||||
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
|
||||
prune := false
|
||||
if stack.Option != nil {
|
||||
prune = stack.Option.Prune
|
||||
}
|
||||
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, prune)
|
||||
if httpErr != nil {
|
||||
return httpErr
|
||||
}
|
||||
|
||||
@@ -922,6 +922,8 @@ type (
|
||||
AdditionalFiles []string `json:"AdditionalFiles"`
|
||||
// The auto update settings of a git stack
|
||||
AutoUpdate *StackAutoUpdate `json:"AutoUpdate"`
|
||||
// The stack deployment option
|
||||
Option *StackOption `json:"Option"`
|
||||
// The git config of this stack
|
||||
GitConfig *gittypes.RepoConfig
|
||||
// Whether the stack is from a app template
|
||||
@@ -942,6 +944,12 @@ type (
|
||||
JobID string `example:"15"`
|
||||
}
|
||||
|
||||
// StackOption represents the options for stack deployment
|
||||
StackOption struct {
|
||||
// Prune services that are no longer referenced
|
||||
Prune bool `example:"false"`
|
||||
}
|
||||
|
||||
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
|
||||
StackID int
|
||||
|
||||
|
||||
186
app/assets/css/bootstrap-override.css
vendored
186
app/assets/css/bootstrap-override.css
vendored
@@ -20,107 +20,10 @@
|
||||
background: var(--bg-dashboard-item) !important;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
justify-content: space-around;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
pr-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Button Primary */
|
||||
.btn-primary {
|
||||
background-color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus,
|
||||
.btn-primary:active .active {
|
||||
background-color: var(--ui-blue-9);
|
||||
}
|
||||
|
||||
.btn-primary:active,
|
||||
.btn-primary.active,
|
||||
.open > .dropdown-toggle.btn-primary {
|
||||
background-color: var(--ui-blue-9);
|
||||
}
|
||||
|
||||
.nav-pills > li.active > a,
|
||||
.nav-pills > li.active > a:hover,
|
||||
.nav-pills > li.active > a:focus {
|
||||
background-color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
|
||||
.btn-dangerlight {
|
||||
background-color: var(--ui-error-2) !important;
|
||||
border: 1px solid var(--border-button-group);
|
||||
color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.btn-dangerlight:hover {
|
||||
color: var(--ui-error-9) !important;
|
||||
background-color: var(--ui-error-3) !important;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background-color: var(--bg-button-group);
|
||||
border: 1px solid var(--border-button-group);
|
||||
color: var(--text-button-group);
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background-color: var(--ui-gray-2) !important;
|
||||
}
|
||||
|
||||
.btn-light:active,
|
||||
.btn-light.active,
|
||||
.open > .dropdown-toggle.btn-light {
|
||||
background-color: var(--ui-gray-3);
|
||||
}
|
||||
|
||||
/* Button Secondary */
|
||||
.btn-secondary {
|
||||
background-color: var(--ui-blue-2);
|
||||
border: 1px solid var(--ui-blue-8);
|
||||
color: var(--ui-blue-9);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary:focus,
|
||||
.btn-secondary:active .active {
|
||||
background-color: var(--ui-blue-3) !important;
|
||||
color: var(--ui-blue-9) !important;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
background-color: var(--ui-blue-1);
|
||||
border: 1px solid var(--ui-blue-1);
|
||||
color: var(--ui-blue-5);
|
||||
}
|
||||
|
||||
/* Input Group Addon */
|
||||
.input-group-addon:first-child {
|
||||
border-top-left-radius: 5px;
|
||||
@@ -485,95 +388,6 @@ input:checked + .slider:before {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
|
||||
/* Feather Icon Variants */
|
||||
|
||||
.feather {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.icon-xs {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.icon-sm {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.icon-white {
|
||||
color: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-dark {
|
||||
color: var(--ui-black);
|
||||
}
|
||||
|
||||
.icon-secondary {
|
||||
color: var(--ui-gray-8);
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: var(--ui-warning-8);
|
||||
}
|
||||
|
||||
.icon-danger {
|
||||
color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--ui-success-6);
|
||||
}
|
||||
|
||||
.icon-nested-gray {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ui-gray-4);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-nested-blue {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ui-blue-3);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* Required Label with asterisk */
|
||||
|
||||
.required:after {
|
||||
|
||||
91
app/assets/css/button.css
Normal file
91
app/assets/css/button.css
Normal file
@@ -0,0 +1,91 @@
|
||||
.btn {
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
justify-content: space-around;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.btn.active {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.btn-primary:hover,
|
||||
.btn-primary:focus,
|
||||
.btn-primary:active .active {
|
||||
background-color: var(--ui-blue-9);
|
||||
}
|
||||
|
||||
.btn-primary:active,
|
||||
.btn-primary.active,
|
||||
.open > .dropdown-toggle.btn-primary {
|
||||
background-color: var(--ui-blue-9);
|
||||
}
|
||||
|
||||
.nav-pills > li.active > a,
|
||||
.nav-pills > li.active > a:hover,
|
||||
.nav-pills > li.active > a:focus {
|
||||
background-color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--ui-success-7);
|
||||
}
|
||||
|
||||
.btn-dangerlight {
|
||||
background-color: var(--ui-error-2) !important;
|
||||
border: 1px solid var(--border-button-group);
|
||||
color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.btn-dangerlight:hover {
|
||||
color: var(--ui-error-9) !important;
|
||||
background-color: var(--ui-error-3) !important;
|
||||
}
|
||||
|
||||
.btn-light {
|
||||
background-color: var(--bg-button-group);
|
||||
border: 1px solid var(--border-button-group);
|
||||
color: var(--text-button-group);
|
||||
}
|
||||
|
||||
.btn-light:hover {
|
||||
background-color: var(--ui-gray-2) !important;
|
||||
}
|
||||
|
||||
.btn-light:active,
|
||||
.btn-light.active,
|
||||
.open > .dropdown-toggle.btn-light {
|
||||
background-color: var(--ui-gray-3);
|
||||
}
|
||||
|
||||
/* Button Secondary */
|
||||
.btn-secondary {
|
||||
background-color: var(--ui-blue-2);
|
||||
border: 1px solid var(--ui-blue-8);
|
||||
color: var(--ui-blue-9);
|
||||
}
|
||||
|
||||
.btn-secondary:hover,
|
||||
.btn-secondary:focus,
|
||||
.btn-secondary:active .active {
|
||||
background-color: var(--ui-blue-3) !important;
|
||||
color: var(--ui-blue-9) !important;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
background-color: var(--ui-blue-1);
|
||||
border: 1px solid var(--ui-blue-1);
|
||||
color: var(--ui-blue-5);
|
||||
}
|
||||
115
app/assets/css/icon.css
Normal file
115
app/assets/css/icon.css
Normal file
@@ -0,0 +1,115 @@
|
||||
.feather {
|
||||
display: block;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
pr-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.icon {
|
||||
color: currentColor;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.icon-xs {
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.icon-sm {
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.icon-md {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
}
|
||||
|
||||
.icon-xl {
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
}
|
||||
|
||||
.icon.icon-alt {
|
||||
fill: var(--black-color);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-primary {
|
||||
color: var(--ui-blue-8);
|
||||
}
|
||||
|
||||
.icon.icon-primary-alt {
|
||||
fill: var(--ui-blue-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-secondary {
|
||||
color: var(--ui-gray-8);
|
||||
}
|
||||
|
||||
.icon.icon-secondary-alt {
|
||||
fill: var(--ui-gray-8);
|
||||
stroke: var(--black-color);
|
||||
}
|
||||
|
||||
.icon-warning {
|
||||
color: var(--ui-warning-8);
|
||||
}
|
||||
|
||||
.icon.icon-warning-alt {
|
||||
fill: var(--ui-warning-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-danger {
|
||||
color: var(--ui-error-8);
|
||||
}
|
||||
|
||||
.icon.icon-danger-alt {
|
||||
fill: var(--ui-error-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: var(--ui-success-6);
|
||||
}
|
||||
|
||||
.icon.icon-success-alt {
|
||||
fill: var(--ui-success-8);
|
||||
stroke: var(--white-color);
|
||||
}
|
||||
|
||||
.icon-nested-gray {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ui-gray-4);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-nested-blue {
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
background-color: var(--ui-blue-3);
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -24,3 +24,5 @@ import './theme.css';
|
||||
import './vendor-override.css';
|
||||
import '../fonts/nomad-icon.css';
|
||||
import './bootstrap-override.css';
|
||||
import './icon.css';
|
||||
import './button.css';
|
||||
|
||||
@@ -3,9 +3,35 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="actionBar" authorization="DockerConfigDelete, DockerConfigCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerConfigDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.configs.new" authorization="DockerConfigCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add config
|
||||
</button>
|
||||
</div>
|
||||
<div 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><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||
<span uib-dropdown-toggle aria-label="Settings"><i class="fa fa-cog" aria-hidden="true"></i></span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader"> Table settings </div>
|
||||
@@ -38,32 +64,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar" authorization="DockerConfigDelete, DockerConfigCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerConfigDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.configs.new" authorization="DockerConfigCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add config
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
|
||||
@@ -3,9 +3,62 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerImageDelete, DockerImageBuild, DockerImageLoad, DockerImageGet">
|
||||
<div class="btn-group" authorization="DockerImageDelete">
|
||||
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems, false)">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
>
|
||||
<span class="caret"></span>
|
||||
<span class="sr-only">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="$ctrl.forceRemoveAction($ctrl.state.selectedItems, true)" ng-disabled="$ctrl.state.selectedItemCount === 0">Force Remove</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.images.build" authorization="DockerImageBuild">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Build a new image
|
||||
</button>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-primary" ng-disabled="$ctrl.exportInProgress" ui-sref="docker.images.import" authorization="DockerImageLoad">
|
||||
<i class="fa fa-upload space-right" aria-hidden="true"></i>Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0 || $ctrl.exportInProgress"
|
||||
ng-click="$ctrl.downloadAction($ctrl.state.selectedItems)"
|
||||
button-spinner="$ctrl.exportInProgress"
|
||||
authorization="DockerImageGet"
|
||||
>
|
||||
<i class="fa fa-download space-right" aria-hidden="true"></i>
|
||||
<span ng-hide="$ctrl.exportInProgress">Export</span>
|
||||
<span ng-show="$ctrl.exportInProgress">Export in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||
<span uib-dropdown-toggle aria-label="Settings"><i class="fa fa-cog" aria-hidden="true"></i></span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader"> Table settings </div>
|
||||
@@ -38,59 +91,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerImageDelete, DockerImageBuild, DockerImageLoad, DockerImageGet">
|
||||
<div class="btn-group" authorization="DockerImageDelete">
|
||||
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems, false)">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger dropdown-toggle"
|
||||
data-toggle="dropdown"
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
>
|
||||
<span class="caret"></span>
|
||||
<span class="sr-only">Toggle Dropdown</span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="$ctrl.forceRemoveAction($ctrl.state.selectedItems, true)" ng-disabled="$ctrl.state.selectedItemCount === 0">Force Remove</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.images.build" authorization="DockerImageBuild">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Build a new image
|
||||
</button>
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-primary" ng-disabled="$ctrl.exportInProgress" ui-sref="docker.images.import" authorization="DockerImageLoad">
|
||||
<i class="fa fa-upload space-right" aria-hidden="true"></i>Import
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0 || $ctrl.exportInProgress"
|
||||
ng-click="$ctrl.downloadAction($ctrl.state.selectedItems)"
|
||||
button-spinner="$ctrl.exportInProgress"
|
||||
authorization="DockerImageGet"
|
||||
>
|
||||
<i class="fa fa-download space-right" aria-hidden="true"></i>
|
||||
<span ng-hide="$ctrl.exportInProgress">Export</span>
|
||||
<span ng-show="$ctrl.exportInProgress">Export in progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-filters nowrap-cells">
|
||||
<thead>
|
||||
|
||||
@@ -3,9 +3,35 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerNetworkDelete, DockerNetworkCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerNetworkDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.networks.new" authorization="DockerNetworkCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add network
|
||||
</button>
|
||||
</div>
|
||||
<div 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><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||
<span uib-dropdown-toggle aria-label="Settings"><i class="fa fa-cog" aria-hidden="true"></i></span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader"> Table settings </div>
|
||||
@@ -38,32 +64,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerNetworkDelete, DockerNetworkCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerNetworkDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.networks.new" authorization="DockerNetworkCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add network
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
|
||||
@@ -3,9 +3,35 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="actionBar" authorization="DockerSecretDelete, DockerSecretCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerSecretDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.secrets.new" authorization="DockerSecretCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret
|
||||
</button>
|
||||
</div>
|
||||
<div 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><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||
<span uib-dropdown-toggle aria-label="Settings"><i class="fa fa-cog" aria-hidden="true"></i></span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader"> Table settings </div>
|
||||
@@ -38,32 +64,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar" authorization="DockerSecretDelete, DockerSecretCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerSecretDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.secrets.new" authorization="DockerSecretCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add secret
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
|
||||
@@ -3,9 +3,35 @@
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px"></i> {{ $ctrl.titleText }} </div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerVolumeDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.volumes.new" authorization="DockerVolumeCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add volume
|
||||
</button>
|
||||
</div>
|
||||
<div 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><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
|
||||
<span uib-dropdown-toggle aria-label="Settings"><i class="fa fa-cog" aria-hidden="true"></i></span>
|
||||
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
|
||||
<div class="tableMenu">
|
||||
<div class="menuHeader"> Table settings </div>
|
||||
@@ -38,32 +64,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger"
|
||||
authorization="DockerVolumeDelete"
|
||||
ng-disabled="$ctrl.state.selectedItemCount === 0"
|
||||
ng-click="$ctrl.removeAction($ctrl.state.selectedItems)"
|
||||
>
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.volumes.new" authorization="DockerVolumeCreate">
|
||||
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add volume
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input
|
||||
type="text"
|
||||
class="searchInput"
|
||||
ng-model="$ctrl.state.textFilter"
|
||||
ng-change="$ctrl.onTextFilterChange()"
|
||||
placeholder="Search..."
|
||||
auto-focus
|
||||
ng-model-options="{ debounce: 300 }"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-filters nowrap-cells">
|
||||
<thead>
|
||||
|
||||
@@ -43,7 +43,7 @@ angular.module('portainer.docker').controller('HostViewController', [
|
||||
ctrl.state.offlineMode = isOfflineEndpoint(ctrl.endpoint);
|
||||
ctrl.jobs = data.jobs;
|
||||
|
||||
if (ctrl.state.isAgent && agentApiVersion > 1) {
|
||||
if (ctrl.state.isAgent && agentApiVersion > 1 && ctrl.state.enableHostManagementFeatures) {
|
||||
return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) {
|
||||
ctrl.devices = agentHostInfo.PCIDevices;
|
||||
ctrl.disks = agentHostInfo.PhysicalDisks;
|
||||
|
||||
@@ -38,7 +38,7 @@ angular.module('portainer.docker').controller('NodeDetailsViewController', [
|
||||
if (ctrl.state.isAgent) {
|
||||
var agentApiVersion = applicationState.endpoint.agentApiVersion;
|
||||
ctrl.state.agentApiVersion = agentApiVersion;
|
||||
if (agentApiVersion < 2) {
|
||||
if (agentApiVersion < 2 || !ctrl.state.enableHostManagementFeatures) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@
|
||||
</div>
|
||||
|
||||
<span class="pull-right myaccount-container" uib-dropdown on-toggle="toggled(open)">
|
||||
<a href class="myaccount-dropdown" uib-dropdown-toggle>
|
||||
<a href class="myaccount-dropdown" uib-dropdown-toggle data-cy="userMenu-button" aria-label="User menu toggle">
|
||||
<span class="pull-right user-box" ng-if="$ctrl.username">
|
||||
<i class="fa fa-user-circle" aria-hidden="true"></i> {{ $ctrl.username }} <i class="fa fa-angle-down myaccount-icon" aria-hidden="true"></i>
|
||||
</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu>
|
||||
<ul class="dropdown-menu" uib-dropdown-menu aria-label="User Menu" data-cy="userMenu">
|
||||
<li>
|
||||
<a ui-sref="portainer.account">My account</a>
|
||||
<a ui-sref="portainer.account" data-cy="userMenu-myAccount">My account</a>
|
||||
</li>
|
||||
<li>
|
||||
<a ui-sref="portainer.logout({performApiLogout: true})" data-cy="template-logoutButton">Log out</a>
|
||||
<a ui-sref="portainer.logout({performApiLogout: true})" data-cy="userMenu-logOut">Log out</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
|
||||
@@ -28,6 +28,9 @@ class StackRedeployGitFormController {
|
||||
RepositoryUsername: '',
|
||||
RepositoryPassword: '',
|
||||
Env: [],
|
||||
Option: {
|
||||
Prune: false,
|
||||
},
|
||||
// auto update
|
||||
AutoUpdate: {
|
||||
RepositoryAutomaticUpdates: false,
|
||||
@@ -41,6 +44,7 @@ class StackRedeployGitFormController {
|
||||
this.onChangeRef = this.onChangeRef.bind(this);
|
||||
this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
|
||||
this.onChangeEnvVar = this.onChangeEnvVar.bind(this);
|
||||
this.onChangeOption = this.onChangeOption.bind(this);
|
||||
}
|
||||
|
||||
buildAnalyticsProperties() {
|
||||
@@ -88,6 +92,15 @@ class StackRedeployGitFormController {
|
||||
this.onChange({ Env: value });
|
||||
}
|
||||
|
||||
onChangeOption(values) {
|
||||
this.onChange({
|
||||
Option: {
|
||||
...this.formValues.Option,
|
||||
...values,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const tplCrop =
|
||||
'<div>Any changes to this stack or application made locally in Portainer will be overridden, which may cause service interruption.</div>' +
|
||||
@@ -101,7 +114,13 @@ class StackRedeployGitFormController {
|
||||
}
|
||||
try {
|
||||
this.state.redeployInProgress = true;
|
||||
await this.StackService.updateGit(this.stack.Id, this.stack.EndpointId, this.FormHelper.removeInvalidEnvVars(this.formValues.Env), false, this.formValues);
|
||||
await this.StackService.updateGit(
|
||||
this.stack.Id,
|
||||
this.stack.EndpointId,
|
||||
this.FormHelper.removeInvalidEnvVars(this.formValues.Env),
|
||||
this.formValues.Option.Prune,
|
||||
this.formValues
|
||||
);
|
||||
this.Notifications.success('Pulled and redeployed stack successfully');
|
||||
this.$state.reload();
|
||||
} catch (err) {
|
||||
@@ -148,6 +167,9 @@ class StackRedeployGitFormController {
|
||||
$onInit() {
|
||||
this.formValues.RefName = this.model.ReferenceName;
|
||||
this.formValues.Env = this.stack.Env;
|
||||
if (this.stack.Option) {
|
||||
this.formValues.Option = this.stack.Option;
|
||||
}
|
||||
|
||||
// Init auto update
|
||||
if (this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook)) {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
explanation="These values will be used as substitutions in the stack file"
|
||||
on-change="($ctrl.onChangeEnvVar)"
|
||||
></environment-variables-panel>
|
||||
|
||||
<option-panel ng-model="$ctrl.formValues.Option" on-change="($ctrl.onChangeOption)"></option-panel>
|
||||
<button
|
||||
class="btn btn-sm btn-primary"
|
||||
ng-click="$ctrl.submit()"
|
||||
|
||||
13
app/portainer/components/option-panel/index.js
Normal file
13
app/portainer/components/option-panel/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import controller from './option-panel.controller.js';
|
||||
|
||||
angular.module('portainer.app').component('optionPanel', {
|
||||
templateUrl: './option-panel.html',
|
||||
controller,
|
||||
bindings: {
|
||||
ngModel: '<',
|
||||
explanation: '@',
|
||||
onChange: '<',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export default class OptionPanelController {
|
||||
/* @ngInject */
|
||||
constructor() {
|
||||
this.switchPruneService = this.switchPruneService.bind(this);
|
||||
}
|
||||
|
||||
switchPruneService() {
|
||||
this.onChange(this.ngModel);
|
||||
}
|
||||
}
|
||||
15
app/portainer/components/option-panel/option-panel.html
Normal file
15
app/portainer/components/option-panel/option-panel.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<ng-form class="form-horizontal" name="$ctrl.optionForm">
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12 form-section-title" style="margin-top: 10px; margin-left: 15px; width: 98%"> Options </div>
|
||||
|
||||
<!-- Prune service -->
|
||||
<div class="col-sm-12">
|
||||
<label class="control-label text-left">
|
||||
Prune services
|
||||
<portainer-tooltip position="top" message="Prune services that are no longer referenced."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px"> <input type="checkbox" ng-model="$ctrl.ngModel.Prune" ng-change="$ctrl.switchPruneService()" /><i></i> </label>
|
||||
</div>
|
||||
<!-- !Prune service -->
|
||||
</div>
|
||||
</ng-form>
|
||||
@@ -7,6 +7,7 @@ export function StackViewModel(data) {
|
||||
this.EndpointId = data.EndpointId;
|
||||
this.SwarmId = data.SwarmId;
|
||||
this.Env = data.Env ? data.Env : [];
|
||||
this.Option = data.Option;
|
||||
this.IsComposeFormat = data.IsComposeFormat;
|
||||
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
|
||||
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
|
||||
@@ -44,6 +45,7 @@ export function OrphanedStackViewModel(data) {
|
||||
this.EndpointId = data.EndpointId;
|
||||
this.SwarmId = data.SwarmId;
|
||||
this.Env = data.Env ? data.Env : [];
|
||||
this.Option = data.Option;
|
||||
if (data.ResourceControl && data.ResourceControl.Id !== 0) {
|
||||
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
|
||||
}
|
||||
|
||||
@@ -34,5 +34,8 @@ export const componentsModule = angular
|
||||
'pageHeader',
|
||||
r2a(PageHeader, ['title', 'breadcrumbs', 'loading', 'onReload', 'reload'])
|
||||
)
|
||||
.component('prIcon', r2a(Icon, ['className', 'feather', 'icon']))
|
||||
.component(
|
||||
'prIcon',
|
||||
r2a(Icon, ['className', 'feather', 'icon', 'mode', 'size'])
|
||||
)
|
||||
.component('reactQueryDevTools', r2a(ReactQueryDevtoolsWrapper, [])).name;
|
||||
|
||||
@@ -12,4 +12,5 @@ export const switchField = r2a(SwitchField, [
|
||||
'disabled',
|
||||
'onChange',
|
||||
'featureId',
|
||||
'switchValues',
|
||||
]);
|
||||
|
||||
@@ -484,6 +484,7 @@ angular.module('portainer.app').factory('StackService', [
|
||||
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
|
||||
RepositoryUsername: gitConfig.RepositoryUsername,
|
||||
RepositoryPassword: gitConfig.RepositoryPassword,
|
||||
Prune: gitConfig.Option.Prune,
|
||||
}
|
||||
).$promise;
|
||||
};
|
||||
|
||||
@@ -12,9 +12,22 @@ interface Props {
|
||||
icon: ReactNode | ComponentType<unknown>;
|
||||
feather?: boolean;
|
||||
className?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
mode?:
|
||||
| 'alt'
|
||||
| 'primary'
|
||||
| 'primary-alt'
|
||||
| 'secondary'
|
||||
| 'secondary-alt'
|
||||
| 'warning'
|
||||
| 'warning-alt'
|
||||
| 'danger'
|
||||
| 'danger-alt'
|
||||
| 'success'
|
||||
| 'success-alt';
|
||||
}
|
||||
|
||||
export function Icon({ icon, feather, className }: Props) {
|
||||
export function Icon({ icon, feather, className, mode, size }: Props) {
|
||||
useEffect(() => {
|
||||
if (feather) {
|
||||
featherIcons.replace();
|
||||
@@ -31,11 +44,18 @@ export function Icon({ icon, feather, className }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
const classes = clsx(
|
||||
className,
|
||||
'icon',
|
||||
{ [`icon-${mode}`]: mode },
|
||||
{ [`icon-${size}`]: size }
|
||||
);
|
||||
|
||||
if (feather) {
|
||||
return (
|
||||
<i
|
||||
data-feather={icon}
|
||||
className={className}
|
||||
className={classes}
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
/>
|
||||
|
||||
@@ -7,9 +7,10 @@ import {
|
||||
} from '@reach/menu-button';
|
||||
import clsx from 'clsx';
|
||||
import { User, ChevronDown } from 'react-feather';
|
||||
import { useSref } from '@uirouter/react';
|
||||
import { UISrefProps, useSref } from '@uirouter/react';
|
||||
|
||||
import { useUser } from '@/portainer/hooks/useUser';
|
||||
import { AutomationTestingProps } from '@/types';
|
||||
|
||||
import { useHeaderContext } from './HeaderContainer';
|
||||
import styles from './HeaderTitle.module.css';
|
||||
@@ -32,37 +33,59 @@ export function HeaderTitle({ title, children }: PropsWithChildren<Props>) {
|
||||
'pull-right flex items-center gap-1',
|
||||
styles.menuButton
|
||||
)}
|
||||
data-cy="userMenu-button"
|
||||
aria-label="User menu toggle"
|
||||
>
|
||||
<User className="icon-nested-gray" />
|
||||
{user && <span>{user.Username}</span>}
|
||||
<ChevronDown className={styles.arrowDown} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuList className={styles.menuList}>
|
||||
<MenuList
|
||||
className={styles.menuList}
|
||||
aria-label="User Menu"
|
||||
data-cy="userMenu"
|
||||
>
|
||||
{!window.ddExtension && (
|
||||
<MenuLink to="portainer.account" label="My account" />
|
||||
<MenuLink
|
||||
to="portainer.account"
|
||||
label="My account"
|
||||
data-cy="userMenu-myAccount"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MenuLink to="portainer.logout" label="Log out" />
|
||||
<MenuLink
|
||||
to="portainer.logout"
|
||||
label="Log out"
|
||||
data-cy="userMenu-logOut"
|
||||
params={{ performApiLogout: true }}
|
||||
/>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MenuLinkProps {
|
||||
to: string;
|
||||
interface MenuLinkProps extends AutomationTestingProps, UISrefProps {
|
||||
label: string;
|
||||
}
|
||||
|
||||
function MenuLink({ to, label }: MenuLinkProps) {
|
||||
const anchorProps = useSref(to);
|
||||
function MenuLink({
|
||||
to,
|
||||
label,
|
||||
params,
|
||||
options,
|
||||
'data-cy': dataCy,
|
||||
}: MenuLinkProps) {
|
||||
const anchorProps = useSref(to, params, options);
|
||||
|
||||
return (
|
||||
<ReachMenuLink
|
||||
href={anchorProps.href}
|
||||
onClick={anchorProps.onClick}
|
||||
className={styles.menuLink}
|
||||
aria-label={label}
|
||||
data-cy={dataCy}
|
||||
>
|
||||
{label}
|
||||
</ReachMenuLink>
|
||||
|
||||
@@ -18,6 +18,10 @@ export interface Props {
|
||||
dataCy?: string;
|
||||
disabled?: boolean;
|
||||
featureId?: FeatureId;
|
||||
switchValues?: {
|
||||
on: string;
|
||||
off: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SwitchField({
|
||||
@@ -30,6 +34,7 @@ export function SwitchField({
|
||||
disabled,
|
||||
onChange,
|
||||
featureId,
|
||||
switchValues,
|
||||
}: Props) {
|
||||
const toggleName = name ? `toggle_${name}` : '';
|
||||
|
||||
@@ -55,6 +60,12 @@ export function SwitchField({
|
||||
featureId={featureId}
|
||||
dataCy={dataCy}
|
||||
/>
|
||||
{switchValues && checked && (
|
||||
<span className="ml-2">{switchValues.on}</span>
|
||||
)}
|
||||
{switchValues && !checked && (
|
||||
<span className="ml-2">{switchValues.off}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user