mirror of https://github.com/portainer/portainer
feature(helm): move helm charts inside advance deployments (create from manifest) [EE-5999] (#10395)
parent
9885694df6
commit
b468070945
|
@ -81,6 +81,7 @@ type Handler struct {
|
|||
UserHandler *users.Handler
|
||||
WebSocketHandler *websocket.Handler
|
||||
WebhookHandler *webhooks.Handler
|
||||
UserHelmHandler *helm.Handler
|
||||
}
|
||||
|
||||
// @title PortainerCE API
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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',
|
||||
|
|
|
@ -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: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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: '<',
|
||||
},
|
||||
});
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -7,7 +7,6 @@ angular.module('portainer.kubernetes').component('helmTemplatesList', {
|
|||
bindings: {
|
||||
loading: '<',
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
charts: '<',
|
||||
tableKey: '@',
|
||||
selectAction: '<',
|
||||
|
|
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -6,5 +6,7 @@ angular.module('portainer.kubernetes').component('helmTemplatesView', {
|
|||
controller,
|
||||
bindings: {
|
||||
endpoint: '<',
|
||||
namespace: '<',
|
||||
stackName: '<',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -7,6 +7,7 @@ export const KubernetesDeployBuildMethods = Object.freeze({
|
|||
WEB_EDITOR: 2,
|
||||
CUSTOM_TEMPLATE: 3,
|
||||
URL: 4,
|
||||
HELM: 5,
|
||||
});
|
||||
|
||||
export const KubernetesDeployRequestMethods = Object.freeze({
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ import Svg, { SvgIcons } from './Svg';
|
|||
|
||||
export interface IconProps {
|
||||
icon: ReactNode | ComponentType<unknown>;
|
||||
iconClass?: string;
|
||||
}
|
||||
|
||||
export type IconMode =
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
import { HelmRepository } from '../types';
|
||||
|
||||
export const columnHelper = createColumnHelper<HelmRepository>();
|
|
@ -0,0 +1,3 @@
|
|||
import { url } from './url';
|
||||
|
||||
export const columns = [url];
|
|
@ -0,0 +1,3 @@
|
|||
import { columnHelper } from './helper';
|
||||
|
||||
export const url = columnHelper.accessor('URL', { id: 'url' });
|
|
@ -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`;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { HelmRepositoryDatatable } from './HelmRepositoryDatatable';
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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');
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export { CreateHelmRepositoriesView } from './CreateHelmRepositoriesView';
|
|
@ -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'),
|
||||
});
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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)}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 }}
|
||||
|
|
Loading…
Reference in New Issue