feature(helm): move helm charts inside advance deployments (create from manifest) [EE-5999] (#10395)

pull/10441/head
Prabhat Khera 2023-10-09 11:20:44 +13:00 committed by GitHub
parent 9885694df6
commit b468070945
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 877 additions and 388 deletions

View File

@ -81,6 +81,7 @@ type Handler struct {
UserHandler *users.Handler
WebSocketHandler *websocket.Handler
WebhookHandler *webhooks.Handler
UserHelmHandler *helm.Handler
}
// @title PortainerCE API

View File

@ -52,10 +52,11 @@ func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStor
h.Handle("/{id}/kubernetes/helm",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.helmInstall))).Methods(http.MethodPost)
// Deprecated
h.Handle("/{id}/kubernetes/helm/repositories",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userGetHelmRepos))).Methods(http.MethodGet)
httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
h.Handle("/{id}/kubernetes/helm/repositories",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.userCreateHelmRepo))).Methods(http.MethodPost)
httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
return h
}

View File

@ -27,7 +27,7 @@ func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
}
// @id HelmUserRepositoryCreate
// @id HelmUserRepositoryCreateDeprecated
// @summary Create a user helm repository
// @description Create a user helm repository.
// @description **Access policy**: authenticated
@ -42,6 +42,7 @@ func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @deprecated
// @router /endpoints/{id}/kubernetes/helm/repositories [post]
func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)
@ -85,7 +86,7 @@ func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Reques
return response.JSON(w, record)
}
// @id HelmUserRepositoriesList
// @id HelmUserRepositoriesListDeprecated
// @summary List a users helm repositories
// @description Inspect a user helm repositories.
// @description **Access policy**: authenticated
@ -98,6 +99,7 @@ func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Reques
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @deprecated
// @router /endpoints/{id}/kubernetes/helm/repositories [get]
func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tokenData, err := security.RetrieveTokenData(r)

View File

@ -37,6 +37,7 @@ type Handler struct {
CryptoService portainer.CryptoService
passwordStrengthChecker security.PasswordStrengthChecker
AdminCreationDone chan<- struct{}
FileService portainer.FileService
}
// NewHandler creates a handler to manage user operations.
@ -77,5 +78,10 @@ func NewHandler(bouncer security.BouncerService, rateLimiter *security.RateLimit
publicRouter.Handle("/users/admin/check", httperror.LoggerHandler(h.adminCheck)).Methods(http.MethodGet)
publicRouter.Handle("/users/admin/init", httperror.LoggerHandler(h.adminInit)).Methods(http.MethodPost)
// Helm repositories
authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userGetHelmRepos)).Methods(http.MethodGet)
authenticatedRouter.Handle("/users/{id}/helm/repositories", httperror.LoggerHandler(h.userCreateHelmRepo)).Methods(http.MethodPost)
authenticatedRouter.Handle("/users/{id}/helm/repositories/{repositoryID}", httperror.LoggerHandler(h.userDeleteHelmRepo)).Methods(http.MethodDelete)
return h
}

View File

@ -0,0 +1,196 @@
package users
import (
"net/http"
"strings"
portainer "github.com/portainer/portainer/api"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/pkg/libhelm"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
"github.com/portainer/portainer/pkg/libhttp/response"
"github.com/pkg/errors"
)
type helmUserRepositoryResponse struct {
GlobalRepository string `json:"GlobalRepository"`
UserRepositories []portainer.HelmUserRepository `json:"UserRepositories"`
}
type addHelmRepoUrlPayload struct {
URL string `json:"url"`
}
func (p *addHelmRepoUrlPayload) Validate(_ *http.Request) error {
return libhelm.ValidateHelmRepositoryURL(p.URL, nil)
}
// @id HelmUserRepositoryCreate
// @summary Create a user helm repository
// @description Create a user helm repository.
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth
// @security jwt
// @accept json
// @produce json
// @param id path int true "User identifier"
// @param payload body addHelmRepoUrlPayload true "Helm Repository"
// @success 200 {object} portainer.HelmUserRepository "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @router /users/{id}/helm/repositories [post]
func (handler *Handler) userCreateHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid user identifier route variable", err)
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
userID := portainer.UserID(userIDEndpoint)
if tokenData.ID != userID {
return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized)
}
p := new(addHelmRepoUrlPayload)
err = request.DecodeAndValidateJSONPayload(r, p)
if err != nil {
return httperror.BadRequest("Invalid Helm repository URL", err)
}
// lowercase, remove trailing slash
p.URL = strings.TrimSuffix(strings.ToLower(p.URL), "/")
records, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to access the DataStore", err)
}
// check if repo already exists - by doing case insensitive comparison
for _, record := range records {
if strings.EqualFold(record.URL, p.URL) {
errMsg := "Helm repo already registered for user"
return httperror.BadRequest(errMsg, errors.New(errMsg))
}
}
record := portainer.HelmUserRepository{
UserID: userID,
URL: p.URL,
}
err = handler.DataStore.HelmUserRepository().Create(&record)
if err != nil {
return httperror.InternalServerError("Unable to save a user Helm repository URL", err)
}
return response.JSON(w, record)
}
// @id HelmUserRepositoriesList
// @summary List a users helm repositories
// @description Inspect a user helm repositories.
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "User identifier"
// @success 200 {object} helmUserRepositoryResponse "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @router /users/{id}/helm/repositories [get]
func (handler *Handler) userGetHelmRepos(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid user identifier route variable", err)
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
userID := portainer.UserID(userIDEndpoint)
if tokenData.ID != userID {
return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized)
}
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve settings from the database", err)
}
userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to get user Helm repositories", err)
}
resp := helmUserRepositoryResponse{
GlobalRepository: settings.HelmRepositoryURL,
UserRepositories: userRepos,
}
return response.JSON(w, resp)
}
// @id HelmUserRepositoryDelete
// @summary Delete a users helm repositoryies
// @description **Access policy**: authenticated
// @tags helm
// @security ApiKeyAuth
// @security jwt
// @produce json
// @param id path int true "User identifier"
// @param repositoryID path int true "Repository identifier"
// @success 204 "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 500 "Server error"
// @router /users/{id}/helm/repositories/{repositoryID} [delete]
func (handler *Handler) userDeleteHelmRepo(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
userIDEndpoint, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return httperror.BadRequest("Invalid user identifier route variable", err)
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
return httperror.InternalServerError("Unable to retrieve user authentication token", err)
}
userID := portainer.UserID(userIDEndpoint)
if tokenData.ID != userID {
return httperror.Forbidden("Couldn't create Helm repositories for another user", httperrors.ErrUnauthorized)
}
repositoryID, err := request.RetrieveNumericRouteVariableValue(r, "repositoryID")
if err != nil {
return httperror.BadRequest("Invalid user identifier route variable", err)
}
userRepos, err := handler.DataStore.HelmUserRepository().HelmUserRepositoryByUserID(userID)
if err != nil {
return httperror.InternalServerError("Unable to get user Helm repositories", err)
}
for _, repo := range userRepos {
if repo.ID == portainer.HelmUserRepositoryID(repositoryID) && repo.UserID == userID {
err = handler.DataStore.HelmUserRepository().Delete(portainer.HelmUserRepositoryID(repositoryID))
if err != nil {
return httperror.InternalServerError("Unable to delete user Helm repository", err)
}
}
}
return response.JSON(w, nil)
}

View File

@ -282,6 +282,7 @@ func (server *Server) Start() error {
userHandler.DataStore = server.DataStore
userHandler.CryptoService = server.CryptoService
userHandler.AdminCreationDone = server.AdminCreationDone
userHandler.FileService = server.FileService
var websocketHandler = websocket.NewHandler(server.KubernetesTokenCacheManager, requestBouncer)
websocketHandler.DataStore = server.DataStore

View File

@ -1 +1 @@
<svg width="auto" height="auto" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <g clip-path="url(#clip0_2026_8347)"> <path d="M2.03394 12.0799L21.9659 12.0799M4.05923 7.91314C4.44428 7.25611 4.90783 6.65064 5.437 6.10964M5.437 6.10964C7.12201 4.38695 9.4724 3.31777 12.0725 3.31777C14.6331 3.31777 16.9516 4.35473 18.6308 6.03161M5.437 6.10964L3.87003 4.54238M5.437 6.10964L5.44586 6.1185M18.6308 6.03161C19.1922 6.59221 19.6821 7.22434 20.0858 7.91314M18.6308 6.03161L20.1869 4.47547M18.6308 6.03161L18.6243 6.03807M11.9948 1.04492L11.9948 3.31804M19.969 16.069C19.584 16.726 19.1204 17.3315 18.5912 17.8725M18.5912 17.8725C16.9062 19.5952 14.5559 20.6643 11.9557 20.6643C9.39512 20.6643 7.07669 19.6274 5.39746 17.9505M18.5912 17.8725L20.1582 19.4397M18.5912 17.8725L18.5824 17.8636M5.39746 17.9505C4.83608 17.3899 4.34614 16.7578 3.94248 16.069M5.39746 17.9505L3.84132 19.5066M5.39746 17.9505L5.40392 17.944M12.0335 22.9372L12.0335 20.6641" stroke="currentColor" stroke-width="2" stroke-linecap="round"/> </g> <defs> <clipPath id="clip0_2026_8347"> <rect width="24" height="24" fill="white" transform="translate(24) rotate(90)"/> </clipPath> </defs> </svg>
<svg fill="currentColor" width="auto" height="auto" viewBox="0 0 24 24" role="img" xmlns="http://www.w3.org/2000/svg"><title>Helm</title><path d="M18.651,19.252c0.704,1.005,0.96,2.039,0.573,2.31c-0.387,0.271-1.271-0.324-1.975-1.329 c-0.259-0.37-0.456-0.744-0.584-1.082c-1.156,0.772-2.493,1.258-3.898,1.4c0.081,0.34,0.13,0.737,0.13,1.166 c0,1.227-0.383,2.221-0.856,2.221c-0.473,0-0.856-0.994-0.856-2.221c0-0.42,0.047-0.807,0.125-1.142 c-1.414-0.099-2.765-0.547-3.944-1.284c-0.127,0.301-0.3,0.621-0.524,0.942c-0.704,1.005-1.588,1.6-1.975,1.329 c-0.387-0.271-0.131-1.305,0.573-2.31c0.236-0.337,0.492-0.622,0.743-0.85c-0.487-0.437-0.928-0.931-1.309-1.479l1.124-0.782 c1.345,1.934,3.554,3.088,5.908,3.088c2.36,0,4.571-1.158,5.916-3.098l1.125,0.78c-0.348,0.502-0.747,0.957-1.183,1.366 C18.06,18.518,18.369,18.85,18.651,19.252z M6.277,5.623C5.682,6.143,5.153,6.746,4.711,7.43l1.15,0.743 C7.193,6.111,9.453,4.88,11.907,4.88c2.535,0,4.835,1.294,6.151,3.461l1.17-0.711c-0.435-0.716-0.963-1.349-1.56-1.895 c0.324-0.245,0.671-0.603,0.983-1.049c0.704-1.005,0.96-2.039,0.573-2.31c-0.387-0.271-1.271,0.324-1.975,1.329 c-0.294,0.419-0.504,0.84-0.627,1.212c-1.152-0.761-2.485-1.232-3.9-1.364c0.108-0.372,0.175-0.83,0.175-1.333 C12.897,0.994,12.514,0,12.041,0c-0.473,0-0.856,0.994-0.856,2.221c0,0.491,0.063,0.941,0.167,1.308 c-1.413,0.09-2.757,0.525-3.93,1.247c-0.128-0.336-0.323-0.705-0.58-1.071C6.139,2.7,5.255,2.106,4.868,2.377 c-0.387,0.271-0.131,1.305,0.573,2.31C5.706,5.065,5.997,5.385,6.277,5.623z M0.5,15.272h1.648V12.8h1.859v2.473h1.648V9.043H4.008 v2.319H2.148V9.043H0.5V15.272z M7.036,9.043v6.229h4.121v-1.38H8.684v-1.112h2.032v-1.38H8.684v-0.978h2.377v-1.38L7.036,9.043 L7.036,9.043z M12.364,9.043v6.229h4.006v-1.38h-2.358V9.043L12.364,9.043L12.364,9.043z M17.443,9.043v6.229h1.514v-1.84 c0-0.16-0.008-0.335-0.024-0.527c-0.016-0.192-0.034-0.388-0.053-0.589c-0.019-0.201-0.042-0.398-0.067-0.589 c-0.026-0.192-0.048-0.364-0.067-0.517h0.038l0.498,1.457l0.863,2.099h0.613l0.862-2.099l0.517-1.457h0.038 c-0.019,0.153-0.042,0.326-0.067,0.518c-0.026,0.192-0.048,0.388-0.067,0.589c-0.019,0.201-0.037,0.398-0.053,0.589 c-0.016,0.192-0.024,0.367-0.024,0.527v1.84H23.5V9.043h-1.706l-0.939,2.588l-0.345,1.016h-0.038l-0.345-1.016l-0.978-2.588 L17.443,9.043L17.443,9.043z"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -327,7 +327,7 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule, custo
const deploy = {
name: 'kubernetes.deploy',
url: '/deploy?templateId&referrer&tab',
url: '/deploy?templateId&referrer&tab&buildMethod&chartName',
views: {
'content@': {
component: 'kubernetesDeployView',

View File

@ -1,39 +0,0 @@
export default class HelmAddRepositoryController {
/* @ngInject */
constructor($state, $async, HelmService, Notifications) {
this.$state = $state;
this.$async = $async;
this.HelmService = HelmService;
this.Notifications = Notifications;
}
doesRepoExist() {
if (!this.state.repository) {
return false;
}
// lowercase, strip trailing slash and compare
return this.repos.includes(this.state.repository.toLowerCase().replace(/\/$/, ''));
}
async addRepository() {
this.state.isAddingRepo = true;
try {
await this.HelmService.addHelmRepository(this.endpoint.Id, { url: this.state.repository });
this.Notifications.success('Success', 'Helm repository added successfully');
this.$state.reload(this.$state.current);
} catch (err) {
this.Notifications.error('Installation error', err);
} finally {
this.state.isAddingRepo = false;
}
}
$onInit() {
return this.$async(async () => {
this.state = {
isAddingRepo: false,
repository: '',
};
});
}
}

View File

@ -1,72 +0,0 @@
<rd-widget>
<div class="toolBar px-5 pt-5">
<div class="toolBarTitle vertical-center text-[16px] font-medium">
<div class="widget-icon space-right">
<pr-icon icon="'svg-helm'"></pr-icon>
</div>
Additional repositories
</div>
</div>
<rd-widget-body>
<div class="actionBar">
<form class="form-horizontal" name="addUserHelmRepoForm">
<div class="form-group">
<span class="col-sm-12 text-muted small inline-flex gap-1 !align-top">
<div class="icon icon-sm">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
</div>
<div> Add a Helm repository. All Helm charts in the repository will be added to the list. </div>
</span>
</div>
<div class="form-group mb-2">
<div class="col-sm-12">
<input
type="url"
name="repo"
class="form-control"
ng-model="$ctrl.state.repository"
placeholder="https://charts.bitnami.com/bitnami"
ng-pattern="/^https?:///"
required
/>
</div>
</div>
<div class="form-group nomargin" ng-show="addUserHelmRepoForm.repo.$invalid">
<div class="small">
<div ng-messages="addUserHelmRepoForm.repo.$error">
<p class="vertical-center text-warning" ng-message="pattern"
><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> A valid URL beginning with http(s) is required.</p
>
</div>
</div>
</div>
<div class="form-group nomargin" ng-show="$ctrl.doesRepoExist()">
<div class="small">
<div ng-messages="addUserHelmRepoForm.repo.$error">
<p class="vertical-center text-warning"><pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> Helm repository already exists.</p>
</div>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm nomargin"
ng-click="$ctrl.addRepository()"
ng-disabled="$ctrl.state.isAddingRepo || addUserHelmRepoForm.repo.$invalid || $ctrl.doesRepoExist()"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-helm-add-repository"
>
Add repository
</button>
</div>
</div>
</form>
</div>
</rd-widget-body>
</rd-widget>

View File

@ -1,11 +0,0 @@
import angular from 'angular';
import controller from './helm-add-repository.controller';
angular.module('portainer.kubernetes').component('helmAddRepository', {
templateUrl: './helm-add-repository.html',
controller,
bindings: {
repos: '<',
endpoint: '<',
},
});

View File

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

View File

@ -1,53 +1,54 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1">
<div class="toolBarTitle vertical-center">
<div class="widget-icon space-right">
<pr-icon icon="$ctrl.titleIcon"></pr-icon>
</div>
<div class="toolBar vertical-center relative w-full flex-wrap !gap-x-5 !gap-y-1 !px-0">
<div class="toolBarTitle vertical-center">
{{ $ctrl.titleText }}
</div>
{{ $ctrl.titleText }}
</div>
<div class="searchBar vertical-center !mr-0">
<pr-icon icon="'search'" class="searchIcon"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search for a chart..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="w-1/5">
<por-select
placeholder="'Select a category'"
value="$ctrl.state.selectedCategory"
options="$ctrl.state.categories"
on-change="($ctrl.onCategoryChange)"
is-clearable="true"
bind-to-body="true"
></por-select>
</div>
</div>
<div class="w-full">
<div class="mb-2 small"
>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
>
<beta-alert
is-html="true"
message="'Beta feature - so far, this functionality has been tested in limited scenarios. For more information, see this <a href=\'https://www.portainer.io/blog/portainer-now-with-helm-support\' target=\'_blank\' class=\'hyperlink\'>blog post on Portainer Helm support</a>.'"
></beta-alert>
</div>
<div class="searchBar vertical-center !mr-0">
<pr-icon icon="'search'" class="searchIcon"></pr-icon>
<input
type="text"
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onTextFilterChange()"
placeholder="Search for a chart..."
auto-focus
ng-model-options="{ debounce: 300 }"
/>
</div>
<div class="w-1/5">
<por-select
placeholder="'Select a category'"
value="$ctrl.state.selectedCategory"
options="$ctrl.state.categories"
on-change="($ctrl.onCategoryChange)"
is-clearable="true"
bind-to-body="true"
></por-select>
</div>
</div>
<div class="blocklist">
<helm-templates-list-item
ng-repeat="chart in $ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory"
model="chart"
type-label="helm"
on-select="($ctrl.selectAction)"
>
</helm-templates-list-item>
<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>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div>
</rd-widget-body>
</rd-widget>
<div class="blocklist !px-0">
<helm-templates-list-item
ng-repeat="chart in $ctrl.charts | filter:$ctrl.state.textFilter | filter: $ctrl.state.selectedCategory"
model="chart"
type-label="helm"
on-select="($ctrl.selectAction)"
>
</helm-templates-list-item>
<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>
<div ng-if="!$ctrl.loading && $ctrl.charts.length === 0" class="text-muted text-center"> No helm charts available. </div>
</div>
</div>

View File

@ -7,7 +7,6 @@ angular.module('portainer.kubernetes').component('helmTemplatesList', {
bindings: {
loading: '<',
titleText: '@',
titleIcon: '@',
charts: '<',
tableKey: '@',
selectAction: '<',

View File

@ -50,11 +50,11 @@ export default class HelmTemplatesController {
this.state.actionInProgress = true;
try {
const payload = {
Name: this.state.appName,
Name: this.stackName,
Repo: this.state.chart.repo,
Chart: this.state.chart.name,
Values: this.state.values,
Namespace: this.state.resourcePool.Namespace.Name,
Namespace: this.namespace,
};
await this.HelmService.install(this.endpoint.Id, payload);
this.Notifications.success('Success', 'Helm Chart successfully installed');
@ -96,7 +96,7 @@ export default class HelmTemplatesController {
this.state.reposLoading = true;
try {
// fetch globally set helm repo and user helm repos (parallel)
const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.endpoint.Id);
const { GlobalRepository, UserRepositories } = await this.HelmService.getHelmRepositories(this.user.ID);
this.state.globalRepository = GlobalRepository;
const userHelmReposUrls = UserRepositories.map((repo) => repo.URL);
const uniqueHelmRepos = [...new Set([GlobalRepository, ...userHelmReposUrls])].map((url) => url.toLowerCase()).filter((url) => url); // remove duplicates and blank, to lowercase
@ -155,6 +155,8 @@ export default class HelmTemplatesController {
$onInit() {
return this.$async(async () => {
this.user = this.Authentication.getUserDetails();
this.state = {
appName: '',
chart: null,
@ -178,6 +180,13 @@ export default class HelmTemplatesController {
const helmRepos = await this.getHelmRepoURLs();
await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
if (this.state.charts.length > 0 && this.$state.params.chartName) {
const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName);
if (chart) {
this.selectHelmChart(chart);
}
}
this.state.viewReady = true;
});
}

View File

@ -1,180 +1,108 @@
<page-header title="'Helm'" breadcrumbs="['Charts']" reload="true"></page-header>
<information-panel title-text="Information" ng-if="!$ctrl.state.chart">
<beta-alert
is-html="true"
message="'Beta feature - initial version of Helm charts functionality, for more information see this <a href=\'https://www.portainer.io/blog/portainer-now-with-helm-support\' target=\'_blank\' class=\'hyperlink\'>blog post</a>.'"
></beta-alert>
<span class="small text-muted">
<p ng-if="$ctrl.state.globalRepository === ''" class="inline-flex items-center">
<pr-icon icon="'info'"></pr-icon>
<span>The Global Helm Repository is not configured.</span>
<a ng-if="$ctrl.state.isAdmin" ui-sref="portainer.settings">Configure Global Helm Repository in Settings</a>.
</p>
</span>
</information-panel>
<div class="row">
<!-- helmchart-form -->
<div class="col-sm-12" ng-if="$ctrl.state.chart">
<div class="col-sm-12 p-0" ng-if="$ctrl.state.chart">
<rd-widget>
<div class="toolBarTitle vertical-center px-5 pt-5 text-[16px] font-medium">
<fallback-image src="$ctrl.state.chart.icon" fallback-icon="'svg-helm'" class-name="'h-8 w-8'" size="'lg'"></fallback-image>
{{ $ctrl.state.chart.name }}
<div class="flex">
<div class="basis-3/4 rounded-[8px] m-2 bg-gray-4 th-highcontrast:bg-black th-highcontrast:text-white th-dark:bg-gray-iron-10 th-dark:text-white">
<div class="vertical-center p-5">
<fallback-image src="$ctrl.state.chart.icon" fallback-icon="'svg-helm'" class-name="'h-16 w-16'" size="'lg'"></fallback-image>
<div class="font-medium ml-4">
<div class="toolBarTitle text-[24px] mb-2">
{{ $ctrl.state.chart.name }}
<span class="space-left text-[14px] vertical-center font-normal">
<pr-icon icon="'svg-helm'" mode="'primary'"></pr-icon>
Helm
</span>
</div>
<div class="text-muted text-xs" ng-bind-html="$ctrl.state.chart.description"></div>
</div>
</div>
</div>
<div class="basis-1/4">
<div class="h-full w-full vertical-center justify-end pr-5">
<button type="button" class="btn btn-sm btn-link !text-gray-8 hover:no-underline th-highcontrast:!text-white th-dark:!text-white" ng-click="$ctrl.state.chart = null">
Clear selection
<pr-icon icon="'x'" class="ml-1"></pr-icon>
</button>
</div>
</div>
</div>
<rd-widget-body classes="padding">
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
<!-- description -->
<div>
<div class="col-sm-12 form-section-title"> Description </div>
<div class="form-group">
<div class="col-sm-12">
<div class="text-muted text-xs" ng-bind-html="$ctrl.state.chart.description"></div>
</div>
</div>
</div>
<!-- !description -->
<div class="col-sm-12 form-section-title"> Configuration </div>
<!-- namespace-input -->
<div class="form-group" ng-if="$ctrl.state.resourcePool">
<label for="resource-pool-selector" class="col-sm-2 control-label text-left">Namespace</label>
<div class="col-sm-10">
<select
class="form-control"
id="resource-pool-selector"
ng-model="$ctrl.state.resourcePool"
ng-options="resourcePool.Namespace.Name for resourcePool in $ctrl.state.resourcePools"
ng-change=""
ng-disabled="$ctrl.state.isEdit"
></select>
</div>
</div>
<div class="form-group" ng-if="!$ctrl.state.resourcePool">
<div class="col-sm-12 small text-warning vertical-center">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
You do not have access to any namespace. Contact your administrator to get access to a namespace.
</div>
</div>
<!-- !namespace-input -->
<!-- name-input -->
<div class="form-group mb-2">
<label for="release_name" class="col-sm-2 control-label required text-left">Name</label>
<div class="col-sm-10">
<input
type="text"
name="release_name"
class="form-control"
ng-model="$ctrl.state.appName"
placeholder="e.g. my-app"
required
ng-pattern="/^[a-z]([-a-z0-9]*[a-z0-9])?$/"
/>
</div>
</div>
<div class="form-group" ng-show="$ctrl.helmTemplateCreationForm.release_name.$invalid">
<div class="small">
<div ng-messages="$ctrl.helmTemplateCreationForm.release_name.$error">
<div class="col-sm-2"></div>
<p class="vertical-center col-sm-10 text-warning" ng-message="required">
<pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon>
This field is required.
</p>
<p class="vertical-center col-sm-10 text-warning" ng-message="pattern">
<pr-icon icon="'alert-triangle'" mode="'warning'" class="vertical-center"></pr-icon>
This field must consist of lower case alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character (e.g. 'my-name',
or 'abc-123').
</p>
</div>
</div>
</div>
<!-- !name-input -->
<div class="form-group">
<div class="col-sm-12">
<button
ng-if="!$ctrl.state.showCustomValues && !$ctrl.state.loadingValues"
class="btn btn-xs btn-default vertical-center !ml-0 mr-2"
ng-click="$ctrl.state.showCustomValues = true;"
>
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
Show custom values
</button>
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues">
<pr-icon icon="'refresh-cw'" class="mr-1"></pr-icon>
Loading values.yaml...
</span>
<button ng-if="$ctrl.state.showCustomValues" class="btn btn-xs btn-default vertical-center !ml-0 mr-2" ng-click="$ctrl.state.showCustomValues = false;">
<pr-icon icon="'minus'" class="vertical-center"></pr-icon>
Hide custom values
</button>
</div>
</div>
<!-- values override -->
<div ng-if="$ctrl.state.showCustomValues">
<!-- web-editor -->
<div>
<div class="form-group">
<div class="col-sm-12">
<web-editor-form
identifier="helm-app-creation-editor"
value="$ctrl.state.values"
on-change="($ctrl.editorUpdate)"
yml="true"
placeholder="Define or paste the content of your values yaml file here"
>
<editor-description class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span>
You can get more information about Helm values file format in the
<a href="https://helm.sh/docs/chart_template_guide/values_files/" target="_blank" class="text-blue-8 hover:text-blue-8 hover:underline"
>official documentation</a
>.
</span>
</editor-description>
</web-editor-form>
</div>
</div>
</div>
<!-- !web-editor -->
</div>
<!-- !values override -->
<!-- helm actions -->
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!($ctrl.state.appName && $ctrl.state.resourcePool && !$ctrl.state.loadingValues && !$ctrl.state.actionInProgress)"
ng-click="$ctrl.installHelmchart()"
button-spinner="$ctrl.state.actionInProgress"
data-cy="helm-install"
>
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
<span ng-hide="!$ctrl.state.actionInProgress">Helm installing in progress</span>
</button>
<button type="button" class="btn btn-sm btn-default" ng-click="$ctrl.state.chart = null">Hide</button>
</div>
</div>
<!-- !helm actions -->
</form>
</rd-widget-body>
</rd-widget>
<form class="form-horizontal" name="$ctrl.helmTemplateCreationForm">
<div class="form-group mt-4">
<div class="col-sm-12">
<button
ng-if="!$ctrl.state.showCustomValues && !$ctrl.state.loadingValues"
class="btn btn-xs btn-default vertical-center !ml-0 mr-2"
ng-click="$ctrl.state.showCustomValues = true;"
>
<pr-icon icon="'plus'" class="vertical-center"></pr-icon>
Show custom values
</button>
<span class="small interactive vertical-center" ng-if="$ctrl.state.loadingValues">
<pr-icon icon="'refresh-cw'" class="mr-1"></pr-icon>
Loading values.yaml...
</span>
<button ng-if="$ctrl.state.showCustomValues" class="btn btn-xs btn-default vertical-center !ml-0 mr-2" ng-click="$ctrl.state.showCustomValues = false;">
<pr-icon icon="'minus'" class="vertical-center"></pr-icon>
Hide custom values
</button>
</div>
</div>
<!-- values override -->
<div ng-if="$ctrl.state.showCustomValues">
<!-- web-editor -->
<div class="form-group">
<div class="col-sm-12">
<web-editor-form
identifier="helm-app-creation-editor"
value="$ctrl.state.values"
on-change="($ctrl.editorUpdate)"
yml="true"
placeholder="Define or paste the content of your values yaml file here"
>
<editor-description class="vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
<span>
You can get more information about Helm values file format in the
<a href="https://helm.sh/docs/chart_template_guide/values_files/" target="_blank" class="hyperlink">official documentation</a>.
</span>
</editor-description>
</web-editor-form>
</div>
</div>
<!-- !web-editor -->
</div>
<!-- !values override -->
<!-- helm actions -->
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm !ml-0"
ng-disabled="!($ctrl.stackName && $ctrl.state.resourcePool && !$ctrl.state.loadingValues && !$ctrl.state.actionInProgress)"
ng-click="$ctrl.installHelmchart()"
button-spinner="$ctrl.state.actionInProgress"
data-cy="helm-install"
>
<span ng-hide="$ctrl.state.actionInProgress">Install</span>
<span ng-hide="!$ctrl.state.actionInProgress">Helm installing in progress</span>
</button>
</div>
</div>
<!-- !helm actions -->
</form>
</div>
<!-- helmchart-form -->
</div>
<div class="row">
<div class="col-sm-12">
<helm-add-repository repos="$ctrl.state.repos" endpoint="$ctrl.endpoint"></helm-add-repository>
</div>
</div>
<!-- Helm Charts Component -->
<div class="row">
<div class="col-sm-12">
<div class="row" ng-if="!$ctrl.state.chart">
<div class="col-sm-12 p-0">
<helm-templates-list
title-text="Charts"
title-icon="compass"
title-text="Helm chart"
charts="$ctrl.state.charts"
table-key="$ctrl.state.charts"
select-action="$ctrl.selectHelmChart"

View File

@ -6,5 +6,7 @@ angular.module('portainer.kubernetes').component('helmTemplatesView', {
controller,
bindings: {
endpoint: '<',
namespace: '<',
stackName: '<',
},
});

View File

@ -3,9 +3,10 @@ import angular from 'angular';
angular.module('portainer.kubernetes').factory('HelmFactory', HelmFactory);
/* @ngInject */
function HelmFactory($resource, API_ENDPOINT_ENDPOINTS) {
function HelmFactory($resource, API_ENDPOINT_ENDPOINTS, API_ENDPOINT_USERS) {
const helmUrl = API_ENDPOINT_ENDPOINTS + '/:endpointId/kubernetes/helm';
const templatesUrl = 'api/templates/helm';
const userHelmUrl = API_ENDPOINT_USERS + '/:userId/helm';
return $resource(
helmUrl,
@ -27,11 +28,11 @@ function HelmFactory($resource, API_ENDPOINT_ENDPOINTS) {
},
getHelmRepositories: {
method: 'GET',
url: `${helmUrl}/repositories`,
url: `${userHelmUrl}/repositories`,
},
addHelmRepository: {
method: 'POST',
url: `${helmUrl}/repositories`,
url: `${userHelmUrl}/repositories`,
},
list: {
method: 'GET',

View File

@ -49,8 +49,8 @@ export function HelmService(HelmFactory) {
* @returns {Promise} - Resolves with an object containing list of user helm repos and default/global settings helm repo
* @throws {PortainerError} - Rejects with error if helm show fails
*/
async function getHelmRepositories(endpointId) {
return await HelmFactory.getHelmRepositories({ endpointId }).$promise;
async function getHelmRepositories(userId) {
return await HelmFactory.getHelmRepositories({ userId }).$promise;
}
/**

View File

@ -7,6 +7,7 @@ export const KubernetesDeployBuildMethods = Object.freeze({
WEB_EDITOR: 2,
CUSTOM_TEMPLATE: 3,
URL: 4,
HELM: 5,
});
export const KubernetesDeployRequestMethods = Object.freeze({

View File

@ -1,4 +1,4 @@
<page-header ng-if="ctrl.state.viewReady" title="'Advanced deployment'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
<page-header ng-if="ctrl.state.viewReady" title="'Create from manifest'" breadcrumbs="['Deploy Kubernetes resources']" reload="true"></page-header>
<kubernetes-view-loading view-ready="ctrl.state.viewReady"></kubernetes-view-loading>
@ -64,7 +64,7 @@
on-change="(ctrl.onChangeMethod)"
></box-selector>
<div ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.CUSTOM_TEMPLATE">
<div ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="col-sm-12 form-section-title"> Deployment type </div>
<box-selector
radio-name="'deploy'"
@ -165,9 +165,15 @@
</div>
<!-- !url -->
<!-- Helm -->
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.HELM">
<helm-templates-view endpoint="ctrl.endpoint" namespace="ctrl.formValues.Namespace" stack-name="ctrl.formValues.StackName"></helm-templates-view>
</div>
<!-- !Helm -->
<!-- actions -->
<div class="col-sm-12 form-section-title"> Actions </div>
<div class="form-group">
<div class="col-sm-12 form-section-title !mt-4" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM"> Actions </div>
<div class="form-group" ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.HELM">
<div class="col-sm-12">
<button
type="button"

View File

@ -7,7 +7,7 @@ import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, Kubernetes
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url } from '@@/BoxSelector/common-options/build-methods';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm';
@ -33,10 +33,17 @@ class KubernetesDeployController {
{ ...editor, value: KubernetesDeployBuildMethods.WEB_EDITOR },
{ ...url, value: KubernetesDeployBuildMethods.URL },
{ ...customTemplate, value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
{ ...customTemplate, description: 'Use custom template', value: KubernetesDeployBuildMethods.CUSTOM_TEMPLATE },
{ ...helm, value: KubernetesDeployBuildMethods.HELM },
];
let buildMethod = Number(this.$state.params.buildMethod) || KubernetesDeployBuildMethods.GIT;
if (buildMethod > Object.keys(KubernetesDeployBuildMethods).length) {
buildMethod = KubernetesDeployBuildMethods.GIT;
}
this.state = {
DeployType: KubernetesDeployManifestTypes.KUBERNETES,
DeployType: buildMethod,
BuildMethod: KubernetesDeployBuildMethods.GIT,
tabLogsDisabled: true,
activeTab: 0,

View File

@ -423,6 +423,16 @@ angular
},
};
const createHelmRepository = {
name: 'portainer.account.createHelmRepository',
url: '/helm-repository/new',
views: {
'content@': {
component: 'createHelmRepositoryView',
},
},
};
$stateRegistryProvider.register(root);
$stateRegistryProvider.register(endpointRoot);
$stateRegistryProvider.register(portainer);
@ -454,6 +464,7 @@ angular
$stateRegistryProvider.register(tags);
$stateRegistryProvider.register(users);
$stateRegistryProvider.register(user);
$stateRegistryProvider.register(createHelmRepository);
},
]);

View File

@ -8,6 +8,7 @@ import { AnnotationsBeTeaser } from '@/react/kubernetes/annotations/AnnotationsB
import { withFormValidation } from '@/react-tools/withFormValidation';
import { GroupAssociationTable } from '@/react/portainer/environments/environment-groups/components/GroupAssociationTable';
import { AssociatedEnvironmentsSelector } from '@/react/portainer/environments/environment-groups/components/AssociatedEnvironmentsSelector';
import { HelmRepositoryDatatable } from '@/react/portainer/account/AccountView/HelmRepositoryDatatable';
import {
EnvironmentVariablesFieldset,
@ -137,6 +138,7 @@ export const ngModule = angular
'isLoading',
'isRefetching',
'dataCy',
'iconClass',
])
)
.component(
@ -150,7 +152,7 @@ export const ngModule = angular
'className',
])
)
.component('badgeIcon', r2a(BadgeIcon, ['icon', 'size']))
.component('badgeIcon', r2a(BadgeIcon, ['icon', 'size', 'iconClass']))
.component(
'teamsSelector',
r2a(TeamsSelector, [
@ -223,6 +225,13 @@ export const ngModule = angular
.component(
'associatedEndpointsSelector',
r2a(withReactQuery(AssociatedEnvironmentsSelector), ['onChange', 'value'])
)
.component(
'helmRepositoryDatatable',
r2a(
withUIRouter(withReactQuery(withCurrentUser(HelmRepositoryDatatable))),
[]
)
);
export const componentsModule = ngModule.name;

View File

@ -12,6 +12,7 @@ import { EdgeAutoCreateScriptView } from '@/react/portainer/environments/EdgeAut
import { ListView as EnvironmentsListView } from '@/react/portainer/environments/ListView';
import { BackupSettingsPanel } from '@/react/portainer/settings/SettingsView/BackupSettingsView/BackupSettingsPanel';
import { SettingsView } from '@/react/portainer/settings/SettingsView/SettingsView';
import { CreateHelmRepositoriesView } from '@/react/portainer/account/help-repositories/CreateHelmRepositoryView';
import { wizardModule } from './wizard';
import { teamsModule } from './teams';
@ -59,4 +60,11 @@ export const viewsModule = angular
.component(
'settingsView',
r2a(withUIRouter(withReactQuery(withCurrentUser(SettingsView))), [])
)
.component(
'createHelmRepositoryView',
r2a(
withUIRouter(withReactQuery(withCurrentUser(CreateHelmRepositoriesView))),
[]
)
).name;

View File

@ -2,6 +2,12 @@
<demo-feature-indicator ng-if="isDemoUser" content="'You cannot change the password of this account in the demo version of Portainer.'"> </demo-feature-indicator>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<theme-settings></theme-settings>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
@ -94,8 +100,4 @@
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<theme-settings></theme-settings>
</div>
</div>
<helm-repository-datatable></helm-repository-datatable>

View File

@ -8,7 +8,7 @@ export interface Props extends IconProps {
size?: BadgeSize;
}
export function BadgeIcon({ icon, size = '3xl' }: Props) {
export function BadgeIcon({ icon, size = '3xl', iconClass }: Props) {
const sizeClasses = iconSizeToClasses(size);
return (
<div
@ -22,7 +22,7 @@ export function BadgeIcon({ icon, size = '3xl' }: Props) {
`
)}
>
<Icon icon={icon} className="!flex" />
<Icon icon={icon} className={clsx('!flex', iconClass)} />
</div>
);
}

View File

@ -92,18 +92,18 @@ export function BoxSelectorItem<T extends Value>({
}
if (option.iconType === 'badge') {
return <BadgeIcon icon={option.icon} />;
return <BadgeIcon icon={option.icon} iconClass={option.iconClass} />;
}
if (option.iconType === 'raw') {
return (
<Icon
icon={option.icon}
className={clsx(styles.icon, '!flex items-center')}
className={clsx(styles.icon, option.iconClass, '!flex items-center')}
/>
);
}
return <LogoIcon icon={option.icon} />;
return <LogoIcon icon={option.icon} iconClass={option.iconClass} />;
}
}

View File

@ -1,8 +1,10 @@
import clsx from 'clsx';
import { Icon, IconProps } from '@@/Icon';
type Props = IconProps;
export function LogoIcon({ icon }: Props) {
export function LogoIcon({ icon, iconClass }: Props) {
return (
<div
className={`
@ -10,7 +12,7 @@ export function LogoIcon({ icon }: Props) {
items-center justify-center text-7xl
`}
>
<Icon icon={icon} className="!flex" />
<Icon icon={icon} className={clsx('!flex', iconClass)} />
</div>
);
}

View File

@ -1,6 +1,7 @@
import { Edit, FileText, Globe, UploadCloud } from 'lucide-react';
import GitIcon from '@/assets/ico/git.svg?c';
import Helm from '@/assets/ico/helm.svg?c';
import { BoxSelectorOption } from '../types';
@ -49,6 +50,15 @@ export const customTemplate: BoxSelectorOption<'template'> = {
value: 'template',
};
export const helm: BoxSelectorOption<'helm'> = {
id: 'method_helm',
icon: Helm,
label: 'Helm chart',
description: 'Use a Helm chart',
value: 'helm',
iconClass: '!text-[#0f1689] th-dark:!text-white th-highcontrast:!text-white',
};
export const url: BoxSelectorOption<'url'> = {
id: 'method_url',
icon: Globe,

View File

@ -17,4 +17,5 @@ export interface BoxSelectorOption<T extends Value> extends IconProps {
readonly disabledWhenLimited?: boolean;
readonly hide?: boolean;
readonly iconType?: 'raw' | 'badge' | 'logo';
readonly iconClass?: string;
}

View File

@ -7,6 +7,7 @@ import Svg, { SvgIcons } from './Svg';
export interface IconProps {
icon: ReactNode | ComponentType<unknown>;
iconClass?: string;
}
export type IconMode =

View File

@ -0,0 +1,57 @@
import { useMemo } from 'react';
import { useCurrentUser } from '@/react/hooks/useUser';
import helm from '@/assets/ico/helm.svg?c';
import { Datatable } from '@@/datatables';
import { createPersistedStore } from '@@/datatables/types';
import { useTableState } from '@@/datatables/useTableState';
import { columns } from './columns';
import { HelmRepositoryDatatableActions } from './HelmRepositoryDatatableActions';
import { useHelmRepositories } from './helm-repositories.service';
import { HelmRepository } from './types';
const storageKey = 'helmRepository';
const settingsStore = createPersistedStore(storageKey);
export function HelmRepositoryDatatable() {
const { user } = useCurrentUser();
const helmReposQuery = useHelmRepositories(user.Id);
const tableState = useTableState(settingsStore, storageKey);
const helmRepos = useMemo(() => {
const helmRepos = [];
if (helmReposQuery.data?.GlobalRepository) {
const helmrepository: HelmRepository = {
Global: true,
URL: helmReposQuery.data.GlobalRepository,
Id: 0,
UserId: 0,
};
helmRepos.push(helmrepository);
}
return [...helmRepos, ...(helmReposQuery.data?.UserRepositories ?? [])];
}, [
helmReposQuery.data?.GlobalRepository,
helmReposQuery.data?.UserRepositories,
]);
return (
<Datatable
dataset={helmRepos}
settingsManager={tableState}
columns={columns}
title="Helm Repositories"
titleIcon={helm}
renderTableActions={(selectedRows) => (
<HelmRepositoryDatatableActions selectedItems={selectedRows} />
)}
emptyContentLabel="No Helm repository found"
isLoading={helmReposQuery.isLoading}
isRowSelectable={(row) => !row.original.Global}
/>
);
}

View File

@ -0,0 +1,60 @@
import { useRouter } from '@uirouter/react';
import { Plus, Trash2 } from 'lucide-react';
import { pluralize } from '@/portainer/helpers/strings';
import { confirmDestructive } from '@@/modals/confirm';
import { Button } from '@@/buttons';
import { HelmRepository } from './types';
import { useDeleteHelmRepositoriesMutation } from './helm-repositories.service';
interface Props {
selectedItems: HelmRepository[];
}
export function HelmRepositoryDatatableActions({ selectedItems }: Props) {
const router = useRouter();
const deleteHelmRepoMutation = useDeleteHelmRepositoriesMutation();
return (
<>
<Button
disabled={selectedItems.length < 1}
color="dangerlight"
onClick={() => onDeleteClick(selectedItems)}
data-cy="credentials-deleteButton"
icon={Trash2}
>
Remove
</Button>
<Button
onClick={() =>
router.stateService.go('portainer.account.createHelmRepository')
}
data-cy="credentials-addButton"
icon={Plus}
>
Add Helm Repository
</Button>
</>
);
async function onDeleteClick(selectedItems: HelmRepository[]) {
const confirmed = await confirmDestructive({
title: 'Confirm action',
message: `Are you sure you want to remove the selected Helm ${pluralize(
selectedItems.length,
'repository',
'repositories'
)}?`,
});
if (!confirmed) {
return;
}
deleteHelmRepoMutation.mutate(selectedItems);
}
}

View File

@ -0,0 +1,5 @@
import { createColumnHelper } from '@tanstack/react-table';
import { HelmRepository } from '../types';
export const columnHelper = createColumnHelper<HelmRepository>();

View File

@ -0,0 +1,3 @@
import { url } from './url';
export const columns = [url];

View File

@ -0,0 +1,3 @@
import { columnHelper } from './helper';
export const url = columnHelper.accessor('URL', { id: 'url' });

View File

@ -0,0 +1,108 @@
import { useMutation, useQuery, useQueryClient } from 'react-query';
import axios, { parseAxiosError } from '@/portainer/services/axios';
import { success as notifySuccess } from '@/portainer/services/notifications';
import { withError } from '@/react-tools/react-query';
import { pluralize } from '@/portainer/helpers/strings';
import {
CreateHelmRepositoryPayload,
HelmRepository,
HelmRepositories,
} from './types';
export async function createHelmRepository(
helmRepository: CreateHelmRepositoryPayload
) {
try {
const { data } = await axios.post<{ helmRepository: HelmRepository }>(
buildUrl(helmRepository.UserId),
helmRepository
);
return data.helmRepository;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to create Helm repository');
}
}
export async function getHelmRepositories(userId: number) {
try {
const { data } = await axios.get<HelmRepositories>(buildUrl(userId));
return data;
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to get Helm repositories');
}
}
export async function deleteHelmRepository(repo: HelmRepository) {
try {
await axios.delete<HelmRepository[]>(buildUrl(repo.UserId, repo.Id));
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete Helm repository');
}
}
export async function deleteHelmRepositories(repos: HelmRepository[]) {
try {
await Promise.all(repos.map((repo) => deleteHelmRepository(repo)));
} catch (e) {
throw parseAxiosError(e as Error, 'Unable to delete Helm repositories');
}
}
export function useDeleteHelmRepositoryMutation() {
const queryClient = useQueryClient();
return useMutation(deleteHelmRepository, {
onSuccess: (_, helmRepository) => {
notifySuccess('Helm repository deleted successfully', helmRepository.URL);
return queryClient.invalidateQueries(['helmrepositories']);
},
...withError('Unable to delete Helm repository'),
});
}
export function useDeleteHelmRepositoriesMutation() {
const queryClient = useQueryClient();
return useMutation(deleteHelmRepositories, {
onSuccess: () => {
notifySuccess(
'Success',
`Helm ${pluralize(
deleteHelmRepositories.length,
'repository',
'repositories'
)} deleted successfully`
);
return queryClient.invalidateQueries(['helmrepositories']);
},
...withError('Unable to delete Helm repositories'),
});
}
export function useHelmRepositories(userId: number) {
return useQuery('helmrepositories', () => getHelmRepositories(userId), {
staleTime: 20,
...withError('Unable to retrieve Helm repositories'),
});
}
export function useCreateHelmRepositoryMutation() {
const queryClient = useQueryClient();
return useMutation(createHelmRepository, {
onSuccess: (_, payload) => {
notifySuccess('Helm repository created successfully', payload.URL);
return queryClient.invalidateQueries(['helmrepositories']);
},
...withError('Unable to create Helm repository'),
});
}
function buildUrl(userId: number, helmRepositoryId?: number) {
if (helmRepositoryId) {
return `/users/${userId}/helm/repositories/${helmRepositoryId}`;
}
return `/users/${userId}/helm/repositories`;
}

View File

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

View File

@ -0,0 +1,20 @@
export interface CreateHelmRepositoryPayload {
UserId: number;
URL: string;
}
export interface HelmRepositoryFormValues {
URL: string;
}
export interface HelmRepository {
Id: number;
UserId: number;
URL: string;
Global: boolean;
}
export interface HelmRepositories {
UserRepositories: HelmRepository[];
GlobalRepository: string;
}

View File

@ -0,0 +1,28 @@
import { PageHeader } from '@@/PageHeader';
import { Widget, WidgetBody } from '@@/Widget';
import { CreateHelmRepositoryForm } from './CreateHelmRespositoriesForm';
export function CreateHelmRepositoriesView() {
return (
<>
<PageHeader
title="Create Helm repository"
breadcrumbs={[
{ label: 'My account', link: 'portainer.account' },
{ label: 'Create Helm repository' },
]}
/>
<div className="row">
<div className="col-sm-12">
<Widget>
<WidgetBody>
<CreateHelmRepositoryForm />
</WidgetBody>
</Widget>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,41 @@
import { useRouter } from '@uirouter/react';
import { useCurrentUser } from '@/react/hooks/useUser';
import {
CreateHelmRepositoryPayload,
HelmRepositoryFormValues,
} from '../../AccountView/HelmRepositoryDatatable/types';
import {
useHelmRepositories,
useCreateHelmRepositoryMutation,
} from '../../AccountView/HelmRepositoryDatatable/helm-repositories.service';
import { HelmRepositoryForm } from '../components/HelmRepositoryForm';
export function CreateHelmRepositoryForm() {
const router = useRouter();
const currentUser = useCurrentUser();
const createHelmRepositoryMutation = useCreateHelmRepositoryMutation();
const helmReposQuery = useHelmRepositories(currentUser.user.Id);
return (
<HelmRepositoryForm
isLoading={createHelmRepositoryMutation.isLoading}
onSubmit={onSubmit}
URLs={helmReposQuery.data?.UserRepositories.map((x) => x.URL) || []}
/>
);
function onSubmit(values: HelmRepositoryFormValues) {
const payload: CreateHelmRepositoryPayload = {
...values,
UserId: currentUser.user.Id,
};
createHelmRepositoryMutation.mutate(payload, {
onSuccess: () => {
router.stateService.go('portainer.account');
},
});
}
}

View File

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

View File

@ -0,0 +1,19 @@
import { object, string } from 'yup';
import { isValidUrl } from '@@/form-components/validate-url';
export function noDuplicateURLsSchema(urls: string[]) {
return string()
.required('URL is required')
.test('not existing name', 'URL is already added', (newName) =>
urls.every((name) => name !== newName)
);
}
export function validationSchema(urls: string[]) {
return object().shape({
URL: noDuplicateURLsSchema(urls)
.test('valid-url', 'Invalid URL', (value) => !value || isValidUrl(value))
.required('URL is required'),
});
}

View File

@ -0,0 +1,74 @@
import { Field, Form, Formik } from 'formik';
import { useRouter } from '@uirouter/react';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { LoadingButton } from '@@/buttons/LoadingButton';
import { Button } from '@@/buttons';
import { HelmRepositoryFormValues } from '../../AccountView/HelmRepositoryDatatable/types';
import { validationSchema } from './CreateHelmRepositoryForm.validation';
type Props = {
isEditing?: boolean;
isLoading: boolean;
onSubmit: (formValues: HelmRepositoryFormValues) => void;
URLs: string[];
};
const defaultInitialValues: HelmRepositoryFormValues = {
URL: '',
};
export function HelmRepositoryForm({
isEditing = false,
isLoading,
onSubmit,
URLs,
}: Props) {
const router = useRouter();
return (
<Formik<HelmRepositoryFormValues>
initialValues={defaultInitialValues}
enableReinitialize
validationSchema={() => validationSchema(URLs)}
onSubmit={(values) => onSubmit(values)}
validateOnMount
>
{({ values, errors, handleSubmit, isValid, dirty }) => (
<Form className="form-horizontal" onSubmit={handleSubmit} noValidate>
<FormControl inputId="url" label="URL" errors={errors.URL} required>
<Field
as={Input}
name="URL"
value={values.URL}
autoComplete="off"
id="URL"
/>
</FormControl>
<div className="form-group">
<div className="col-sm-12 mt-3">
<LoadingButton
disabled={!isValid || !dirty}
isLoading={isLoading}
loadingText="Saving Helm repository..."
>
{isEditing ? 'Update Helm repository' : 'Save Helm repository'}
</LoadingButton>
{isEditing && (
<Button
color="default"
onClick={() => router.stateService.go('portainer.account')}
>
Cancel
</Button>
)}
</div>
</div>
</Form>
)}
</Formik>
);
}

View File

@ -23,7 +23,7 @@ export function ScreenBannerFieldset() {
<SwitchField
labelClass="col-sm-3 col-lg-2"
label="Login screen banner"
checked={isEnabled}
checked
name="toggle_login_banner"
disabled={isDemoQuery.data}
onChange={(checked) => setIsEnabled(checked)}

View File

@ -12,7 +12,7 @@ export function HelmSection() {
<FormSection title="Helm Repository">
<div className="mb-2">
<TextTip color="blue">
You can specify the URL to your own helm repository here. See the{' '}
You can specify the URL to your own Helm repository here. See the{' '}
<a
href="https://helm.sh/docs/topics/chart_repository/"
target="_blank"

View File

@ -2,7 +2,6 @@ import { Box, Edit, Layers, Lock, Server, Shuffle } from 'lucide-react';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { Authorized } from '@/react/hooks/useUser';
import Helm from '@/assets/ico/vendor/helm.svg?c';
import Route from '@/assets/ico/route.svg?c';
import { DashboardLink } from '../items/DashboardLink';
@ -49,19 +48,6 @@ export function KubernetesSidebar({ environmentId }: Props) {
data-cy="k8sSidebar-namespaces"
/>
<Authorized
authorizations="HelmInstallChart"
environmentId={environmentId}
>
<SidebarItem
to="kubernetes.templates.helm"
params={{ endpointId: environmentId }}
icon={Helm}
label="Helm"
data-cy="k8sSidebar-helm"
/>
</Authorized>
<SidebarItem
to="kubernetes.applications"
params={{ endpointId: environmentId }}