Compare commits

...

50 Commits

Author SHA1 Message Date
Oscar Zhou
b2ac2b4c6d chore(lint): fix lint failure after latest merge 2024-02-15 20:29:51 +13: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
Dakota Walsh
5ee6efb145 fix(backup): restore over network share EE-6578 (#11044) 2024-02-01 11:41:32 +13:00
Matt Hook
a618ee78e4 fix(helm): minor helm screen page corrections [EE-6642] (#11045) 2024-02-01 11:34:33 +13:00
Ali
9a1604e775 fix(kubeclient): cache kubeclient by user token [EE-6610] (#11039) 2024-01-31 14:50:41 +13:00
Prabhat Khera
9615e678e6 chore(golang): version upgrade to 1.21.6 [EE-6634] (#11036) 2024-01-31 06:28:53 +13:00
Dakota Walsh
e39c19bcca fix(console): export LANG and LC_ALL for kube app console EE-6593 (#11037) 2024-01-30 15:19:53 +13:00
Matt Hook
16ae4f8681 fix(kube): change pod security policy teaser screen wording [EE-6629] (#11035) 2024-01-30 13:03:54 +13:00
Matt Hook
70deba50ba fix(kube): clear kube cache on login/logout [EE-6620] (#11026) 2024-01-30 10:39:12 +13:00
Dakota Walsh
89359dae8c ix(console): docker console UTF-8 EE-6593 (#11034) 2024-01-30 09:34:10 +13:00
Chaim Lev-Ari
97d227be2a fix(swarm/services): convert webhooks API filters to JSON on list request [EE-6621] (#11031)
Co-authored-by: matias-portainer <matias.spinarolli@portainer.io>
2024-01-29 18:08:25 +02:00
Matt Hook
8a98704111 fix(helm): increase default helm timeouts [EE-6617] 2024-01-29 13:03:11 +13:00
Prabhat Khera
46b2175729 fix(kubernetes): placement rules calculations [EE-6552] (#11013) 2024-01-29 08:00:15 +13:00
Chaim Lev-Ari
1561814fe5 feat(gitops): add autocomplete to ref selector [EE-6245] (#10935) 2024-01-28 15:55:10 +02:00
Chaim Lev-Ari
2826a4ce39 feat(custom-templates): filter templates by edge [EE-6565] (#10979) 2024-01-28 15:54:34 +02:00
Matt Hook
441a8bbbbf fix(helm): add clarifying text and new badge to helm user repo settings table [EE-6609] (#11018) 2024-01-26 12:37:13 +13:00
Ali
2248ce0173 fix(secret): update hide secret tooltip [EE-6568] (#11020)
Co-authored-by: testa113 <testa113>
2024-01-26 11:21:34 +13:00
Dakota Walsh
b640b58371 fix(console): use writeUtf8 instead of environment variables EE-6593 (#11019) 2024-01-26 11:21:00 +13:00
Ali
249b6bc628 fix(secrets): teaser wording updates [EE-6568] (#11017) 2024-01-26 10:28:57 +13:00
Chaim Lev-Ari
4a10c2bb07 feat(version): show git commit and env [EE-6021] (#10748) 2024-01-25 07:41:33 +02:00
Chaim Lev-Ari
52db4cba0e fix(storybook): fix msw stories [EE-6503] (#10985) 2024-01-24 10:06:38 +02:00
Chaim Lev-Ari
079bade139 refactor(kube/app): use structuredClone to copy object [EE-6581] (#11004) 2024-01-24 09:31:33 +02:00
Ali
26e52a0f00 fix(pods): don't add labels to old pod that has none [EE-6587] (#11009) 2024-01-24 14:44:15 +13:00
Ali
3ccc764d40 fix(images): update up to date teaser wording [EE-6537] (#11008)
Co-authored-by: testa113 <testa113>
2024-01-24 14:22:15 +13:00
Dakota Walsh
dd068473d2 fix(console): minor typo in tooltip EE-1976 (#11007) 2024-01-24 12:02:56 +13:00
Dakota Walsh
fe47318e26 fix(terminal): display os specific copy/paste tooltip EE-1976 (#10835) 2024-01-24 09:45:40 +13:00
Dakota Walsh
fc7d9ca2cd fix(secrets): add CE teaser EE-6568 (#11001) 2024-01-24 09:44:50 +13:00
Ali
7bf346bd2d fix(app): no summary for existing pvc on edit [EE-6569] (#11003) 2024-01-24 08:09:59 +13:00
Chaim Lev-Ari
8f0f9d7aaa fix(ui): stub unused modules [EE-6583] (#11006) 2024-01-23 15:22:56 +02:00
214 changed files with 3143 additions and 1303 deletions

View File

@@ -5,7 +5,7 @@ on:
push:
branches:
- 'develop'
- '!release/*'
- 'release/*'
pull_request:
branches:
- 'develop'
@@ -20,9 +20,9 @@ on:
- ready_for_review
env:
DOCKER_HUB_REPO: portainerci/portainer
NODE_ENV: testing
GO_VERSION: 1.21.5
DOCKER_HUB_REPO: portainerci/portainer-ce
EXTENSION_HUB_REPO: portainerci/portainer-docker-extension
GO_VERSION: 1.21.6
NODE_VERSION: 18.x
jobs:
@@ -30,86 +30,72 @@ 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)
export WEBPACK_VERSION=$(yarn list webpack --depth=0 | grep webpack | awk -F@ '{print $2}')
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 }}
@@ -121,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

@@ -18,7 +18,7 @@ on:
- ready_for_review
env:
GO_VERSION: 1.21.5
GO_VERSION: 1.21.6
NODE_VERSION: 18.x
jobs:

View File

@@ -6,7 +6,7 @@ on:
workflow_dispatch:
env:
GO_VERSION: 1.21.5
GO_VERSION: 1.21.6
jobs:
client-dependencies:

View File

@@ -14,7 +14,7 @@ on:
- '.github/workflows/pr-security.yml'
env:
GO_VERSION: 1.21.3
GO_VERSION: 1.21.6
NODE_VERSION: 18.x
jobs:

View File

@@ -1,7 +1,7 @@
name: Test
env:
GO_VERSION: 1.21.5
GO_VERSION: 1.21.6
NODE_VERSION: 18.x
on:

View File

@@ -13,7 +13,7 @@ on:
- ready_for_review
env:
GO_VERSION: 1.21.5
GO_VERSION: 1.21.6
NODE_VERSION: 18.x
jobs:

View File

@@ -3,6 +3,7 @@ import { StorybookConfig } from '@storybook/react-webpack5';
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin';
import { Configuration } from 'webpack';
import postcss from 'postcss';
const config: StorybookConfig = {
stories: ['../app/**/*.stories.@(ts|tsx)'],
addons: [
@@ -87,9 +88,6 @@ const config: StorybookConfig = {
name: '@storybook/react-webpack5',
options: {},
},
docs: {
autodocs: true,
},
};
export default config;

View File

@@ -1,23 +1,26 @@
import '../app/assets/css';
import React from 'react';
import { pushStateLocationPlugin, UIRouter } from '@uirouter/react';
import { initialize as initMSW, mswDecorator } from 'msw-storybook-addon';
import { handlers } from '@/setup-tests/server-handlers';
import { initialize as initMSW, mswLoader } from 'msw-storybook-addon';
import { handlers } from '../app/setup-tests/server-handlers';
import { QueryClient, QueryClientProvider } from 'react-query';
// Initialize MSW
initMSW({
onUnhandledRequest: ({ method, url }) => {
if (url.pathname.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.
initMSW(
{
onUnhandledRequest: ({ method, url }) => {
console.log(method, url);
if (url.startsWith('/api')) {
console.error(`Unhandled ${method} request to ${url}.
This exception has been only logged in the console, however, it's strongly recommended to resolve this error as you don't want unmocked data in Storybook stories.
If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses
`);
}
}
},
},
});
handlers
);
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
@@ -44,5 +47,6 @@ export const decorators = [
</UIRouter>
</QueryClientProvider>
),
mswDecorator,
];
export const loaders = [mswLoader];

View File

@@ -2,22 +2,22 @@
/* tslint:disable */
/**
* Mock Service Worker (0.36.3).
* Mock Service Worker (2.0.11).
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
* - Please do NOT serve this file on production.
*/
const INTEGRITY_CHECKSUM = '02f4ad4a2797f85668baf196e553d929';
const bypassHeaderName = 'x-msw-bypass';
const INTEGRITY_CHECKSUM = 'c5f7f8e188b673ea4e677df7ea3c5a39';
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse');
const activeClientIds = new Set();
self.addEventListener('install', function () {
return self.skipWaiting();
self.skipWaiting();
});
self.addEventListener('activate', async function (event) {
return self.clients.claim();
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
self.addEventListener('message', async function (event) {
@@ -33,7 +33,9 @@ self.addEventListener('message', async function (event) {
return;
}
const allClients = await self.clients.matchAll();
const allClients = await self.clients.matchAll({
type: 'window',
});
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
@@ -83,165 +85,8 @@ self.addEventListener('message', async function (event) {
}
});
// Resolve the "main" client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (client.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll();
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event);
const response = await getResponse(event, client, requestId);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
(async function () {
const clonedResponse = response.clone();
sendToClient(client, {
type: 'RESPONSE',
payload: {
requestId,
type: clonedResponse.type,
ok: clonedResponse.ok,
status: clonedResponse.status,
statusText: clonedResponse.statusText,
body: clonedResponse.body === null ? null : await clonedResponse.text(),
headers: serializeHeaders(clonedResponse.headers),
redirected: clonedResponse.redirected,
},
});
})();
}
return response;
}
async function getResponse(event, client, requestId) {
const { request } = event;
const requestClone = request.clone();
const getOriginalResponse = () => fetch(requestClone);
// Bypass mocking when the request client is not active.
if (!client) {
return getOriginalResponse();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return await getOriginalResponse();
}
// Bypass requests with the explicit bypass header
if (requestClone.headers.get(bypassHeaderName) === 'true') {
const cleanRequestHeaders = serializeHeaders(requestClone.headers);
// Remove the bypass header to comply with the CORS preflight check.
delete cleanRequestHeaders[bypassHeaderName];
const originalRequest = new Request(requestClone, {
headers: new Headers(cleanRequestHeaders),
});
return fetch(originalRequest);
}
// Send the request to the client-side MSW.
const reqHeaders = serializeHeaders(request.headers);
const body = await request.text();
const clientMessage = await sendToClient(client, {
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
method: request.method,
headers: reqHeaders,
cache: request.cache,
mode: request.mode,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body,
bodyUsed: request.bodyUsed,
keepalive: request.keepalive,
},
});
switch (clientMessage.type) {
case 'MOCK_SUCCESS': {
return delayPromise(() => respondWithMock(clientMessage), clientMessage.payload.delay);
}
case 'MOCK_NOT_FOUND': {
return getOriginalResponse();
}
case 'NETWORK_ERROR': {
const { name, message } = clientMessage.payload;
const networkError = new Error(message);
networkError.name = name;
// Rejecting a request Promise emulates a network error.
throw networkError;
}
case 'INTERNAL_ERROR': {
const parsedBody = JSON.parse(clientMessage.payload.body);
console.error(
`\
[MSW] Uncaught exception in the request handler for "%s %s":
${parsedBody.location}
This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
`,
request.method,
request.url
);
return respondWithMock(clientMessage);
}
}
return getOriginalResponse();
}
self.addEventListener('fetch', function (event) {
const { request } = event;
const accept = request.headers.get('accept') || '';
// Bypass server-sent events.
if (accept.includes('text/event-stream')) {
return;
}
// Bypass navigation requests.
if (request.mode === 'navigate') {
@@ -261,36 +106,149 @@ self.addEventListener('fetch', function (event) {
return;
}
const requestId = uuidv4();
return event.respondWith(
handleRequest(event, requestId).catch((error) => {
if (error.name === 'NetworkError') {
console.warn('[MSW] Successfully emulated a network error for the "%s %s" request.', request.method, request.url);
return;
}
// At this point, any exception indicates an issue with the original request/response.
console.error(
`\
[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
request.method,
request.url,
`${error.name}: ${error.message}`
);
})
);
// Generate unique request ID.
const requestId = crypto.randomUUID();
event.respondWith(handleRequest(event, requestId));
});
function serializeHeaders(headers) {
const reqHeaders = {};
headers.forEach((value, name) => {
reqHeaders[name] = reqHeaders[name] ? [].concat(reqHeaders[name]).concat(value) : value;
});
return reqHeaders;
async function handleRequest(event, requestId) {
const client = await resolveMainClient(event);
const response = await getResponse(event, client, requestId);
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
(async function () {
const responseClone = response.clone();
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
requestId,
isMockedResponse: IS_MOCKED_RESPONSE in response,
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
body: responseClone.body,
headers: Object.fromEntries(responseClone.headers.entries()),
},
},
[responseClone.body]
);
})();
}
return response;
}
function sendToClient(client, message) {
// Resolve the main client for the given event.
// Client that issues a request doesn't necessarily equal the client
// that registered the worker. It's with the latter the worker should
// communicate with during the response resolving phase.
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId);
if (client?.frameType === 'top-level') {
return client;
}
const allClients = await self.clients.matchAll({
type: 'window',
});
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible';
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id);
});
}
async function getResponse(event, client, requestId) {
const { request } = event;
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = request.clone();
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries());
// Remove internal MSW request header so the passthrough request
// complies with any potential CORS preflight checks on the server.
// Some servers forbid unknown request headers.
delete headers['x-msw-intention'];
return fetch(requestClone, { headers });
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough();
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough();
}
// Bypass requests with the explicit bypass header.
// Such requests can be issued by "ctx.fetch()".
const mswIntention = request.headers.get('x-msw-intention');
if (['bypass', 'passthrough'].includes(mswIntention)) {
return passthrough();
}
// Notify the client that a request has been intercepted.
const requestBuffer = await request.arrayBuffer();
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: requestBuffer,
keepalive: request.keepalive,
},
},
[requestBuffer]
);
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data);
}
case 'MOCK_NOT_FOUND': {
return passthrough();
}
}
return passthrough();
}
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel();
@@ -302,27 +260,25 @@ function sendToClient(client, message) {
resolve(event.data);
};
client.postMessage(JSON.stringify(message), [channel.port2]);
client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean)));
});
}
function delayPromise(cb, duration) {
return new Promise((resolve) => {
setTimeout(() => resolve(cb()), duration);
});
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error();
}
function respondWithMock(clientMessage) {
return new Response(clientMessage.payload.body, {
...clientMessage.payload,
headers: clientMessage.payload.headers,
});
}
const mockedResponse = new Response(response.body, response);
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c == 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
});
return mockedResponse;
}

View File

@@ -82,14 +82,8 @@ func CreateBackupArchive(password string, gate *offlinegate.OfflineGate, datasto
}
func backupDb(backupDirPath string, datastore dataservices.DataStore) error {
backupWriter, err := os.Create(filepath.Join(backupDirPath, "portainer.db"))
if err != nil {
return err
}
if err = datastore.BackupTo(backupWriter); err != nil {
return err
}
return backupWriter.Close()
_, err := datastore.Backup(filepath.Join(backupDirPath, "portainer.db"))
return err
}
func encrypt(path string, passphrase string) (string, error) {

View File

@@ -1,9 +1,12 @@
package build
import "runtime"
// Variables to be set during the build time
var BuildNumber string
var ImageTag string
var NodejsVersion string
var YarnVersion string
var WebpackVersion string
var GoVersion string
var GoVersion string = runtime.Version()
var GitCommit string

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

@@ -1,8 +1,6 @@
package dataservices
import (
"io"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/database/models"
)
@@ -46,7 +44,7 @@ type (
MigrateData() error
Rollback(force bool) error
CheckCurrentEdition() error
BackupTo(w io.Writer) error
Backup(path string) (string, error)
Export(filename string) (err error)
DataStoreTx

View File

@@ -9,12 +9,19 @@ import (
"github.com/rs/zerolog/log"
)
func (store *Store) Backup() (string, error) {
// Backup takes an optional output path and creates a backup of the database.
// The database connection is stopped before running the backup to avoid any
// corruption and if a path is not given a default is used.
// The path or an error are returned.
func (store *Store) Backup(path string) (string, error) {
if err := store.createBackupPath(); err != nil {
return "", err
}
backupFilename := store.backupFilename()
if path != "" {
backupFilename = path
}
log.Info().Str("from", store.connection.GetDatabaseFilePath()).Str("to", backupFilename).Msgf("Backing up database")
// Close the store before backing up
@@ -69,7 +76,7 @@ func (store *Store) RestoreFromFile(backupFilename string) error {
func (store *Store) createBackupPath() error {
backupDir := path.Join(store.connection.GetStorePath(), "backups")
if exists, _ := store.fileService.FileExists(backupDir); !exists {
if err := os.MkdirAll(backupDir, 0700); err != nil {
if err := os.MkdirAll(backupDir, 0o700); err != nil {
return fmt.Errorf("unable to create backup folder: %w", err)
}
}

View File

@@ -39,7 +39,7 @@ func TestBackup(t *testing.T) {
SchemaVersion: portainer.APIVersion,
}
store.VersionService.UpdateVersion(&v)
store.Backup()
store.Backup("")
if !isFileExist(backupFileName) {
t.Errorf("Expect backup file to be created %s", backupFileName)
@@ -55,7 +55,7 @@ func TestRestore(t *testing.T) {
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup()
store.Backup("")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)
store.Restore()
@@ -68,7 +68,7 @@ func TestRestore(t *testing.T) {
// override and set initial db version and edition
updateEdition(store, portainer.PortainerCE)
updateVersion(store, "2.4")
store.Backup()
store.Backup("")
updateVersion(store, "2.14")
updateVersion(store, "2.16")
testVersion(store, "2.16", t)

View File

@@ -31,7 +31,7 @@ func (store *Store) Open() (newStore bool, err error) {
}
if encryptionReq {
backupFilename, err := store.Backup()
backupFilename, err := store.Backup("")
if err != nil {
return false, fmt.Errorf("failed to backup database prior to encrypting: %w", err)
}

View File

@@ -40,7 +40,7 @@ func (store *Store) MigrateData() error {
}
// before we alter anything in the DB, create a backup
_, err = store.Backup()
_, err = store.Backup("")
if err != nil {
return errors.Wrap(err, "while backing up database")
}
@@ -131,7 +131,6 @@ func (store *Store) FailSafeMigrate(migrator *migrator.Migrator, version *models
// Rollback to a pre-upgrade backup copy/snapshot of portainer.db
func (store *Store) connectionRollback(force bool) error {
if !force {
confirmed, err := cli.Confirm("Are you sure you want to rollback your database to the previous backup?")
if err != nil || !confirmed {

View File

@@ -165,7 +165,7 @@ func TestRollback(t *testing.T) {
_, store := MustNewTestStore(t, false, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
_, err := store.Backup("")
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -199,7 +199,7 @@ func TestRollback(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
store.VersionService.UpdateVersion(&v)
_, err := store.Backup()
_, err := store.Backup("")
if err != nil {
log.Fatal().Err(err).Msg("")
}
@@ -305,7 +305,7 @@ func migrateDBTestHelper(t *testing.T, srcPath, wantPath string, overrideInstanc
os.WriteFile(
gotPath,
gotJSON,
0600,
0o600,
)
t.Errorf(
"migrate data from %s to %s failed\nwrote migrated input to %s\nmismatch (-want +got):\n%s",

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

@@ -8,8 +8,11 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/slices"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/rs/zerolog/log"
)
// @id CustomTemplateList
@@ -21,6 +24,7 @@ import (
// @security jwt
// @produce json
// @param type query []int true "Template types" Enums(1,2,3)
// @param edge query boolean false "Filter by edge templates"
// @success 200 {array} portainer.CustomTemplate "Success"
// @failure 500 "Server error"
// @router /custom_templates [get]
@@ -30,6 +34,8 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
return httperror.BadRequest("Invalid Custom template type", err)
}
edge := retrieveEdgeParam(r)
customTemplates, err := handler.DataStore.CustomTemplate().ReadAll()
if err != nil {
return httperror.InternalServerError("Unable to retrieve custom templates from the database", err)
@@ -63,9 +69,37 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
customTemplates = filterByType(customTemplates, templateTypes)
if edge != nil {
customTemplates = slices.Filter(customTemplates, func(customTemplate portainer.CustomTemplate) bool {
return customTemplate.EdgeTemplate == *edge
})
}
for i := range customTemplates {
customTemplate := &customTemplates[i]
if customTemplate.GitConfig != nil && customTemplate.GitConfig.Authentication != nil {
customTemplate.GitConfig.Authentication.Password = ""
}
}
return response.JSON(w, customTemplates)
}
func retrieveEdgeParam(r *http.Request) *bool {
var edge *bool
edgeParam, _ := request.RetrieveQueryParameter(r, "edge", true)
if edgeParam != "" {
edgeVal, err := strconv.ParseBool(edgeParam)
if err != nil {
log.Warn().Err(err).Msg("failed parsing edge param")
return nil
}
edge = &edgeVal
}
return edge
}
func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
err := r.ParseForm()
if err != nil {

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

@@ -126,7 +126,7 @@ func (h *Handler) getProxyKubeClient(r *http.Request) (*cli.KubeClient, *httperr
return nil, httperror.Forbidden("Permission denied to access environment", err)
}
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
cli, ok := h.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
if !ok {
return nil, httperror.InternalServerError("Failed to lookup KubeClient", nil)
}
@@ -153,7 +153,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
}
// Check if we have a kubeclient against this auth token already, otherwise generate a new one
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Username)
_, ok := handler.KubernetesClientFactory.GetProxyKubeClient(strconv.Itoa(endpointID), tokenData.Token)
if ok {
next.ServeHTTP(w, r)
return
@@ -213,7 +213,7 @@ func (handler *Handler) kubeClientMiddleware(next http.Handler) http.Handler {
return
}
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Username, kubeCli)
handler.KubernetesClientFactory.SetProxyKubeClient(strconv.Itoa(int(endpoint.ID)), tokenData.Token, kubeCli)
next.ServeHTTP(w, r)
})
}

View File

@@ -47,7 +47,7 @@ func NewHandler(bouncer security.BouncerService,
authenticatedRouter := router.PathPrefix("/").Subrouter()
authenticatedRouter.Use(bouncer.AuthenticatedAccess)
authenticatedRouter.Handle("/version", http.HandlerFunc(h.version)).Methods(http.MethodGet)
authenticatedRouter.Handle("/version", httperror.LoggerHandler(h.version)).Methods(http.MethodGet)
authenticatedRouter.Handle("/nodes", httperror.LoggerHandler(h.systemNodesCount)).Methods(http.MethodGet)
authenticatedRouter.Handle("/info", httperror.LoggerHandler(h.systemInfo)).Methods(http.MethodGet)

View File

@@ -2,10 +2,13 @@ package system
import (
"net/http"
"os"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/build"
"github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/http/security"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/coreos/go-semver/semver"
@@ -32,6 +35,8 @@ type BuildInfo struct {
YarnVersion string
WebpackVersion string
GoVersion string
GitCommit string
Env []string `json:",omitempty"`
}
// @id systemVersion
@@ -44,7 +49,11 @@ type BuildInfo struct {
// @produce json
// @success 200 {object} versionResponse "Success"
// @router /system/version [get]
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
func (handler *Handler) version(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
isAdmin, err := security.IsAdmin(r)
if err != nil {
return httperror.Forbidden("Permission denied to access Portainer", err)
}
result := &versionResponse{
ServerVersion: portainer.APIVersion,
@@ -57,16 +66,21 @@ func (handler *Handler) version(w http.ResponseWriter, r *http.Request) {
YarnVersion: build.YarnVersion,
WebpackVersion: build.WebpackVersion,
GoVersion: build.GoVersion,
GitCommit: build.GitCommit,
},
}
if isAdmin {
result.Build.Env = os.Environ()
}
latestVersion := GetLatestVersion()
if HasNewerVersion(portainer.APIVersion, latestVersion) {
result.UpdateAvailable = true
result.LatestVersion = latestVersion
}
response.JSON(w, &result)
return response.JSON(w, &result)
}
func GetLatestVersion() string {

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

@@ -8,3 +8,16 @@ func Map[T, U any](s []T, f func(T) U) []U {
}
return result
}
// Filter returns a new slice containing only the elements of the slice for which the given predicate returns true
func Filter[T any](s []T, predicate func(T) bool) []T {
n := 0
for _, v := range s {
if predicate(v) {
s[n] = v
n++
}
}
return s[:n]
}

View File

@@ -0,0 +1,131 @@
package slices
import (
"strconv"
"testing"
"github.com/stretchr/testify/assert"
)
type filterTestCase[T any] struct {
name string
input []T
expected []T
predicate func(T) bool
}
func TestFilter(t *testing.T) {
intTestCases := []filterTestCase[int]{
{
name: "Filter even numbers",
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
expected: []int{2, 4, 6, 8},
predicate: func(n int) bool {
return n%2 == 0
},
},
{
name: "Filter odd numbers",
input: []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
expected: []int{1, 3, 5, 7, 9},
predicate: func(n int) bool {
return n%2 != 0
},
},
}
runTestCases(t, intTestCases)
stringTestCases := []filterTestCase[string]{
{
name: "Filter strings starting with 'A'",
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
expected: []string{"Apple", "Avocado", "Apricot"},
predicate: func(s string) bool {
return s[0] == 'A'
},
},
{
name: "Filter strings longer than 5 characters",
input: []string{"Apple", "Banana", "Avocado", "Grapes", "Apricot"},
expected: []string{"Banana", "Avocado", "Grapes", "Apricot"},
predicate: func(s string) bool {
return len(s) > 5
},
},
}
runTestCases(t, stringTestCases)
}
func runTestCases[T any](t *testing.T, testCases []filterTestCase[T]) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
is := assert.New(t)
result := Filter(testCase.input, testCase.predicate)
is.Equal(len(testCase.expected), len(result))
is.ElementsMatch(testCase.expected, result)
})
}
}
func TestMap(t *testing.T) {
intTestCases := []struct {
name string
input []int
expected []string
mapper func(int) string
}{
{
name: "Map integers to strings",
input: []int{1, 2, 3, 4, 5},
expected: []string{"1", "2", "3", "4", "5"},
mapper: func(n int) string {
return strconv.Itoa(n)
},
},
}
runMapTestCases(t, intTestCases)
stringTestCases := []struct {
name string
input []string
expected []int
mapper func(string) int
}{
{
name: "Map strings to integers",
input: []string{"1", "2", "3", "4", "5"},
expected: []int{1, 2, 3, 4, 5},
mapper: func(s string) int {
n, _ := strconv.Atoi(s)
return n
},
},
}
runMapTestCases(t, stringTestCases)
}
func runMapTestCases[T, U any](t *testing.T, testCases []struct {
name string
input []T
expected []U
mapper func(T) U
}) {
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
is := assert.New(t)
result := Map(testCase.input, testCase.mapper)
is.Equal(len(testCase.expected), len(result))
is.ElementsMatch(testCase.expected, result)
})
}
}

View File

@@ -1,7 +1,6 @@
package testhelpers
import (
"io"
"time"
portainer "github.com/portainer/portainer/api"
@@ -37,7 +36,7 @@ type testDatastore struct {
pendingActionsService dataservices.PendingActionsService
}
func (d *testDatastore) BackupTo(io.Writer) error { return nil }
func (d *testDatastore) Backup(path string) (string, error) { return "", nil }
func (d *testDatastore) Open() (bool, error) { return false, nil }
func (d *testDatastore) Init() error { return nil }
func (d *testDatastore) Close() error { return nil }
@@ -57,9 +56,11 @@ func (d *testDatastore) EndpointGroup() dataservices.EndpointGroupService { re
func (d *testDatastore) FDOProfile() dataservices.FDOProfileService {
return d.fdoProfile
}
func (d *testDatastore) EndpointRelation() dataservices.EndpointRelationService {
return d.endpointRelation
}
func (d *testDatastore) HelmUserRepository() dataservices.HelmUserRepositoryService {
return d.helmUserRepository
}
@@ -94,6 +95,7 @@ func (d *testDatastore) IsErrObjectNotFound(e error) bool {
func (d *testDatastore) Export(filename string) (err error) {
return nil
}
func (d *testDatastore) Import(filename string) (err error) {
return nil
}
@@ -119,10 +121,12 @@ func (s *stubSettingsService) BucketName() string { return "settings" }
func (s *stubSettingsService) Settings() (*portainer.Settings, error) {
return s.settings, nil
}
func (s *stubSettingsService) UpdateSettings(settings *portainer.Settings) error {
s.settings = settings
return nil
}
func WithSettingsService(settings *portainer.Settings) datastoreOption {
return func(d *testDatastore) {
d.settings = &stubSettingsService{
@@ -162,15 +166,19 @@ func (s *stubEdgeJobService) ReadAll() ([]portainer.EdgeJob, error) { return s.j
func (s *stubEdgeJobService) Read(ID portainer.EdgeJobID) (*portainer.EdgeJob, error) {
return nil, nil
}
func (s *stubEdgeJobService) Create(edgeJob *portainer.EdgeJob) error {
return nil
}
func (s *stubEdgeJobService) CreateWithID(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
return nil
}
func (s *stubEdgeJobService) Update(ID portainer.EdgeJobID, edgeJob *portainer.EdgeJob) error {
return nil
}
func (s *stubEdgeJobService) UpdateEdgeJobFunc(ID portainer.EdgeJobID, updateFunc func(edgeJob *portainer.EdgeJob)) error {
return nil
}
@@ -192,6 +200,7 @@ func (s *stubEndpointRelationService) BucketName() string { return "endpoint_rel
func (s *stubEndpointRelationService) EndpointRelations() ([]portainer.EndpointRelation, error) {
return s.relations, nil
}
func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID) (*portainer.EndpointRelation, error) {
for _, relation := range s.relations {
if relation.EndpointID == ID {
@@ -201,9 +210,11 @@ func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID)
return nil, errors.ErrObjectNotFound
}
func (s *stubEndpointRelationService) Create(EndpointRelation *portainer.EndpointRelation) error {
return nil
}
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
for i, r := range s.relations {
if r.EndpointID == ID {
@@ -213,6 +224,7 @@ func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.Endpoi
return nil
}
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
return nil
}
@@ -307,7 +319,7 @@ func (s *stubEndpointService) GetNextIdentifier() int {
}
func (s *stubEndpointService) EndpointsByTeamID(teamID portainer.TeamID) ([]portainer.Endpoint, error) {
var endpoints = make([]portainer.Endpoint, 0)
endpoints := make([]portainer.Endpoint, 0)
for _, e := range s.endpoints {
for t := range e.TeamAccessPolicies {

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

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

@@ -1 +0,0 @@
export function loadProgressBar() {}

View File

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

View File

@@ -183,8 +183,11 @@ angular.module('portainer.docker').controller('ContainerConsoleController', [
socket.onopen = function () {
$scope.state = states.connected;
term = new Terminal();
socket.send('export LANG=C.UTF-8\n');
socket.send('export LC_ALL=C.UTF-8\n');
socket.send('clear\n');
term.on('data', function (data) {
term.onData(function (data) {
socket.send(data);
});
var terminal_container = document.getElementById('terminal-container');

View File

@@ -69,10 +69,11 @@
</div>
</div>
<div ng-if="state !== states.disconnected">
<label
<label class="control-label text-left"
>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code></label
>
<code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code>
<terminal-tooltip> </terminal-tooltip>
</label>
<button type="button" class="btn btn-primary" ng-click="disconnect()">
<span ng-show="state === states.connected">Disconnect</span>
<span ng-show="state === states.connecting">Connecting...</span>

View File

@@ -171,7 +171,7 @@
</div>
<div class="col-sm-12">
<por-switch-field
label="'Show an image(s) up to date indicator for Stacks, Services and Containers'"
label="'Show image up to date indicators for Stacks, Services and Containers'"
checked="false"
name="'outOfDateImageToggle'"
label-class="'col-sm-7 col-lg-4'"

View File

@@ -62,7 +62,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') {
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,
}
: {}),
};
}
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

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

@@ -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"
@@ -40,7 +41,7 @@
<div ng-if="!allCharts.length" class="text-muted small mt-4"> No Helm charts found </div>
<div ng-if="$ctrl.loading" class="text-muted text-center">
Loading...
<div class="text-muted text-center"> Initial download of Helm Charts can take a few minutes </div>
<div class="text-muted text-center"> Initial download of Helm charts can take a few minutes </div>
</div>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div>

View File

@@ -65,7 +65,7 @@ export default class HelmTemplatesController {
Namespace: this.namespace,
};
await this.HelmService.install(this.endpoint.Id, payload);
this.Notifications.success('Success', 'Helm Chart successfully installed');
this.Notifications.success('Success', 'Helm chart successfully installed');
this.$analytics.eventTrack('kubernetes-helm-install', { category: 'kubernetes', metadata: { 'chart-name': this.state.chart.name } });
this.state.isEditorDirty = false;
this.$state.go('kubernetes.applications');

View File

@@ -88,7 +88,7 @@
data-cy="helm-install"
>
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
<span ng-hide="!$ctrl.state.actionInProgress">Helm installing in progress</span>
<span ng-hide="!$ctrl.state.actionInProgress">Installing Helm chart</span>
</button>
</div>
</div>

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

@@ -166,10 +166,16 @@ function createPayload(pod) {
const payload = createPayloadFactory();
payload.metadata.name = pod.Name;
payload.metadata.namespace = pod.Namespace;
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = pod.StackName;
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = pod.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pod.ApplicationOwner;
payload.metadata.labels = { ...(pod.Labels || {}), ...(pod.ServiceSelector || {}), ...payload.metadata.labels };
// it's possible for pods not to have labels. Keep labels empty in the oldpayload if there aren't any, otherwise patch will fail
// TODO: when migrating to react, the oldValues should just be the fetched manifest directly from the kube api
if (Object.keys(pod.Labels || {}).length || Object.keys(pod.ServiceSelector || {}).length) {
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = pod.StackName;
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = pod.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = pod.ApplicationOwner;
payload.metadata.labels = { ...(pod.Labels || {}), ...(pod.ServiceSelector || {}), ...payload.metadata.labels };
} else {
payload.metadata.labels = undefined;
}
if (pod.Note) {
payload.metadata.annotations[KubernetesPortainerApplicationNote] = pod.Note;
} else {

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>
@@ -226,9 +227,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 +405,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,
@@ -255,7 +258,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 {
@@ -290,7 +293,7 @@ class KubernetesCreateApplicationController {
onAutoScaleChange(values) {
return this.$async(async () => {
// when enabling the auto scaler, set the default values
if (!this.oldFormValues.AutoScaler.isUsed && values.isUsed) {
if (!this.formValues.AutoScaler.isUsed && values.isUsed) {
this.formValues.AutoScaler = {
isUsed: values.isUsed,
minReplicas: 1,
@@ -643,6 +646,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]"
@@ -63,7 +64,7 @@
</div>
<div class="form-group">
<label for="name" class="col-lg-2 col-sm-3 control-label text-left">Name</label>
<label for="name" class="col-lg-2 col-sm-3 control-label text-left" ng-class="{ required: ctrl.state.BuildMethod === ctrl.BuildMethods.HELM }">Name</label>
<div class="col-sm-8 small text-muted pt-[7px]" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
Resource names specified in the manifest will be used
</div>
@@ -85,11 +86,15 @@
</div>
</div>
<div class="w-fit mb-4">
<stack-name-label-insight></stack-name-label-insight>
</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"
stacks="ctrl.stacks"
></kube-stack-name>
<!-- !namespace -->

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

@@ -1,6 +1,6 @@
<page-header
ng-if="state.viewReady"
title="'Kubernetes security constraints'"
title="'Security constraints'"
breadcrumbs="[
{ label:'Environments', link:'portainer.endpoints' },
{ label:endpoint.Name, link:'portainer.endpoints.endpoint', linkParams:{id: endpoint.Id} },

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

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

View File

@@ -30,6 +30,7 @@ import { SearchBar } from '@@/datatables/SearchBar';
import { FallbackImage } from '@@/FallbackImage';
import { BadgeIcon } from '@@/BadgeIcon';
import { TeamsSelector } from '@@/TeamsSelector';
import { TerminalTooltip } from '@@/TerminalTooltip';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
import { Slider } from '@@/form-components/Slider';
import { TagButton } from '@@/TagButton';
@@ -84,6 +85,7 @@ export const ngModule = angular
'portainerTooltip',
r2a(Tooltip, ['message', 'position', 'className', 'setHtmlMessage', 'size'])
)
.component('terminalTooltip', r2a(TerminalTooltip, []))
.component('badge', r2a(Badge, ['type', 'className']))
.component('fileUploadField', fileUploadField)
.component('porSwitchField', switchField)

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

@@ -11,8 +11,8 @@ function CustomTemplateServiceFactory($sanitize, CustomTemplates, FileUploadServ
return CustomTemplates.get({ id }).$promise;
};
service.customTemplates = async function customTemplates(type) {
const templates = await CustomTemplates.query({ type }).$promise;
service.customTemplates = async function customTemplates(type, edge = false) {
const templates = await CustomTemplates.query({ type, edge }).$promise;
templates.forEach((template) => {
if (template.Note) {
template.Note = $('<p>').html($sanitize(template.Note)).find('img').remove().end().html();

View File

@@ -1,4 +1,5 @@
import { getCurrentUser } from '../users/queries/useLoadCurrentUser';
import * as userHelpers from '../users/user.helpers';
import { clear as clearSessionStorage } from './session-storage';
const DEFAULT_USER = 'admin';
@@ -25,6 +26,9 @@ angular.module('portainer.app').factory('Authentication', [
service.isAuthenticated = isAuthenticated;
service.getUserDetails = getUserDetails;
service.isAdmin = isAdmin;
service.isEdgeAdmin = isEdgeAdmin;
service.isPureAdmin = isPureAdmin;
service.hasAuthorizations = hasAuthorizations;
async function initAsync() {
try {
@@ -120,8 +124,36 @@ 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();
}
// 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 });
}
function hasAuthorizations(authorizations) {
const endpointId = EndpointProvider.endpointID();
if (isAdmin()) {
return true;
}
if (!user.endpointAuthorizations || !user.endpointAuthorizations[endpointId]) {
return false;
}
const userEndpointAuthorizations = user.endpointAuthorizations[endpointId];
return authorizations.some((authorization) => userEndpointAuthorizations[authorization]);
}
if (process.env.NODE_ENV === 'development') {

View File

@@ -2,7 +2,6 @@ import toastr from 'toastr';
import { notifyError, notifySuccess, notifyWarning } from './notifications';
vi.mock('toastr');
vi.spyOn(console, 'error').mockImplementation(() => vi.fn());
afterEach(() => {

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

@@ -1,6 +1,7 @@
import angular from 'angular';
import uuidv4 from 'uuid/v4';
import { getEnvironments } from '@/react/portainer/environments/environment.service';
import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper';
class AuthenticationController {
/* @ngInject */
@@ -261,6 +262,9 @@ class AuthenticationController {
this.LocalStorage.cleanLogoutReason();
}
// always clear the kubernetes cache on login
dispatchCacheRefreshEvent();
if (this.Authentication.isAuthenticated()) {
await this.postLoginSteps();
}

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

@@ -1,4 +1,5 @@
import angular from 'angular';
import { dispatchCacheRefreshEvent } from '@/portainer/services/http-request.helper';
class LogoutController {
/* @ngInject */
@@ -30,6 +31,9 @@ class LogoutController {
try {
await this.Authentication.logout();
} finally {
// always clear the kubernetes cache
dispatchCacheRefreshEvent();
this.LocalStorage.storeLogoutReason(error);
if (settings.OAuthLogoutURI && this.Authentication.getUserDetails().ID !== 1) {
this.$window.location.href = settings.OAuthLogoutURI;

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

@@ -29,15 +29,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

@@ -37,7 +37,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',
};

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

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

@@ -0,0 +1,42 @@
import { BROWSER_OS_PLATFORM } from '@/react/constants';
import { Tooltip } from '@@/Tip/Tooltip';
const editorConfig = {
mac: {
tooltip: (
<>
<div>Within the console:</div>
<div>Cmd+C - Copy</div>
<div>Cmd+V - Paste</div>
<div>or right-click -&gt; Copy/Paste</div>
</>
),
},
lin: {
tooltip: (
<>
<div>Within the console:</div>
<div>Ctrl+Insert - Copy</div>
<div>Shift+Insert - Paste</div>
<div>or right-click -&gt; Copy/Paste</div>
</>
),
},
win: {
tooltip: (
<>
<div>Within the console:</div>
<div>Ctrl+Insert - Copy</div>
<div>Shift+Insert - Paste</div>
<div>or right-click -&gt; Copy/Paste</div>
</>
),
},
} as const;
export function TerminalTooltip() {
return <Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />;
}

View File

@@ -0,0 +1 @@
export { TerminalTooltip } from './TerminalTooltip';

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

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

@@ -8,6 +8,7 @@ interface Props {
label: string;
description?: ReactNode;
className?: string;
id?: string;
}
export function TableTitle({
@@ -16,10 +17,11 @@ 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">
{icon && (

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,7 +48,7 @@ function CreateForm() {
const environmentId = useEnvironmentId();
const router = useRouter();
const { trackEvent } = useAnalytics();
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const isEnvironmentAdmin = useIsEnvironmentAdmin();
const [isDockerhubRateLimited, setIsDockerhubRateLimited] = useState(false);
@@ -67,7 +67,7 @@ function CreateForm() {
const envQuery = useCurrentEnvironment();
const validationSchema = useValidation({
isAdmin,
isAdmin: isAdminQuery.isAdmin,
maxCpu,
maxMemory,
isDuplicating: initialValuesQuery?.isDuplicating,

View File

@@ -102,7 +102,7 @@ export function InnerForm({
}
errors={errors.volumes}
allowBindMounts={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowBindMountsForRegularUsers
}
@@ -166,18 +166,18 @@ export function InnerForm({
setFieldValue(`resources.${field}`, value)
}
allowPrivilegedMode={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowPrivilegedModeForRegularUsers
}
isDevicesFieldVisible={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowDeviceMappingForRegularUsers
}
isInitFieldVisible={apiVersion >= 1.37}
isSysctlFieldVisible={
isEnvironmentAdmin ||
isEnvironmentAdmin.authorized ||
environment.SecuritySettings
.allowSysctlSettingForRegularUsers
}

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

@@ -1,4 +1,4 @@
import { render } from '@/react-tools/test-utils';
import { renderWithQueryClient } from '@/react-tools/test-utils';
import { UserContext } from '@/react/hooks/useUser';
import { UserViewModel } from '@/portainer/models/user';
@@ -50,7 +50,7 @@ test('Non system networks should have a delete button', async () => {
async function renderComponent(isAdmin: boolean, network: DockerNetwork) {
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

@@ -10,7 +10,12 @@ export async function getWebhooks(
) {
try {
const { data } = await axios.get<Array<Webhook>>(buildUrl(), {
params: { filters: { EndpointID: environmentId, ResourceID: serviceId } },
params: {
filters: JSON.stringify({
EndpointID: environmentId,
ResourceID: serviceId,
}),
},
});
return data;
} catch (error) {

View File

@@ -1,7 +1,7 @@
import { Layers } from 'lucide-react';
import { Row } from '@tanstack/react-table';
import { useAuthorizations, useCurrentUser } from '@/react/hooks/useUser';
import { useAuthorizations, useIsEdgeAdmin } from '@/react/hooks/useUser';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { Datatable } from '@@/datatables';
@@ -34,7 +34,7 @@ export function StacksDatatable({
}) {
const tableState = useTableState(settingsStore, tableKey);
useRepeater(tableState.autoRefreshRate, onReload);
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const canManageStacks = useAuthorizations([
'PortainerStackCreate',
'PortainerStackDelete',
@@ -58,7 +58,7 @@ export function StacksDatatable({
columns={columns}
dataset={dataset}
isRowSelectable={({ original: item }) =>
allowSelection(item, isAdmin, canManageStacks)
allowSelection(item, isAdminQuery.isAdmin, canManageStacks.authorized)
}
getRowId={(item) => item.Id.toString()}
initialTableState={{

View File

@@ -1,6 +1,6 @@
import { CellContext, Column } from '@tanstack/react-table';
import { useCurrentUser } from '@/react/hooks/useUser';
import { useIsEdgeAdmin } from '@/react/hooks/useUser';
import { getValueAsArrayOfStrings } from '@/portainer/helpers/array';
import { StackStatus } from '@/react/common/stacks/types';
import {
@@ -67,7 +67,7 @@ function NameCell({
}
function NameLink({ item }: { item: DecoratedStack }) {
const { isAdmin } = useCurrentUser();
const isAdminQuery = useIsEdgeAdmin();
const name = item.Name;
@@ -87,7 +87,7 @@ function NameLink({ item }: { item: DecoratedStack }) {
);
}
if (!isAdmin && isOrphanedStack(item)) {
if (!isAdminQuery.isAdmin && isOrphanedStack(item)) {
return <>{name}</>;
}

View File

@@ -4,6 +4,7 @@ import { notifySuccess } from '@/portainer/services/notifications';
import { useDeleteEnvironmentsMutation } from '@/react/portainer/environments/queries/useDeleteEnvironmentsMutation';
import { Environment } from '@/react/portainer/environments/types';
import { withReactQuery } from '@/react-tools/withReactQuery';
import { useIsPureAdmin } from '@/react/hooks/useUser';
import { Button } from '@@/buttons';
import { ModalType, openModal } from '@@/modals';
@@ -28,6 +29,7 @@ export function TableActions({
}: {
selectedRows: WaitingRoomEnvironment[];
}) {
const isPureAdmin = useIsPureAdmin();
const associateMutation = useAssociateDeviceMutation();
const removeMutation = useDeleteEnvironmentsMutation();
const licenseOverused = useLicenseOverused(selectedRows.length);
@@ -58,7 +60,9 @@ export function TableActions({
<span>
<Button
onClick={() => handleAssociateAndAssign(selectedRows)}
disabled={selectedRows.length === 0 || licenseOverused}
disabled={
selectedRows.length === 0 || licenseOverused || !isPureAdmin
}
color="secondary"
icon={CheckCircle}
>

View File

@@ -1,147 +0,0 @@
import { SetStateAction, useEffect, useState } from 'react';
import sanitize from 'sanitize-html';
import { FormikErrors } from 'formik';
import { useCustomTemplates } from '@/react/portainer/templates/custom-templates/queries/useCustomTemplates';
import { CustomTemplate } from '@/react/portainer/templates/custom-templates/types';
import {
CustomTemplatesVariablesField,
VariablesFieldValue,
getVariablesFieldDefaultValues,
} from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { FormControl } from '@@/form-components/FormControl';
import { PortainerSelect } from '@@/form-components/PortainerSelect';
export interface Values {
template: CustomTemplate | undefined;
variables: VariablesFieldValue;
}
export function TemplateFieldset({
values: initialValues,
setValues: setInitialValues,
errors,
}: {
errors?: FormikErrors<Values>;
values: Values;
setValues: (values: SetStateAction<Values>) => void;
}) {
const [values, setControlledValues] = useState(initialValues); // todo remove when all view is in react
useEffect(() => {
if (initialValues.template?.Id !== values.template?.Id) {
setControlledValues(initialValues);
}
}, [initialValues, values.template?.Id]);
const templatesQuery = useCustomTemplates({
select: (templates) =>
templates.filter((template) => template.EdgeTemplate),
});
return (
<>
<TemplateSelector
error={errors?.template}
value={values.template?.Id}
onChange={(value) => {
setValues((values) => {
const template = templatesQuery.data?.find(
(template) => template.Id === value
);
return {
...values,
template,
variables: getVariablesFieldDefaultValues(
template?.Variables || []
),
};
});
}}
/>
{values.template && (
<>
{values.template.Note && (
<div>
<div className="col-sm-12 form-section-title"> Information </div>
<div className="form-group">
<div className="col-sm-12">
<div
className="template-note"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: sanitize(values.template.Note),
}}
/>
</div>
</div>
</div>
)}
<CustomTemplatesVariablesField
onChange={(value) => {
setValues((values) => ({
...values,
variables: value,
}));
}}
value={values.variables}
definitions={values.template.Variables}
errors={errors?.variables}
/>
</>
)}
</>
);
function setValues(values: SetStateAction<Values>) {
setControlledValues(values);
setInitialValues(values);
}
}
function TemplateSelector({
value,
onChange,
error,
}: {
value: CustomTemplate['Id'] | undefined;
onChange: (value: CustomTemplate['Id'] | undefined) => void;
error?: string;
}) {
const templatesQuery = useCustomTemplates({
select: (templates) =>
templates.filter((template) => template.EdgeTemplate),
});
if (!templatesQuery.data) {
return null;
}
return (
<FormControl label="Template" inputId="stack_template" errors={error}>
<PortainerSelect
placeholder="Select an Edge stack template"
value={value}
onChange={handleChange}
options={templatesQuery.data.map((template) => ({
label: `${template.Title} - ${template.Description}`,
value: template.Id,
}))}
/>
</FormControl>
);
function handleChange(value: CustomTemplate['Id']) {
onChange(value);
}
}
export function getInitialTemplateValues() {
return {
template: null,
variables: [],
file: '',
};
}

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