From 1f2a90a7220686c4e91408c9ee8064efe57cba4d Mon Sep 17 00:00:00 2001 From: cong meng Date: Thu, 10 Jun 2021 07:54:36 +1200 Subject: [PATCH 01/10] fix(frontend): When a docker endpoint is selected, configuring a newly added k8s agent fails EE-821 (#5115) * fix(frontend): When a docker endpoint is selected, configuring a newly added k8s agent fails EE-821 * fix(frontend): restore endpointID in a finally block EE-821 Co-authored-by: Simon Meng --- .../views/configure/configureController.js | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js index a3c5b4e5e..0d7fdda15 100644 --- a/app/kubernetes/views/configure/configureController.js +++ b/app/kubernetes/views/configure/configureController.js @@ -139,20 +139,32 @@ class KubernetesConfigureController { } async removeIngressesAcrossNamespaces() { - const promises = []; const ingressesToDel = _.filter(this.formValues.IngressClasses, { NeedsDeletion: true }); - const allResourcePools = await this.KubernetesResourcePoolService.get(); - const resourcePools = _.filter( - allResourcePools, - (resourcePool) => - !this.KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && !this.KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) - ); - ingressesToDel.forEach((ingress) => { - resourcePools.forEach((resourcePool) => { - promises.push(this.KubernetesIngressService.delete(resourcePool.Namespace.Name, ingress.Name)); + if (!ingressesToDel.length) { + return; + } + + const promises = []; + const oldEndpointID = this.EndpointProvider.endpointID(); + this.EndpointProvider.setEndpointID(this.endpoint.Id); + + try { + const allResourcePools = await this.KubernetesResourcePoolService.get(); + const resourcePools = _.filter( + allResourcePools, + (resourcePool) => + !this.KubernetesNamespaceHelper.isSystemNamespace(resourcePool.Namespace.Name) && !this.KubernetesNamespaceHelper.isDefaultNamespace(resourcePool.Namespace.Name) + ); + + ingressesToDel.forEach((ingress) => { + resourcePools.forEach((resourcePool) => { + promises.push(this.KubernetesIngressService.delete(resourcePool.Namespace.Name, ingress.Name)); + }); }); - }); + } finally { + this.EndpointProvider.setEndpointID(oldEndpointID); + } const responses = await Promise.allSettled(promises); responses.forEach((respons) => { From 45ceece1a91ace4366ad4475108882254ed241aa Mon Sep 17 00:00:00 2001 From: Maxime Bajeux Date: Mon, 14 Jun 2021 01:06:54 +0200 Subject: [PATCH 02/10] feat(application): Invalid environment variable form validation when creating an application (#5019) --- .../views/applications/create/createApplication.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html index 58537b1e4..13ff1455a 100644 --- a/app/kubernetes/views/applications/create/createApplication.html +++ b/app/kubernetes/views/applications/create/createApplication.html @@ -162,7 +162,7 @@ class="form-control" ng-model="envVar.Name" ng-change="ctrl.onChangeEnvironmentName()" - ng-pattern="/^[a-zA-Z]([-_a-zA-Z0-9]*[a-zA-Z0-9])?$/" + ng-pattern="/^[-._a-zA-Z][-._a-zA-Z0-9]*$/" placeholder="foo" ng-disabled="ctrl.formValues.Containers.length > 1" required @@ -207,8 +207,8 @@

Environment variable name is required.

This field must consist alphanumeric characters, '-' or '_', start with an alphabetic - character, and end with an alphanumeric character (e.g. 'my-var', or 'MY_VAR123').

This field must consist of alphabetic characters, digits, '_', '-', or '.', and must + not start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.

Date: Mon, 14 Jun 2021 12:29:41 +1200 Subject: [PATCH 03/10] Feat 4612 real time metrics for kube nodes (#4708) * feat(k8s/node): display realtime node metrics GH#4612 * feat(k8s): show observation timestamp instead of real timestamp GH#4612 Co-authored-by: Simon Meng --- app/constants.js | 2 + app/kubernetes/__module.js | 11 ++ .../nodes-datatable/nodesDatatable.html | 6 + .../nodes-datatable/nodesDatatable.js | 1 + app/kubernetes/metrics/metrics.js | 21 +++ app/kubernetes/metrics/rest.js | 4 + app/kubernetes/views/cluster/cluster.html | 1 + .../views/cluster/clusterController.js | 5 +- .../views/cluster/node/stats/stats.html | 71 +++++++++ .../views/cluster/node/stats/stats.js | 8 + .../cluster/node/stats/statsController.js | 144 ++++++++++++++++++ 11 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 app/kubernetes/views/cluster/node/stats/stats.html create mode 100644 app/kubernetes/views/cluster/node/stats/stats.js create mode 100644 app/kubernetes/views/cluster/node/stats/statsController.js diff --git a/app/constants.js b/app/constants.js index cb0e8f17f..febc848e8 100644 --- a/app/constants.js +++ b/app/constants.js @@ -30,3 +30,5 @@ angular .constant('PREDEFINED_NETWORKS', ['host', 'bridge', 'none']) .constant('KUBERNETES_DEFAULT_NAMESPACE', 'default') .constant('KUBERNETES_SYSTEM_NAMESPACES', ['kube-system', 'kube-public', 'kube-node-lease', 'portainer']); + +export const PORTAINER_FADEOUT = 1500; diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js index c48095d81..d867a288f 100644 --- a/app/kubernetes/__module.js +++ b/app/kubernetes/__module.js @@ -182,6 +182,16 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([ }, }; + const nodeStats = { + name: 'kubernetes.cluster.node.stats', + url: '/stats', + views: { + 'content@': { + component: 'kubernetesNodeStatsView', + }, + }, + }; + const dashboard = { name: 'kubernetes.dashboard', url: '/dashboard', @@ -280,6 +290,7 @@ angular.module('portainer.kubernetes', ['portainer.app']).config([ $stateRegistryProvider.register(dashboard); $stateRegistryProvider.register(deploy); $stateRegistryProvider.register(node); + $stateRegistryProvider.register(nodeStats); $stateRegistryProvider.register(resourcePools); $stateRegistryProvider.register(resourcePoolCreation); $stateRegistryProvider.register(resourcePool); diff --git a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html index 8b976a235..b5f84a99e 100644 --- a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.html @@ -107,6 +107,9 @@ + + Actions + @@ -128,6 +131,9 @@ {{ item.Memory | humansize }} {{ item.Version }} {{ item.IPAddress }} + + Stats + Loading... diff --git a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js index 3c1312302..17fc80b92 100644 --- a/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js +++ b/app/kubernetes/components/datatables/nodes-datatable/nodesDatatable.js @@ -9,5 +9,6 @@ angular.module('portainer.kubernetes').component('kubernetesNodesDatatable', { orderBy: '@', refreshCallback: '<', isAdmin: '<', + useServerMetrics: '<', }, }); diff --git a/app/kubernetes/metrics/metrics.js b/app/kubernetes/metrics/metrics.js index 6bacdbbee..391a26509 100644 --- a/app/kubernetes/metrics/metrics.js +++ b/app/kubernetes/metrics/metrics.js @@ -10,6 +10,7 @@ class KubernetesMetricsService { this.capabilitiesAsync = this.capabilitiesAsync.bind(this); this.getPodAsync = this.getPodAsync.bind(this); + this.getNodeAsync = this.getNodeAsync.bind(this); } /** @@ -27,6 +28,26 @@ class KubernetesMetricsService { return this.$async(this.capabilitiesAsync, endpointID); } + /** + * Stats of Node + * + * @param {string} nodeName + */ + async getNodeAsync(nodeName) { + try { + const params = new KubernetesCommonParams(); + params.id = nodeName; + const data = await this.KubernetesMetrics().getNode(params).$promise; + return data; + } catch (err) { + throw new PortainerError('Unable to retrieve node stats', err); + } + } + + getNode(nodeName) { + return this.$async(this.getNodeAsync, nodeName); + } + /** * Stats * diff --git a/app/kubernetes/metrics/rest.js b/app/kubernetes/metrics/rest.js index 22f071b3b..92fa6b95b 100644 --- a/app/kubernetes/metrics/rest.js +++ b/app/kubernetes/metrics/rest.js @@ -20,6 +20,10 @@ angular.module('portainer.kubernetes').factory('KubernetesMetrics', [ method: 'GET', url: podUrl, }, + getNode: { + method: 'GET', + url: `${url}/nodes/:id`, + }, } ); }; diff --git a/app/kubernetes/views/cluster/cluster.html b/app/kubernetes/views/cluster/cluster.html index 47a198c6e..d1c9f08cb 100644 --- a/app/kubernetes/views/cluster/cluster.html +++ b/app/kubernetes/views/cluster/cluster.html @@ -89,6 +89,7 @@ order-by="Name" refresh-callback="ctrl.getNodes" is-admin="ctrl.isAdmin" + use-server-metrics="ctrl.state.useServerMetrics" > diff --git a/app/kubernetes/views/cluster/clusterController.js b/app/kubernetes/views/cluster/clusterController.js index 7726834d2..6824e0738 100644 --- a/app/kubernetes/views/cluster/clusterController.js +++ b/app/kubernetes/views/cluster/clusterController.js @@ -15,7 +15,8 @@ class KubernetesClusterController { KubernetesNodeService, KubernetesApplicationService, KubernetesComponentStatusService, - KubernetesEndpointService + KubernetesEndpointService, + EndpointProvider ) { this.$async = $async; this.$state = $state; @@ -26,6 +27,7 @@ class KubernetesClusterController { this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesComponentStatusService = KubernetesComponentStatusService; this.KubernetesEndpointService = KubernetesEndpointService; + this.EndpointProvider = EndpointProvider; this.onInit = this.onInit.bind(this); this.getNodes = this.getNodes.bind(this); @@ -132,6 +134,7 @@ class KubernetesClusterController { } this.state.viewReady = true; + this.state.useServerMetrics = this.EndpointProvider.currentEndpoint().Kubernetes.Configuration.UseServerMetrics; } $onInit() { diff --git a/app/kubernetes/views/cluster/node/stats/stats.html b/app/kubernetes/views/cluster/node/stats/stats.html new file mode 100644 index 000000000..be2341dbf --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/stats.html @@ -0,0 +1,71 @@ + + Cluster > {{ ctrl.state.transition.nodeName }} > + {{ ctrl.state.transition.nodeName }} + + + + +

+ + + + Portainer was unable to retrieve any metrics associated to that node. Please contact your administrator to ensure that the Kubernetes metrics feature is properly configured. + + +
+
+ + + +
+
+
+ + This view displays real-time statistics about the node {{ ctrl.state.transition.nodeName }}. + +
+
+
+ +
+ +
+ + + +
+
+
+
+
+
+ +
+
+ + + +
+ +
+
+
+
+
+ + + +
+ +
+
+
+
+
+
diff --git a/app/kubernetes/views/cluster/node/stats/stats.js b/app/kubernetes/views/cluster/node/stats/stats.js new file mode 100644 index 000000000..98e362777 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/stats.js @@ -0,0 +1,8 @@ +angular.module('portainer.kubernetes').component('kubernetesNodeStatsView', { + templateUrl: './stats.html', + controller: 'KubernetesNodeStatsController', + controllerAs: 'ctrl', + bindings: { + $transition$: '<', + }, +}); diff --git a/app/kubernetes/views/cluster/node/stats/statsController.js b/app/kubernetes/views/cluster/node/stats/statsController.js new file mode 100644 index 000000000..e56424508 --- /dev/null +++ b/app/kubernetes/views/cluster/node/stats/statsController.js @@ -0,0 +1,144 @@ +import angular from 'angular'; +import moment from 'moment'; +import filesizeParser from 'filesize-parser'; +import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; +import { PORTAINER_FADEOUT } from '@/constants'; + +class KubernetesNodeStatsController { + /* @ngInject */ + constructor($async, $state, $interval, $document, Notifications, KubernetesNodeService, KubernetesMetricsService, ChartService) { + this.$async = $async; + this.$state = $state; + this.$interval = $interval; + this.$document = $document; + this.Notifications = Notifications; + this.KubernetesNodeService = KubernetesNodeService; + this.KubernetesMetricsService = KubernetesMetricsService; + this.ChartService = ChartService; + + this.onInit = this.onInit.bind(this); + } + + changeUpdateRepeater() { + var cpuChart = this.cpuChart; + var memoryChart = this.memoryChart; + + this.stopRepeater(); + this.setUpdateRepeater(cpuChart, memoryChart); + $('#refreshRateChange').show(); + $('#refreshRateChange').fadeOut(PORTAINER_FADEOUT); + } + + updateCPUChart() { + const label = moment(this.stats.read).format('HH:mm:ss'); + this.ChartService.UpdateCPUChart(label, this.stats.CPUUsage, this.cpuChart); + } + + updateMemoryChart() { + const label = moment(this.stats.read).format('HH:mm:ss'); + this.ChartService.UpdateMemoryChart(label, this.stats.MemoryUsage, 0, this.memoryChart); + } + + stopRepeater() { + var repeater = this.repeater; + if (angular.isDefined(repeater)) { + this.$interval.cancel(repeater); + this.repeater = undefined; + } + } + + setUpdateRepeater() { + const refreshRate = this.state.refreshRate; + + this.repeater = this.$interval(async () => { + try { + await this.getStats(); + this.updateCPUChart(); + this.updateMemoryChart(); + } catch (error) { + this.stopRepeater(); + this.Notifications.error('Failure', error); + } + }, refreshRate * 1000); + } + + initCharts() { + const cpuChartCtx = $('#cpuChart'); + const cpuChart = this.ChartService.CreateCPUChart(cpuChartCtx); + this.cpuChart = cpuChart; + + const memoryChartCtx = $('#memoryChart'); + const memoryChart = this.ChartService.CreateMemoryChart(memoryChartCtx); + this.memoryChart = memoryChart; + + this.updateCPUChart(); + this.updateMemoryChart(); + this.setUpdateRepeater(); + } + + getStats() { + return this.$async(async () => { + try { + const stats = await this.KubernetesMetricsService.getNode(this.state.transition.nodeName); + if (stats) { + const memory = filesizeParser(stats.usage.memory); + const cpu = KubernetesResourceReservationHelper.parseCPU(stats.usage.cpu); + this.stats = { + read: stats.creationTimestamp, + MemoryUsage: memory, + CPUUsage: (cpu / this.nodeCPU) * 100, + }; + } + } catch (err) { + this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); + } + }); + } + + $onDestroy() { + this.stopRepeater(); + } + + async onInit() { + this.state = { + autoRefresh: false, + refreshRate: '30', + viewReady: false, + transition: { + nodeName: this.$transition$.params().name, + }, + getMetrics: true, + }; + + try { + const nodeMetrics = await this.KubernetesMetricsService.getNode(this.state.transition.nodeName); + + if (nodeMetrics) { + const node = await this.KubernetesNodeService.get(this.state.transition.nodeName); + this.nodeCPU = node.CPU || 1; + + await this.getStats(); + + if (this.state.getMetrics) { + this.$document.ready(() => { + this.initCharts(); + }); + } + } else { + this.state.getMetrics = false; + } + } catch (err) { + this.state.getMetrics = false; + this.Notifications.error('Failure', err, 'Unable to retrieve node stats'); + } finally { + this.state.viewReady = true; + } + } + + $onInit() { + return this.$async(this.onInit); + } +} + +export default KubernetesNodeStatsController; +angular.module('portainer.kubernetes').controller('KubernetesNodeStatsController', KubernetesNodeStatsController); From 49bd139466f80028e1b4785648397a473b9498b1 Mon Sep 17 00:00:00 2001 From: Hui Date: Mon, 14 Jun 2021 14:45:57 +1200 Subject: [PATCH 04/10] fix swagger param (#5183) --- api/http/handler/endpoints/endpoint_create.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 770c599c9..946fa7d7e 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -156,7 +156,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error { // @accept multipart/form-data // @produce json // @param Name formData string true "Name that will be used to identify this endpoint (example: my-endpoint)" -// @param EndpointType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5) +// @param EndpointCreationType formData integer true "Environment type. Value must be one of: 1 (Local Docker environment), 2 (Agent environment), 3 (Azure environment), 4 (Edge agent environment) or 5 (Local Kubernetes Environment" Enum(1,2,3,4,5) // @param URL formData string false "URL or IP address of a Docker host (example: docker.mydomain.tld:2375). Defaults to local if not specified (Linux: /var/run/docker.sock, Windows: //./pipe/docker_engine)" // @param PublicURL formData string false "URL or IP address where exposed containers will be reachable. Defaults to URL if not specified (example: docker.mydomain.tld:2375)" // @param GroupID formData int false "Endpoint group identifier. If not specified will default to 1 (unassigned)." From 6e9f47272350962434a7d3a579570fe1e1750681 Mon Sep 17 00:00:00 2001 From: zees-dev <63374656+zees-dev@users.noreply.github.com> Date: Mon, 14 Jun 2021 15:57:00 +1200 Subject: [PATCH 05/10] feat(container-stats): introduce container block I/O stats (#5017) * feat(container-stats):introduce container block io stats * Change charts to 2x2 view * fix(container-stats): handle missing io stats by detecting stats based on op codes Co-authored-by: DarkAEther <30438425+DarkAEther@users.noreply.github.com> --- app/docker/models/container.js | 14 ++++++ .../stats/containerStatsController.js | 28 +++++++++--- .../containers/stats/containerstats.html | 26 +++++++++-- app/portainer/services/chartService.js | 45 +++++++++++++++++++ 4 files changed, 105 insertions(+), 8 deletions(-) diff --git a/app/docker/models/container.js b/app/docker/models/container.js index 58cc51a20..dafcdb63c 100644 --- a/app/docker/models/container.js +++ b/app/docker/models/container.js @@ -91,6 +91,20 @@ export function ContainerStatsViewModel(data) { this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length; } this.Networks = _.values(data.networks); + if (data.blkio_stats !== undefined) { + //TODO: take care of multiple block devices + var readData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Read'); + if (readData !== undefined) { + this.BytesRead = readData.value; + } + var writeData = data.blkio_stats.io_service_bytes_recursive.find((d) => d.op === 'Write'); + if (writeData !== undefined) { + this.BytesWrite = writeData.value; + } + } else { + //no IO related data is available + this.noIOdata = true; + } } export function ContainerDetailsViewModel(data) { diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js index 9fef44e41..48803264f 100644 --- a/app/docker/views/containers/stats/containerStatsController.js +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -14,6 +14,7 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ $scope.state = { refreshRate: '5', networkStatsUnavailable: false, + ioStatsUnavailable: false, }; $scope.$on('$destroy', function () { @@ -44,6 +45,13 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart); } + function updateIOChart(stats, chart) { + var label = moment(stats.read).format('HH:mm:ss'); + if (stats.noIOData !== true) { + ChartService.UpdateIOChart(label, stats.BytesRead, stats.BytesWrite, chart); + } + } + function updateCPUChart(stats, chart) { var label = moment(stats.read).format('HH:mm:ss'); var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats); @@ -77,14 +85,15 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ var networkChart = $scope.networkChart; var cpuChart = $scope.cpuChart; var memoryChart = $scope.memoryChart; + var ioChart = $scope.ioChart; stopRepeater(); - setUpdateRepeater(networkChart, cpuChart, memoryChart); + setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart); $('#refreshRateChange').show(); $('#refreshRateChange').fadeOut(1500); }; - function startChartUpdate(networkChart, cpuChart, memoryChart) { + function startChartUpdate(networkChart, cpuChart, memoryChart, ioChart) { $q.all({ stats: ContainerService.containerStats($transition$.params().id), top: ContainerService.containerTop($transition$.params().id), @@ -95,10 +104,14 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ if (stats.Networks.length === 0) { $scope.state.networkStatsUnavailable = true; } + if (stats.noIOData === true) { + $scope.state.ioStatsUnavailable = true; + } updateNetworkChart(stats, networkChart); updateMemoryChart(stats, memoryChart); updateCPUChart(stats, cpuChart); - setUpdateRepeater(networkChart, cpuChart, memoryChart); + updateIOChart(stats, ioChart); + setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart); }) .catch(function error(err) { stopRepeater(); @@ -106,7 +119,7 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ }); } - function setUpdateRepeater(networkChart, cpuChart, memoryChart) { + function setUpdateRepeater(networkChart, cpuChart, memoryChart, ioChart) { var refreshRate = $scope.state.refreshRate; $scope.repeater = $interval(function () { $q.all({ @@ -119,6 +132,7 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ updateNetworkChart(stats, networkChart); updateMemoryChart(stats, memoryChart); updateCPUChart(stats, cpuChart); + updateIOChart(stats, ioChart); }) .catch(function error(err) { stopRepeater(); @@ -140,7 +154,11 @@ angular.module('portainer.docker').controller('ContainerStatsController', [ var memoryChart = ChartService.CreateMemoryChart(memoryChartCtx); $scope.memoryChart = memoryChart; - startChartUpdate(networkChart, cpuChart, memoryChart); + var ioChartCtx = $('#ioChart'); + var ioChart = ChartService.CreateIOChart(ioChartCtx); + $scope.ioChart = ioChart; + + startChartUpdate(networkChart, cpuChart, memoryChart, ioChart); } function initView() { diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html index 0a9d019c8..c6fc413f4 100644 --- a/app/docker/views/containers/stats/containerstats.html +++ b/app/docker/views/containers/stats/containerstats.html @@ -42,6 +42,11 @@ Network stats are unavailable for this container. +
+
+ I/O stats are unavailable for this container. +
+
@@ -49,7 +54,7 @@
-
+
@@ -59,7 +64,8 @@
-
+ +
@@ -69,7 +75,8 @@
-
+ +
@@ -80,6 +87,19 @@
+
+ + + +
+ +
+
+
+
+
+ +
Date: Mon, 14 Jun 2021 18:59:07 +1200 Subject: [PATCH 06/10] feat(UX): introduce new env variables UI (#4175) * feat(app): introduce new env vars ui feat(app): introduce new env vars ui feat(UX): WIP new env variables UI feat(UX): update button and placeholder feat(UX): mention .env file in message feat(UX): allow add/remove value & load correctly feat(UX): restrict filesize to 1MB feat(UX): vertical align error message feat(UX): fill UI from file & when switching modes feat(UX): strip un-needed newline character feat(UX): introduce component to other views feat(UX): fix title alignment feat(UX): only populate editor on mode switch when key exists feat(UX): prevent trimming of whitespace on values feat(UX): change editor to async feat(UX): add message describing use feat(UX): Refactor variable text to editorText refactor(app): rename env vars controller refactor(app): move env var explanation to parent refactor(app): order env var panels refactor(app): move simple env vars mode to component refactor(app): parse env vars refactor(app): move styles to css refactor(app): rename functions refactor(container): parse env vars refactor(env-vars): move utils to helper module refactor(env-vars): use util function for parse dot env file fix(env-vars): ignore comments refactor(services): use env vars utils refactor(env-vars): rename files refactor(env-panel): use utils style(stack): revert EnvContent to Env style(service): revert EnvContent to Env style(container): revert EnvContent to Env refactor(env-vars): support default value refactor(service): use new env var component refactor(env-var): use one way data flow refactor(containers): remove unused function * fix(env-vars): prevent using non .env files * refactor(env-vars): move env vars items to a component * feat(app): fixed env vars form validation in Stack * feat(services): disable env form submit if invalid * fix(app): show key pairs correctly * fix(env-var): use the same validation as with kubernetes * fix(env-vars): parse env var Co-authored-by: Chaim Lev-Ari Co-authored-by: Felix Han --- app/docker/helpers/serviceHelper.js | 33 ---------- .../create/createContainerController.js | 34 ++++------ .../containers/create/createcontainer.html | 36 ++--------- .../create/createServiceController.js | 23 +++---- .../views/services/create/createservice.html | 38 +++-------- .../edit/includes/environmentvariables.html | 55 ++++------------ .../views/services/edit/serviceController.js | 40 +++++++----- .../environment-variables-panel.controller.js | 34 ++++++++++ .../environment-variables-panel.css | 11 ++++ .../environment-variables-panel.html | 30 +++++++++ ...t-variables-simple-mode-item.controller.js | 41 ++++++++++++ ...nvironment-variables-simple-mode-item.html | 63 +++++++++++++++++++ .../index.js | 17 +++++ ...onment-variables-simple-mode.controller.js | 43 +++++++++++++ .../environment-variables-simple-mode.css | 33 ++++++++++ .../environment-variables-simple-mode.html | 36 +++++++++++ .../index.js | 15 +++++ .../environment-variables-panel/index.js | 15 +++++ app/portainer/filters/filters.js | 8 +++ app/portainer/helpers/env-vars.js | 61 ++++++++++++++++++ .../stacks/create/createStackController.js | 13 ++-- .../views/stacks/create/createstack.html | 35 ++--------- app/portainer/views/stacks/edit/stack.html | 37 +++-------- .../views/stacks/edit/stackController.js | 20 +++--- 24 files changed, 506 insertions(+), 265 deletions(-) create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-panel.controller.js create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-panel.css create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-panel.html create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/index.js create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.controller.js create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.css create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.html create mode 100644 app/portainer/components/environment-variables-panel/environment-variables-simple-mode/index.js create mode 100644 app/portainer/components/environment-variables-panel/index.js create mode 100644 app/portainer/helpers/env-vars.js diff --git a/app/docker/helpers/serviceHelper.js b/app/docker/helpers/serviceHelper.js index 8788c2c74..cd2e658ab 100644 --- a/app/docker/helpers/serviceHelper.js +++ b/app/docker/helpers/serviceHelper.js @@ -67,39 +67,6 @@ angular.module('portainer.docker').factory('ServiceHelper', [ return []; }; - helper.translateEnvironmentVariables = function (env) { - if (env) { - var variables = []; - env.forEach(function (variable) { - var idx = variable.indexOf('='); - var keyValue = [variable.slice(0, idx), variable.slice(idx + 1)]; - var originalValue = keyValue.length > 1 ? keyValue[1] : ''; - variables.push({ - key: keyValue[0], - value: originalValue, - originalKey: keyValue[0], - originalValue: originalValue, - added: true, - }); - }); - return variables; - } - return []; - }; - - helper.translateEnvironmentVariablesToEnv = function (env) { - if (env) { - var variables = []; - env.forEach(function (variable) { - if (variable.key && variable.key !== '') { - variables.push(variable.key + '=' + variable.value); - } - }); - return variables; - } - return []; - }; - helper.translatePreferencesToKeyValue = function (preferences) { if (preferences) { var keyValuePreferences = []; diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 19965b7ae..f75b000ae 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -1,5 +1,8 @@ import _ from 'lodash-es'; + +import * as envVarsUtils from '@/portainer/helpers/env-vars'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; + import { ContainerCapabilities, ContainerCapability } from '../../../models/containerCapabilities'; import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; import { ContainerDetailsViewModel } from '../../../models/container'; @@ -78,6 +81,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ MemoryReservation: 0, CmdMode: 'default', EntrypointMode: 'default', + Env: [], NodeName: null, capabilities: [], Sysctls: [], @@ -95,6 +99,11 @@ angular.module('portainer.docker').controller('CreateContainerController', [ pullImageValidity: true, }; + $scope.handleEnvVarChange = handleEnvVarChange; + function handleEnvVarChange(value) { + $scope.formValues.Env = value; + } + $scope.refreshSlider = function () { $timeout(function () { $scope.$broadcast('rzSliderForceRender'); @@ -153,14 +162,6 @@ angular.module('portainer.docker').controller('CreateContainerController', [ $scope.formValues.Volumes.splice(index, 1); }; - $scope.addEnvironmentVariable = function () { - $scope.config.Env.push({ name: '', value: '' }); - }; - - $scope.removeEnvironmentVariable = function (index) { - $scope.config.Env.splice(index, 1); - }; - $scope.addPortBinding = function () { $scope.config.HostConfig.PortBindings.push({ hostPort: '', containerPort: '', protocol: 'tcp' }); }; @@ -254,13 +255,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ } function prepareEnvironmentVariables(config) { - var env = []; - config.Env.forEach(function (v) { - if (v.name && v.value) { - env.push(v.name + '=' + v.value); - } - }); - config.Env = env; + config.Env = envVarsUtils.convertToArrayOfStrings($scope.formValues.Env); } function prepareVolumes(config) { @@ -537,14 +532,7 @@ angular.module('portainer.docker').controller('CreateContainerController', [ } function loadFromContainerEnvironmentVariables() { - var envArr = []; - for (var e in $scope.config.Env) { - if ({}.hasOwnProperty.call($scope.config.Env, e)) { - var arr = $scope.config.Env[e].split(/\=(.*)/); - envArr.push({ name: arr[0], value: arr[1] }); - } - } - $scope.config.Env = envArr; + $scope.formValues.Env = envVarsUtils.parseArrayOfStrings($scope.config.Env); } function loadFromContainerLabels() { diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index de43d1fa2..6f2aaa9cd 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -583,37 +583,13 @@
-
- -
-
- - - add environment variable - -
- -
-
-
- name - -
-
- value - -
- -
-
- -
- -
+
- +
diff --git a/app/docker/views/services/create/createServiceController.js b/app/docker/views/services/create/createServiceController.js index 6aa70264c..0458ed513 100644 --- a/app/docker/views/services/create/createServiceController.js +++ b/app/docker/views/services/create/createServiceController.js @@ -1,4 +1,6 @@ import _ from 'lodash-es'; + +import * as envVarsUtils from '@/portainer/helpers/env-vars'; import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; import { AccessControlFormData } from '../../../../portainer/components/accessControlForm/porAccessControlFormModel'; @@ -109,6 +111,11 @@ angular.module('portainer.docker').controller('CreateServiceController', [ $scope.allowBindMounts = false; + $scope.handleEnvVarChange = handleEnvVarChange; + function handleEnvVarChange(value) { + $scope.formValues.Env = value; + } + $scope.refreshSlider = function () { $timeout(function () { $scope.$broadcast('rzSliderForceRender'); @@ -168,14 +175,6 @@ angular.module('portainer.docker').controller('CreateServiceController', [ $scope.formValues.Secrets.splice(index, 1); }; - $scope.addEnvironmentVariable = function () { - $scope.formValues.Env.push({ name: '', value: '' }); - }; - - $scope.removeEnvironmentVariable = function (index) { - $scope.formValues.Env.splice(index, 1); - }; - $scope.addPlacementConstraint = function () { $scope.formValues.PlacementConstraints.push({ key: '', operator: '==', value: '' }); }; @@ -277,13 +276,7 @@ angular.module('portainer.docker').controller('CreateServiceController', [ } function prepareEnvConfig(config, input) { - var env = []; - input.Env.forEach(function (v) { - if (v.name) { - env.push(v.name + '=' + v.value); - } - }); - config.TaskTemplate.ContainerSpec.Env = env; + config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(input.Env); } function prepareLabelsConfig(config, input) { diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 3e2be2cba..45a0a9f16 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -160,6 +160,7 @@
  • Command & Logging
  • Volumes
  • Network
  • +
  • Env
  • Labels
  • Update config & Restart
  • Secrets
  • @@ -202,34 +203,6 @@
    - -
    -
    - - - add environment variable - -
    - -
    -
    -
    - name - -
    -
    - value - -
    - -
    -
    - -
    - -
    Logging
    @@ -443,6 +416,15 @@
    + +
    + +
    +
    diff --git a/app/docker/views/services/edit/includes/environmentvariables.html b/app/docker/views/services/edit/includes/environmentvariables.html index 795c8d77a..20b0acdd6 100644 --- a/app/docker/views/services/edit/includes/environmentvariables.html +++ b/app/docker/views/services/edit/includes/environmentvariables.html @@ -1,8 +1,8 @@ -
    + @@ -10,49 +10,20 @@

    There are no environment variables for this service.

    - - - - - - - - - - - - - - -
    NameValue
    -
    - name - -
    -
    -
    - value - - - - -
    -
    + + +
    diff --git a/app/docker/views/services/edit/serviceController.js b/app/docker/views/services/edit/serviceController.js index cb41d94d8..f1aef75a4 100644 --- a/app/docker/views/services/edit/serviceController.js +++ b/app/docker/views/services/edit/serviceController.js @@ -18,6 +18,9 @@ require('./includes/tasks.html'); require('./includes/updateconfig.html'); import _ from 'lodash-es'; + +import * as envVarsUtils from '@/portainer/helpers/env-vars'; + import { PorImageRegistryModel } from 'Docker/models/porImageRegistry'; angular.module('portainer.docker').controller('ServiceController', [ @@ -114,21 +117,25 @@ angular.module('portainer.docker').controller('ServiceController', [ }; $scope.addEnvironmentVariable = function addEnvironmentVariable(service) { - service.EnvironmentVariables.push({ key: '', value: '', originalValue: '' }); + service.EnvironmentVariables.push({ name: '', value: '' }); updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); }; - $scope.removeEnvironmentVariable = function removeEnvironmentVariable(service, item) { - const index = service.EnvironmentVariables.indexOf(item); - const removedElement = service.EnvironmentVariables.splice(index, 1); - if (removedElement !== null) { - updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); - } - }; - $scope.updateEnvironmentVariable = function updateEnvironmentVariable(service, variable) { - if (variable.value !== variable.originalValue || variable.key !== variable.originalKey) { - updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); - } - }; + + $scope.onChangeEnvVars = onChangeEnvVars; + + function onChangeEnvVars(env) { + const service = $scope.service; + + const orgEnv = service.EnvironmentVariables; + service.EnvironmentVariables = env.map((v) => { + const orgVar = orgEnv.find(({ name }) => v.name === name); + const added = orgVar && orgVar.added; + return { ...v, added }; + }); + + updateServiceArray(service, 'EnvironmentVariables', service.EnvironmentVariables); + } + $scope.addConfig = function addConfig(service, config) { if ( config && @@ -395,7 +402,7 @@ angular.module('portainer.docker').controller('ServiceController', [ var config = ServiceHelper.serviceToConfig(service.Model); config.Name = service.Name; config.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceLabels); - config.TaskTemplate.ContainerSpec.Env = ServiceHelper.translateEnvironmentVariablesToEnv(service.EnvironmentVariables); + config.TaskTemplate.ContainerSpec.Env = envVarsUtils.convertToArrayOfStrings(service.EnvironmentVariables); config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels); if ($scope.hasChanges(service, ['Image'])) { @@ -625,7 +632,10 @@ angular.module('portainer.docker').controller('ServiceController', [ function translateServiceArrays(service) { service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : []; service.ServiceConfigs = service.Configs ? service.Configs.map(ConfigHelper.flattenConfig) : []; - service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env); + service.EnvironmentVariables = envVarsUtils + .parseArrayOfStrings(service.Env) + .map((v) => ({ ...v, added: true })) + .sort((v1, v2) => (v1.name > v2.name ? 1 : -1)); service.LogDriverOpts = ServiceHelper.translateLogDriverOptsToKeyValue(service.LogDriverOpts); service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels); service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels); diff --git a/app/portainer/components/environment-variables-panel/environment-variables-panel.controller.js b/app/portainer/components/environment-variables-panel/environment-variables-panel.controller.js new file mode 100644 index 000000000..9f842e40e --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-panel.controller.js @@ -0,0 +1,34 @@ +import { parseDotEnvFile, convertToArrayOfStrings } from '@/portainer/helpers/env-vars'; + +export default class EnvironmentVariablesPanelController { + /* @ngInject */ + constructor() { + this.mode = 'simple'; + this.editorText = ''; + + this.switchEnvMode = this.switchEnvMode.bind(this); + this.editorUpdate = this.editorUpdate.bind(this); + this.handleSimpleChange = this.handleSimpleChange.bind(this); + } + + switchEnvMode() { + if (this.mode === 'simple') { + const editorText = convertToArrayOfStrings(this.ngModel).join('\n'); + + this.editorText = editorText; + + this.mode = 'advanced'; + } else { + this.mode = 'simple'; + } + } + + handleSimpleChange(value) { + this.onChange(value); + } + + editorUpdate(cm) { + this.editorText = cm.getValue(); + this.onChange(parseDotEnvFile(this.editorText)); + } +} diff --git a/app/portainer/components/environment-variables-panel/environment-variables-panel.css b/app/portainer/components/environment-variables-panel/environment-variables-panel.css new file mode 100644 index 000000000..84b30444e --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-panel.css @@ -0,0 +1,11 @@ +.environment-variables-panel { + margin-top: 15px; +} + +.environment-variables-panel--explanation { + margin-bottom: 5px; +} + +.environment-variables-panel--advanced > * + * { + margin-top: 5px; +} diff --git a/app/portainer/components/environment-variables-panel/environment-variables-panel.html b/app/portainer/components/environment-variables-panel/environment-variables-panel.html new file mode 100644 index 000000000..c84f37ff2 --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-panel.html @@ -0,0 +1,30 @@ + +
    +
    + Environment variables +
    +
    + {{::$ctrl.explanation}} +
    + + + +
    + +
    + + Switch to simple mode to define variables line by line, or load from .env file +
    +
    + +
    +
    +
    +
    diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js new file mode 100644 index 000000000..2138e4f57 --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.controller.js @@ -0,0 +1,41 @@ +import { KEY_REGEX, VALUE_REGEX } from '@/portainer/helpers/env-vars'; + +class EnvironmentVariablesSimpleModeItemController { + /* @ngInject */ + constructor() { + this.KEY_REGEX = KEY_REGEX; + this.VALUE_REGEX = VALUE_REGEX; + } + + onChangeName(name) { + const fieldIsInvalid = typeof name === 'undefined'; + if (fieldIsInvalid) { + return; + } + + this.onChange(this.index, { ...this.variable, name }); + } + + onChangeValue(value) { + const fieldIsInvalid = typeof value === 'undefined'; + if (fieldIsInvalid) { + return; + } + + this.onChange(this.index, { ...this.variable, value }); + } + + hasValue() { + return typeof this.variable.value !== 'undefined'; + } + + removeValue() { + this.onChange(this.index, { name: this.variable.name }); + } + + $onInit() { + this.formName = `variableForm${this.index}`; + } +} + +export default EnvironmentVariablesSimpleModeItemController; diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html new file mode 100644 index 000000000..c53af0699 --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/environment-variables-simple-mode-item.html @@ -0,0 +1,63 @@ + +
    +
    + name + +
    +
    +
    +
    +

    Name is required.

    +

    + + This field must consist alphanumeric characters, '-' or '_', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-var', or 'MY_VAR123'). +

    +
    +
    +
    +
    +
    +
    +
    + value + +
    + +
    +
    +
    +
    +

    Value is required.

    +

    Value is invalid.

    +
    +
    +
    +
    +
    + + +
    +
    diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/index.js b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/index.js new file mode 100644 index 000000000..b95b3b7db --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode-item/index.js @@ -0,0 +1,17 @@ +import angular from 'angular'; +import controller from './environment-variables-simple-mode-item.controller.js'; + +export const environmentVariablesSimpleModeItem = { + templateUrl: './environment-variables-simple-mode-item.html', + controller, + + bindings: { + variable: '<', + index: '<', + + onChange: '<', + onRemove: '<', + }, +}; + +angular.module('portainer.app').component('environmentVariablesSimpleModeItem', environmentVariablesSimpleModeItem); diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.controller.js b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.controller.js new file mode 100644 index 000000000..813911832 --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.controller.js @@ -0,0 +1,43 @@ +import { parseDotEnvFile } from '@/portainer/helpers/env-vars'; + +export default class EnvironmentVariablesSimpleModeController { + /* @ngInject */ + constructor($async) { + this.$async = $async; + + this.onChangeVariable = this.onChangeVariable.bind(this); + this.remove = this.remove.bind(this); + } + + add() { + this.onChange([...this.ngModel, { name: '', value: '' }]); + } + + remove(index) { + this.onChange(this.ngModel.filter((_, i) => i !== index)); + } + + addFromFile(file) { + return this.$async(async () => { + if (!file) { + return; + } + const text = await this.getTextFromFile(file); + const parsed = parseDotEnvFile(text); + this.onChange(this.ngModel.concat(parsed)); + }); + } + + getTextFromFile(file) { + return new Promise((resolve, reject) => { + const temporaryFileReader = new FileReader(); + temporaryFileReader.readAsText(file); + temporaryFileReader.onload = (event) => resolve(event.target.result); + temporaryFileReader.onerror = (error) => reject(error); + }); + } + + onChangeVariable(index, variable) { + this.onChange(this.ngModel.map((v, i) => (i !== index ? v : variable))); + } +} diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.css b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.css new file mode 100644 index 000000000..155c9684a --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.css @@ -0,0 +1,33 @@ +.advanced-actions > * + * { + margin-top: 5px; +} + +.environment-variables-simple-mode--actions { + display: flex; + align-items: center; + margin-left: 10px; + margin-top: 10px; +} + +.env-items-list { + margin-top: 10px; +} + +.env-items-list > * + * { + margin-top: 2px; +} + +.env-items-list .env-item { + display: flex; +} + +.env-item .env-item-key { +} + +.env-item .env-item-value { + display: flex; +} + +.env-item .env-item-value .input-group { + flex: 1; +} diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.html b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.html new file mode 100644 index 000000000..66a6f0fe4 --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/environment-variables-simple-mode.html @@ -0,0 +1,36 @@ +
    + +
    + + Switch to advanced mode to copy & paste multiple variables +
    +
    + + + + + File too large! Try uploading a file smaller than 1MB + +
    +
    + +
    +
    diff --git a/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/index.js b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/index.js new file mode 100644 index 000000000..d51f2c11d --- /dev/null +++ b/app/portainer/components/environment-variables-panel/environment-variables-simple-mode/index.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +import './environment-variables-simple-mode.css'; + +import controller from './environment-variables-simple-mode.controller'; + +angular.module('portainer.app').component('environmentVariablesSimpleMode', { + templateUrl: './environment-variables-simple-mode.html', + controller, + bindings: { + ngModel: '<', + onSwitchModeClick: '<', + onChange: '<', + }, +}); diff --git a/app/portainer/components/environment-variables-panel/index.js b/app/portainer/components/environment-variables-panel/index.js new file mode 100644 index 000000000..9da15a489 --- /dev/null +++ b/app/portainer/components/environment-variables-panel/index.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +import './environment-variables-panel.css'; + +import controller from './environment-variables-panel.controller.js'; + +angular.module('portainer.app').component('environmentVariablesPanel', { + templateUrl: './environment-variables-panel.html', + controller, + bindings: { + ngModel: '<', + explanation: '@', + onChange: '<', + }, +}); diff --git a/app/portainer/filters/filters.js b/app/portainer/filters/filters.js index f31705979..4ed03afc7 100644 --- a/app/portainer/filters/filters.js +++ b/app/portainer/filters/filters.js @@ -79,12 +79,20 @@ angular .filter('key', function () { 'use strict'; return function (pair, separator) { + if (!pair.includes(separator)) { + return pair; + } + return pair.slice(0, pair.indexOf(separator)); }; }) .filter('value', function () { 'use strict'; return function (pair, separator) { + if (!pair.includes(separator)) { + return ''; + } + return pair.slice(pair.indexOf(separator) + 1); }; }) diff --git a/app/portainer/helpers/env-vars.js b/app/portainer/helpers/env-vars.js new file mode 100644 index 000000000..972fc5ebd --- /dev/null +++ b/app/portainer/helpers/env-vars.js @@ -0,0 +1,61 @@ +import _ from 'lodash-es'; + +export const KEY_REGEX = /[a-zA-Z]([-_a-zA-Z0-9]*[a-zA-Z0-9])?/.source; + +export const VALUE_REGEX = /(.*)?/.source; + +const KEY_VALUE_REGEX = new RegExp(`^(${KEY_REGEX})\\s*=(${VALUE_REGEX})$`); +const NEWLINES_REGEX = /\n|\r|\r\n/; + +/** + * @param {string} src the source of the .env file + * + * @returns {[{name: string, value: string}]} array of {name, value} + */ +export function parseDotEnvFile(src) { + return parseArrayOfStrings( + _.compact(src.split(NEWLINES_REGEX)) + .map((v) => v.trim()) + .filter((v) => !v.startsWith('#')) + ); +} + +/** + * parses an array of name=value to array of {name, value} + * + * @param {[string]} array array of strings in format name=value + * + * @returns {[{name: string, value: string}]} array of {name, value} + */ +export function parseArrayOfStrings(array) { + if (!array) { + return []; + } + + return _.compact( + array.map((variableString) => { + if (!variableString.includes('=')) { + return { name: variableString }; + } + + const parsedKeyValArr = variableString.trim().match(KEY_VALUE_REGEX); + if (parsedKeyValArr != null && parsedKeyValArr.length > 4) { + return { name: parsedKeyValArr[1], value: parsedKeyValArr[3] || '' }; + } + }) + ); +} +/** + * converts an array of {name, value} to array of `name=value`, name is always defined + * + * @param {[{name, value}]} array array of {name, value} + * + * @returns {[string]} array of `name=value` + */ +export function convertToArrayOfStrings(array) { + if (!array) { + return []; + } + + return array.filter((variable) => variable.name).map(({ name, value }) => (value || value === '' ? `${name}=${value}` : name)); +} diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index 76a932e67..f632101ea 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -53,14 +53,6 @@ angular } }; - $scope.addEnvironmentVariable = function () { - $scope.formValues.Env.push({ name: '', value: '' }); - }; - - $scope.removeEnvironmentVariable = function (index) { - $scope.formValues.Env.splice(index, 1); - }; - function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; @@ -123,6 +115,11 @@ angular } } + $scope.handleEnvVarChange = handleEnvVarChange; + function handleEnvVarChange(value) { + $scope.formValues.Env = value; + } + $scope.deployStack = function () { var name = $scope.formValues.Name; var method = $scope.state.Method; diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index 6719edfec..4edb6cf50 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -7,7 +7,7 @@
    - +
    @@ -261,36 +261,8 @@
    -
    -
    - Environment -
    -
    -
    - - - add environment variable - -
    - -
    -
    -
    - name - -
    -
    - value - -
    - -
    -
    - -
    -
    + + @@ -303,6 +275,7 @@ type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress + || !createStackForm.$valid || (state.Method === 'editor' && (!formValues.StackFileContent || state.editorYamlValidationError)) || (state.Method === 'upload' && (!formValues.StackFile || state.uploadYamlValidationError)) || (state.Method === 'template' && (!formValues.StackFileContent || !selectedTemplate || state.editorYamlValidationError)) diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 707bf5c2e..1981b613d 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -126,7 +126,7 @@ Editor - +
    This stack will be deployed using the equivalent of docker-compose. Only Compose file format version 2 is supported at the moment. @@ -152,34 +152,11 @@
    -
    - Environment -
    -
    -
    - - - add environment variable - -
    - -
    -
    -
    - name - -
    -
    - value - -
    - -
    -
    - -
    +
    @@ -207,7 +184,7 @@
    -
    -
    - Git repository -
    -
    - - You can use the URL of a git repository. - -
    -
    - -
    - -
    -
    -
    - - Specify a reference of the repository using the following syntax: branches with - refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally the - master branch. - -
    -
    - -
    - -
    -
    -
    - - Indicate the path to the Compose file from the root of your repository. - -
    -
    - -
    - -
    -
    -
    -
    - - -
    -
    -
    - - If your git account has 2FA enabled, you may receive an - authentication required error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password. - -
    -
    - -
    - -
    - -
    - -
    -
    -
    +
    diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js index 7636f2bf0..aaf10b64b 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js @@ -40,6 +40,7 @@ export class CreateEdgeStackViewController { this.onChangeTemplate = this.onChangeTemplate.bind(this); this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeFormValues = this.onChangeFormValues.bind(this); } async uiCanExit() { @@ -161,6 +162,10 @@ export class CreateEdgeStackViewController { return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups); } + onChangeFormValues(values) { + this.formValues = values; + } + editorUpdate(cm) { this.formValues.StackFileContent = cm.getValue(); this.state.isEditorDirty = true; diff --git a/app/portainer/__module.js b/app/portainer/__module.js index b8f01310c..21e14120b 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -1,5 +1,7 @@ import _ from 'lodash-es'; +import componentsModule from './components'; + async function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); // The unauthenticated event is broadcasted by the jwtInterceptor when @@ -15,7 +17,7 @@ async function initAuthentication(authManager, Authentication, $rootScope, $stat return await Authentication.init(); } -angular.module('portainer.app', ['portainer.oauth']).config([ +angular.module('portainer.app', ['portainer.oauth', componentsModule]).config([ '$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js new file mode 100644 index 000000000..1fac1f4fd --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.controller.js @@ -0,0 +1,20 @@ +class GitFormComposeAuthFieldsetController { + /* @ngInject */ + constructor() { + this.onChangeField = this.onChangeField.bind(this); + this.onChangeAuth = this.onChangeField('RepositoryAuthentication'); + this.onChangeUsername = this.onChangeField('RepositoryUsername'); + this.onChangePassword = this.onChangeField('RepositoryPassword'); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + ...this.model, + [field]: value, + }); + }; + } +} + +export default GitFormComposeAuthFieldsetController; diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html new file mode 100644 index 000000000..b884c248d --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/git-form-auth-fieldset.html @@ -0,0 +1,39 @@ +
    +
    + +
    +
    +
    +
    + + If your git account has 2FA enabled, you may receive an authentication required error when deploying your stack. In this case, you will need to provide a + personal-access token instead of your password. + +
    +
    + +
    + +
    + +
    + +
    +
    +
    diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js b/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js new file mode 100644 index 000000000..5c2373387 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js @@ -0,0 +1,10 @@ +import controller from './git-form-auth-fieldset.controller.js'; + +export const gitFormAuthFieldset = { + templateUrl: './git-form-auth-fieldset.html', + controller, + bindings: { + model: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html b/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html new file mode 100644 index 000000000..541023c49 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html @@ -0,0 +1,11 @@ +
    + + Indicate the path to the Compose file from the root of your repository. + +
    +
    + +
    + +
    +
    diff --git a/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js b/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js new file mode 100644 index 000000000..7906d0e85 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js @@ -0,0 +1,7 @@ +export const gitFormComposePathField = { + templateUrl: './git-form-compose-path-field.html', + bindings: { + value: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html b/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html new file mode 100644 index 000000000..a12639d24 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html @@ -0,0 +1,12 @@ +
    + + Specify a reference of the repository using the following syntax: branches with refs/heads/branch_name or tags with refs/tags/tag_name. If not + specified, will use the default HEAD reference normally the master branch. + +
    +
    + +
    + +
    +
    diff --git a/app/portainer/components/forms/git-form/git-form-ref-field/index.js b/app/portainer/components/forms/git-form/git-form-ref-field/index.js new file mode 100644 index 000000000..df8145061 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-ref-field/index.js @@ -0,0 +1,7 @@ +export const gitFormRefField = { + templateUrl: './git-form-ref-field.html', + bindings: { + value: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form-url-field/git-form-url-field.html b/app/portainer/components/forms/git-form/git-form-url-field/git-form-url-field.html new file mode 100644 index 000000000..c9083ce60 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-url-field/git-form-url-field.html @@ -0,0 +1,18 @@ +
    + + You can use the URL of a git repository. + +
    +
    + +
    + +
    +
    diff --git a/app/portainer/components/forms/git-form/git-form-url-field/index.js b/app/portainer/components/forms/git-form/git-form-url-field/index.js new file mode 100644 index 000000000..972df9aaa --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-url-field/index.js @@ -0,0 +1,7 @@ +export const gitFormUrlField = { + templateUrl: './git-form-url-field.html', + bindings: { + value: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/git-form.controller.js b/app/portainer/components/forms/git-form/git-form.controller.js new file mode 100644 index 000000000..795e9a7ba --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.controller.js @@ -0,0 +1,18 @@ +export default class GitFormController { + /* @ngInject */ + constructor() { + this.onChangeField = this.onChangeField.bind(this); + this.onChangeURL = this.onChangeField('RepositoryURL'); + this.onChangeRefName = this.onChangeField('RepositoryReferenceName'); + this.onChangeComposePath = this.onChangeField('ComposeFilePathInRepository'); + } + + onChangeField(field) { + return (value) => { + this.onChange({ + ...this.model, + [field]: value, + }); + }; + } +} diff --git a/app/portainer/components/forms/git-form/git-form.html b/app/portainer/components/forms/git-form/git-form.html new file mode 100644 index 000000000..16b131008 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.html @@ -0,0 +1,9 @@ +
    +
    + Git repository +
    + + + + +
    diff --git a/app/portainer/components/forms/git-form/git-form.js b/app/portainer/components/forms/git-form/git-form.js new file mode 100644 index 000000000..4a1c5733e --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form.js @@ -0,0 +1,10 @@ +import controller from './git-form.controller.js'; + +export const gitForm = { + templateUrl: './git-form.html', + controller, + bindings: { + model: '<', + onChange: '<', + }, +}; diff --git a/app/portainer/components/forms/git-form/index.js b/app/portainer/components/forms/git-form/index.js new file mode 100644 index 000000000..323c1d271 --- /dev/null +++ b/app/portainer/components/forms/git-form/index.js @@ -0,0 +1,15 @@ +import angular from 'angular'; + +import { gitForm } from './git-form'; +import { gitFormAuthFieldset } from './git-form-auth-fieldset'; +import { gitFormComposePathField } from './git-form-compose-path-field'; +import { gitFormRefField } from './git-form-ref-field'; +import { gitFormUrlField } from './git-form-url-field'; + +export default angular + .module('portainer.app.components.forms.git', []) + .component('gitFormComposePathField', gitFormComposePathField) + .component('gitFormRefField', gitFormRefField) + .component('gitForm', gitForm) + .component('gitFormUrlField', gitFormUrlField) + .component('gitFormAuthFieldset', gitFormAuthFieldset).name; diff --git a/app/portainer/components/forms/stack-redeploy-git-form/index.js b/app/portainer/components/forms/stack-redeploy-git-form/index.js new file mode 100644 index 000000000..2372e8b58 --- /dev/null +++ b/app/portainer/components/forms/stack-redeploy-git-form/index.js @@ -0,0 +1,13 @@ +import angular from 'angular'; +import controller from './stack-redeploy-git-form.controller.js'; + +export const stackRedeployGitForm = { + templateUrl: './stack-redeploy-git-form.html', + controller, + bindings: { + model: '<', + stack: '<', + }, +}; + +angular.module('portainer.app').component('stackRedeployGitForm', stackRedeployGitForm); diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js new file mode 100644 index 000000000..f489cc2d9 --- /dev/null +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -0,0 +1,75 @@ +class StackRedeployGitFormController { + /* @ngInject */ + constructor($async, $state, StackService, ModalService, Notifications) { + this.$async = $async; + this.$state = $state; + this.StackService = StackService; + this.ModalService = ModalService; + this.Notifications = Notifications; + + this.state = { + inProgress: false, + }; + + this.formValues = { + RefName: '', + RepositoryAuthentication: false, + RepositoryUsername: '', + RepositoryPassword: '', + }; + + this.onChange = this.onChange.bind(this); + this.onChangeRef = this.onChangeRef.bind(this); + } + + onChangeRef(value) { + this.onChange({ RefName: value }); + } + + onChange(values) { + this.formValues = { + ...this.formValues, + ...values, + }; + } + + async submit() { + return this.$async(async () => { + try { + const confirmed = await this.ModalService.confirmAsync({ + title: 'Are you sure?', + message: 'Any changes to this stack made locally in Portainer will be overridden by the definition in git and may cause a service interruption. Do you wish to continue', + buttons: { + confirm: { + label: 'Update', + className: 'btn-warning', + }, + }, + }); + if (!confirmed) { + return; + } + + this.state.inProgress = true; + + await this.StackService.updateGit(this.stack.Id, this.stack.EndpointId, [], false, this.formValues); + + await this.$state.reload(); + } catch (err) { + this.Notifications.error('Failure', err, 'Failed redeploying stack'); + } finally { + this.state.inProgress = false; + } + }); + } + + isSubmitButtonDisabled() { + return this.state.inProgress; + } + + $onInit() { + this.formValues.RefName = this.model.ReferenceName; + } +} + +export default StackRedeployGitFormController; diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html new file mode 100644 index 000000000..5b5efc42b --- /dev/null +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html @@ -0,0 +1,30 @@ + +
    + Redeploy from git repository +
    +
    +
    +

    + This stack was deployed from the git repository {{ $ctrl.model.URL }} + . +

    +

    + Update {{ $ctrl.model.ConfigFilePath }} in git and pull from here to update the stack. +

    +
    +
    + + + + + + diff --git a/app/portainer/components/index.js b/app/portainer/components/index.js new file mode 100644 index 000000000..8fa3030a0 --- /dev/null +++ b/app/portainer/components/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import gitFormModule from './forms/git-form'; + +export default angular.module('portainer.app.components', [gitFormModule]).name; diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js index 10fcf3e05..1b11fa432 100644 --- a/app/portainer/models/stack.js +++ b/app/portainer/models/stack.js @@ -15,11 +15,11 @@ export function StackViewModel(data) { this.CreatedBy = data.CreatedBy; this.UpdateDate = data.UpdateDate; this.UpdatedBy = data.UpdatedBy; - this.Regular = true; this.External = false; this.Orphaned = false; this.Checked = false; + this.GitConfig = data.GitConfig; } export function ExternalStackViewModel(name, type, creationDate) { diff --git a/app/portainer/rest/stack.js b/app/portainer/rest/stack.js index 16c5f46b5..6327fc647 100644 --- a/app/portainer/rest/stack.js +++ b/app/portainer/rest/stack.js @@ -18,6 +18,7 @@ angular.module('portainer.app').factory('Stack', [ migrate: { method: 'POST', params: { id: '@id', action: 'migrate', endpointId: '@endpointId' }, ignoreLoadingBar: true }, start: { method: 'POST', params: { id: '@id', action: 'start' } }, stop: { method: 'POST', params: { id: '@id', action: 'stop' } }, + updateGit: { method: 'PUT', params: { action: 'git' } }, } ); }, diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 7c9a698cc..fd9b84871 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -13,7 +13,9 @@ angular.module('portainer.app').factory('StackService', [ 'EndpointProvider', function StackServiceFactory($q, $async, Stack, FileUploadService, StackHelper, ServiceService, ContainerService, SwarmService, EndpointProvider) { 'use strict'; - var service = {}; + var service = { + updateGit, + }; service.stack = function (id) { var deferred = $q.defer(); @@ -394,6 +396,20 @@ angular.module('portainer.app').factory('StackService', [ return Stack.stop({ id }).$promise; } + function updateGit(id, endpointId, env, prune, gitConfig) { + return Stack.updateGit( + { endpointId, id }, + { + env, + prune, + RepositoryReferenceName: gitConfig.RefName, + RepositoryAuthentication: gitConfig.RepositoryAuthentication, + RepositoryUsername: gitConfig.RepositoryUsername, + RepositoryPassword: gitConfig.RepositoryPassword, + } + ).$promise; + } + return service; }, ]); diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html index cb4b96d90..c84198744 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateView.html @@ -103,84 +103,7 @@
    -
    -
    - Git repository -
    -
    - - You can use the URL of a git repository. - -
    -
    - -
    - -
    -
    -
    - - Specify a reference of the repository using the following syntax: branches with - refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally the - master branch. - -
    -
    - -
    - -
    -
    -
    - - Indicate the path to the Compose file from the root of your repository. - -
    -
    - -
    - -
    -
    -
    -
    - - -
    -
    -
    - - If your git account has 2FA enabled, you may receive an - authentication required error when creating your template. In this case, you will need to provide a personal-access token instead of your password. - -
    -
    - -
    - -
    - -
    - -
    -
    -
    + diff --git a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js index e24b54853..f64778957 100644 --- a/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js +++ b/app/portainer/views/custom-templates/create-custom-template-view/createCustomTemplateViewController.js @@ -54,6 +54,7 @@ class CreateCustomTemplateViewController { this.createCustomTemplateFromGitRepository = this.createCustomTemplateFromGitRepository.bind(this); this.editorUpdate = this.editorUpdate.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this); + this.onChangeFormValues = this.onChangeFormValues.bind(this); } createCustomTemplate() { @@ -150,6 +151,10 @@ class CreateCustomTemplateViewController { this.state.isEditorDirty = true; } + onChangeFormValues(newValues) { + this.formValues = newValues; + } + async $onInit() { const applicationState = this.StateManager.getState(); diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index f632101ea..07cea305b 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -53,6 +53,16 @@ angular } }; + $scope.onChangeFormValues = onChangeFormValues; + + $scope.addEnvironmentVariable = function () { + $scope.formValues.Env.push({ name: '', value: '' }); + }; + + $scope.removeEnvironmentVariable = function (index) { + $scope.formValues.Env.splice(index, 1); + }; + function validateForm(accessControlData, isAdmin) { $scope.state.formValidationError = ''; var error = ''; @@ -236,4 +246,8 @@ angular }; initView(); + + function onChangeFormValues(newValues) { + $scope.formValues = newValues; + } }); diff --git a/app/portainer/views/stacks/create/createstack.html b/app/portainer/views/stacks/create/createstack.html index 4edb6cf50..8e545b7e3 100644 --- a/app/portainer/views/stacks/create/createstack.html +++ b/app/portainer/views/stacks/create/createstack.html @@ -130,79 +130,7 @@
    - -
    -
    - Git repository -
    -
    - - You can use the URL of a git repository. - -
    -
    - -
    - -
    -
    -
    - - Specify a reference of the repository using the following syntax: branches with refs/heads/branch_name or tags with refs/tags/tag_name. If - not specified, will use the default HEAD reference normally the master branch. - -
    -
    - -
    - -
    -
    -
    - - Indicate the path to the Compose file from the root of your repository. - -
    -
    - -
    - -
    -
    -
    -
    - - -
    -
    -
    - - If your git account has 2FA enabled, you may receive an authentication required error when deploying your stack. In this case, you will need to provide - a personal-access token instead of your password. - -
    -
    - -
    - -
    - -
    - -
    -
    -
    - +
    diff --git a/app/portainer/views/stacks/edit/stack.html b/app/portainer/views/stacks/edit/stack.html index 1981b613d..aae58de07 100644 --- a/app/portainer/views/stacks/edit/stack.html +++ b/app/portainer/views/stacks/edit/stack.html @@ -110,6 +110,7 @@
    + Date: Wed, 16 Jun 2021 07:28:44 +0200 Subject: [PATCH 10/10] feat(k8s/advanced-deployment): allow standard users to see and use advanced deployment feature EE-446 (#5050) --- api/http/handler/stacks/stack_create.go | 8 ++------ api/internal/endpointutils/endpointutils.go | 14 ++++++++++++++ .../templates/advancedDeploymentPanel.html | 2 +- .../views/applications/applications.html | 2 +- .../views/configurations/configurations.html | 2 +- app/kubernetes/views/volumes/volumes.html | 2 +- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index eae955cf5..1919993dd 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -14,9 +14,9 @@ import ( portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" gittypes "github.com/portainer/portainer/api/git/types" - httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" + "github.com/portainer/portainer/api/internal/endpointutils" "github.com/portainer/portainer/api/internal/stackutils" ) @@ -78,7 +78,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if !endpoint.SecuritySettings.AllowStackManagementForRegularUsers { + if endpointutils.IsDockerEndpoint(endpoint) && !endpoint.SecuritySettings.AllowStackManagementForRegularUsers { securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve user info from request context", err} @@ -112,10 +112,6 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt case portainer.DockerComposeStack: return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) case portainer.KubernetesStack: - if tokenData.Role != portainer.AdministratorRole { - return &httperror.HandlerError{http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized} - } - return handler.createKubernetesStack(w, r, endpoint) } diff --git a/api/internal/endpointutils/endpointutils.go b/api/internal/endpointutils/endpointutils.go index 249ee11cb..48c2c5fd1 100644 --- a/api/internal/endpointutils/endpointutils.go +++ b/api/internal/endpointutils/endpointutils.go @@ -9,3 +9,17 @@ import ( func IsLocalEndpoint(endpoint *portainer.Endpoint) bool { return strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") || endpoint.Type == 5 } + +// IsKubernetesEndpoint returns true if this is a kubernetes endpoint +func IsKubernetesEndpoint(endpoint *portainer.Endpoint) bool { + return endpoint.Type == portainer.KubernetesLocalEnvironment || + endpoint.Type == portainer.AgentOnKubernetesEnvironment || + endpoint.Type == portainer.EdgeAgentOnKubernetesEnvironment +} + +// IsDockerEndpoint returns true if this is a docker endpoint +func IsDockerEndpoint(endpoint *portainer.Endpoint) bool { + return endpoint.Type == portainer.DockerEnvironment || + endpoint.Type == portainer.AgentOnDockerEnvironment || + endpoint.Type == portainer.EdgeAgentOnDockerEnvironment +} diff --git a/app/kubernetes/templates/advancedDeploymentPanel.html b/app/kubernetes/templates/advancedDeploymentPanel.html index 1072dc615..34044cbf8 100644 --- a/app/kubernetes/templates/advancedDeploymentPanel.html +++ b/app/kubernetes/templates/advancedDeploymentPanel.html @@ -2,7 +2,7 @@

    - As an administrator user, you have access to the advanced deployment feature allowing you to deploy any Kubernetes manifest inside your cluster. + Advanced deployment allows you to deploy any Kubernetes manifest inside your cluster.

    diff --git a/app/kubernetes/views/applications/applications.html b/app/kubernetes/views/applications/applications.html index c0c86d0b8..30e599101 100644 --- a/app/kubernetes/views/applications/applications.html +++ b/app/kubernetes/views/applications/applications.html @@ -5,7 +5,7 @@

    -
    +
    diff --git a/app/kubernetes/views/configurations/configurations.html b/app/kubernetes/views/configurations/configurations.html index 6d07dc244..4e53089ef 100644 --- a/app/kubernetes/views/configurations/configurations.html +++ b/app/kubernetes/views/configurations/configurations.html @@ -5,7 +5,7 @@
    -
    +
    diff --git a/app/kubernetes/views/volumes/volumes.html b/app/kubernetes/views/volumes/volumes.html index 8fdb7bd7e..61693b24d 100644 --- a/app/kubernetes/views/volumes/volumes.html +++ b/app/kubernetes/views/volumes/volumes.html @@ -5,7 +5,7 @@
    -
    +