Compare commits
139 Commits
2.21.0
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b95bb06535 | ||
|
|
76e49ed9a8 | ||
|
|
e9ebef15a0 | ||
|
|
6ff4fd3db2 | ||
|
|
d38085a560 | ||
|
|
3cad13388c | ||
|
|
0b62456236 | ||
|
|
c22d280491 | ||
|
|
960d18998f | ||
|
|
3f3db75d85 | ||
|
|
48aab77058 | ||
|
|
7e53d01d0f | ||
|
|
bd271ec5a1 | ||
|
|
8913e75484 | ||
|
|
c95ffa9e2d | ||
|
|
ddb89f71b4 | ||
|
|
45be6c2b45 | ||
|
|
a00cb951bc | ||
|
|
f584bf3830 | ||
|
|
9600eb6fa1 | ||
|
|
d88ef03ddb | ||
|
|
dc9d7ae3f1 | ||
|
|
a3c7eb0ce0 | ||
|
|
d1ba484be1 | ||
|
|
521eb5f114 | ||
|
|
66770bebd4 | ||
|
|
86c4b3059e | ||
|
|
e3a8853212 | ||
|
|
194b6e491d | ||
|
|
a439695248 | ||
|
|
86f1b8df6e | ||
|
|
a5faddc56c | ||
|
|
9c68c6c9f3 | ||
|
|
d99486ee72 | ||
|
|
946166319f | ||
|
|
26bb028ace | ||
|
|
da615afc92 | ||
|
|
2b53bebcb3 | ||
|
|
d336a14e50 | ||
|
|
4ca6292805 | ||
|
|
44ef5bb12a | ||
|
|
bf600f8b11 | ||
|
|
d6d7afddbc | ||
|
|
61642b8df6 | ||
|
|
07de1b2c06 | ||
|
|
bd3440bf3c | ||
|
|
573f003226 | ||
|
|
6e169662c2 | ||
|
|
31658d4028 | ||
|
|
bb02c69d14 | ||
|
|
73307e164b | ||
|
|
9ea5efb6ba | ||
|
|
3cd58cac54 | ||
|
|
1303a08f5a | ||
|
|
3b1d853090 | ||
|
|
a2a4c85f2d | ||
|
|
506ee389e3 | ||
|
|
8635bc9b9c | ||
|
|
447f497506 | ||
|
|
71292a60b1 | ||
|
|
51449490fa | ||
|
|
ae4970f0ed | ||
|
|
e96d5c245d | ||
|
|
f8e3d75797 | ||
|
|
27aaf322b2 | ||
|
|
b77132dbb1 | ||
|
|
c35473f308 | ||
|
|
a570073d12 | ||
|
|
0ad4826fab | ||
|
|
6db7d31554 | ||
|
|
21d67a971d | ||
|
|
8dfa5efa71 | ||
|
|
529750fa21 | ||
|
|
96b1d36280 | ||
|
|
31c5a82749 | ||
|
|
82516620e7 | ||
|
|
d26d5840f1 | ||
|
|
ebd26316bf | ||
|
|
18dbad232e | ||
|
|
ebcc98d5c5 | ||
|
|
e919da3771 | ||
|
|
eda2dd20ee | ||
|
|
385fd95779 | ||
|
|
88185d7f6d | ||
|
|
253cda8cef | ||
|
|
b34afba7cd | ||
|
|
6c70049ecc | ||
|
|
42c2a52a6b | ||
|
|
19a6a5c608 | ||
|
|
d8e374fb76 | ||
|
|
84ca6185dc | ||
|
|
5088634a41 | ||
|
|
f6beedf0d5 | ||
|
|
3caf1ddb7d | ||
|
|
c622f6da4e | ||
|
|
9ec7394124 | ||
|
|
af8fde66b0 | ||
|
|
709315dde5 | ||
|
|
8856bae5c6 | ||
|
|
90451bfd47 | ||
|
|
0c05539dee | ||
|
|
a2a2c6cf3e | ||
|
|
76aa086d79 | ||
|
|
76fdfeaafc | ||
|
|
5932c78b88 | ||
|
|
68f5ca249f | ||
|
|
2d87a8d8c3 | ||
|
|
988d4103d4 | ||
|
|
ce3a1b8ba5 | ||
|
|
6c89d3c0c9 | ||
|
|
6b91fbf7f4 | ||
|
|
4f3f5e57b6 | ||
|
|
6b3f30e32f | ||
|
|
bdeedb4018 | ||
|
|
50946e087c | ||
|
|
7b89b04667 | ||
|
|
f5f84c5fa4 | ||
|
|
437831fa80 | ||
|
|
31f5b42962 | ||
|
|
7a6c872948 | ||
|
|
4bf18b1d65 | ||
|
|
2d25bf4afa | ||
|
|
56ae19c5ab | ||
|
|
cdf9197274 | ||
|
|
901549e8dd | ||
|
|
80b1cd19cb | ||
|
|
c4942de89b | ||
|
|
80d02f9cd1 | ||
|
|
671b22b5d6 | ||
|
|
43e56bf1c0 | ||
|
|
a175619623 | ||
|
|
63c11d9310 | ||
|
|
4c00b72ae3 | ||
|
|
f4db09a534 | ||
|
|
01cd64037f | ||
|
|
a93344386c | ||
|
|
a2195caa10 | ||
|
|
9ad78753bc | ||
|
|
517190e28b |
@@ -10,6 +10,7 @@ globals:
|
||||
extends:
|
||||
- 'eslint:recommended'
|
||||
- 'plugin:storybook/recommended'
|
||||
- 'plugin:import/typescript'
|
||||
- prettier
|
||||
|
||||
plugins:
|
||||
@@ -29,6 +30,7 @@ rules:
|
||||
no-empty: warn
|
||||
no-empty-function: warn
|
||||
no-useless-escape: 'off'
|
||||
import/named: error
|
||||
import/order:
|
||||
[
|
||||
'error',
|
||||
@@ -43,6 +45,12 @@ rules:
|
||||
pathGroupsExcludedImportTypes: ['internal'],
|
||||
},
|
||||
]
|
||||
no-restricted-imports:
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
|
||||
settings:
|
||||
'import/resolver':
|
||||
@@ -51,6 +59,8 @@ settings:
|
||||
- ['@@', './app/react/components']
|
||||
- ['@', './app']
|
||||
extensions: ['.js', '.ts', '.tsx']
|
||||
typescript: true
|
||||
node: true
|
||||
|
||||
overrides:
|
||||
- files:
|
||||
@@ -75,6 +85,7 @@ overrides:
|
||||
settings:
|
||||
react:
|
||||
version: 'detect'
|
||||
|
||||
rules:
|
||||
import/order:
|
||||
[
|
||||
@@ -108,6 +119,12 @@ overrides:
|
||||
'no-await-in-loop': 'off'
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }]
|
||||
'regex/invalid': ['error', [{ 'regex': '<Icon icon="(.*)"', 'message': 'Please directly import the `lucide-react` icon instead of using the string' }]]
|
||||
'@typescript-eslint/no-restricted-imports':
|
||||
- error
|
||||
- patterns:
|
||||
- group:
|
||||
- '@/react/test-utils/*'
|
||||
message: 'These utils are just for test files'
|
||||
overrides: # allow props spreading for hoc files
|
||||
- files:
|
||||
- app/**/with*.ts{,x}
|
||||
@@ -121,7 +138,13 @@ overrides:
|
||||
'vitest/env': true
|
||||
rules:
|
||||
'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
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -93,6 +93,8 @@ 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'
|
||||
|
||||
125
.github/workflows/ci.yaml
vendored
125
.github/workflows/ci.yaml
vendored
@@ -5,7 +5,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 'develop'
|
||||
- '!release/*'
|
||||
- 'release/*'
|
||||
pull_request:
|
||||
branches:
|
||||
- 'develop'
|
||||
@@ -20,8 +20,8 @@ on:
|
||||
- ready_for_review
|
||||
|
||||
env:
|
||||
DOCKER_HUB_REPO: portainerci/portainer
|
||||
NODE_ENV: testing
|
||||
DOCKER_HUB_REPO: portainerci/portainer-ce
|
||||
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
|
||||
GO_VERSION: 1.21.6
|
||||
NODE_VERSION: 18.x
|
||||
|
||||
@@ -30,81 +30,59 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
config:
|
||||
- { platform: linux, arch: amd64 }
|
||||
- { platform: linux, arch: arm64 }
|
||||
- { platform: linux, arch: amd64, version: "" }
|
||||
- { platform: linux, arch: arm64, version: "" }
|
||||
- { platform: linux, arch: arm, version: "" }
|
||||
- { platform: linux, arch: ppc64le, version: "" }
|
||||
- { platform: linux, arch: s390x, version: "" }
|
||||
- { platform: windows, arch: amd64, version: 1809 }
|
||||
- { platform: windows, arch: amd64, version: ltsc2022 }
|
||||
runs-on: arc-runner-set
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
steps:
|
||||
- name: '[preparation] checkout the current branch'
|
||||
uses: actions/checkout@v3.5.3
|
||||
uses: actions/checkout@v4.1.1
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
- name: '[preparation] set up golang'
|
||||
uses: actions/setup-go@v4.0.1
|
||||
uses: actions/setup-go@v5.0.0
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
cache: false
|
||||
- name: '[preparation] cache paths'
|
||||
id: cache-dir-path
|
||||
run: |
|
||||
echo "yarn-cache-dir=$(yarn cache dir)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-build-dir=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
|
||||
echo "go-mod-dir=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
|
||||
- name: '[preparation] cache go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
${{ steps.cache-dir-path.outputs.go-build-dir }}
|
||||
${{ steps.cache-dir-path.outputs.go-mod-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-go-
|
||||
enableCrossOsArchive: true
|
||||
- name: '[preparation] set up node.js'
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4.0.1
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: ''
|
||||
- name: '[preparation] cache yarn'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
**/node_modules
|
||||
${{ steps.cache-dir-path.outputs.yarn-cache-dir }}
|
||||
key: ${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ matrix.config.platform }}-${{ matrix.config.arch }}-yarn-
|
||||
enableCrossOsArchive: true
|
||||
cache: 'yarn'
|
||||
- name: '[preparation] set up qemu'
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3.0.0
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set the container image tag'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
# use the release branch name as the tag for release branches
|
||||
# for instance, release/2.19 becomes 2.19
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
# use pr${{ github.event.number }} as the tag for pull requests
|
||||
# for instance, pr123
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
if [ "${{ matrix.config.platform }}" == "windows" ]; then
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}"
|
||||
else
|
||||
CONTAINER_IMAGE_TAG="${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}-${{ matrix.config.arch }}"
|
||||
fi
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}" >> $GITHUB_ENV
|
||||
|
||||
echo "CONTAINER_IMAGE_TAG=${CONTAINER_IMAGE_TAG}-${{ matrix.config.platform }}${{ matrix.config.version }}-${{ matrix.config.arch }}" >> $GITHUB_ENV
|
||||
- name: '[execution] build linux & windows portainer binaries'
|
||||
run: |
|
||||
export YARN_VERSION=$(yarn --version)
|
||||
@@ -112,6 +90,12 @@ jobs:
|
||||
export BUILDNUMBER=${GITHUB_RUN_NUMBER}
|
||||
GIT_COMMIT_HASH_LONG=${{ github.sha }}
|
||||
export GIT_COMMIT_HASH_SHORT={GIT_COMMIT_HASH_LONG:0:7}
|
||||
|
||||
NODE_ENV="testing"
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
NODE_ENV="production"
|
||||
fi
|
||||
|
||||
make build-all PLATFORM=${{ matrix.config.platform }} ARCH=${{ matrix.config.arch }} ENV=${NODE_ENV}
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
@@ -123,35 +107,70 @@ jobs:
|
||||
else
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" -f build/${{ matrix.config.platform }}/Dockerfile .
|
||||
docker buildx build --output=type=registry --platform ${{ matrix.config.platform }}/${{ matrix.config.arch }} -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" -f build/${{ matrix.config.platform }}/alpine.Dockerfile .
|
||||
fi
|
||||
fi
|
||||
env:
|
||||
CONTAINER_IMAGE_TAG: ${{ env.CONTAINER_IMAGE_TAG }}
|
||||
build_manifests:
|
||||
runs-on: arc-runner-set
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.pull_request.draft == false
|
||||
needs: [build_images]
|
||||
steps:
|
||||
- name: '[preparation] docker login'
|
||||
uses: docker/login-action@v2.2.0
|
||||
uses: docker/login-action@v3.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- name: '[preparation] set up docker context for buildx'
|
||||
run: docker version && docker context create builders
|
||||
- name: '[preparation] set up docker buildx'
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3.0.0
|
||||
with:
|
||||
endpoint: builders
|
||||
- name: '[execution] build and push manifests'
|
||||
run: |
|
||||
if [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
# use the release branch name as the tag for release branches
|
||||
# for instance, release/2.19 becomes 2.19
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | cut -d "/" -f 2)
|
||||
elif [ "${GITHUB_EVENT_NAME}" == "pull_request" ]; then
|
||||
# use pr${{ github.event.number }} as the tag for pull requests
|
||||
# for instance, pr123
|
||||
CONTAINER_IMAGE_TAG="pr${{ github.event.number }}"
|
||||
else
|
||||
# replace / with - in the branch name
|
||||
# for instance, feature/1.0.0 -> feature-1.0.0
|
||||
CONTAINER_IMAGE_TAG=$(echo $GITHUB_REF_NAME | sed 's/\//-/g')
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windows1809-amd64" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-windowsltsc2022-amd64"
|
||||
|
||||
docker buildx imagetools create -t "${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${DOCKER_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
|
||||
if [[ "${GITHUB_REF_NAME}" =~ ^release/.*$ ]]; then
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-ppc64le" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-s390x"
|
||||
|
||||
docker buildx imagetools create -t "${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-amd64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm64-alpine" \
|
||||
"${EXTENSION_HUB_REPO}:${CONTAINER_IMAGE_TAG}-linux-arm-alpine"
|
||||
fi
|
||||
|
||||
8
.github/workflows/test.yaml
vendored
8
.github/workflows/test.yaml
vendored
@@ -6,12 +6,20 @@ env:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- synchronize
|
||||
- ready_for_review
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release/*
|
||||
|
||||
jobs:
|
||||
test-client:
|
||||
|
||||
@@ -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")
|
||||
return errors.Wrap(err, "failed to decrypt the archive. Please ensure the password is correct and try again")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package chisel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -28,12 +29,17 @@ 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() {
|
||||
require.NoError(t, http.Serve(ln, mux))
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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("PRETTY", "JSON"),
|
||||
LogMode: kingpin.Flag("log-mode", "Set the logging output mode").Default("PRETTY").Enum("NOCOLOR", "PRETTY", "JSON"),
|
||||
}
|
||||
|
||||
kingpin.Parse()
|
||||
|
||||
@@ -42,6 +42,13 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,52 +1,216 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/scrypt"
|
||||
)
|
||||
|
||||
// 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
|
||||
const (
|
||||
// AES GCM settings
|
||||
aesGcmHeader = "AES256-GCM" // The encrypted file header
|
||||
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
|
||||
|
||||
var emptySalt []byte = make([]byte, 0)
|
||||
// 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
|
||||
)
|
||||
|
||||
// AesEncrypt reads from input, encrypts with AES-256 and writes to the output.
|
||||
// passphrase is used to generate an encryption key.
|
||||
// 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 {
|
||||
// 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)
|
||||
err := aesEncryptGCM(input, output, passphrase)
|
||||
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 fmt.Errorf("error encrypting file: %w", 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.
|
||||
// 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.
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
@@ -59,11 +223,9 @@ func AesDecrypt(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
|
||||
|
||||
@@ -2,6 +2,7 @@ package crypto
|
||||
|
||||
import (
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -9,7 +10,19 @@ 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 (
|
||||
@@ -18,17 +31,99 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
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)
|
||||
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")
|
||||
@@ -57,7 +152,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1024 * 50)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
@@ -96,7 +191,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
decryptedFilePath = filepath.Join(tmpdir, "decrypted")
|
||||
)
|
||||
|
||||
content := []byte("content")
|
||||
content := randBytes(1034)
|
||||
os.WriteFile(originFilePath, content, 0600)
|
||||
|
||||
originFile, _ := os.Open(originFilePath)
|
||||
@@ -117,11 +212,6 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
|
||||
decryptedFileWriter, _ := os.Create(decryptedFilePath)
|
||||
defer decryptedFileWriter.Close()
|
||||
|
||||
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")
|
||||
_, err = AesDecrypt(encryptedFileReader, []byte("garbage"))
|
||||
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase")
|
||||
}
|
||||
|
||||
61
api/crypto/nonce.go
Normal file
61
api/crypto/nonce.go
Normal file
@@ -0,0 +1,61 @@
|
||||
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")
|
||||
}
|
||||
@@ -23,3 +23,29 @@ func (migrator *Migrator) updateAppTemplatesVersionForDB110() error {
|
||||
|
||||
return migrator.settingsService.UpdateSettings(settings)
|
||||
}
|
||||
|
||||
// In PortainerCE the resource overcommit option should always be true across all endpoints
|
||||
func (migrator *Migrator) updateResourceOverCommitToDB110() error {
|
||||
log.Info().Msg("updating resource overcommit setting to true")
|
||||
|
||||
endpoints, err := migrator.endpointService.Endpoints()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, endpoint := range endpoints {
|
||||
if endpoint.Type == portainer.KubernetesLocalEnvironment ||
|
||||
endpoint.Type == portainer.AgentOnKubernetesEnvironment ||
|
||||
endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment {
|
||||
|
||||
endpoint.Kubernetes.Configuration.EnableResourceOverCommit = true
|
||||
|
||||
err = migrator.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ func (m *Migrator) initMigrations() {
|
||||
)
|
||||
m.addMigrations("2.20",
|
||||
m.updateAppTemplatesVersionForDB110,
|
||||
m.updateResourceOverCommitToDB110,
|
||||
)
|
||||
|
||||
// Add new migrations below...
|
||||
|
||||
@@ -939,6 +939,6 @@
|
||||
}
|
||||
],
|
||||
"version": {
|
||||
"VERSION": "{\"SchemaVersion\":\"2.20.0\",\"MigratorCount\":1,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
"VERSION": "{\"SchemaVersion\":\"2.22.0\",\"MigratorCount\":0,\"Edition\":1,\"InstanceID\":\"463d5c47-0ea5-4aca-85b1-405ceefee254\"}"
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,11 @@ func WithProtect(handler http.Handler) (http.Handler, error) {
|
||||
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
|
||||
}
|
||||
|
||||
handler = gorillacsrf.Protect([]byte(token), gorillacsrf.Path("/"))(handler)
|
||||
handler = gorillacsrf.Protect(
|
||||
[]byte(token),
|
||||
gorillacsrf.Path("/"),
|
||||
gorillacsrf.Secure(false),
|
||||
)(handler)
|
||||
|
||||
return withSkipCSRF(handler), nil
|
||||
}
|
||||
|
||||
@@ -135,6 +135,11 @@ func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, r *ht
|
||||
}
|
||||
|
||||
func updateEnvStatus(environmentId portainer.EndpointID, stack *portainer.EdgeStack, deploymentStatus portainer.EdgeStackDeploymentStatus) {
|
||||
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
|
||||
delete(stack.Status, environmentId)
|
||||
return
|
||||
}
|
||||
|
||||
environmentStatus, ok := stack.Status[environmentId]
|
||||
if !ok {
|
||||
environmentStatus = portainer.EdgeStackStatus{
|
||||
|
||||
@@ -85,7 +85,7 @@ type Handler struct {
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
// @version 2.20.0
|
||||
// @version 2.22.0
|
||||
// @description.markdown api-description.md
|
||||
// @termsOfService
|
||||
|
||||
|
||||
@@ -38,19 +38,20 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
|
||||
kubeClusterAccessService: kubeClusterAccessService,
|
||||
}
|
||||
|
||||
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"))
|
||||
h.Use(middlewares.WithEndpoint(dataStore.Endpoint(), "id"),
|
||||
bouncer.AuthenticatedAccess)
|
||||
|
||||
// `helm list -o json`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmList))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmList)).Methods(http.MethodGet)
|
||||
|
||||
// `helm delete RELEASE_NAME`
|
||||
h.Handle("/{id}/kubernetes/helm/{release}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmDelete))).Methods(http.MethodDelete)
|
||||
httperror.LoggerHandler(h.helmDelete)).Methods(http.MethodDelete)
|
||||
|
||||
// `helm install [NAME] [CHART] flags`
|
||||
h.Handle("/{id}/kubernetes/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost)
|
||||
httperror.LoggerHandler(h.helmInstall)).Methods(http.MethodPost)
|
||||
|
||||
// Deprecated
|
||||
h.Handle("/{id}/kubernetes/helm/repositories",
|
||||
@@ -69,12 +70,14 @@ func NewTemplateHandler(bouncer security.BouncerService, helmPackageManager libh
|
||||
requestBouncer: bouncer,
|
||||
}
|
||||
|
||||
h.Use(bouncer.AuthenticatedAccess)
|
||||
|
||||
h.Handle("/templates/helm",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmRepoSearch))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmRepoSearch)).Methods(http.MethodGet)
|
||||
|
||||
// helm show [COMMAND] [CHART] [REPO] flags
|
||||
h.Handle("/templates/helm/{command:chart|values|readme}",
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmShow))).Methods(http.MethodGet)
|
||||
httperror.LoggerHandler(h.helmShow)).Methods(http.MethodGet)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ type stackListOperationFilters struct {
|
||||
// @description List all stacks based on the current user authorizations.
|
||||
// @description Will return all stacks if using an administrator account otherwise it
|
||||
// @description will only return the list of stacks the user have access to.
|
||||
// @description Limited stacks will not be returned by this endpoint.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags stacks
|
||||
// @security ApiKeyAuth
|
||||
@@ -91,25 +92,55 @@ func (handler *Handler) stackList(w http.ResponseWriter, r *http.Request) *httpe
|
||||
return response.JSON(w, stacks)
|
||||
}
|
||||
|
||||
// filterStacks refines a collection of Stack instances using specified criteria.
|
||||
// This function examines the provided filters: EndpointID, SwarmID, and IncludeOrphanedStacks.
|
||||
// - If both EndpointID is zero and SwarmID is an empty string, the function directly returns the original stack list without any modifications.
|
||||
// - If either filter is specified, it proceeds to selectively include stacks that match the criteria.
|
||||
|
||||
// Key Points on Business Logic:
|
||||
// 1. Determining Inclusion of Orphaned Stacks:
|
||||
// - The decision to include orphaned stacks is influenced by the user's role and usually set by the client (UI).
|
||||
// - Administrators or environment administrators can include orphaned stacks by setting IncludeOrphanedStacks to true, reflecting their broader access rights.
|
||||
// - For non-administrative users, this is typically set to false, limiting their visibility to only stacks within their purview.
|
||||
|
||||
// 2. Inclusion Criteria for Orphaned Stacks:
|
||||
// - When IncludeOrphanedStacks is true and an EndpointID is specified (not zero), the function selects:
|
||||
// a) Stacks linked to the specified EndpointID.
|
||||
// b) Orphaned stacks that don't have a naming conflict with any stack associated with the EndpointID.
|
||||
// - This approach is designed to avoid name conflicts within Docker Compose, which restricts the creation of multiple stacks with the same name.
|
||||
|
||||
// 3. Type Matching for Orphaned Stacks:
|
||||
// - The function ensures that orphaned stacks are compatible with the environment's stack type (compose or swarm).
|
||||
// - It filters out orphaned swarm stacks in Docker standalone environments
|
||||
// - It filters out orphaned standalone stack in Docker swarm environments
|
||||
// - This ensures that re-association respects the constraints of the environment and stack type.
|
||||
|
||||
// The outcome is a new list of stacks that align with these filtering and business logic criteria.
|
||||
func filterStacks(stacks []portainer.Stack, filters *stackListOperationFilters, endpoints []portainer.Endpoint) []portainer.Stack {
|
||||
if filters.EndpointID == 0 && filters.SwarmID == "" {
|
||||
return stacks
|
||||
}
|
||||
|
||||
filteredStacks := make([]portainer.Stack, 0, len(stacks))
|
||||
uniqueStackNames := make(map[string]struct{})
|
||||
for _, stack := range stacks {
|
||||
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
|
||||
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if stack.Type == portainer.DockerComposeStack && stack.EndpointID == portainer.EndpointID(filters.EndpointID) {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
uniqueStackNames[stack.Name] = struct{}{}
|
||||
}
|
||||
if stack.Type == portainer.DockerSwarmStack && stack.SwarmID == filters.SwarmID {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
uniqueStackNames[stack.Name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, stack := range stacks {
|
||||
if filters.IncludeOrphanedStacks && isOrphanedStack(stack, endpoints) {
|
||||
if (stack.Type == portainer.DockerComposeStack && filters.SwarmID == "") || (stack.Type == portainer.DockerSwarmStack && filters.SwarmID != "") {
|
||||
if _, exists := uniqueStackNames[stack.Name]; !exists {
|
||||
filteredStacks = append(filteredStacks, stack)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
api/http/handler/stacks/stack_list_test.go
Normal file
74
api/http/handler/stacks/stack_list_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterStacks(t *testing.T) {
|
||||
t.Run("filter stacks against particular endpoint and all orphaned stacks", func(t *testing.T) {
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
|
||||
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
|
||||
|
||||
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
|
||||
actualStacks := filterStacks(stacks, filters, endpoints)
|
||||
|
||||
isEqualStacks(t, expectStacks, actualStacks)
|
||||
})
|
||||
|
||||
t.Run("filter unique stacks against particular endpoint and all orphaned stacks and an orphaned stack has the same name with normal stack", func(t *testing.T) {
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: true}
|
||||
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
|
||||
|
||||
expectStacks := []portainer.Stack{{ID: 1}, {ID: 2}}
|
||||
actualStacks := filterStacks(stacks, filters, endpoints)
|
||||
|
||||
isEqualStacks(t, expectStacks, actualStacks)
|
||||
})
|
||||
|
||||
t.Run("only filter stacks against particular endpoint and no orphaned stacks", func(t *testing.T) {
|
||||
stacks := []portainer.Stack{
|
||||
{ID: 1, EndpointID: 3, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 2, EndpointID: 4, Name: "orphaned_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 3, EndpointID: 5, Name: "other_stack", Type: portainer.DockerComposeStack},
|
||||
{ID: 4, EndpointID: 4, Name: "normal_stack", Type: portainer.DockerComposeStack},
|
||||
}
|
||||
filters := &stackListOperationFilters{EndpointID: 3, IncludeOrphanedStacks: false}
|
||||
endpoints := []portainer.Endpoint{{ID: 3}, {ID: 5}}
|
||||
|
||||
expectStacks := []portainer.Stack{{ID: 1}}
|
||||
actualStacks := filterStacks(stacks, filters, endpoints)
|
||||
|
||||
isEqualStacks(t, expectStacks, actualStacks)
|
||||
})
|
||||
}
|
||||
|
||||
func isEqualStacks(t *testing.T, expectStacks, actualStacks []portainer.Stack) {
|
||||
expectStackIDs := make([]int, len(expectStacks))
|
||||
for i, stack := range expectStacks {
|
||||
expectStackIDs[i] = int(stack.ID)
|
||||
}
|
||||
sort.Ints(expectStackIDs)
|
||||
|
||||
actualStackIDs := make([]int, len(actualStacks))
|
||||
for i, stack := range actualStacks {
|
||||
actualStackIDs[i] = int(stack.ID)
|
||||
}
|
||||
sort.Ints(actualStackIDs)
|
||||
|
||||
assert.Equal(t, expectStackIDs, actualStackIDs)
|
||||
}
|
||||
@@ -27,6 +27,8 @@ type stackGitRedployPayload struct {
|
||||
Prune bool
|
||||
// Force a pulling to current image with the original tag though the image is already the latest
|
||||
PullImage bool `example:"false"`
|
||||
|
||||
StackName string
|
||||
}
|
||||
|
||||
func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
@@ -44,7 +46,7 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
|
||||
// @produce json
|
||||
// @param id path int true "Stack identifier"
|
||||
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated environment(endpoint) identifier. Use this optional parameter to set the environment(endpoint) identifier used by the stack."
|
||||
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy a stack"
|
||||
// @param body body stackGitRedployPayload true "Git configs for pull and redeploy of a stack. **StackName** may only be populated for Kuberenetes stacks, and if specified with a blank string, it will be set to blank"
|
||||
// @success 200 {object} portainer.Stack "Success"
|
||||
// @failure 400 "Invalid request"
|
||||
// @failure 403 "Permission denied"
|
||||
@@ -136,6 +138,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
if stack.Type == portainer.KubernetesStack {
|
||||
stack.Name = payload.StackName
|
||||
}
|
||||
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if payload.RepositoryAuthentication {
|
||||
|
||||
@@ -2,6 +2,7 @@ package users
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
@@ -20,9 +21,6 @@ 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")
|
||||
}
|
||||
@@ -44,6 +42,7 @@ 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
|
||||
@@ -60,8 +59,13 @@ type accessTokenResponse struct {
|
||||
// @router /users/{id}/tokens [post]
|
||||
func (handler *Handler) userCreateAccessToken(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
// specifically require Cookie auth for this endpoint since API-Key based auth is not supported
|
||||
if jwt, _ := handler.bouncer.CookieAuthLookup(r); jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Cookie Authentication required"))
|
||||
jwt, _ := handler.bouncer.CookieAuthLookup(r)
|
||||
if jwt == nil {
|
||||
jwt, _ = handler.bouncer.JWTAuthLookup(r)
|
||||
}
|
||||
|
||||
if jwt == nil {
|
||||
return httperror.Unauthorized("Auth not supported", errors.New("Authentication required"))
|
||||
}
|
||||
|
||||
var payload userAccessTokenCreatePayload
|
||||
@@ -89,9 +93,21 @@ 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)
|
||||
}
|
||||
|
||||
err = handler.CryptoService.CompareHashAndData(user.Password, payload.Password)
|
||||
internalAuth, err := handler.usesInternalAuthentication(portainer.UserID(userID))
|
||||
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.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"))
|
||||
}
|
||||
}
|
||||
|
||||
rawAPIKey, apiKey, err := handler.apiKeyService.GenerateApiKey(*user, payload.Description)
|
||||
@@ -102,3 +118,18 @@ 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
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ func Test_userCreateAccessToken(t *testing.T) {
|
||||
|
||||
body, err := io.ReadAll(rr.Body)
|
||||
is.NoError(err, "ReadAll should not return error")
|
||||
is.Equal(`{"message":"Auth not supported","details":"Cookie Authentication required"}`, string(body))
|
||||
is.Equal(`{"message":"Auth not supported","details":"Authentication required"}`, string(body))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ type webhookListOperationFilters struct {
|
||||
// @tags webhooks
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param filters query webhookListOperationFilters false "Filters"
|
||||
// @param filters query string false "Filters (json-string)" example({"EndpointID":1,"ResourceID":"abc12345-abcd-2345-ab12-58005b4a0260"})
|
||||
// @success 200 {array} portainer.Webhook
|
||||
// @failure 400
|
||||
// @failure 500
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -27,6 +29,7 @@ type (
|
||||
AuthorizedEdgeEndpointOperation(*http.Request, *portainer.Endpoint) error
|
||||
TrustedEdgeEnvironmentAccess(dataservices.DataStoreTx, *portainer.Endpoint) error
|
||||
CookieAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
JWTAuthLookup(*http.Request) (*portainer.TokenData, error)
|
||||
}
|
||||
|
||||
// RequestBouncer represents an entity that manages API request accesses
|
||||
@@ -280,7 +283,7 @@ func (bouncer *RequestBouncer) mwAuthenticateFirst(tokenLookups []tokenLookup, n
|
||||
for _, lookup := range tokenLookups {
|
||||
resultToken, err := lookup(r)
|
||||
if err != nil {
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid API key", httperrors.ErrUnauthorized)
|
||||
httperror.WriteError(w, http.StatusUnauthorized, "Invalid JWT token", httperrors.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -316,7 +319,7 @@ func (bouncer *RequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.Tok
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
@@ -332,7 +335,7 @@ func (bouncer *RequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenD
|
||||
|
||||
tokenData, err := bouncer.jwtService.ParseAndVerifyToken(token)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tokenData, nil
|
||||
@@ -366,7 +369,8 @@ func (bouncer *RequestBouncer) apiKeyLookup(r *http.Request) (*portainer.TokenDa
|
||||
Role: user.Role,
|
||||
}
|
||||
if _, _, err := bouncer.jwtService.GenerateToken(tokenData); err != nil {
|
||||
return nil, ErrInvalidKey
|
||||
log.Debug().Err(err).Msg("Failed to generate token")
|
||||
return nil, fmt.Errorf("failed to generate token")
|
||||
}
|
||||
|
||||
if now := time.Now().UTC().Unix(); now-apiKey.LastUsed > 60 { // [seconds]
|
||||
|
||||
@@ -54,6 +54,10 @@ func (testRequestBouncer) CookieAuthLookup(r *http.Request) (*portainer.TokenDat
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (testRequestBouncer) JWTAuthLookup(r *http.Request) (*portainer.TokenData, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// AddTestSecurityCookie adds a security cookie to the request
|
||||
func AddTestSecurityCookie(r *http.Request, jwt string) {
|
||||
r.AddCookie(&http.Cookie{
|
||||
|
||||
@@ -3,10 +3,11 @@ package portainer
|
||||
func KubernetesDefault() KubernetesData {
|
||||
return KubernetesData{
|
||||
Configuration: KubernetesConfiguration{
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
StorageClasses: []KubernetesStorageClassConfig{},
|
||||
IngressClasses: []KubernetesIngressClassConfig{},
|
||||
UseLoadBalancer: false,
|
||||
UseServerMetrics: false,
|
||||
EnableResourceOverCommit: true,
|
||||
StorageClasses: []KubernetesStorageClassConfig{},
|
||||
IngressClasses: []KubernetesIngressClassConfig{},
|
||||
},
|
||||
Snapshots: []KubernetesSnapshot{},
|
||||
}
|
||||
|
||||
@@ -80,22 +80,31 @@ func (factory *ClientFactory) RemoveKubeClient(endpointID portainer.EndpointID)
|
||||
// GetKubeClient checks if an existing client is already registered for the environment(endpoint) and returns it if one is found.
|
||||
// If no client is registered, it will create a new client, register it, and returns it.
|
||||
func (factory *ClientFactory) GetKubeClient(endpoint *portainer.Endpoint) (*KubeClient, error) {
|
||||
factory.mu.Lock()
|
||||
key := strconv.Itoa(int(endpoint.ID))
|
||||
if client, ok := factory.endpointClients[key]; ok {
|
||||
factory.mu.Unlock()
|
||||
return client, nil
|
||||
}
|
||||
factory.mu.Unlock()
|
||||
|
||||
// EE-6901: Do not lock
|
||||
client, err := factory.createCachedAdminKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
factory.mu.Lock()
|
||||
defer factory.mu.Unlock()
|
||||
|
||||
key := strconv.Itoa(int(endpoint.ID))
|
||||
client, ok := factory.endpointClients[key]
|
||||
if !ok {
|
||||
var err error
|
||||
|
||||
client, err = factory.createCachedAdminKubeClient(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
factory.endpointClients[key] = client
|
||||
// The lock was released before the client was created,
|
||||
// so we need to check again
|
||||
if c, ok := factory.endpointClients[key]; ok {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
factory.endpointClients[key] = client
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -242,6 +251,10 @@ 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
|
||||
|
||||
@@ -241,7 +241,10 @@ func (kcl *KubeClient) DeleteIngresses(reqs models.K8sIngressDeleteRequests) err
|
||||
// UpdateIngress updates an existing ingress in a given namespace in a k8s endpoint.
|
||||
func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInfo) error {
|
||||
ingressClient := kcl.cli.NetworkingV1().Ingresses(namespace)
|
||||
var ingress netv1.Ingress
|
||||
ingress, err := ingressClient.Get(context.Background(), info.Name, metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ingress.Name = info.Name
|
||||
ingress.Namespace = info.Namespace
|
||||
@@ -278,6 +281,7 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
|
||||
})
|
||||
}
|
||||
|
||||
ingress.Spec.Rules = make([]netv1.IngressRule, 0)
|
||||
for rule, paths := range rules {
|
||||
ingress.Spec.Rules = append(ingress.Spec.Rules, netv1.IngressRule{
|
||||
Host: rule,
|
||||
@@ -299,6 +303,6 @@ func (kcl *KubeClient) UpdateIngress(namespace string, info models.K8sIngressInf
|
||||
}
|
||||
}
|
||||
|
||||
_, err := ingressClient.Update(context.Background(), &ingress, metav1.UpdateOptions{})
|
||||
_, err = ingressClient.Update(context.Background(), ingress, metav1.UpdateOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -73,31 +73,30 @@ func (kcl *KubeClient) CreateNamespace(info models.K8sNamespaceDetails) error {
|
||||
ns.Annotations = info.Annotations
|
||||
ns.Labels = portainerLabels
|
||||
|
||||
resourceQuota := &v1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: v1.ResourceQuotaSpec{
|
||||
Hard: v1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := kcl.cli.CoreV1().Namespaces().Create(context.Background(), &ns, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("Namespace", info.Name).
|
||||
Interface("ResourceQuota", resourceQuota).
|
||||
Msg("Failed to create the namespace due to a resource quota issue.")
|
||||
Msg("Failed to create the namespace")
|
||||
return err
|
||||
}
|
||||
|
||||
if info.ResourceQuota != nil {
|
||||
if info.ResourceQuota != nil && info.ResourceQuota.Enabled {
|
||||
log.Info().Msgf("Creating resource quota for namespace %s", info.Name)
|
||||
log.Debug().Msgf("Creating resource quota with details: %+v", info.ResourceQuota)
|
||||
|
||||
resourceQuota := &v1.ResourceQuota{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "portainer-rq-" + info.Name,
|
||||
Namespace: info.Name,
|
||||
Labels: portainerLabels,
|
||||
},
|
||||
Spec: v1.ResourceQuotaSpec{
|
||||
Hard: v1.ResourceList{},
|
||||
},
|
||||
}
|
||||
|
||||
if info.ResourceQuota.Enabled {
|
||||
memory := resource.MustParse(info.ResourceQuota.Memory)
|
||||
cpu := resource.MustParse(info.ResourceQuota.CPU)
|
||||
|
||||
@@ -1595,7 +1595,7 @@ type (
|
||||
|
||||
const (
|
||||
// APIVersion is the version number of the Portainer API
|
||||
APIVersion = "2.20.0"
|
||||
APIVersion = "2.22.0"
|
||||
// Edition is what this edition of Portainer is called
|
||||
Edition = PortainerCE
|
||||
// ComposeSyntaxMaxVersion is a maximum supported version of the docker compose syntax
|
||||
|
||||
@@ -138,13 +138,14 @@ func agentServer(t *testing.T) string {
|
||||
Handler: h,
|
||||
}
|
||||
|
||||
errCh := make(chan error)
|
||||
go func() {
|
||||
err := s.Serve(l)
|
||||
require.ErrorIs(t, err, http.ErrServerClosed)
|
||||
errCh <- s.Serve(l)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
s.Shutdown(context.Background())
|
||||
require.NoError(t, s.Shutdown(context.Background()))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
})
|
||||
|
||||
return "http://" + l.Addr().String()
|
||||
|
||||
@@ -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"></select>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -82,6 +82,7 @@ function config($analyticsProvider, $windowProvider) {
|
||||
push('setReferrerUrl', '');
|
||||
push('setCustomUrl', basePath + path);
|
||||
push('trackPageView');
|
||||
push('enableLinkTracking');
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
10
app/app.js
10
app/app.js
@@ -17,6 +17,16 @@ export function onStartupAngular($rootScope, $state, cfpLoadingBar, $transitions
|
||||
HttpRequestHelper.resetAgentHeaders();
|
||||
});
|
||||
|
||||
// EE-6751: screens not loading when switching quickly between side menu options
|
||||
// Known bug of @uirouter/angularjs
|
||||
// Fix found at https://github.com/angular-ui/ui-router/issues/3652#issuecomment-574499009
|
||||
// This hook is cleaning the internal viewConfigs list, removing leftover data unrelated to the current transition
|
||||
$transitions.onStart({}, (transition) => {
|
||||
const toList = transition.treeChanges().to.map((t) => t.state.name);
|
||||
const toConfigs = transition.router.viewService._viewConfigs.filter((vc) => toList.includes(vc.viewDecl.$context.name));
|
||||
transition.router.viewService._viewConfigs = toConfigs;
|
||||
});
|
||||
|
||||
$(document).ajaxSend((event, jqXhr, jqOpts) => {
|
||||
const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH';
|
||||
const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type'];
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
html {
|
||||
font-size: 16px;
|
||||
overflow-y: scroll;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
html[theme='dark'],
|
||||
@@ -614,24 +615,6 @@ 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);
|
||||
@@ -728,3 +711,21 @@ 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;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@
|
||||
z-index: unset;
|
||||
}
|
||||
|
||||
.input-group-sm > .input-group-addon {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--ui-error-9);
|
||||
}
|
||||
@@ -164,17 +168,6 @@ 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);
|
||||
|
||||
@@ -104,6 +104,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controllerAs: 'ctrl',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/configs/add',
|
||||
},
|
||||
};
|
||||
|
||||
const customTemplates = {
|
||||
@@ -122,7 +125,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
|
||||
const customTemplatesNew = {
|
||||
name: 'docker.templates.custom.new',
|
||||
url: '/new?appTemplateId&type',
|
||||
url: '/new?fileContent&appTemplateId&type',
|
||||
|
||||
views: {
|
||||
'content@': {
|
||||
@@ -165,7 +168,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/host',
|
||||
docs: '/user/docker/host/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -227,6 +230,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'BuildImageController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/images/build',
|
||||
},
|
||||
};
|
||||
|
||||
var imageImport = {
|
||||
@@ -238,6 +244,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'ImportImageController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/images/import',
|
||||
},
|
||||
};
|
||||
|
||||
var networks = {
|
||||
@@ -273,6 +282,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateNetworkController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/networks/add',
|
||||
},
|
||||
};
|
||||
|
||||
var nodes = {
|
||||
@@ -280,7 +292,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
url: '/nodes',
|
||||
abstract: true,
|
||||
data: {
|
||||
docs: '/user/docker/swarm',
|
||||
docs: '/user/docker/swarm/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -338,6 +350,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateSecretController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/secrets/add',
|
||||
},
|
||||
};
|
||||
|
||||
var services = {
|
||||
@@ -374,6 +389,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateServiceController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/stacks/add',
|
||||
},
|
||||
};
|
||||
|
||||
var serviceLogs = {
|
||||
@@ -444,7 +462,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/swarm',
|
||||
docs: '/user/docker/swarm/details',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -492,15 +510,14 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
|
||||
var templates = {
|
||||
name: 'docker.templates',
|
||||
url: '/templates',
|
||||
url: '/templates?template',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: '~Portainer/views/templates/templates.html',
|
||||
controller: 'TemplatesController',
|
||||
component: 'appTemplatesView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/templates',
|
||||
docs: '/user/docker/templates/application',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -549,6 +566,9 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
controller: 'CreateVolumeController',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/volumes/add',
|
||||
},
|
||||
};
|
||||
|
||||
const dockerFeaturesConfiguration = {
|
||||
@@ -582,7 +602,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
url: '/registries',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'endpointRegistriesView',
|
||||
component: 'environmentRegistriesView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
@@ -595,7 +615,7 @@ angular.module('portainer.docker', ['portainer.app', reactModule]).config([
|
||||
url: '/registries',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'endpointRegistriesView',
|
||||
component: 'environmentRegistriesView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
<span>Name</span>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-control" ng-model="$ctrl.state.editModel.name" disable-authorization="DockerContainerUpdate">
|
||||
<select class="form-control" ng-model="$ctrl.state.editModel.name" disable-authorization="DockerContainerUpdate" data-cy="container-restart-policy-select">
|
||||
>
|
||||
<option value="no">None</option>
|
||||
<option value="on-failure">On Failure</option>
|
||||
<option value="always">Always</option>
|
||||
@@ -19,7 +20,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" />
|
||||
<input type="number" class="form-control" ng-model="$ctrl.state.editModel.maximumRetryCount" data-cy="container-restart-max-retry-input" />
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<div class="input-group input-group-sm">
|
||||
<select name="nodeAvailability" class="selectpicker form-control" ng-model="$ctrl.availability" ng-change="$ctrl.onChange()">
|
||||
<select name="nodeAvailability" class="selectpicker form-control" ng-model="$ctrl.availability" ng-change="$ctrl.onChange()" data-cy="node-availability-select">
|
||||
>
|
||||
<option value="active">Active</option>
|
||||
<option value="pause">Pause</option>
|
||||
<option value="drain">Drain</option>
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
<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)" />
|
||||
<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 }}"
|
||||
/>
|
||||
</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)" />
|
||||
<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 }}" />
|
||||
</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>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<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"
|
||||
@@ -19,6 +20,7 @@
|
||||
<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"
|
||||
@@ -27,7 +29,6 @@
|
||||
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
|
||||
@@ -51,7 +52,15 @@
|
||||
</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 />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<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"
|
||||
@@ -69,6 +70,7 @@
|
||||
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>
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
<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 />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_address.$invalid">
|
||||
@@ -21,7 +29,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 />
|
||||
<input type="text" class="form-control" ng-model="$ctrl.data.share" name="cifs_share" placeholder="e.g. /myshare" required data-cy="cifs-share-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_share.$invalid">
|
||||
@@ -36,7 +44,14 @@
|
||||
<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></select>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_version.$invalid">
|
||||
@@ -51,7 +66,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 />
|
||||
<input type="text" class="form-control" ng-model="$ctrl.data.username" name="cifs_username" required data-cy="cifs-username-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="cifsInformationForm.cifs_username.$invalid">
|
||||
@@ -66,7 +81,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 />
|
||||
<input type="text" class="form-control" ng-model="$ctrl.data.password" name="cifs_password" required data-cy="cifs-password-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="cifsInformationForm.password.$invalid">
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
<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 />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_address.$invalid">
|
||||
@@ -21,7 +29,14 @@
|
||||
<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></select>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_version.$invalid">
|
||||
@@ -38,6 +53,7 @@
|
||||
<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"
|
||||
@@ -61,7 +77,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 />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group col-md-12" ng-show="nfsInformationForm.nfs_options.$invalid">
|
||||
|
||||
@@ -16,7 +16,7 @@ function ImageHelperFactory() {
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {import('@/react/docker/images/queries/useImages').ImagesListResponse[]} images
|
||||
* @param {Array<{tags: Array<string>; id: string;}>} images
|
||||
* @returns {{names: string[]}}}
|
||||
*/
|
||||
function getImagesNamesForDownload(images) {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ImageViewModel(data) {
|
||||
}
|
||||
}
|
||||
|
||||
this.VirtualSize = data.VirtualSize;
|
||||
this.Size = data.Size;
|
||||
this.Used = data.Used;
|
||||
|
||||
if (data.Portainer && data.Portainer.Agent && data.Portainer.Agent.NodeName) {
|
||||
|
||||
@@ -6,15 +6,22 @@ export function ImageDetailsViewModel(data) {
|
||||
this.Created = data.Created;
|
||||
this.Checked = false;
|
||||
this.RepoTags = data.RepoTags;
|
||||
this.VirtualSize = data.VirtualSize;
|
||||
this.Size = data.Size;
|
||||
this.DockerVersion = data.DockerVersion;
|
||||
this.Os = data.Os;
|
||||
this.Architecture = data.Architecture;
|
||||
this.Author = data.Author;
|
||||
this.Command = data.Config.Cmd;
|
||||
this.Entrypoint = data.ContainerConfig.Entrypoint ? data.ContainerConfig.Entrypoint : '';
|
||||
this.ExposedPorts = data.ContainerConfig.ExposedPorts ? Object.keys(data.ContainerConfig.ExposedPorts) : [];
|
||||
this.Volumes = data.ContainerConfig.Volumes ? Object.keys(data.ContainerConfig.Volumes) : [];
|
||||
this.Env = data.ContainerConfig.Env ? data.ContainerConfig.Env : [];
|
||||
this.Labels = data.ContainerConfig.Labels;
|
||||
|
||||
let config = {};
|
||||
if (data.Config) {
|
||||
config = data.Config; // this is part of OCI images-spec
|
||||
} else if (data.ContainerConfig != null) {
|
||||
config = data.ContainerConfig; // not OCI ; has been removed in Docker 26 (API v1.45) along with .Container
|
||||
}
|
||||
this.Entrypoint = config.Entrypoint ? config.Entrypoint : '';
|
||||
this.ExposedPorts = config.ExposedPorts ? Object.keys(config.ExposedPorts) : [];
|
||||
this.Volumes = config.Volumes ? Object.keys(config.Volumes) : [];
|
||||
this.Env = config.Env ? config.Env : [];
|
||||
this.Labels = config.Labels;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ 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', [
|
||||
@@ -34,6 +35,7 @@ const ngModule = angular
|
||||
networksModule,
|
||||
swarmModule,
|
||||
volumesModule,
|
||||
templatesModule,
|
||||
])
|
||||
.component('dockerfileDetails', r2a(DockerfileDetails, ['image']))
|
||||
.component('dockerHealthStatus', r2a(HealthStatus, ['health']))
|
||||
@@ -79,7 +81,7 @@ const ngModule = angular
|
||||
)
|
||||
.component(
|
||||
'dockerConfigsDatatable',
|
||||
r2a(withUIRouter(ConfigsDatatable), [
|
||||
r2a(withUIRouter(withCurrentUser(ConfigsDatatable)), [
|
||||
'dataset',
|
||||
'onRemoveClick',
|
||||
'onRefresh',
|
||||
@@ -121,7 +123,11 @@ const ngModule = angular
|
||||
.component('dockerEventsDatatable', r2a(EventsDatatable, ['dataset']))
|
||||
.component(
|
||||
'dockerSecretsDatatable',
|
||||
r2a(withUIRouter(SecretsDatatable), ['dataset', 'onRefresh', 'onRemove'])
|
||||
r2a(withUIRouter(withCurrentUser(SecretsDatatable)), [
|
||||
'dataset',
|
||||
'onRefresh',
|
||||
'onRemove',
|
||||
])
|
||||
)
|
||||
.component(
|
||||
'dockerStacksDatatable',
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
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';
|
||||
|
||||
export const servicesModule = angular
|
||||
const ngModule = angular
|
||||
.module('portainer.docker.react.components.services', [])
|
||||
.component(
|
||||
'dockerServiceTasksDatatable',
|
||||
@@ -25,4 +32,14 @@ export const servicesModule = angular
|
||||
'onRefresh',
|
||||
'titleIcon',
|
||||
])
|
||||
).name;
|
||||
);
|
||||
|
||||
export const servicesModule = ngModule.name;
|
||||
|
||||
withFormValidation(
|
||||
ngModule,
|
||||
withUIRouter(withCurrentUser(PortsMappingField)),
|
||||
'dockerServicePortsMappingField',
|
||||
['disabled', 'readOnly', 'hasChanges', 'onReset', 'onSubmit'],
|
||||
portsMappingUtils.validation as unknown as () => SchemaOf<PortsMappingValues>
|
||||
);
|
||||
|
||||
17
app/docker/react/components/templates.ts
Normal file
17
app/docker/react/components/templates.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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;
|
||||
@@ -85,6 +85,9 @@ function config($stateRegistryProvider: StateRegistry) {
|
||||
component: 'createContainerView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/docker/containers/add',
|
||||
},
|
||||
});
|
||||
|
||||
$stateRegistryProvider.register({
|
||||
|
||||
@@ -112,24 +112,6 @@ 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();
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ angular.module('portainer.docker').factory('ImageService', [
|
||||
return Image.tag({ id: id, repo: image }).$promise;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Array<{tags: Array<string>; id: string;}>} images
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
service.downloadImages = function (images) {
|
||||
var names = ImageHelper.getImagesNamesForDownload(images);
|
||||
return Image.download(names).$promise;
|
||||
|
||||
@@ -92,14 +92,6 @@ 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;
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import angular from 'angular';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
||||
class ConfigsController {
|
||||
/* @ngInject */
|
||||
@@ -34,10 +33,6 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
<input type="text" class="form-control" ng-model="ctrl.formValues.Name" id="config_name" placeholder="e.g. myConfig" data-cy="config-name-input" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. com.example.foo" data-cy="config-label-input_{{ $index }}" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="config-label-value_{{ $index }}" />
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
<select class="form-control" ng-model="formValues.command" id="command" data-cy="command-select">
|
||||
<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,7 +35,15 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !command-list -->
|
||||
@@ -53,7 +61,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" />
|
||||
<input class="form-control" type="text" ng-model="formValues.user" placeholder="root" data-cy="container-exec-user" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -69,10 +77,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div ng-if="state !== states.disconnected">
|
||||
<label class="control-label text-left"
|
||||
>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command
|
||||
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
|
||||
<terminal-tooltip> </terminal-tooltip>
|
||||
<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>
|
||||
<terminal-tooltip class="align-sub"> </terminal-tooltip>
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary" ng-click="disconnect()">
|
||||
<span ng-show="state === states.connected">Disconnect</span>
|
||||
|
||||
@@ -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">
|
||||
<table class="table" data-cy="container-status-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" />
|
||||
<input type="text" class="containerNameInput" ng-model="container.newContainerName" data-cy="containerNameInput" />
|
||||
<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">
|
||||
<table class="table" data-cy="container-volumes-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host/volume</th>
|
||||
|
||||
@@ -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">
|
||||
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control" data-cy="docker-containers-stats-refresh-rate">
|
||||
<option value="1">1s</option>
|
||||
<option value="3">3s</option>
|
||||
<option value="5">5s</option>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'MANAGER'">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
Portainer is connected to a node that is part of a Swarm cluster. Some resources located on other nodes in the cluster might not be available for management, have a look at
|
||||
<a href="https://docs.portainer.io/admin/environments/add/swarm/agent" target="_blank">our agent setup</a> for more details.
|
||||
<help-link doc-link="'/admin/environments/add/swarm/agent'" target="'_blank'" children="'our agent setup'"></help-link> for more details.
|
||||
</p>
|
||||
<p class="text-muted" ng-if="applicationState.endpoint.mode.role === 'WORKER'">
|
||||
<pr-icon icon="'alert-circle'" mode="'primary'"></pr-icon>
|
||||
|
||||
@@ -137,5 +137,5 @@ angular.module('portainer.docker').controller('DashboardController', [
|
||||
]);
|
||||
|
||||
function imagesTotalSize(images) {
|
||||
return images.reduce((acc, image) => acc + image.VirtualSize, 0);
|
||||
return images.reduce((acc, image) => acc + image.Size, 0);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,15 @@
|
||||
<!-- 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 />
|
||||
<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"
|
||||
/>
|
||||
<span class="input-group-addon" ng-if="!item.Valid">
|
||||
<pr-icon icon="'x'" mode="'danger'"></pr-icon>
|
||||
</span>
|
||||
@@ -101,9 +109,7 @@
|
||||
</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>
|
||||
@@ -133,7 +139,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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile" data-cy="image-path-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,6 +162,7 @@
|
||||
<div class="col-sm-10">
|
||||
<input
|
||||
type="text"
|
||||
data-cy="image-url-input"
|
||||
class="form-control"
|
||||
ng-model="formValues.URL"
|
||||
id="image_url"
|
||||
@@ -172,7 +179,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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile" data-cy="image-path-input" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -209,7 +216,7 @@
|
||||
</uib-tab>
|
||||
<uib-tab index="1" disable="!buildLogs">
|
||||
<uib-tab-heading class="vertical-center"> <pr-icon icon="'file-text'" class="leading-none"></pr-icon> Output </uib-tab-heading>
|
||||
<pre class="log_viewer">
|
||||
<pre class="log_viewer" data-cy="logViewer">
|
||||
<div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div>
|
||||
<div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div>
|
||||
</pre>
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Size</td>
|
||||
<td>{{ image.VirtualSize | humansize }}</td>
|
||||
<td>{{ image.Size | humansize }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
|
||||
@@ -162,7 +162,7 @@ angular.module('portainer.docker').controller('ImageController', [
|
||||
function exportImage(image) {
|
||||
HttpRequestHelper.setPortainerAgentTargetHeader(image.NodeName);
|
||||
$scope.state.exportInProgress = true;
|
||||
ImageService.downloadImages([image])
|
||||
ImageService.downloadImages([{ tags: image.RepoTags, id: image.Id }])
|
||||
.then(function success(data) {
|
||||
var downloadData = new Blob([data.file], { type: 'application/x-tar' });
|
||||
FileSaver.saveAs(downloadData, 'images.tar');
|
||||
|
||||
@@ -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" />
|
||||
<input type="text" class="form-control" ng-model="config.Name" id="network_name" placeholder="e.g. myNetwork" data-cy="network-name-input" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
@@ -18,10 +18,24 @@
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !driver-input -->
|
||||
@@ -38,11 +52,17 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. true" data-cy="network-driver-option-value-input" />
|
||||
<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>
|
||||
@@ -64,11 +84,25 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !subnet-gateway-inputs -->
|
||||
@@ -76,7 +110,14 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-repeat="auxAddress in formValues.IPV4.AuxiliaryAddresses track by $index" class="form-group">
|
||||
@@ -84,6 +125,7 @@
|
||||
<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)"
|
||||
@@ -111,11 +153,25 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !subnet-gateway-inputs -->
|
||||
@@ -123,7 +179,14 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div ng-repeat="auxAddress in formValues.IPV6.AuxiliaryAddresses track by $index" class="form-group">
|
||||
@@ -131,6 +194,7 @@
|
||||
<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)"
|
||||
@@ -160,11 +224,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" />
|
||||
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="network-label-key-input" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="network-label-value-input" />
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import _ from 'lodash-es';
|
||||
import DockerNetworkHelper from '@/docker/helpers/networkHelper';
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
||||
angular.module('portainer.docker').controller('NetworksController', [
|
||||
'$q',
|
||||
@@ -13,10 +12,6 @@ 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);
|
||||
|
||||
@@ -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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.Name" id="secret_name" placeholder="e.g. mySecret" data-cy="createSecret-nameInput" />
|
||||
</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"></textarea>
|
||||
<textarea class="form-control" rows="5" ng-model="formValues.Data" ng-trim="false" data-cy="createSecret-secretDataInput"></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" />
|
||||
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="createSecret-labelNameInput" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="createSecret-labelValueInput" />
|
||||
<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>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
angular.module('portainer.docker').controller('SecretsController', [
|
||||
'$scope',
|
||||
'$state',
|
||||
@@ -6,10 +5,6 @@ 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)
|
||||
|
||||
@@ -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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.Name" id="service_name" placeholder="e.g. myService" data-cy="service-name-input" />
|
||||
</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" />
|
||||
<input type="number" class="form-control" ng-model="formValues.Replicas" id="replicas" placeholder="e.g. 3" data-cy="docker-service-create-replica-count-input" />
|
||||
</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)" />
|
||||
<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" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="portBinding.TargetPort" placeholder="e.g. 80" data-cy="container-port-input" />
|
||||
</div>
|
||||
<!-- !container-port -->
|
||||
<!-- protocol-actions -->
|
||||
@@ -159,7 +159,14 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !command-input -->
|
||||
@@ -167,7 +174,14 @@
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="formValues.EntryPoint"
|
||||
id="service_entrypoint"
|
||||
placeholder="e.g. /bin/sh -c"
|
||||
data-cy="service-entrypoint-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !entrypoint-input -->
|
||||
@@ -175,11 +189,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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.WorkingDir" id="service_workingdir" placeholder="e.g. /myapp" data-cy="service-workingdir-input" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.User" id="service_user" placeholder="e.g. nginx" data-cy="service-user-input" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !workdir-user-input -->
|
||||
@@ -188,7 +202,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">
|
||||
<select class="form-control" ng-model="formValues.LogDriverName" id="log-driver" data-cy="service-creation-logging-driver">
|
||||
<option selected value="">Default logging driver</option>
|
||||
<option ng-repeat="driver in availableLoggingDrivers" ng-value="driver">{{ driver }}</option>
|
||||
<option value="none">none</option>
|
||||
@@ -226,11 +240,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" />
|
||||
<input type="text" class="form-control" ng-model="opt.name" placeholder="e.g. FOO" data-cy="service-creation-logging-driver-option" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="opt.value" placeholder="e.g. bar" data-cy="service-creation-logging-driver-option-value" />
|
||||
</div>
|
||||
<button class="btn btn-dangerlight" type="button" ng-click="removeLogDriverOpt($index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
@@ -264,7 +278,13 @@
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="volume.Target"
|
||||
placeholder="e.g. /path/in/container"
|
||||
data-cy="service-creation-volume-container-path"
|
||||
/>
|
||||
</div>
|
||||
<div class="small text-warning" ng-show="!volume.Target"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Target is required. </div>
|
||||
</div>
|
||||
@@ -295,6 +315,7 @@
|
||||
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>
|
||||
@@ -306,7 +327,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" />
|
||||
<input type="text" class="form-control" ng-model="volume.Source" placeholder="e.g. /path/on/host" data-cy="service-creation-volume-host-path" />
|
||||
</div>
|
||||
<div class="small text-warning" ng-show="!volume.Source"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Source is required. </div>
|
||||
</div>
|
||||
@@ -337,7 +358,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">
|
||||
<select class="form-control" ng-model="formValues.Network" data-cy="create-service-network-select">
|
||||
<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>
|
||||
@@ -356,7 +377,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">
|
||||
<select class="form-control" ng-model="network.Name" data-cy="create-service-extra-network-select-{{ $index }}">
|
||||
<option selected disabled hidden value="">Select a network</option>
|
||||
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
|
||||
</select>
|
||||
@@ -381,7 +402,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" />
|
||||
<input type="text" class="form-control" ng-model="variable.value" placeholder="e.g. host:IP" data-cy="service-creation-hosts-entry" />
|
||||
</div>
|
||||
<button class="btn btn-dangerlight" type="button" ng-click="removeHostsEntry($index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
@@ -421,11 +442,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" />
|
||||
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="service-creation-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" />
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="service-creation-label-value" />
|
||||
</div>
|
||||
<button class="btn btn-dangerlight" type="button" ng-click="removeLabel($index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
@@ -448,11 +469,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" />
|
||||
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" data-cy="service-creation-container-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" />
|
||||
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" data-cy="service-creation-container-label-value" />
|
||||
</div>
|
||||
<button class="btn btn-dangerlight" type="button" ng-click="removeContainerLabel($index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
<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'">
|
||||
<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"
|
||||
>
|
||||
<option value="" selected="selected">Select a config</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,17 @@
|
||||
<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"></slider>
|
||||
<slider
|
||||
model="formValues.MemoryReservation"
|
||||
floor="0"
|
||||
ceil="state.sliderMaxMemory"
|
||||
step="256"
|
||||
ng-if="state.sliderMaxMemory"
|
||||
data-cy="docker-services-create-memory-reservation-slider"
|
||||
></slider>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryReservation" />
|
||||
<input type="number" data-cy="docker-services-create-memory-reservation-input" 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>
|
||||
@@ -18,10 +25,17 @@
|
||||
<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"></slider>
|
||||
<slider
|
||||
model="formValues.MemoryLimit"
|
||||
floor="0"
|
||||
ceil="state.sliderMaxMemory"
|
||||
step="256"
|
||||
ng-if="state.sliderMaxMemory"
|
||||
data-cy="docker-services-create-memory-limit-slider"
|
||||
></slider>
|
||||
</div>
|
||||
<div class="col-sm-2">
|
||||
<input type="number" min="0" class="form-control" ng-model="formValues.MemoryLimit" />
|
||||
<input type="number" data-cy="docker-services-create-memory-limit-input" 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>
|
||||
@@ -32,7 +46,15 @@
|
||||
<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"></slider>
|
||||
<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>
|
||||
</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>
|
||||
@@ -43,7 +65,15 @@
|
||||
<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"></slider>
|
||||
<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>
|
||||
</div>
|
||||
<div class="col-sm-4" style="margin-top: 20px">
|
||||
<p class="small text-muted"> Maximum CPU usage per task </p>
|
||||
@@ -63,17 +93,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" />
|
||||
<input type="text" class="form-control" ng-model="constraint.key" placeholder="e.g. node.role" data-cy="docker-services-create-placement-constraint-name-{{ $index }}" />
|
||||
</div>
|
||||
<div class="input-group col-sm-1 input-group-sm">
|
||||
<select name="constraintOperator" class="form-control" ng-model="constraint.operator">
|
||||
<select name="constraintOperator" class="form-control" ng-model="constraint.operator" data-cy="docker-services-create-placement-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" />
|
||||
<input type="text" class="form-control" ng-model="constraint.value" placeholder="e.g. manager" data-cy="docker-services-create-placement-constraint-value-{{ $index }}" />
|
||||
</div>
|
||||
<button class="btn btn-dangerlight" type="button" ng-click="removePlacementConstraint($index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
@@ -94,11 +124,23 @@
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="preference.strategy"
|
||||
placeholder="e.g. spread"
|
||||
data-cy="docker-services-create-placement-preference-strategy-{{ $index }}"
|
||||
/>
|
||||
</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" />
|
||||
<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 }}"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-dangerlight" type="button" ng-click="removePlacementPreference($index)">
|
||||
<pr-icon icon="'trash-2'" size="'md'"></pr-icon>
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
<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'">
|
||||
<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"
|
||||
>
|
||||
<option value="" selected="selected">Select a secret</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -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" class="form-control" ng-model="formValues.Parallelism" id="parallelism" placeholder="e.g. 1" />
|
||||
<input type="number" data-cy="docker-service-update-parallelism-input" 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,7 +18,15 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
@@ -77,7 +85,15 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
@@ -88,7 +104,14 @@
|
||||
<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" class="form-control" ng-model="formValues.RestartMaxAttempts" id="restart-max-attempts" placeholder="e.g. 0" />
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
@@ -102,7 +125,15 @@
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-sm-5">
|
||||
<p class="small text-muted">
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
@@ -57,7 +62,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServiceConfigs'])">Reset changes</a></li>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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"
|
||||
@@ -43,6 +44,7 @@
|
||||
ng-change="updatePlacementConstraint(service, constraint)"
|
||||
ng-disabled="isUpdating"
|
||||
disable-authorization="DockerServiceUpdate"
|
||||
data-cy="placement-constraint-operator=selectoer"
|
||||
>
|
||||
<option value="==">==</option>
|
||||
<option value="!=">!=</option>
|
||||
@@ -53,6 +55,7 @@
|
||||
<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"
|
||||
@@ -76,7 +79,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServiceConstraints'])">Reset changes</a></li>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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"
|
||||
@@ -39,6 +40,7 @@
|
||||
<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"
|
||||
@@ -64,7 +66,7 @@
|
||||
>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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServiceContainerLabels'])">Reset changes</a></li>
|
||||
|
||||
@@ -11,9 +11,7 @@
|
||||
<p>There are no environment variables for this service.</p>
|
||||
</rd-widget-body>
|
||||
<rd-widget-body ng-if="service.EnvironmentVariables.length > 0">
|
||||
<div class="form-group">
|
||||
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
|
||||
</div>
|
||||
<environment-variables-fieldset values="service.EnvironmentVariables" on-change="(onChangeEnvVars)"></environment-variables-fieldset>
|
||||
</rd-widget-body>
|
||||
<rd-widget-footer authorization="DockerServiceUpdate">
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
@@ -27,7 +25,7 @@
|
||||
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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['EnvironmentVariables'])">Reset changes</a></li>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<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)"
|
||||
@@ -37,6 +38,7 @@
|
||||
<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"
|
||||
@@ -60,7 +62,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Hosts'])">Reset changes</a></li>
|
||||
|
||||
@@ -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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Image'])">Reset changes</a></li>
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
<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">
|
||||
<select
|
||||
class="form-control !h-[30px] !text-[13px]"
|
||||
ng-model="service.LogDriverName"
|
||||
ng-change="updateLogDriverName(service)"
|
||||
ng-disabled="isUpdating"
|
||||
data-cy="logging-driver-selector"
|
||||
>
|
||||
<option selected value="">Default logging driver</option>
|
||||
<option ng-repeat="driver in availableLoggingDrivers" ng-value="driver">{{ driver }}</option>
|
||||
<option value="none">none</option>
|
||||
@@ -25,7 +31,14 @@
|
||||
<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" class="form-control" ng-model="option.key" ng-disabled="option.added || isUpdating" placeholder="e.g. FOO" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -33,6 +46,7 @@
|
||||
<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)"
|
||||
@@ -61,7 +75,7 @@
|
||||
>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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['LogDriverName', 'LogDriverOpts'])">Reset changes</a></li>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<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)"
|
||||
@@ -43,11 +44,13 @@
|
||||
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"
|
||||
@@ -64,6 +67,7 @@
|
||||
<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"
|
||||
@@ -96,7 +100,7 @@
|
||||
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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServiceMounts'])">Reset changes</a></li>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
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>
|
||||
@@ -37,9 +38,7 @@
|
||||
<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">
|
||||
@@ -59,7 +58,7 @@
|
||||
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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Networks'])">Reset changes</a></li>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<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"
|
||||
@@ -37,6 +38,7 @@
|
||||
<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"
|
||||
@@ -60,7 +62,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServicePreferences'])">Reset changes</a></li>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
<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>
|
||||
@@ -10,6 +10,7 @@
|
||||
<input
|
||||
class="input-sm"
|
||||
type="number"
|
||||
data-cy="docker-service-memory-reservation-input"
|
||||
step="0.125"
|
||||
min="0"
|
||||
ng-model="service.ReservationMemoryBytes"
|
||||
@@ -27,6 +28,7 @@
|
||||
<input
|
||||
class="input-sm"
|
||||
type="number"
|
||||
data-cy="docker-service-memory-limit-input"
|
||||
step="0.125"
|
||||
min="0"
|
||||
ng-model="service.LimitMemoryBytes"
|
||||
@@ -44,6 +46,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<slider
|
||||
data-cy="docker-service-cpu-reservation-slider"
|
||||
model="service.ReservationNanoCPUs"
|
||||
floor="0"
|
||||
ceil="state.sliderMaxCpu"
|
||||
@@ -64,6 +67,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<slider
|
||||
data-cy="docker-service-cpu-limit-slider"
|
||||
model="service.LimitNanoCPUs"
|
||||
floor="0"
|
||||
ceil="state.sliderMaxCpu"
|
||||
@@ -92,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['LimitNanoCPUs', 'LimitMemoryBytes', 'ReservationNanoCPUs', 'ReservationMemoryBytes'])">Reset changes</a></li>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<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"
|
||||
@@ -30,6 +31,7 @@
|
||||
<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"
|
||||
@@ -48,6 +50,7 @@
|
||||
<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"
|
||||
@@ -63,6 +66,7 @@
|
||||
<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"
|
||||
@@ -89,7 +93,7 @@
|
||||
>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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['RestartCondition', 'RestartDelay', 'RestartMaxAttempts', 'RestartWindow'])">Reset changes</a></li>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<option selected disabled hidden value="">Select a secret</option>
|
||||
</select>
|
||||
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.3 && state.addSecret.override">
|
||||
@@ -54,7 +59,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServiceSecrets'])">Reset changes</a></li>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<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"
|
||||
@@ -37,6 +38,7 @@
|
||||
<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"
|
||||
@@ -60,7 +62,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">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['ServiceLabels'])">Reset changes</a></li>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<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"
|
||||
@@ -25,6 +26,7 @@
|
||||
<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"
|
||||
@@ -43,6 +45,7 @@
|
||||
<input
|
||||
type="radio"
|
||||
name="failure_action"
|
||||
data-cy="update-failure-action-continue"
|
||||
ng-model="service.UpdateFailureAction"
|
||||
value="continue"
|
||||
ng-change="updateServiceAttribute(service, 'UpdateFailureAction')"
|
||||
@@ -54,6 +57,7 @@
|
||||
<input
|
||||
type="radio"
|
||||
name="failure_action"
|
||||
data-cy="update-failure-action-pause"
|
||||
ng-model="service.UpdateFailureAction"
|
||||
value="pause"
|
||||
ng-change="updateServiceAttribute(service, 'UpdateFailureAction')"
|
||||
@@ -75,6 +79,7 @@
|
||||
<input
|
||||
type="radio"
|
||||
name="updateconfig_order"
|
||||
data-cy="update-order-start-first"
|
||||
ng-model="service.UpdateOrder"
|
||||
value="start-first"
|
||||
ng-change="updateServiceAttribute(service, 'UpdateOrder')"
|
||||
@@ -86,6 +91,7 @@
|
||||
<input
|
||||
type="radio"
|
||||
name="updateconfig_order"
|
||||
data-cy="update-order-stop-first"
|
||||
ng-model="service.UpdateOrder"
|
||||
value="stop-first"
|
||||
ng-change="updateServiceAttribute(service, 'UpdateOrder')"
|
||||
@@ -113,7 +119,7 @@
|
||||
>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>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])">Reset changes</a></li>
|
||||
|
||||
@@ -18,17 +18,20 @@
|
||||
<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" />
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25">
|
||||
{{ service.Name }}
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
ng-model="service.Name"
|
||||
ng-change="updateServiceAttribute(service, 'Name')"
|
||||
ng-disabled="isUpdating"
|
||||
data-cy="docker-service-edit-name"
|
||||
/>
|
||||
</td>
|
||||
<td ng-if="applicationState.endpoint.apiVersion >= 1.25"> {{ service.Name }} </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>
|
||||
{{ service.Id }}
|
||||
</td>
|
||||
<td> {{ service.Id }} </td>
|
||||
</tr>
|
||||
<tr ng-if="service.CreatedAt">
|
||||
<td>Created at</td>
|
||||
@@ -53,6 +56,7 @@
|
||||
<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"
|
||||
@@ -164,7 +168,7 @@
|
||||
>Apply changes</button
|
||||
>
|
||||
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="caret"></span>
|
||||
<pr-icon icon="'chevron-down'"></pr-icon>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a ng-click="cancelChanges(service, ['Mode', 'Replicas', 'Name'])">Reset changes</a></li>
|
||||
@@ -231,7 +235,17 @@
|
||||
<div class="col-lg-12 col-md-12 col-xs-12">
|
||||
<h3 id="service-network-specs">Networks & ports</h3>
|
||||
<div id="service-networks" class="padding-top" ng-include="'app/docker/views/services/edit/includes/networks.html'"></div>
|
||||
<div id="service-published-ports" class="padding-top" ng-include="'app/docker/views/services/edit/includes/ports.html'"></div>
|
||||
|
||||
<docker-service-ports-mapping-field
|
||||
id="service-published-ports"
|
||||
class="block padding-top"
|
||||
values="formValues.ports"
|
||||
on-change="(onChangePorts)"
|
||||
has-changes="hasChanges(service, ['Ports'])"
|
||||
on-reset="(onResetPorts)"
|
||||
on-submit="(onSubmit)"
|
||||
></docker-service-ports-mapping-field>
|
||||
|
||||
<div id="service-hosts-entries" class="padding-top" ng-include="'app/docker/views/services/edit/includes/hosts.html'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ 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');
|
||||
@@ -27,6 +26,7 @@ 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,6 +108,7 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
|
||||
$scope.formValues = {
|
||||
RegistryModel: new PorImageRegistryModel(),
|
||||
ports: [],
|
||||
};
|
||||
|
||||
$scope.tasks = [];
|
||||
@@ -544,12 +545,8 @@ angular.module('portainer.docker').controller('ServiceController', [
|
||||
}
|
||||
}
|
||||
|
||||
if (service.Ports) {
|
||||
service.Ports.forEach(function (binding) {
|
||||
if (binding.PublishedPort === null || binding.PublishedPort === '') {
|
||||
delete binding.PublishedPort;
|
||||
}
|
||||
});
|
||||
if ($scope.hasChanges(service, ['Ports'])) {
|
||||
service.Ports = portsMappingUtils.toRequest($scope.formValues.ports);
|
||||
}
|
||||
|
||||
config.EndpointSpec = {
|
||||
@@ -714,6 +711,25 @@ 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;
|
||||
@@ -727,6 +743,8 @@ 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);
|
||||
|
||||
@@ -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">
|
||||
<select id="refreshRate" ng-model="state.refreshRate" ng-change="changeUpdateRepeater()" class="form-control" data-cy="swarm-refreshRate-select">
|
||||
<option value="5">5s</option>
|
||||
<option value="10">10s</option>
|
||||
<option value="30">30s</option>
|
||||
@@ -97,9 +97,7 @@
|
||||
<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>
|
||||
|
||||
@@ -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" />
|
||||
<input type="text" class="form-control" ng-model="formValues.Name" id="volume_name" placeholder="e.g. myVolume" data-cy="volume-name-input" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
@@ -18,10 +18,24 @@
|
||||
<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">
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !driver-input -->
|
||||
@@ -41,11 +55,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" />
|
||||
<input type="text" class="form-control" ng-model="option.name" placeholder="e.g. mountpoint" data-cy="driver-option-name-input" />
|
||||
</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" />
|
||||
<input type="text" class="form-control" ng-model="option.value" placeholder="e.g. /path/on/host" data-cy="driver-option-value-input" />
|
||||
</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>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { confirmDelete } from '@@/modals/confirm';
|
||||
|
||||
angular.module('portainer.docker').controller('VolumesController', [
|
||||
'$q',
|
||||
'$scope',
|
||||
@@ -13,28 +11,24 @@ angular.module('portainer.docker').controller('VolumesController', [
|
||||
'endpoint',
|
||||
function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, Authentication, endpoint) {
|
||||
$scope.removeAction = function (selectedItems) {
|
||||
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();
|
||||
}
|
||||
});
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import angular from 'angular';
|
||||
|
||||
import { AccessHeaders } from '@/portainer/authorization-guard';
|
||||
import edgeStackModule from './views/edge-stacks';
|
||||
import { reactModule } from './react';
|
||||
|
||||
@@ -12,6 +13,9 @@ angular
|
||||
url: '/edge',
|
||||
parent: 'root',
|
||||
abstract: true,
|
||||
data: {
|
||||
access: AccessHeaders.EdgeAdmin,
|
||||
},
|
||||
};
|
||||
|
||||
const groups = {
|
||||
@@ -62,12 +66,15 @@ angular
|
||||
|
||||
const stacksNew = {
|
||||
name: 'edge.stacks.new',
|
||||
url: '/new?templateId',
|
||||
url: '/new?templateId&templateType',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'createEdgeStackView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/edge/stacks/add',
|
||||
},
|
||||
};
|
||||
|
||||
const stacksEdit = {
|
||||
@@ -137,7 +144,7 @@ angular
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/edge/devices',
|
||||
docs: '/user/edge/waiting-room',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -147,11 +154,11 @@ angular
|
||||
url: '/templates?template',
|
||||
views: {
|
||||
'content@': {
|
||||
component: 'edgeAppTemplatesView',
|
||||
component: 'appTemplatesView',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
docs: '/user/edge/templates',
|
||||
docs: '/user/edge/templates/application',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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_.-]+$/"
|
||||
@@ -43,7 +44,9 @@
|
||||
<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" /><span class="slider round"></span></label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<!-- not-recurring -->
|
||||
@@ -51,7 +54,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" />
|
||||
<input class="form-control" moment-picker ng-model="$ctrl.formValues.datetime" format="YYYY-MM-DD HH:mm" data-cy="edge-job-date-time-picker" />
|
||||
</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">
|
||||
@@ -73,6 +76,7 @@
|
||||
<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"
|
||||
@@ -101,6 +105,7 @@
|
||||
<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"
|
||||
@@ -193,9 +198,7 @@
|
||||
<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 -->
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
.edge-job-results-datatable thead th {
|
||||
width: 50%;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user