Compare commits

...

41 Commits

Author SHA1 Message Date
hookenz
1b8a604575 update runc and docker-compose
Some checks are pending
Test / test-client (push) Waiting to run
Test / test-server (map[arch:amd64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:arm64 platform:linux]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Waiting to run
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Waiting to run
2024-02-22 16:23:01 +13:00
Matt Hook
90451bfd47 fix(helm) tighten up helm requests [EE-6722] (#11236) 2024-02-22 11:35:33 +13:00
Ali
0c05539dee fix(input): allow clearing number inputs [EE-6714] (#11186) 2024-02-21 10:43:35 +13:00
Ali
a2a2c6cf3e fix(inputlist): update warning style [EE-6737] (#11221) 2024-02-21 08:29:10 +13:00
Matt Hook
76aa086d79 fix(libhttp): capitalize http error responses for better display [EE-6698] (#11114) 2024-02-21 07:51:46 +13:00
Chaim Lev-Ari
76fdfeaafc fix(ui): check for authorization [EE-6733] (#11208) 2024-02-20 11:06:09 +02:00
Chaim Lev-Ari
5932c78b88 fix(kube/apps): move namespace selector in apps view [EE-6612] (#11024) 2024-02-20 10:14:11 +02:00
Ali
68f5ca249f fix(app): remove insight from helm [EE-6693] (#11213)
Co-authored-by: testa113 <testa113>
2024-02-20 17:25:19 +13:00
Ali
2d87a8d8c3 fix(app): set values in react autoscaling form section [EE-6740] (#11219) 2024-02-20 09:35:27 +13:00
Prabhat Khera
988d4103d4 fix(git): update stack name for git stacks [EE-6670] (#11217) 2024-02-20 09:23:46 +13:00
Chaim Lev-Ari
ce3a1b8ba5 feat(a11y): add labels and roles [EE-6717] (#11181) 2024-02-19 16:37:26 +02:00
Oscar Zhou
6c89d3c0c9 fix(edge/template): custom template git fields not pre-filled [EE-6695] (#11112) 2024-02-19 08:39:05 +13:00
Ali
6b91fbf7f4 fix(app): update app type when changing data access policy [EE-6719] (#11211)
Co-authored-by: testa113 <testa113>
2024-02-19 08:08:22 +13:00
Ali
4f3f5e57b6 fix(app): avoid duplicate env requests [EE-6727] (#11194)
Co-authored-by: testa113 <testa113>
2024-02-16 14:02:05 +13:00
Prabhat Khera
6b3f30e32f fix(ui): update search placeholder [EE-6667] (#11190)
* update search placeholder

* remove box selector description
2024-02-16 12:34:06 +13:00
Matt Hook
bdeedb4018 fix(namespace): fix default namespace quota [EE-6700] (#11185) 2024-02-16 08:20:24 +13:00
Chaim Lev-Ari
50946e087c chore(eslint): add rule to check imports [EE-6730] (#11201) 2024-02-15 17:46:03 +02:00
Chaim Lev-Ari
7b89b04667 fix(auth): export hasAuthorizations [EE-6595] (#11199) 2024-02-15 14:05:52 +02:00
Chaim Lev-Ari
f5f84c5fa4 feat(ui): restrict views by role [EE-6595] (#11010) 2024-02-15 13:29:55 +02:00
Chaim Lev-Ari
437831fa80 feat(edge/stacks): add app templates to deploy types [EE-6632] (#11040) 2024-02-15 09:01:01 +02:00
Chaim Lev-Ari
31f5b42962 feat(auth): add useIsEdgeAdmin hook [EE-6627] (#11057) 2024-02-14 19:50:20 -03:00
Ali
7a6c872948 fix(insight): split insight from input [EE-6693] (#11176)
Co-authored-by: testa113 <testa113>
2024-02-15 10:45:59 +13:00
Chaim Lev-Ari
4bf18b1d65 feat(ui): write tests [EE-6685] (#11081) 2024-02-14 17:25:37 +02:00
Ali
2d25bf4afa fix(configs): correct 'external' display in tables [EE-6649] (#11110)
Co-authored-by: testa113 <testa113>
2024-02-14 11:48:09 +13:00
Ali
56ae19c5ab fix(stacks): add app form stacks input [EE-6693] (#11104) 2024-02-14 09:00:51 +13:00
Matt Hook
cdf9197274 fix(logs): add NOCOLOR option for use when exporting to greylog etc [EE-6696] (#11106) 2024-02-14 07:55:00 +13:00
Ali
901549e8dd fix(kube-owner): owner labels from resources created via manifest [EE-6647] (#11102)
Co-authored-by: testa113 <testa113>
2024-02-12 15:30:49 +13:00
Dakota Walsh
80b1cd19cb fix(restore): add S3 teaser EE-6675 (#11095) 2024-02-12 13:12:45 +13:00
Prabhat Khera
c4942de89b fix(ui): stackname auto fill on create from manifest screen [EE-6688] (#11099)
* fix(ui): stackname auto fill on create from manifest screen [EE-6688]

* address review comment
2024-02-12 10:54:29 +13:00
Ali
80d02f9cd1 fix(auth): isAdmin redirect for wizard [EE-6669] (#11074) 2024-02-12 08:04:39 +13:00
Prabhat Khera
671b22b5d6 fix(ui): scroll issue [EE-6667] (#11084)
* Fix scroll issue

* fix minorissue

* address review comments

* add comment
2024-02-09 15:35:34 +13:00
Steven Kang
43e56bf1c0 fix: pre-release build only after merging (#11097) 2024-02-09 15:26:43 +13:00
Matt Hook
a175619623 fix(docs): fix swagger docs for webhook params [EE-6668] (#11088) 2024-02-09 14:44:14 +13:00
Prabhat Khera
63c11d9310 fix(kube): ingress path duplication issue [EE-6649] (#11086) 2024-02-09 07:49:48 +13:00
Prabhat Khera
4c00b72ae3 fix stack name update issue (#11064) 2024-02-08 13:51:01 +13:00
Matt Hook
f4db09a534 fix(kube-apps): add helm insights, remove namespace insights panel [EE-6671] (#11077) 2024-02-08 11:38:04 +13:00
Prabhat Khera
01cd64037f fix(UI): some minor fixes [EE-6667] (#11061)
* minor tweeks for kubernetes settings

* address review comments
2024-02-06 12:17:38 +13:00
Steven Kang
a93344386c Pre-release as part of the CI (#11066)
* feat: add pre-release
* feat: add extension
* feat: fix typo
2024-02-05 18:24:16 +13:00
Prabhat Khera
a2195caa10 keep labels on edit ingress, configmaps and secrets (#11050) 2024-02-05 16:30:36 +13:00
Ali
9ad78753bc fix(r2a): don't set errors to undefined [EE-6665] (#11059)
Co-authored-by: testa113 <testa113>
2024-02-05 14:24:11 +13:00
Prabhat Khera
517190e28b chore(version): bump to 2.21.0 [EE-6652] (#11047)
* chore(version): bump to 2.21.0 [EE-6652]

* address review comments
2024-02-02 15:17:52 +13:00
216 changed files with 3141 additions and 1108 deletions

View File

@@ -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',
@@ -51,6 +53,8 @@ settings:
- ['@@', './app/react/components']
- ['@', './app']
extensions: ['.js', '.ts', '.tsx']
typescript: true
node: true
overrides:
- files:
@@ -75,6 +79,7 @@ overrides:
settings:
react:
version: 'detect'
rules:
import/order:
[

View File

@@ -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

View File

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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -230,6 +230,7 @@ func (m *Migrator) initMigrations() {
)
m.addMigrations("2.20",
m.updateAppTemplatesVersionForDB110,
m.updateResourceOverCommitToDB110,
)
// Add new migrations below...

View File

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

View File

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

View File

@@ -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
}

View File

@@ -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 {
@@ -136,6 +138,10 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
}
}
if payload.StackName != "" {
stack.Name = payload.StackName
}
repositoryUsername := ""
repositoryPassword := ""
if payload.RepositoryAuthentication {

View File

@@ -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

View File

@@ -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{},
}

View File

@@ -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
}

View File

@@ -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)

View File

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

View File

@@ -17,6 +17,7 @@
html {
font-size: 16px;
overflow-y: scroll;
scroll-behavior: smooth;
}
html[theme='dark'],

View File

@@ -209,7 +209,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>

View File

@@ -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,7 +66,7 @@ angular
const stacksNew = {
name: 'edge.stacks.new',
url: '/new?templateId',
url: '/new?templateId&templateType',
views: {
'content@': {
component: 'createEdgeStackView',

View File

@@ -13,7 +13,7 @@ import { withUIRouter } from '@/react-tools/withUIRouter';
import { EdgeGroupAssociationTable } from '@/react/edge/components/EdgeGroupAssociationTable';
import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector';
import { EnvironmentsDatatable } from '@/react/edge/edge-stacks/ItemView/EnvironmentsDatatable';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
import { TemplateFieldset } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
const ngModule = angular
.module('portainer.edge.react.components', [])

View File

@@ -13,7 +13,11 @@ import { StackType } from '@/react/common/stacks/types';
import { applySetStateAction } from '@/react-tools/apply-set-state-action';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
import { getAppTemplates } from '@/react/portainer/templates/app-templates/queries/useAppTemplates';
import { fetchFilePreview } from '@/react/portainer/templates/app-templates/queries/useFetchTemplateFile';
import { TemplateViewModel } from '@/react/portainer/templates/app-templates/view-model';
import { getDefaultValues as getAppVariablesDefaultValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/EnvVarsFieldset';
export default class CreateEdgeStackViewController {
/* @ngInject */
@@ -73,7 +77,7 @@ export default class CreateEdgeStackViewController {
}
/**
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset').Values>} templateAction
* @param {import('react').SetStateAction<import('@/react/edge/edge-stacks/CreateView/TemplateFieldset/types').Values>} templateAction
*/
setTemplateValues(templateAction) {
return this.$async(async () => {
@@ -82,44 +86,52 @@ export default class CreateEdgeStackViewController {
const newTemplateId = newTemplateValues.template && newTemplateValues.template.Id;
this.state.templateValues = newTemplateValues;
if (newTemplateId !== oldTemplateId) {
await this.onChangeTemplate(newTemplateValues.template);
await this.onChangeTemplate(newTemplateValues.type, newTemplateValues.template);
}
let definitions = [];
if (this.state.templateValues.template) {
definitions = this.state.templateValues.template.Variables;
}
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
if (newTemplateValues.type === 'custom') {
const definitions = this.state.templateValues.template.Variables;
const newFile = renderTemplate(this.state.templateValues.file, this.state.templateValues.variables, definitions);
this.formValues.StackFileContent = newFile;
this.formValues.StackFileContent = newFile;
}
});
}
onChangeTemplate(template) {
onChangeTemplate(type, template) {
return this.$async(async () => {
if (!template) {
return;
}
this.state.templateValues.template = template;
this.state.templateValues.variables = getVariablesFieldDefaultValues(template.Variables);
if (type === 'custom') {
this.formValues = {
...this.formValues,
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
...toGitFormModel(template.GitConfig),
...(template.EdgeSettings
? {
PrePullImage: template.EdgeSettings.PrePullImage || false,
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
...template.EdgeSettings.RelativePathSettings,
}
: {}),
};
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
this.state.templateValues.file = fileContent;
const fileContent = await getCustomTemplateFile({ id: template.Id, git: !!template.GitConfig });
this.state.templateValues.file = fileContent;
}
this.formValues = {
...this.formValues,
DeploymentType: template.Type === StackType.Kubernetes ? DeploymentType.Kubernetes : DeploymentType.Compose,
...toGitFormModel(template.GitConfig),
...(template.EdgeSettings
? {
PrePullImage: template.EdgeSettings.PrePullImage || false,
RetryDeploy: template.EdgeSettings.RetryDeploy || false,
PrivateRegistryId: template.EdgeSettings.PrivateRegistryId || null,
...template.EdgeSettings.RelativePathSettings,
}
: {}),
};
if (type === 'app') {
this.formValues.StackFileContent = '';
try {
const fileContent = await fetchFilePreview(template.Id);
this.formValues.StackFileContent = fileContent;
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
}
}
});
}
@@ -159,13 +171,27 @@ export default class CreateEdgeStackViewController {
}
}
async preSelectTemplate(templateId) {
/**
*
* @param {'app' | 'custom'} templateType
* @param {number} templateId
* @returns {Promise<void>}
*/
async preSelectTemplate(templateType, templateId) {
return this.$async(async () => {
try {
this.state.Method = 'template';
const template = await getCustomTemplate(templateId);
const template = await getTemplate(templateType, templateId);
if (!template) {
return;
}
this.setTemplateValues({ template });
this.setTemplateValues({
template,
type: templateType,
envVars: templateType === 'app' ? getAppVariablesDefaultValues(template.Env) : {},
variables: templateType === 'custom' ? getVariablesFieldDefaultValues(template.Variables) : [],
});
} catch (e) {
notifyError('Failed loading template', e);
}
@@ -179,9 +205,10 @@ export default class CreateEdgeStackViewController {
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
}
const templateId = this.$state.params.templateId;
if (templateId) {
this.preSelectTemplate(templateId);
const templateId = parseInt(this.$state.params.templateId, 10);
const templateType = this.$state.params.templateType;
if (templateType && templateId && !Number.isNaN(templateId)) {
this.preSelectTemplate(templateType, templateId);
}
this.$window.onbeforeunload = () => {
@@ -198,6 +225,12 @@ export default class CreateEdgeStackViewController {
createStack() {
return this.$async(async () => {
const name = this.formValues.Name;
let envVars = this.formValues.envVars;
if (this.state.Method === 'template' && this.state.templateValues.type === 'app') {
envVars = [...envVars, ...Object.entries(this.state.templateValues.envVars).map(([key, value]) => ({ name: key, value }))];
}
const method = getMethod(this.state.Method, this.state.templateValues.template);
if (!this.validateForm(method)) {
@@ -206,7 +239,7 @@ export default class CreateEdgeStackViewController {
this.state.actionInProgress = true;
try {
await this.createStackByMethod(name, method);
await this.createStackByMethod(name, method, envVars);
this.Notifications.success('Success', 'Stack successfully deployed');
this.state.isEditorDirty = false;
@@ -258,19 +291,19 @@ export default class CreateEdgeStackViewController {
return true;
}
createStackByMethod(name, method) {
createStackByMethod(name, method, envVars) {
switch (method) {
case 'editor':
return this.createStackFromFileContent(name);
return this.createStackFromFileContent(name, envVars);
case 'upload':
return this.createStackFromFileUpload(name);
return this.createStackFromFileUpload(name, envVars);
case 'repository':
return this.createStackFromGitRepository(name);
return this.createStackFromGitRepository(name, envVars);
}
}
createStackFromFileContent(name) {
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
createStackFromFileContent(name, envVars) {
const { StackFileContent, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
return this.EdgeStackService.createStackFromFileContent({
name,
@@ -282,8 +315,9 @@ export default class CreateEdgeStackViewController {
});
}
createStackFromFileUpload(name) {
const { StackFile, Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
createStackFromFileUpload(name, envVars) {
const { StackFile, Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
return this.EdgeStackService.createStackFromFileUpload(
{
Name: name,
@@ -296,8 +330,9 @@ export default class CreateEdgeStackViewController {
);
}
createStackFromGitRepository(name) {
const { Groups, DeploymentType, UseManifestNamespaces, envVars } = this.formValues;
async createStackFromGitRepository(name, envVars) {
const { Groups, DeploymentType, UseManifestNamespaces } = this.formValues;
const repositoryOptions = {
RepositoryURL: this.formValues.RepositoryURL,
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
@@ -354,3 +389,25 @@ function getMethod(method, template) {
}
return 'editor';
}
/**
*
* @param {'app' | 'custom'} templateType
* @param {number} templateId
* @returns {Promise<import('@/react/portainer/templates/app-templates/view-model').TemplateViewModel | import('@/react/portainer/templates/custom-templates/types').CustomTemplate | undefined>}
*/
async function getTemplate(templateType, templateId) {
if (!['app', 'custom'].includes(templateType)) {
notifyError('Invalid template type', `Invalid template type: ${templateType}`);
return;
}
if (templateType === 'app') {
const templatesResponse = await getAppTemplates();
const template = templatesResponse.templates.find((t) => t.id === templateId);
return new TemplateViewModel(template, templatesResponse.version);
}
const template = await getCustomTemplate(templateId);
return template;
}

View File

@@ -1,4 +1,4 @@
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset';
import { getInitialTemplateValues } from '@/react/edge/edge-stacks/CreateView/TemplateFieldset/TemplateFieldset';
import { editor, git, edgeStackTemplate, upload } from '@@/BoxSelector/common-options/build-methods';
class DockerComposeFormController {

View File

@@ -113,7 +113,7 @@
</div>
<div class="toolBar !pt-0">
<div class="w-full">
<div class="form-group !h-[30px] min-w-[140px] float-right">
<div class="form-group float-right !h-[30px] min-w-[140px] mr-2">
<div class="input-group">
<span class="input-group-addon">
<div className="flex items-center gap-1">
@@ -140,7 +140,7 @@
</span>
</div>
<div class="w-fit">
<insights-box type="'slim'" header="'From 2.18 on, you can filter this view by namespace.'" insight-close-id="'k8s-namespace-filtering'"></insights-box>
<helm-insights-box></helm-insights-box>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<!-- helm chart -->
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)">
<div ng-class="{ 'blocklist-item--selected': $ctrl.model.Selected }" class="blocklist-item template-item mx-0" ng-click="$ctrl.onSelect($ctrl.model)" role="listitem">
<div class="blocklist-item-box">
<!-- helmchart-image -->
<span class="shrink-0">

View File

@@ -6,7 +6,7 @@
<div class="searchBar vertical-center !mr-0">
<pr-icon icon="'search'" class="searchIcon"></pr-icon>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search for a chart..." auto-focus ng-model-options="{ debounce: 300 }" />
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus ng-model-options="{ debounce: 300 }" />
</div>
<div class="w-1/5">
<por-select
@@ -21,7 +21,8 @@
</div>
<div class="w-full">
<div class="mb-2 small text-muted"
>Select the Helm chart to use. Bring further Helm charts into your selection list via <a ui-sref="portainer.account">User settings - Helm repositories</a>.</div
>Select the Helm chart to use. Bring further Helm charts into your selection list via
<a ui-sref="portainer.account({'#': 'helm-repositories'})">User settings - Helm repositories</a>.</div
>
<beta-alert
is-html="true"
@@ -29,7 +30,7 @@
></beta-alert>
</div>
<div class="blocklist !px-0">
<div class="blocklist !px-0" role="list">
<helm-templates-list-item
ng-repeat="chart in allCharts = ($ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory)"
model="chart"

View File

@@ -38,6 +38,7 @@ class KubernetesConfigMapConverter {
res.ConfigurationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = data.metadata.creationTimestamp;
res.Yaml = yaml ? yaml.data : '';
res.Labels = data.metadata.labels;
res.Data = _.concat(
_.map(data.data, (value, key) => {
@@ -98,6 +99,7 @@ class KubernetesConfigMapConverter {
res.metadata.uid = data.Id;
res.metadata.name = data.Name;
res.metadata.namespace = data.Namespace;
res.metadata.labels = data.Labels || {};
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = data.ConfigurationOwner;
_.forEach(data.Data, (entry) => {
if (entry.IsBinary) {

View File

@@ -21,6 +21,7 @@ class KubernetesConfigurationConverter {
if (secret.Annotations) {
res.ServiceAccountName = secret.Annotations['kubernetes.io/service-account.name'];
}
res.Labels = secret.Labels;
return res;
}
@@ -37,6 +38,7 @@ class KubernetesConfigurationConverter {
});
res.data = res.Data;
res.ConfigurationOwner = configMap.ConfigurationOwner;
res.Labels = configMap.Labels;
return res;
}
}

View File

@@ -39,6 +39,7 @@ class KubernetesSecretConverter {
res.metadata.name = secret.Name;
res.metadata.namespace = secret.Namespace;
res.type = secret.Type;
res.metadata.labels = secret.Labels || {};
res.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] = secret.ConfigurationOwner;
let annotation = '';
@@ -67,6 +68,7 @@ class KubernetesSecretConverter {
res.Name = payload.metadata.name;
res.Namespace = payload.metadata.namespace;
res.Type = payload.type;
res.Labels = payload.metadata.labels || {};
res.ConfigurationOwner = payload.metadata.labels ? payload.metadata.labels[KubernetesPortainerConfigurationOwnerLabel] : '';
res.CreationDate = payload.metadata.creationTimestamp;
res.Annotations = payload.metadata.annotations;

View File

@@ -21,6 +21,7 @@ const _KubernetesConfigMap = Object.freeze({
Yaml: '',
ConfigurationOwner: '',
Data: [],
Labels: {},
});
export class KubernetesConfigMap {

View File

@@ -14,6 +14,7 @@ const _KubernetesConfigurationFormValues = Object.freeze({
IsSimple: true,
ServiceAccountName: '',
Type: KubernetesSecretTypeOptions.OPAQUE.value,
Labels: {},
});
export class KubernetesConfigurationFormValues {

View File

@@ -12,6 +12,7 @@ const _KubernetesApplicationSecret = Object.freeze({
Data: [],
SecretType: '',
Annotations: [],
Labels: {},
});
export class KubernetesApplicationSecret {

View File

@@ -26,6 +26,7 @@ import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
import { NodesDatatable } from '@/react/kubernetes/cluster/HomeView/NodesDatatable';
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
import { StackNameLabelInsight } from '@/react/kubernetes/DeployView/StackName/StackNameLabelInsight';
import { SecretsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/SecretsFormSection';
import { configurationsValidationSchema } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/configurationValidationSchema';
import { ConfigMapsFormSection } from '@/react/kubernetes/applications/components/ConfigurationsFormSection/ConfigMapsFormSection';
@@ -58,6 +59,7 @@ import { deploymentTypeValidation } from '@/react/kubernetes/applications/compon
import { AppDeploymentTypeFormSection } from '@/react/kubernetes/applications/components/AppDeploymentTypeFormSection/AppDeploymentTypeFormSection';
import { EnvironmentVariablesFormSection } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/EnvironmentVariablesFormSection';
import { kubeEnvVarValidationSchema } from '@/react/kubernetes/applications/components/EnvironmentVariablesFormSection/kubeEnvVarValidationSchema';
import { HelmInsightsBox } from '@/react/kubernetes/applications/ListView/ApplicationsDatatable/HelmInsightsBox';
import { applicationsModule } from './applications';
@@ -88,6 +90,7 @@ export const ngModule = angular
'value',
])
)
.component('helmInsightsBox', r2a(HelmInsightsBox, []))
.component(
'namespaceAccessUsersSelector',
r2a(NamespaceAccessUsersSelector, [
@@ -139,9 +142,13 @@ export const ngModule = angular
),
{ stackName: 'setStackName' }
),
['setStackName', 'stackName', 'stacks', 'inputClassName']
['setStackName', 'stackName', 'stacks', 'inputClassName', 'textTip']
)
)
.component(
'stackNameLabelInsight',
r2a(withUIRouter(withCurrentUser(StackNameLabelInsight)), [])
)
.component(
'editYamlFormSection',
r2a(withUIRouter(withReactQuery(withCurrentUser(EditYamlFormSection))), [

View File

@@ -82,10 +82,12 @@ class KubernetesConfigurationService {
if (formValues.Kind === KubernetesConfigurationKinds.CONFIGMAP) {
const configMap = KubernetesConfigMapConverter.configurationFormValuesToConfigMap(formValues);
configMap.ConfigurationOwner = configuration.ConfigurationOwner;
configMap.Labels = configuration.Labels;
await this.KubernetesConfigMapService.update(configMap);
} else {
const secret = KubernetesSecretConverter.configurationFormValuesToSecret(formValues);
secret.ConfigurationOwner = configuration.ConfigurationOwner;
secret.Labels = configuration.Labels;
await this.KubernetesSecretService.update(secret);
}
}

View File

@@ -135,11 +135,11 @@
class="btn btn-sm btn-primary"
ng-click="ctrl.updateApplicationViaWebEditor()"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress"
ng-disabled="ctrl.isUpdateApplicationViaWebEditorButtonDisabled() || !kubernetesApplicationCreationForm.$valid"
style="margin-top: 7px; margin-left: 0"
button-spinner="ctrl.state.updateWebEditorInProgress"
>
<span ng-show="!ctrl.state.updateWebEditorInProgress">Update the application</span>
<span ng-show="!ctrl.state.updateWebEditorInProgress">Update application</span>
<span ng-show="ctrl.state.updateWebEditorInProgress">Update in progress...</span>
</button>
</div>
@@ -169,6 +169,7 @@
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.onChangeStackName)"
text-tip="'Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the application name.'"
stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'"
></kube-stack-name>
@@ -179,6 +180,7 @@
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT"
stack="ctrl.stack"
namespace="ctrl.formValues.ResourcePool.Namespace.Name"
stack-name="ctrl.formValues.StackName"
></kubernetes-redeploy-app-git-form>
<!-- #endregion -->
@@ -226,9 +228,10 @@
<div ng-if="ctrl.formValues.ResourcePool">
<!-- #region STACK -->
<kube-stack-name
ng-if="ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.onChangeStackName)"
text-tip="'Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the application name.'"
stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'"
></kube-stack-name>
@@ -403,7 +406,7 @@
class="btn btn-sm btn-primary"
ng-click="ctrl.updateApplicationViaWebEditor()"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress"
ng-disabled="ctrl.isUpdateApplicationViaWebEditorButtonDisabled() || !kubernetesApplicationCreationForm.$valid"
style="margin-top: 7px; margin-left: 0"
button-spinner="ctrl.state.updateWebEditorInProgress"
>

View File

@@ -38,6 +38,7 @@ class KubernetesCreateApplicationController {
$async,
$state,
$timeout,
$window,
Notifications,
Authentication,
KubernetesResourcePoolService,
@@ -58,6 +59,7 @@ class KubernetesCreateApplicationController {
this.$async = $async;
this.$state = $state;
this.$timeout = $timeout;
this.$window = $window;
this.Notifications = Notifications;
this.Authentication = Authentication;
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
@@ -157,6 +159,7 @@ class KubernetesCreateApplicationController {
this.refreshReactComponent = this.refreshReactComponent.bind(this);
this.onChangeNamespaceName = this.onChangeNamespaceName.bind(this);
this.canSupportSharedAccess = this.canSupportSharedAccess.bind(this);
this.isUpdateApplicationViaWebEditorButtonDisabled = this.isUpdateApplicationViaWebEditorButtonDisabled.bind(this);
this.$scope.$watch(
() => this.formValues,
@@ -233,6 +236,7 @@ class KubernetesCreateApplicationController {
this.$scope.$evalAsync(() => {
this.formValues.DataAccessPolicy = value;
this.resetDeploymentType();
this.updateApplicationType();
});
}
@@ -255,7 +259,7 @@ class KubernetesCreateApplicationController {
{ stackFile: this.stackFileContent, stackName: this.formValues.StackName }
);
this.state.isEditorDirty = false;
await this.$state.reload(this.$state.current);
this.$window.location.reload();
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
} finally {
@@ -289,17 +293,6 @@ class KubernetesCreateApplicationController {
/* #region AUTO SCALER UI MANAGEMENT */
onAutoScaleChange(values) {
return this.$async(async () => {
// when enabling the auto scaler, set the default values
if (!this.oldFormValues.AutoScaler.isUsed && values.isUsed) {
this.formValues.AutoScaler = {
isUsed: values.isUsed,
minReplicas: 1,
maxReplicas: 3,
targetCpuUtilizationPercentage: 50,
};
return;
}
// otherwise, just update the values
this.formValues.AutoScaler = values;
// reset it to previous form values if the user disables the auto scaler
@@ -643,6 +636,10 @@ class KubernetesCreateApplicationController {
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable;
}
isUpdateApplicationViaWebEditorButtonDisabled() {
return (this.savedFormValues.StackName === this.formValues.StackName && !this.state.isEditorDirty) || this.state.updateWebEditorInProgress;
}
isExternalApplication() {
if (this.application) {
return KubernetesApplicationHelper.isExternalApplication(this.application);

View File

@@ -159,6 +159,7 @@ class KubernetesConfigMapController {
this.formValues.Type = this.configuration.Type;
this.formValues.Kind = this.configuration.Kind;
this.oldDataYaml = this.formValues.DataYaml;
this.formValues.Labels = this.configuration.Labels;
return this.configuration;
} catch (err) {

View File

@@ -155,6 +155,7 @@ class KubernetesSecretController {
this.formValues.Type = this.configuration.Type;
this.formValues.Kind = this.configuration.Kind;
this.oldDataYaml = this.formValues.DataYaml;
this.formValues.Labels = this.configuration.Labels;
return this.configuration;
} catch (err) {

View File

@@ -47,6 +47,7 @@
ng-disabled="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
class="form-control"
ng-model="ctrl.formValues.Namespace"
ng-change="ctrl.onChangeNamespace()"
ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"
></select>
<span ng-if="ctrl.formValues.namespace_toggle && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM" class="small text-muted pt-[7px]"
@@ -85,12 +86,12 @@
</div>
</div>
<kube-stack-name
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.setStackName)"
is-admin="ctrl.currentUser.isAdmin"
></kube-stack-name>
<div ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="w-fit mb-4">
<stack-name-label-insight></stack-name-label-insight>
</div>
<kube-stack-name stack-name="ctrl.formValues.StackName" set-stack-name="(ctrl.setStackName)" stacks="ctrl.stacks"></kube-stack-name>
</div>
<!-- !namespace -->
<!-- repository -->

View File

@@ -15,7 +15,7 @@ import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templat
class KubernetesDeployController {
/* @ngInject */
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService) {
constructor($async, $state, $window, Authentication, Notifications, KubernetesResourcePoolService, StackService, CustomTemplateService, KubernetesApplicationService) {
this.$async = $async;
this.$state = $state;
this.$window = $window;
@@ -24,6 +24,7 @@ class KubernetesDeployController {
this.KubernetesResourcePoolService = KubernetesResourcePoolService;
this.StackService = StackService;
this.CustomTemplateService = CustomTemplateService;
this.KubernetesApplicationService = KubernetesApplicationService;
this.isTemplateVariablesEnabled = isTemplateVariablesEnabled;
@@ -33,7 +34,7 @@ class KubernetesDeployController {
{ ...git, value: KubernetesDeployBuildMethods.GIT },
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
{ ...url, value: KubernetesDeployBuildMethods.URL },
{ ...customTemplate, description: 'Use custom template', value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
{ ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
{ ...helm, value: KubernetesDeployBuildMethods.HELM },
];
@@ -78,6 +79,8 @@ class KubernetesDeployController {
Name: '',
};
this.stacks = [];
this.ManifestDeployTypes = KubernetesDeployManifestTypes;
this.BuildMethods = KubernetesDeployBuildMethods;
@@ -92,6 +95,15 @@ class KubernetesDeployController {
this.onChangeDeployType = this.onChangeDeployType.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
this.setStackName = this.setStackName.bind(this);
this.onChangeNamespace = this.onChangeNamespace.bind(this);
}
onChangeNamespace() {
return this.$async(async () => {
const applications = await this.KubernetesApplicationService.get(this.formValues.Namespace);
const stacks = _.map(applications, (item) => item.StackName).filter((item) => item !== '');
this.stacks = _.uniq(stacks);
});
}
onSelectHelmChart(chart) {
@@ -377,6 +389,7 @@ class KubernetesDeployController {
}
}
this.onChangeNamespace();
this.state.viewReady = true;
this.$window.onbeforeunload = () => {

View File

@@ -10,6 +10,7 @@ import { reactModule } from './react';
import { sidebarModule } from './react/views/sidebar';
import environmentsModule from './environments';
import { helpersModule } from './helpers';
import { AccessHeaders, requiresAuthHook } from './authorization-guard';
async function initAuthentication(Authentication) {
return await Authentication.init();
@@ -60,6 +61,9 @@ angular
component: 'sidebar',
},
},
data: {
access: AccessHeaders.Restricted,
},
};
var endpointRoot = {
@@ -122,6 +126,16 @@ angular
},
};
const createHelmRepository = {
name: 'portainer.account.createHelmRepository',
url: '/helm-repository/new',
views: {
'content@': {
component: 'createHelmRepositoryView',
},
},
};
var authentication = {
name: 'portainer.auth',
url: '/auth',
@@ -136,6 +150,9 @@ angular
},
'sidebar@': {},
},
data: {
access: undefined,
},
};
const logout = {
@@ -152,6 +169,9 @@ angular
},
'sidebar@': {},
},
data: {
access: undefined,
},
};
var endpoints = {
@@ -256,6 +276,7 @@ angular
},
data: {
docs: '/admin/environments/groups',
access: AccessHeaders.Admin,
},
};
@@ -312,6 +333,9 @@ angular
views: {
'sidebar@': {},
},
data: {
access: undefined,
},
};
var initAdmin = {
@@ -336,6 +360,7 @@ angular
},
data: {
docs: '/admin/registries',
access: AccessHeaders.Admin,
},
};
@@ -369,6 +394,7 @@ angular
},
data: {
docs: '/admin/settings',
access: AccessHeaders.Admin,
},
};
@@ -410,6 +436,7 @@ angular
},
data: {
docs: '/admin/environments/tags',
access: AccessHeaders.Admin,
},
};
@@ -424,6 +451,7 @@ angular
},
data: {
docs: '/admin/users',
access: AccessHeaders.Restricted, // allow for team leaders
},
};
@@ -438,16 +466,6 @@ angular
},
};
const createHelmRepository = {
name: 'portainer.account.createHelmRepository',
url: '/helm-repository/new',
views: {
'content@': {
component: 'createHelmRepositoryView',
},
},
};
$stateRegistryProvider.register(root);
$stateRegistryProvider.register(endpointRoot);
$stateRegistryProvider.register(portainer);
@@ -481,7 +499,8 @@ angular
$stateRegistryProvider.register(user);
$stateRegistryProvider.register(createHelmRepository);
},
]);
])
.run(run);
function isTransitionRequiresAuthentication(transition) {
const UNAUTHENTICATED_ROUTES = ['portainer.logout', 'portainer.auth'];
@@ -492,3 +511,8 @@ function isTransitionRequiresAuthentication(transition) {
const nextTransitionName = nextTransition ? nextTransition.name : '';
return !UNAUTHENTICATED_ROUTES.some((route) => nextTransitionName.startsWith(route));
}
/* @ngInject */
function run($transitions) {
requiresAuthHook($transitions);
}

View File

@@ -0,0 +1,118 @@
import {
StateDeclaration,
StateService,
Transition,
} from '@uirouter/angularjs';
import { checkAuthorizations } from './authorization-guard';
import { IAuthenticationService } from './services/types';
describe('checkAuthorizations', () => {
let authService = {
init: vi.fn(),
isPureAdmin: vi.fn(),
isAdmin: vi.fn(),
hasAuthorizations: vi.fn(),
getUserDetails: vi.fn(),
isAuthenticated: vi.fn(),
} satisfies IAuthenticationService;
let transition: Transition;
const stateTo: StateDeclaration = {
data: {
access: 'restricted',
},
};
const $state = {
target: vi.fn((t) => t),
} as unknown as StateService;
beforeEach(() => {
authService = {
init: vi.fn(),
isPureAdmin: vi.fn(),
isAdmin: vi.fn(),
hasAuthorizations: vi.fn(),
getUserDetails: vi.fn(),
isAuthenticated: vi.fn(),
};
transition = {
injector: vi.fn().mockReturnValue({
get: vi.fn().mockReturnValue(authService),
}),
to: vi.fn().mockReturnValue(stateTo),
router: {
stateService: $state,
} as Transition['router'],
} as unknown as Transition;
stateTo.data.access = 'restricted';
});
afterEach(() => {
vi.clearAllMocks();
});
it('should return undefined if access is not defined', async () => {
stateTo.data.access = undefined;
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return undefined if user is not authenticated and route access is defined', async () => {
stateTo.data.access = 'something';
authService.init.mockResolvedValue(false);
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return logout if access is "restricted"', async () => {
const result = await checkAuthorizations(transition);
expect(result).toBeDefined();
expect($state.target).toHaveBeenCalledWith('portainer.logout');
});
it('should return undefined if user is an admin and access is "admin"', async () => {
authService.init.mockResolvedValue(true);
authService.isPureAdmin.mockReturnValue(true);
stateTo.data.access = 'admin';
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return undefined if user is an admin and access is "edge-admin"', async () => {
authService.init.mockResolvedValue(true);
authService.isAdmin.mockReturnValue(true);
stateTo.data.access = 'edge-admin';
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should return undefined if user has the required authorizations', async () => {
authService.init.mockResolvedValue(true);
authService.hasAuthorizations.mockReturnValue(true);
stateTo.data.access = ['permission1', 'permission2'];
const result = await checkAuthorizations(transition);
expect(result).toBeUndefined();
});
it('should redirect to home if user does not have the required authorizations', async () => {
authService.init.mockResolvedValue(true);
authService.hasAuthorizations.mockReturnValue(false);
stateTo.data.access = ['permission1', 'permission2'];
const result = await checkAuthorizations(transition);
expect(result).toBeDefined();
expect($state.target).toHaveBeenCalledWith('portainer.home');
});
});

View File

@@ -0,0 +1,99 @@
import { Transition, TransitionService } from '@uirouter/angularjs';
import { IAuthenticationService } from './services/types';
export enum AccessHeaders {
Restricted = 'restricted',
Admin = 'admin',
EdgeAdmin = 'edge-admin',
}
type Authorizations = string[];
type Access =
| AccessHeaders.Restricted
| AccessHeaders.Admin
| AccessHeaders.EdgeAdmin
| Authorizations;
export function requiresAuthHook(transitionService: TransitionService) {
transitionService.onBefore({}, checkAuthorizations);
}
// exported for tests
export async function checkAuthorizations(transition: Transition) {
const authService: IAuthenticationService = transition
.injector()
.get('Authentication');
const stateTo = transition.to();
const $state = transition.router.stateService;
const { access } = stateTo.data || {};
if (!isAccess(access)) {
return undefined;
}
const isLoggedIn = await authService.init();
if (!isLoggedIn) {
// eslint-disable-next-line no-console
console.info(
'User is not authenticated, redirecting to login, access:',
access
);
return $state.target('portainer.logout');
}
if (typeof access === 'string') {
if (access === 'restricted') {
return undefined;
}
if (access === 'admin') {
if (authService.isPureAdmin()) {
return undefined;
}
// eslint-disable-next-line no-console
console.info(
'User is not an admin, redirecting to home, access:',
access
);
return $state.target('portainer.home');
}
if (access === 'edge-admin') {
if (authService.isAdmin()) {
return undefined;
}
// eslint-disable-next-line no-console
console.info(
'User is not an edge admin, redirecting to home, access:',
access
);
return $state.target('portainer.home');
}
}
if (access.length > 0 && !authService.hasAuthorizations(access)) {
// eslint-disable-next-line no-console
console.info(
'User does not have the required authorizations, redirecting to home'
);
return $state.target('portainer.home');
}
return undefined;
}
function isAccess(access: unknown): access is Access {
if (!access || (typeof access !== 'string' && !Array.isArray(access))) {
return false;
}
if (Array.isArray(access)) {
return access.every((a) => typeof a === 'string');
}
return ['restricted', 'admin', 'edge-admin'].includes(access);
}

View File

@@ -64,7 +64,7 @@ angular.module('portainer.app').controller('porAccessControlFormController', [
this.$onInit = $onInit;
function $onInit() {
var isAdmin = Authentication.isAdmin();
var isAdmin = Authentication.isPureAdmin();
ctrl.isAdmin = isAdmin;
if (isAdmin) {

View File

@@ -126,6 +126,10 @@ class KubernetesRedeployAppGitFormController {
return;
}
if (this.stack.Name !== this.stackName) {
this.formValues.StackName = this.stackName;
}
this.state.redeployInProgress = true;
await this.StackService.updateKubeGit(this.stack.Id, this.stack.EndpointId, this.namespace, this.formValues);
this.Notifications.success('Success', 'Pulled and redeployed stack successfully');

View File

@@ -7,6 +7,7 @@ const kubernetesRedeployAppGitForm = {
bindings: {
stack: '<',
namespace: '<',
stackName: '<',
},
};

View File

@@ -1,3 +1,4 @@
import { AccessHeaders } from '../authorization-guard';
import { rolesView } from './views/roles';
import { accessViewer } from './components/access-viewer';
import { accessViewerDatatable } from './components/access-viewer/access-viewer-datatable';
@@ -29,6 +30,7 @@ function config($stateRegistryProvider) {
},
data: {
docs: '/admin/users/roles',
access: AccessHeaders.Admin,
},
};

View File

@@ -48,6 +48,7 @@ export const ngModule = angular
'disabledTypes',
'fixedCategories',
'storageKey',
'templateLinkParams',
])
)
.component(

View File

@@ -184,6 +184,7 @@ export const ngModule = angular
'components',
'isLoading',
'noOptionsMessage',
'aria-label',
])
)
.component(

View File

@@ -25,7 +25,7 @@ export const settingsModule = angular
)
.component(
'applicationSettingsPanel',
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess'])
r2a(withReactQuery(ApplicationSettingsPanel), ['onSuccess', 'settings'])
)
.component(
'sslSettingsPanel',
@@ -38,5 +38,5 @@ export const settingsModule = angular
)
.component(
'kubeSettingsPanel',
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), [])
r2a(withUIRouter(withReactQuery(KubeSettingsPanel)), ['settings'])
).name;

View File

@@ -6,6 +6,7 @@ import { r2a } from '@/react-tools/react2angular';
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AccessHeaders } from '@/portainer/authorization-guard';
export const teamsModule = angular
.module('portainer.app.teams', [])
@@ -31,6 +32,7 @@ function config($stateRegistryProvider: StateRegistry) {
},
data: {
docs: '/admin/users/teams',
access: AccessHeaders.Restricted, // allow for team leaders
},
});

View File

@@ -10,6 +10,7 @@ import {
import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { withUIRouter } from '@/react-tools/withUIRouter';
import { AccessHeaders } from '@/portainer/authorization-guard';
export const wizardModule = angular
.module('portainer.app.react.views.wizard', [])
@@ -42,6 +43,9 @@ function config($stateRegistryProvider: StateRegistry) {
component: 'wizardMainView',
},
},
data: {
access: AccessHeaders.Admin,
},
});
$stateRegistryProvider.register({

View File

@@ -461,6 +461,7 @@ angular.module('portainer.app').factory('StackService', [
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
RepositoryUsername: gitConfig.RepositoryUsername,
RepositoryPassword: gitConfig.RepositoryPassword,
StackName: gitConfig.StackName,
}
).$promise;
}

View File

@@ -1,30 +1,40 @@
import { hasAuthorizations as useUserHasAuthorization } from '@/react/hooks/useUser';
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
import * as userHelpers from '../users/user.helpers';
import { clear as clearSessionStorage } from './session-storage';
const DEFAULT_USER = 'admin';
const DEFAULT_PASSWORD = 'K7yJPP5qNK4hf1QsRnfV';
angular.module('portainer.app').factory('Authentication', [
'$async',
'$state',
'Auth',
'OAuth',
'LocalStorage',
'StateManager',
'EndpointProvider',
'ThemeManager',
function AuthenticationFactory($async, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
function AuthenticationFactory($async, $state, Auth, OAuth, LocalStorage, StateManager, EndpointProvider, ThemeManager) {
'use strict';
var service = {};
var user = {};
if (process.env.NODE_ENV === 'development') {
window.login = loginAsync;
}
service.init = init;
service.OAuthLogin = OAuthLogin;
service.login = login;
service.logout = logout;
service.isAuthenticated = isAuthenticated;
service.getUserDetails = getUserDetails;
service.isAdmin = isAdmin;
return {
init,
OAuthLogin,
login,
logout,
isAuthenticated,
getUserDetails,
isAdmin,
isEdgeAdmin,
isPureAdmin,
hasAuthorizations,
redirectIfUnauthorized,
};
async function initAsync() {
try {
@@ -120,14 +130,48 @@ angular.module('portainer.app').factory('Authentication', [
return login(DEFAULT_USER, DEFAULT_PASSWORD);
}
// To avoid creating divergence between CE and EE
// isAdmin checks if the user is a portainer admin or edge admin
function isEdgeAdmin() {
const environment = EndpointProvider.currentEndpoint();
return userHelpers.isEdgeAdmin({ Role: user.role }, environment);
}
/**
* @deprecated use Authentication.isAdmin instead
*/
function isAdmin() {
return !!user && user.role === 1;
return isEdgeAdmin();
}
if (process.env.NODE_ENV === 'development') {
window.login = loginAsync;
// To avoid creating divergence between CE and EE
// isPureAdmin checks if the user is portainer admin only
function isPureAdmin() {
return userHelpers.isPureAdmin({ Role: user.role });
}
return service;
function hasAuthorizations(authorizations) {
const endpointId = EndpointProvider.endpointID();
if (isEdgeAdmin()) {
return true;
}
return useUserHasAuthorization(
{
EndpointAuthorizations: user.endpointAuthorizations,
},
authorizations,
endpointId
);
}
function redirectIfUnauthorized(authorizations) {
const authorized = hasAuthorizations(authorizations);
if (!authorized) {
$state.go('portainer.home');
}
}
},
]);

View File

@@ -9,6 +9,17 @@ export interface StateManager {
export interface IAuthenticationService {
getUserDetails(): { ID: number };
isAuthenticated(): boolean;
isAdmin(): boolean;
isPureAdmin(): boolean;
hasAuthorizations(authorizations: string[]): boolean;
init(): Promise<boolean>;
// OAuthLogin,
// login,
// logout,
// redirectIfUnauthorized,
}
export type AsyncService = <T>(fn: () => Promise<T>) => Promise<T>;

View File

@@ -1,6 +1,7 @@
import angular from 'angular';
import { NotificationsViewAngular } from '@/react/portainer/notifications/NotificationsView';
import { AccessHeaders } from '../authorization-guard';
import authLogsViewModule from './auth-logs-view';
import activityLogsViewModule from './activity-logs-view';
@@ -18,6 +19,7 @@ function config($stateRegistryProvider) {
},
data: {
docs: '/admin/logs',
access: AccessHeaders.Admin,
},
});
@@ -31,6 +33,7 @@ function config($stateRegistryProvider) {
},
data: {
docs: '/admin/logs/activity',
access: AccessHeaders.Admin,
},
});

View File

@@ -1,9 +1,9 @@
import { useQuery } from 'react-query';
import { TeamRole, TeamMembership } from '@/react/portainer/users/teams/types';
import { useCurrentUser, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { User, UserId } from './types';
import { isAdmin } from './user.helpers';
import { getUserMemberships, getUsers } from './user.service';
interface UseUserMembershipOptions<TSelect> {
@@ -22,14 +22,21 @@ export function useUserMembership<TSelect = TeamMembership[]>(
);
}
export function useIsTeamLeader(user: User) {
export function useIsCurrentUserTeamLeader() {
const { user } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const query = useUserMembership(user.Id, {
enabled: !isAdmin(user),
enabled: !isAdminQuery.isLoading && !isAdminQuery.isAdmin,
select: (memberships) =>
memberships.some((membership) => membership.Role === TeamRole.Leader),
});
return isAdmin(user) ? true : query.data;
if (isAdminQuery.isLoading) {
return false;
}
return isAdminQuery.isAdmin ? true : !!query.data;
}
export function useUsers<T = User[]>(

View File

@@ -7,6 +7,7 @@ export { type UserId };
export enum Role {
Admin = 1,
Standard,
EdgeAdmin,
}
interface AuthorizationMap {

View File

@@ -1,9 +1,30 @@
import { Environment } from '@/react/portainer/environments/types';
import { isEdgeEnvironment } from '@/react/portainer/environments/utils';
import { Role, User } from './types';
export function filterNonAdministratorUsers(users: User[]) {
return users.filter((user) => user.Role !== Role.Admin);
return users.filter((user) => !isPureAdmin(user));
}
export function isAdmin(user?: User): boolean {
return !!user && user.Role === 1;
type UserLike = Pick<User, 'Role'>;
// To avoid creating divergence between CE and EE
// isAdmin checks if the user is portainer admin or edge admin
export function isEdgeAdmin(
user: UserLike | undefined,
environment?: Pick<Environment, 'Type'> | null
): boolean {
return (
isPureAdmin(user) ||
(user?.Role === Role.EdgeAdmin &&
(!environment || isEdgeEnvironment(environment.Type)))
);
}
// To avoid creating divergence between CE and EE
// isPureAdmin checks only if the user is portainer admin
// See bouncer.IsAdmin and bouncer.PureAdminAccess
export function isPureAdmin(user?: UserLike): boolean {
return !!user && user.Role === Role.Admin;
}

View File

@@ -131,48 +131,150 @@
</span>
</div>
</div>
<!-- !note -->
<box-selector slim="true" options="restoreOptions" value="formValues.restoreFormType" on-change="(onChangeRestoreType)" radio-name="'restore-type'"></box-selector>
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>
<div ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.FILE">
<!-- note -->
<div class="form-group">
<div class="col-sm-12">
<span class="small text-muted"> You can upload a backup file from your computer. </span>
</div>
</div>
</div>
<!-- !note -->
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
data-cy="init-selectBackupFileButton"
>Select file</button
>
<span class="space-left vertical-center">
{{ formValues.BackupFile.name }}
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
</span>
<!-- !note -->
<!-- select-file-input -->
<div class="form-group">
<div class="col-sm-12 vertical-center">
<button
class="btn btn-sm btn-primary"
ngf-select
accept=".gz,.encrypted"
ngf-accept="'application/x-tar,application/x-gzip'"
ng-model="formValues.BackupFile"
auto-focus
data-cy="init-selectBackupFileButton"
>Select file</button
>
<span class="space-left vertical-center">
{{ formValues.BackupFile.name }}
<pr-icon icon="'x-circle'" class-name="'icon-danger'" ng-if="!formValues.BackupFile"></pr-icon>
</span>
</div>
</div>
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
</div>
</div>
<!-- !password-input -->
</div>
<!-- !select-file-input -->
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
<div class="limited-be-content" ng-if="formValues.restoreFormType === RESTORE_FORM_TYPES.S3">
<!-- Access key id -->
<div class="form-group">
<label for="access_key_id" class="col-sm-3 control-label text-left">Access key ID</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="access_key_id" name="access_key_id" ng-model="formValues.AccessKeyId" required data-cy="init-accessKeyIdInput" />
</div>
</div>
<!-- !Access key id -->
<!-- Secret access key -->
<div class="form-group">
<label for="secret_access_key" class="col-sm-3 control-label text-left">Secret access key</label>
<div class="col-sm-9">
<input
type="password"
class="form-control"
id="secret_access_key"
name="secret_access_key"
ng-model="formValues.SecretAccessKey"
required
data-cy="init-secretAccessKeyInput"
/>
</div>
</div>
<!-- !Secret access key -->
<!-- Region -->
<div class="form-group">
<label for="backup-s3-region" class="col-sm-3 control-label text-left">Region</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
placeholder="default region is us-east-1 if left empty"
id="backup-s3-region"
name="backup-s3-region"
ng-model="formValues.Region"
data-cy="init-s3RegionInput"
/>
</div>
</div>
<!-- !Region -->
<!-- Bucket name -->
<div class="form-group">
<label for="bucket_name" class="col-sm-3 control-label text-left">Bucket name</label>
<div class="col-sm-9">
<input type="text" class="form-control" id="bucket_name" name="bucket_name" ng-model="formValues.BucketName" required data-cy="init-bucketNameInput" />
</div>
</div>
<!-- !Bucket name -->
<!-- S3 Compatible Host -->
<div class="form-group">
<label for="s3-compatible-host" class="col-sm-3 control-label text-left">
S3 Compatible Host
<portainer-tooltip message="'Hostname of a S3 service'"></portainer-tooltip>
</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="s3-compatible-host"
name="s3-compatible-host"
ng-model="formValues.S3CompatibleHost"
placeholder="leave empty for AWS S3"
data-cy="init-s3CompatibleHostInput"
/>
</div>
</div>
<!-- !S3 Compatible Host -->
<!-- Filename -->
<div class="form-group">
<label for="backup-s3-filename" class="col-sm-3 control-label text-left">Filename</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
id="backup-s3-filename"
name="backup-s3-filename"
ng-model="formValues.Filename"
required
data-cy="init-backupFilenameInput"
/>
</div>
</div>
<!-- !Filename -->
<!-- password-input -->
<div class="form-group">
<label for="password" class="col-sm-3 control-label text-left">
Password
<portainer-tooltip
message="'If the backup is password protected, provide the password in order to extract the backup file, otherwise this field can be left empty.'"
></portainer-tooltip>
</label>
<div class="col-sm-4">
<input type="password" class="form-control" ng-model="formValues.Password" id="password" data-cy="init-backupPasswordInput" />
</div>
</div>
<!-- !password-input -->
</div>
<!-- !password-input -->
<!-- note -->
<div class="form-group">
<div class="col-sm-12">

View File

@@ -17,12 +17,14 @@ angular.module('portainer.app').controller('InitAdminController', [
$scope.uploadBackup = uploadBackup;
$scope.logo = StateManager.getState().application.logo;
$scope.RESTORE_FORM_TYPES = { S3: 's3', FILE: 'file' };
$scope.formValues = {
Username: 'admin',
Password: '',
ConfirmPassword: '',
enableTelemetry: process.env.NODE_ENV === 'production',
restoreFormType: $scope.RESTORE_FORM_TYPES.FILE,
};
$scope.state = {

View File

@@ -38,16 +38,7 @@ export function react2angular<T, U extends PropNames<T>[]>(
Component: React.ComponentType<T & JSX.IntrinsicAttributes>,
propNames: U & ([PropNames<T>] extends [U[number]] ? unknown : PropNames<T>)
): IComponentOptions & { name: string } {
const bindings = Object.fromEntries(
propNames.map((key) => {
// use two way binding for errors, to avoid shifting the layout from errors going between undefined <-> some value when using inputs.
// See https://portainer.atlassian.net/browse/EE-6570 for more context
if (key === 'errors') {
return [key, '='];
}
return [key, '<'];
})
);
const bindings = Object.fromEntries(propNames.map((key) => [key, '<']));
return {
bindings,

View File

@@ -17,6 +17,8 @@ interface FormFieldProps<TValue> {
type WithFormFieldProps<TProps, TValue> = TProps & FormFieldProps<TValue>;
type ValidationResult<T> = FormikErrors<T> | undefined;
/**
* This utility function is used for wrapping React components with form validation.
* When used inside an Angular form, it sets the form to invalid if the component values are invalid.
@@ -109,6 +111,7 @@ function createFormValidatorController<TFormModel, TData = never>(
this.handleChange = this.handleChange.bind(this);
this.runValidation = this.runValidation.bind(this);
this.validate = this.validate.bind(this);
}
async handleChange(newValues: TFormModel) {
@@ -123,21 +126,31 @@ function createFormValidatorController<TFormModel, TData = never>(
this.form?.$setValidity('form', true, this.form);
const schema = schemaBuilder(this.validationData);
this.errors = undefined;
const errors = await (isPrimitive
? validateForm<{ value: TFormModel }>(
() => object({ value: schema }),
{ value }
).then((r) => r?.value)
: validateForm<TFormModel>(() => schema, value));
this.errors = await this.validate(schema, value, isPrimitive);
if (errors && Object.keys(errors).length > 0) {
this.errors = errors as FormikErrors<TFormModel> | undefined;
if (this.errors && Object.keys(this.errors).length > 0) {
this.form?.$setValidity('form', false, this.form);
}
});
}
async validate(
schema: SchemaOf<TFormModel>,
value: TFormModel,
isPrimitive: boolean
): Promise<ValidationResult<TFormModel>> {
return this.$async(async () => {
if (isPrimitive) {
const result = await validateForm<{ value: TFormModel }>(
() => object({ value: schema }),
{ value }
);
return result?.value as ValidationResult<TFormModel>;
}
return validateForm<TFormModel>(() => schema, value);
});
}
async $onChanges(changes: {
values?: { currentValue: TFormModel };
validationData?: { currentValue: TData };

View File

@@ -0,0 +1,19 @@
import { NumberSchema, number } from 'yup';
/**
* Returns a Yup schema for a number that can also be NaN.
*
* This function is a workaround for a known issue in Yup where it throws type errors
* when the number input is empty, having a value NaN. Yup doesn't like NaN values.
* More details can be found in these GitHub issues:
* https://github.com/jquense/yup/issues/1330
* https://github.com/jquense/yup/issues/211
*
* @param errorMessage The custom error message to display when the value is required.
* @returns A Yup number schema with a custom type error message.
*/
export function nanNumberSchema(
errorMessage = 'Value is required'
): NumberSchema {
return number().typeError(errorMessage);
}

View File

@@ -108,6 +108,8 @@ async function renderComponent(
const state = { user };
server.use(
http.get('/api/endpoints/1', () => HttpResponse.json({})),
http.get('/api/endpoints/:endpointId/azure/subscriptions', () =>
HttpResponse.json(createMockSubscriptions(subscriptionsCount), {
status: subscriptionsStatus,

View File

@@ -1,8 +1,10 @@
import userEvent from '@testing-library/user-event';
import { HttpResponse } from 'msw';
import { UserContext } from '@/react/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { http, server } from '@/setup-tests/server';
import { CreateContainerInstanceForm } from './CreateContainerInstanceForm';
@@ -14,6 +16,8 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
}));
test('submit button should be disabled when name or image is missing', async () => {
server.use(http.get('/api/endpoints/5', () => HttpResponse.json({})));
const user = new UserViewModel({ Username: 'user' });
const { findByText, getByText, getByLabelText } = renderWithQueryClient(
@@ -29,15 +33,15 @@ test('submit button should be disabled when name or image is missing', async ()
expect(button).toBeDisabled();
const nameInput = getByLabelText(/name/i);
userEvent.type(nameInput, 'name');
await userEvent.type(nameInput, 'name');
const imageInput = getByLabelText(/image/i);
userEvent.type(imageInput, 'image');
await userEvent.type(imageInput, 'image');
await expect(findByText(/Deploy the container/)).resolves.toBeEnabled();
expect(nameInput).toHaveValue('name');
userEvent.clear(nameInput);
await userEvent.clear(nameInput);
await expect(findByText(/Deploy the container/)).resolves.toBeDisabled();
});

View File

@@ -4,7 +4,7 @@ import { Plus } from 'lucide-react';
import { ContainerInstanceFormValues } from '@/react/azure/types';
import * as notifications from '@/portainer/services/notifications';
import { useUser } from '@/react/hooks/useUser';
import { useCurrentUser } from '@/react/hooks/useUser';
import { AccessControlForm } from '@/react/portainer/access-control/AccessControlForm';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
@@ -24,7 +24,7 @@ import { useCreateInstanceMutation } from './useCreateInstanceMutation';
export function CreateContainerInstanceForm() {
const environmentId = useEnvironmentId();
const { isAdmin } = useUser();
const { isPureAdmin } = useCurrentUser();
const { providers, subscriptions, resourceGroups, isLoading } =
useLoadFormState(environmentId);
@@ -49,7 +49,7 @@ export function CreateContainerInstanceForm() {
return (
<Formik<ContainerInstanceFormValues>
initialValues={initialValues}
validationSchema={() => validationSchema(isAdmin)}
validationSchema={() => validationSchema(isPureAdmin)}
onSubmit={onSubmit}
validateOnMount
validateOnChange

View File

@@ -37,7 +37,7 @@ export function useFormState(
resourceGroups: Record<string, ResourceGroup[]> = {},
providers: Record<string, ProviderViewModel> = {}
) {
const { isAdmin, user } = useCurrentUser();
const { user, isPureAdmin } = useCurrentUser();
const subscriptionOptions = subscriptions.map((s) => ({
value: s.subscriptionId,
@@ -67,7 +67,7 @@ export function useFormState(
cpu: 1,
ports: [{ container: 80, host: 80, protocol: 'TCP' }],
allocatePublicIP: true,
accessControl: parseAccessControlFormData(isAdmin, user.Id),
accessControl: parseAccessControlFormData(isPureAdmin, user.Id),
};
return {

View File

@@ -0,0 +1,26 @@
import { render } from '@/react-tools/test-utils';
import { Badge } from './Badge';
test('should render a Badge component with default type', () => {
const { getByText } = render(<Badge>Default Badge</Badge>);
const badgeElement = getByText('Default Badge');
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveClass('text-blue-9 bg-blue-2');
});
test('should render a Badge component with custom type', () => {
const { getByText } = render(<Badge type="success">Success Badge</Badge>);
const badgeElement = getByText('Success Badge');
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveClass('text-success-9 bg-success-2');
});
test('should render a Badge component with custom className', () => {
const { getByText } = render(
<Badge className="custom-class">Custom Badge</Badge>
);
const badgeElement = getByText('Custom Badge');
expect(badgeElement).toBeInTheDocument();
expect(badgeElement).toHaveClass('custom-class');
});

View File

@@ -69,10 +69,13 @@ export function Badge({
children,
}: PropsWithChildren<Props>) {
const baseClasses =
'flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
'inline-flex w-fit items-center !text-xs font-medium rounded-full px-2 py-0.5';
return (
<span className={clsx(baseClasses, typeClasses[type], className)}>
<span
className={clsx(baseClasses, typeClasses[type], className)}
role="status"
>
{children}
</span>
);

View File

@@ -28,6 +28,7 @@ export function BlocklistItem<T extends ElementType>({
'blocklist-item--selected': isSelected,
}
)}
role="listitem"
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
>

View File

@@ -10,7 +10,6 @@ export const editor: BoxSelectorOption<'editor'> = {
icon: Edit,
iconType: 'badge',
label: 'Web editor',
description: 'Use our Web editor',
value: 'editor',
};
@@ -19,7 +18,6 @@ export const upload: BoxSelectorOption<'upload'> = {
icon: UploadCloud,
iconType: 'badge',
label: 'Upload',
description: 'Upload from your computer',
value: 'upload',
};
@@ -28,7 +26,6 @@ export const git: BoxSelectorOption<'repository'> = {
icon: GitIcon,
iconType: 'logo',
label: 'Repository',
description: 'Use a git repository',
value: 'repository',
};
@@ -37,7 +34,7 @@ export const edgeStackTemplate: BoxSelectorOption<'template'> = {
icon: FileText,
iconType: 'badge',
label: 'Template',
description: 'Use an Edge stack template',
description: 'Use an Edge stack app or custom template',
value: 'template',
};
@@ -46,7 +43,6 @@ export const customTemplate: BoxSelectorOption<'template'> = {
icon: FileText,
iconType: 'badge',
label: 'Custom template',
description: 'Use a custom template',
value: 'template',
};
@@ -54,7 +50,6 @@ export const helm: BoxSelectorOption<'helm'> = {
id: 'method_helm',
icon: Helm,
label: 'Helm chart',
description: 'Use a Helm chart',
value: 'helm',
iconClass: '!text-[#0f1689] th-dark:!text-white th-highcontrast:!text-white',
};
@@ -64,6 +59,5 @@ export const url: BoxSelectorOption<'url'> = {
icon: Globe,
iconType: 'badge',
label: 'URL',
description: 'Specify a URL to a file',
value: 'url',
};

View File

@@ -0,0 +1,90 @@
import { FormikErrors } from 'formik';
import { ComponentProps } from 'react';
import { HttpResponse } from 'msw';
import { renderWithQueryClient, fireEvent } from '@/react-tools/test-utils';
import { http, server } from '@/setup-tests/server';
import { ImageConfigFieldset } from './ImageConfigFieldset';
import { Values } from './types';
vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
...(await importOriginal()),
useCurrentStateAndParams: vi.fn(() => ({
params: { endpointId: 1 },
})),
}));
it('should render SimpleForm when useRegistry is true', () => {
const { getByText } = render({ values: { useRegistry: true } });
expect(getByText('Advanced mode')).toBeInTheDocument();
});
it('should render AdvancedForm when useRegistry is false', () => {
const { getByText } = render({ values: { useRegistry: false } });
expect(getByText('Simple mode')).toBeInTheDocument();
});
it('should call setFieldValue with useRegistry set to false when "Advanced mode" button is clicked', () => {
const setFieldValue = vi.fn();
const { getByText } = render({
values: { useRegistry: true },
setFieldValue,
});
fireEvent.click(getByText('Advanced mode'));
expect(setFieldValue).toHaveBeenCalledWith('useRegistry', false);
});
it('should call setFieldValue with useRegistry set to true when "Simple mode" button is clicked', () => {
const setFieldValue = vi.fn();
const { getByText } = render({
values: { useRegistry: false },
setFieldValue,
});
fireEvent.click(getByText('Simple mode'));
expect(setFieldValue).toHaveBeenCalledWith('useRegistry', true);
});
function render({
values = {
useRegistry: true,
registryId: 123,
image: '',
},
errors = {},
setFieldValue = vi.fn(),
onChangeImage = vi.fn(),
onRateLimit = vi.fn(),
}: {
values?: Partial<Values>;
errors?: FormikErrors<Values>;
setFieldValue?: ComponentProps<typeof ImageConfigFieldset>['setFieldValue'];
onChangeImage?: ComponentProps<typeof ImageConfigFieldset>['onChangeImage'];
onRateLimit?: ComponentProps<typeof ImageConfigFieldset>['onRateLimit'];
} = {}) {
server.use(
http.get('/api/registries/:id', () => HttpResponse.json({})),
http.get('/api/endpoints/:id', () => HttpResponse.json({}))
);
return renderWithQueryClient(
<ImageConfigFieldset
values={{
useRegistry: true,
registryId: 123,
image: '',
...values,
}}
errors={errors}
setFieldValue={setFieldValue}
onChangeImage={onChangeImage}
onRateLimit={onRateLimit}
/>
);
}

View File

@@ -66,7 +66,7 @@ function RateLimitsInner({
environment: Environment;
}) {
const pullRateLimits = useRateLimits(registryId, environment, onRateLimit);
const { isAdmin } = useCurrentUser();
const { isPureAdmin } = useCurrentUser();
if (!pullRateLimits) {
return null;
@@ -88,7 +88,7 @@ function RateLimitsInner({
</>
) : (
<>
{isAdmin ? (
{isPureAdmin ? (
<>
You are currently using an anonymous account to pull images
from DockerHub and will be limited to 100 pulls every 6

View File

@@ -1,8 +1,8 @@
import { FormikErrors } from 'formik';
import _ from 'lodash';
import { useMemo } from 'react';
import { trimSHA, trimVersionTag } from 'Docker/filters/utils';
import { trimSHA, trimVersionTag } from 'Docker/filters/utils';
import DockerIcon from '@/assets/ico/vendor/docker.svg?c';
import { useImages } from '@/react/docker/proxy/queries/images/useImages';
import {

View File

@@ -42,7 +42,7 @@ test('should call onSelect when clicked with id', async () => {
const { findByText } = renderComponent(options, options[1].id, onSelect);
const heading = await findByText(options[0].label);
userEvent.click(heading);
await userEvent.click(heading);
expect(onSelect).toHaveBeenCalledWith(options[0].id);
});

View File

@@ -2,19 +2,23 @@ import { createContext, PropsWithChildren, useContext } from 'react';
const Context = createContext<null | boolean>(null);
Context.displayName = 'WidgetContext';
export function useWidgetContext() {
const context = useContext(Context);
if (context == null) {
throw new Error('Should be inside a Widget component');
}
}
export function Widget({ children }: PropsWithChildren<unknown>) {
export function Widget({
children,
id,
}: PropsWithChildren<{
id?: string;
}>) {
return (
<Context.Provider value>
<div className="widget">{children}</div>
<div id={id} className="widget">
{children}
</div>
</Context.Provider>
);
}

View File

@@ -58,6 +58,7 @@ export interface Props<D extends DefaultType> extends AutomationTestingProps {
emptyContentLabel?: string;
title?: string;
titleIcon?: IconProps['icon'];
titleId?: string;
initialTableState?: Partial<TableState>;
isLoading?: boolean;
description?: ReactNode;
@@ -78,6 +79,7 @@ export function Datatable<D extends DefaultType>({
getRowId = defaultGetRowId,
isRowSelectable = () => true,
title,
titleId,
titleIcon,
emptyContentLabel,
initialTableState = {},
@@ -172,6 +174,7 @@ export function Datatable<D extends DefaultType>({
onSearchChange={handleSearchBarChange}
searchValue={settings.search}
title={title}
titleId={titleId}
titleIcon={titleIcon}
description={description}
renderTableActions={() => renderTableActions(selectedItems)}
@@ -185,6 +188,7 @@ export function Datatable<D extends DefaultType>({
isLoading={isLoading}
onSortChange={handleSortChange}
data-cy={dataCy}
aria-label={`${title} table`}
/>
<DatatableFooter

View File

@@ -11,6 +11,7 @@ interface Props<D extends DefaultType> extends AutomationTestingProps {
onSortChange?(colId: string, desc: boolean): void;
isLoading?: boolean;
emptyContentLabel?: string;
'aria-label'?: string;
}
export function DatatableContent<D extends DefaultType>({
@@ -20,12 +21,13 @@ export function DatatableContent<D extends DefaultType>({
isLoading,
emptyContentLabel,
'data-cy': dataCy,
'aria-label': ariaLabel,
}: Props<D>) {
const headerGroups = tableInstance.getHeaderGroups();
const pageRowModel = tableInstance.getPaginationRowModel();
return (
<Table data-cy={dataCy} className="nowrap-cells">
<Table data-cy={dataCy} className="nowrap-cells" aria-label={ariaLabel}>
<thead>
{headerGroups.map((headerGroup) => (
<Table.HeaderRow<D>

View File

@@ -13,6 +13,7 @@ type Props = {
renderTableSettings?(): ReactNode;
renderTableActions?(): ReactNode;
description?: ReactNode;
titleId?: string;
};
export function DatatableHeader({
@@ -23,6 +24,7 @@ export function DatatableHeader({
title,
titleIcon,
description,
titleId,
}: Props) {
if (!title) {
return null;
@@ -37,7 +39,12 @@ export function DatatableHeader({
);
return (
<Table.Title label={title} icon={titleIcon} description={description}>
<Table.Title
id={titleId}
label={title}
icon={titleIcon}
description={description}
>
{searchBar}
{tableActions}
{tableTitleSettings}

View File

@@ -28,6 +28,8 @@ interface Props<D extends DefaultType> {
* keyword to filter by
*/
search?: string;
'aria-label'?: string;
}
export function NestedDatatable<D extends DefaultType>({
@@ -39,6 +41,7 @@ export function NestedDatatable<D extends DefaultType>({
isLoading,
initialSortBy,
search,
'aria-label': ariaLabel,
}: Props<D>) {
const tableInstance = useReactTable<D>({
columns,
@@ -70,6 +73,7 @@ export function NestedDatatable<D extends DefaultType>({
isLoading={isLoading}
emptyContentLabel={emptyContentLabel}
renderRow={(row) => <Table.Row<D> cells={row.getVisibleCells()} />}
aria-label={ariaLabel}
/>
</Table.Container>
</NestedTable>

View File

@@ -42,6 +42,7 @@ export function SearchBar({
onChange={(e) => setSearchValue(e.target.value)}
placeholder={placeholder}
data-cy={dataCy}
aria-label="Search input"
/>
{children}
<Button onClick={onClear} icon={X} color="none" disabled={!searchValue} />

View File

@@ -1,5 +1,5 @@
import clsx from 'clsx';
import { PropsWithChildren } from 'react';
import { AriaAttributes, PropsWithChildren } from 'react';
import { AutomationTestingProps } from '@/types';
@@ -14,23 +14,21 @@ import { TableHeaderCell } from './TableHeaderCell';
import { TableHeaderRow } from './TableHeaderRow';
import { TableRow } from './TableRow';
interface Props extends AutomationTestingProps {
interface Props extends AutomationTestingProps, AriaAttributes {
className?: string;
}
function MainComponent({
children,
className,
'data-cy': dataCy,
...props
}: PropsWithChildren<Props>) {
return (
<div className="table-responsive">
<table
data-cy={dataCy}
className={clsx(
'table-hover table-filters nowrap-cells table',
className
)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
className={clsx('table-hover table-filters table', className)}
>
{children}
</table>

View File

@@ -8,6 +8,7 @@ interface Props {
label: string;
description?: ReactNode;
className?: string;
id?: string;
}
export function TableTitle({
@@ -16,12 +17,13 @@ export function TableTitle({
children,
description,
className,
id,
}: PropsWithChildren<Props>) {
return (
<>
<div className={clsx('toolBar flex-col', className)}>
<div className={clsx('toolBar flex-col', className)} id={id}>
<div className="flex w-full items-center gap-1 p-0">
<div className="toolBarTitle">
<h2 className="toolBarTitle m-0 text-2xl">
{icon && (
<div className="widget-icon">
<Icon icon={icon} className="space-right" />
@@ -29,7 +31,7 @@ export function TableTitle({
)}
{label}
</div>
</h2>
{children}
</div>
</div>

View File

@@ -17,6 +17,7 @@ export function buildExpandColumn<T extends DefaultType>(): ColumnDef<T> {
onClick={table.getToggleAllRowsExpandedHandler()}
color="none"
icon={table.getIsAllRowsExpanded() ? ChevronDown : ChevronUp}
title="Expand all"
/>
)
);
@@ -32,6 +33,7 @@ export function buildExpandColumn<T extends DefaultType>(): ColumnDef<T> {
}}
color="none"
icon={row.getIsExpanded() ? ChevronDown : ChevronUp}
title={row.getIsExpanded() ? 'Collapse' : 'Expand'}
/>
),
enableColumnFilter: false,

View File

@@ -12,6 +12,8 @@ export const InputWithRef = forwardRef<
export function Input({
className,
mRef: ref,
value,
type,
...props
}: InputHTMLAttributes<HTMLInputElement> & {
mRef?: Ref<HTMLInputElement>;
@@ -20,6 +22,8 @@ export function Input({
<input
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
type={type}
value={type === 'number' && Number.isNaN(value) ? '' : value} // avoid the `"NaN" cannot be parsed, or is out of range.` error for an empty number input
ref={ref}
className={clsx('form-control', className)}
/>

View File

@@ -215,8 +215,8 @@ export function InputList<T = DefaultType>({
</Button>
</div>
{addButtonError && (
<div className="col-sm-12">
<FormError>{addButtonError}</FormError>
<div className="col-sm-12 mt-1">
<TextTip color="blue">{addButtonError}</TextTip>
</div>
)}
</>

View File

@@ -4,6 +4,7 @@ import {
SelectComponentsConfig,
} from 'react-select';
import _ from 'lodash';
import { AriaAttributes } from 'react';
import { AutomationTestingProps } from '@/types';
@@ -20,7 +21,9 @@ type Options<TValue> = OptionsOrGroups<
GroupBase<Option<TValue>>
>;
interface SharedProps extends AutomationTestingProps {
interface SharedProps
extends AutomationTestingProps,
Pick<AriaAttributes, 'aria-label'> {
name?: string;
inputId?: string;
placeholder?: string;
@@ -87,6 +90,8 @@ export function SingleSelect<TValue = string>({
components,
isLoading,
noOptionsMessage,
isMulti,
...aria
}: SingleProps<TValue>) {
const selectedValue =
value || (typeof value === 'number' && value === 0)
@@ -111,6 +116,8 @@ export function SingleSelect<TValue = string>({
components={components}
isLoading={isLoading}
noOptionsMessage={noOptionsMessage}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
/>
);
}
@@ -152,6 +159,7 @@ export function MultiSelect<TValue = string>({
components,
isLoading,
noOptionsMessage,
...aria
}: Omit<MultiProps<TValue>, 'isMulti'>) {
const selectedOptions = findSelectedOptions(options, value);
return (
@@ -174,6 +182,8 @@ export function MultiSelect<TValue = string>({
components={components}
isLoading={isLoading}
noOptionsMessage={noOptionsMessage}
// eslint-disable-next-line react/jsx-props-no-spreading
{...aria}
/>
);
}

View File

@@ -20,7 +20,7 @@ export function SliderWithInput({
visibleTooltip?: boolean;
}) {
return (
<div className="flex items-center gap-4">
<div className="flex items-center gap-6">
{max && (
<div className="mr-2 flex-1">
<Slider
@@ -41,9 +41,7 @@ export function SliderWithInput({
min="0"
max={max}
value={value}
onChange={({ target: { valueAsNumber: value } }) =>
onChange(Number.isNaN(value) ? 0 : value)
}
onChange={(e) => onChange(e.target.valueAsNumber)}
className="w-32"
data-cy={`${dataCy}Input`}
/>

View File

@@ -10,7 +10,7 @@ import { Values } from './BaseForm';
export function toViewModel(
config: ContainerResponse,
isAdmin: boolean,
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string,
image: Values['image'],
@@ -18,7 +18,7 @@ export function toViewModel(
): Values {
// accessControl shouldn't be copied to new container
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId);
if (config.Portainer?.ResourceControl?.Public) {
accessControl.ownership = ResourceControlOwnership.PUBLIC;
@@ -38,11 +38,11 @@ export function toViewModel(
}
export function getDefaultViewModel(
isAdmin: boolean,
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string
): Values {
const accessControl = parseAccessControlFormData(isAdmin, currentUserId);
const accessControl = parseAccessControlFormData(isPureAdmin, currentUserId);
return {
nodeName,

View File

@@ -2,7 +2,7 @@ import { Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { useEffect, useState } from 'react';
import { useCurrentUser, useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { useIsEdgeAdmin, useIsEnvironmentAdmin } from '@/react/hooks/useUser';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useCurrentEnvironment } from '@/react/hooks/useCurrentEnvironment';
import { useEnvironmentRegistries } from '@/react/portainer/environments/queries/useEnvironmentRegistries';
@@ -48,8 +48,8 @@ function CreateForm() {
const environmentId = useEnvironmentId();
const router = useRouter();
const { trackEvent } = useAnalytics();
const { isAdmin } = useCurrentUser();
const isEnvironmentAdmin = useIsEnvironmentAdmin();
const isAdminQuery = useIsEdgeAdmin();
const { authorized: isEnvironmentAdmin } = useIsEnvironmentAdmin();
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
const mutation = useCreateOrReplaceMutation();
@@ -67,7 +67,7 @@ function CreateForm() {
const envQuery = useCurrentEnvironment();
const validationSchema = useValidation({
isAdmin,
isAdmin: isAdminQuery.isAdmin,
maxCpu,
maxMemory,
isDuplicating: initialValuesQuery?.isDuplicating,

View File

@@ -41,7 +41,7 @@ export function InnerForm({
const environmentId = useEnvironmentId();
const [tab, setTab] = useState('commands');
const apiVersion = useApiVersion(environmentId);
const isEnvironmentAdmin = useIsEnvironmentAdmin();
const isEnvironmentAdminQuery = useIsEnvironmentAdmin();
const envQuery = useCurrentEnvironment();
if (!envQuery.data) {
@@ -102,7 +102,7 @@ export function InnerForm({
}
errors={errors.volumes}
allowBindMounts={
isEnvironmentAdmin ||
isEnvironmentAdminQuery.authorized ||
environment.SecuritySettings
.allowBindMountsForRegularUsers
}
@@ -166,18 +166,18 @@ export function InnerForm({
setFieldValue(`resources.${field}`, value)
}
allowPrivilegedMode={
isEnvironmentAdmin ||
isEnvironmentAdminQuery.authorized ||
environment.SecuritySettings
.allowPrivilegedModeForRegularUsers
}
isDevicesFieldVisible={
isEnvironmentAdmin ||
isEnvironmentAdminQuery.authorized ||
environment.SecuritySettings
.allowDeviceMappingForRegularUsers
}
isInitFieldVisible={apiVersion >= 1.37}
isSysctlFieldVisible={
isEnvironmentAdmin ||
isEnvironmentAdminQuery.authorized ||
environment.SecuritySettings
.allowSysctlSettingForRegularUsers
}

View File

@@ -1,8 +1,9 @@
import { FormikErrors } from 'formik';
import { number, object, SchemaOf } from 'yup';
import { object, SchemaOf } from 'yup';
import { useSystemLimits } from '@/react/docker/proxy/queries/useInfo';
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { nanNumberSchema } from '@/react-tools/yup-schemas';
import { FormControl } from '@@/form-components/FormControl';
import { FormSection } from '@@/form-components/FormSection';
@@ -94,15 +95,15 @@ export function resourcesValidation({
maxCpu?: number;
} = {}): SchemaOf<Values> {
return object({
reservation: number()
reservation: nanNumberSchema()
.min(0)
.max(maxMemory, `Value must be between 0 and ${maxMemory}`)
.default(0),
limit: number()
limit: nanNumberSchema()
.min(0)
.max(maxMemory, `Value must be between 0 and ${maxMemory}`)
.default(0),
cpu: number()
cpu: nanNumberSchema()
.min(0)
.max(maxCpu, `Value must be between 0 and ${maxCpu}`)
.default(0),

View File

@@ -62,7 +62,8 @@ export function useInitialValues(submitting: boolean) {
params: { nodeName, from },
} = useCurrentStateAndParams();
const environmentId = useEnvironmentId();
const { isAdmin, user } = useCurrentUser();
const { user, isPureAdmin } = useCurrentUser();
const networksQuery = useNetworksForSelector();
const fromContainerQuery = useContainer(environmentId, from, {
@@ -85,7 +86,7 @@ export function useInitialValues(submitting: boolean) {
if (!from) {
return {
initialValues: defaultValues(isAdmin, user.Id, nodeName),
initialValues: defaultValues(isPureAdmin, user.Id, nodeName),
};
}
@@ -136,7 +137,7 @@ export function useInitialValues(submitting: boolean) {
env: envVarsTabUtils.toViewModel(fromContainer),
...baseFormUtils.toViewModel(
fromContainer,
isAdmin,
isPureAdmin,
user.Id,
nodeName,
imageConfig,
@@ -148,7 +149,7 @@ export function useInitialValues(submitting: boolean) {
}
function defaultValues(
isAdmin: boolean,
isPureAdmin: boolean,
currentUserId: UserId,
nodeName: string
): Values {
@@ -161,6 +162,6 @@ function defaultValues(
resources: resourcesTabUtils.getDefaultViewModel(),
capabilities: capabilitiesTabUtils.getDefaultViewModel(),
env: envVarsTabUtils.getDefaultViewModel(),
...baseFormUtils.getDefaultViewModel(isAdmin, currentUserId, nodeName),
...baseFormUtils.getDefaultViewModel(isPureAdmin, currentUserId, nodeName),
};
}

View File

@@ -25,7 +25,7 @@ export function ConnectNetworkForm({
selectedNetworks: string[];
}) {
const environmentId = useEnvironmentId();
const authorized = useAuthorizations('DockerNetworkConnect');
const { authorized } = useAuthorizations('DockerNetworkConnect');
const connectMutation = useConnectContainerMutation(environmentId);
const router = useRouter();
if (!authorized) {

View File

@@ -68,7 +68,7 @@ export function ContainersDatatableActions({
].includes(item.Status)
);
const isAuthorized = useAuthorizations([
const { authorized } = useAuthorizations([
'DockerContainerStart',
'DockerContainerStop',
'DockerContainerKill',
@@ -81,12 +81,12 @@ export function ContainersDatatableActions({
const router = useRouter();
if (!isAuthorized) {
if (!authorized) {
return null;
}
return (
<>
<div className="flex gap-2">
<ButtonGroup>
<Authorized authorizations="DockerContainerStart">
<Button
@@ -165,7 +165,6 @@ export function ContainersDatatableActions({
</Button>
</Authorized>
</ButtonGroup>
{isAddActionVisible && (
<Authorized authorizations="DockerContainerCreate">
<Link to="docker.containers.new" className="space-left">
@@ -173,7 +172,7 @@ export function ContainersDatatableActions({
</Link>
</Authorized>
)}
</>
</div>
);
function onStartClick(selectedItems: DockerContainer[]) {

View File

@@ -38,7 +38,7 @@ function QuickActionsCell({
wrapperState.showQuickActionLogs ||
wrapperState.showQuickActionStats;
const isAuthorized = useAuthorizations([
const { authorized } = useAuthorizations([
'DockerContainerStats',
'DockerContainerLogs',
'DockerExecStart',
@@ -47,7 +47,7 @@ function QuickActionsCell({
'DockerTaskLogs',
]);
if (!someOn || !isAuthorized) {
if (!someOn || !authorized) {
return null;
}

View File

@@ -1,6 +1,9 @@
import { HttpResponse } from 'msw';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { UserContext } from '@/react/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { http, server } from '@/setup-tests/server';
import { NetworkContainer } from '../types';
@@ -26,6 +29,8 @@ vi.mock('@uirouter/react', async (importOriginal: () => Promise<object>) => ({
}));
test('Network container values should be visible and the link should be valid', async () => {
server.use(http.get('/api/endpoints/1', () => HttpResponse.json({})));
const user = new UserViewModel({ Username: 'test', Role: 1 });
const { findByText } = renderWithQueryClient(
<UserContext.Provider value={{ user }}>

View File

@@ -1,6 +1,9 @@
import { render } from '@/react-tools/test-utils';
import { HttpResponse, http } from 'msw';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { UserContext } from '@/react/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
import { server } from '@/setup-tests/server';
import { DockerNetwork } from '../types';
@@ -48,9 +51,11 @@ test('Non system networks should have a delete button', async () => {
});
async function renderComponent(isAdmin: boolean, network: DockerNetwork) {
server.use(http.get('/api/endpoints/1', () => HttpResponse.json({})));
const user = new UserViewModel({ Username: 'test', Role: isAdmin ? 1 : 2 });
const queries = render(
const queries = renderWithQueryClient(
<UserContext.Provider value={{ user }}>
<NetworkDetailsTable
network={network}

View File

@@ -16,5 +16,11 @@ export function NestedNetworksDatatable({
const isSwarm = useIsSwarm(environmentId);
const columns = useColumns(isSwarm);
return <NestedDatatable columns={columns} dataset={dataset} />;
return (
<NestedDatatable
columns={columns}
dataset={dataset}
aria-label="Networks table"
/>
);
}

View File

@@ -1,6 +1,7 @@
import { truncate } from '@/portainer/filters/filters';
import { Link } from '@@/Link';
import { Badge } from '@@/Badge';
import { columnHelper } from './helper';
@@ -18,12 +19,9 @@ export const name = columnHelper.accessor('Name', {
{truncate(item.Name, 40)}
</Link>
{item.ResourceControl?.System && (
<span
style={{ marginLeft: '10px' }}
className="label label-info image-tag space-left"
>
<Badge type="info" className="ml-2">
System
</span>
</Badge>
)}
</>
);

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