feat(endpoint-groups): add endpoint-groups (#1837)

pull/1333/merge
Anthony Lapenna 2018-04-26 18:08:46 +02:00 committed by GitHub
parent 2ffcb946b1
commit 1162549209
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1838 additions and 265 deletions

View File

@ -20,6 +20,7 @@ type Store struct {
TeamService *TeamService
TeamMembershipService *TeamMembershipService
EndpointService *EndpointService
EndpointGroupService *EndpointGroupService
ResourceControlService *ResourceControlService
VersionService *VersionService
SettingsService *SettingsService
@ -38,6 +39,7 @@ const (
teamBucketName = "teams"
teamMembershipBucketName = "team_membership"
endpointBucketName = "endpoints"
endpointGroupBucketName = "endpoint_groups"
resourceControlBucketName = "resource_control"
settingsBucketName = "settings"
registryBucketName = "registries"
@ -53,6 +55,7 @@ func NewStore(storePath string) (*Store, error) {
TeamService: &TeamService{},
TeamMembershipService: &TeamMembershipService{},
EndpointService: &EndpointService{},
EndpointGroupService: &EndpointGroupService{},
ResourceControlService: &ResourceControlService{},
VersionService: &VersionService{},
SettingsService: &SettingsService{},
@ -64,6 +67,7 @@ func NewStore(storePath string) (*Store, error) {
store.TeamService.store = store
store.TeamMembershipService.store = store
store.EndpointService.store = store
store.EndpointGroupService.store = store
store.ResourceControlService.store = store
store.VersionService.store = store
store.SettingsService.store = store
@ -94,7 +98,7 @@ func (store *Store) Open() error {
store.db = db
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName, stackBucketName}
return db.Update(func(tx *bolt.Tx) error {
@ -110,6 +114,28 @@ func (store *Store) Open() error {
})
}
// Init creates the default data set.
func (store *Store) Init() error {
groups, err := store.EndpointGroupService.EndpointGroups()
if err != nil {
return err
}
if len(groups) == 0 {
unassignedGroup := &portainer.EndpointGroup{
Name: "Unassigned",
Description: "Unassigned endpoints",
Labels: []portainer.Pair{},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup)
}
return nil
}
// Close closes the BoltDB database.
func (store *Store) Close() error {
if store.db != nil {

View File

@ -0,0 +1,114 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// EndpointGroupService represents a service for managing endpoint groups.
type EndpointGroupService struct {
store *Store
}
// EndpointGroup returns an endpoint group by ID.
func (service *EndpointGroupService) EndpointGroup(ID portainer.EndpointGroupID) (*portainer.EndpointGroup, error) {
var data []byte
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
value := bucket.Get(internal.Itob(int(ID)))
if value == nil {
return portainer.ErrEndpointGroupNotFound
}
data = make([]byte, len(value))
copy(data, value)
return nil
})
if err != nil {
return nil, err
}
var endpointGroup portainer.EndpointGroup
err = internal.UnmarshalEndpointGroup(data, &endpointGroup)
if err != nil {
return nil, err
}
return &endpointGroup, nil
}
// EndpointGroups return an array containing all the endpoint groups.
func (service *EndpointGroupService) EndpointGroups() ([]portainer.EndpointGroup, error) {
var endpointGroups = make([]portainer.EndpointGroup, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var endpointGroup portainer.EndpointGroup
err := internal.UnmarshalEndpointGroup(v, &endpointGroup)
if err != nil {
return err
}
endpointGroups = append(endpointGroups, endpointGroup)
}
return nil
})
if err != nil {
return nil, err
}
return endpointGroups, nil
}
// CreateEndpointGroup assign an ID to a new endpoint group and saves it.
func (service *EndpointGroupService) CreateEndpointGroup(endpointGroup *portainer.EndpointGroup) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
id, _ := bucket.NextSequence()
endpointGroup.ID = portainer.EndpointGroupID(id)
data, err := internal.MarshalEndpointGroup(endpointGroup)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(endpointGroup.ID)), data)
if err != nil {
return err
}
return nil
})
}
// UpdateEndpointGroup updates an endpoint group.
func (service *EndpointGroupService) UpdateEndpointGroup(ID portainer.EndpointGroupID, endpointGroup *portainer.EndpointGroup) error {
data, err := internal.MarshalEndpointGroup(endpointGroup)
if err != nil {
return err
}
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
err = bucket.Put(internal.Itob(int(ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteEndpointGroup deletes an endpoint group.
func (service *EndpointGroupService) DeleteEndpointGroup(ID portainer.EndpointGroupID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(endpointGroupBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

View File

@ -47,6 +47,16 @@ func UnmarshalEndpoint(data []byte, endpoint *portainer.Endpoint) error {
return json.Unmarshal(data, endpoint)
}
// MarshalEndpointGroup encodes an endpoint group to binary format.
func MarshalEndpointGroup(group *portainer.EndpointGroup) ([]byte, error) {
return json.Marshal(group)
}
// UnmarshalEndpointGroup decodes an endpoint group from a binary data.
func UnmarshalEndpointGroup(data []byte, group *portainer.EndpointGroup) error {
return json.Unmarshal(data, group)
}
// MarshalStack encodes a stack to binary format.
func MarshalStack(stack *portainer.Stack) ([]byte, error) {
return json.Marshal(stack)

View File

@ -0,0 +1,20 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateEndpointsToVersion9() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.GroupID = portainer.EndpointGroupID(1)
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}

View File

@ -96,6 +96,14 @@ func (m *Migrator) Migrate() error {
}
}
// https: //github.com/portainer/portainer/issues/1396
if m.CurrentDBVersion < 9 {
err := m.updateEndpointsToVersion9()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err

View File

@ -49,6 +49,11 @@ func initStore(dataStorePath string) *bolt.Store {
log.Fatal(err)
}
err = store.Init()
if err != nil {
log.Fatal(err)
}
err = store.MigrateData()
if err != nil {
log.Fatal(err)
@ -275,6 +280,7 @@ func main() {
TeamService: store.TeamService,
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
RegistryService: store.RegistryService,

View File

@ -28,7 +28,7 @@ const (
// TeamMembership errors.
const (
ErrTeamMembershipNotFound = Error("Team membership not found")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team.")
ErrTeamMembershipAlreadyExists = Error("Team membership already exists for this user and team")
)
// ResourceControl errors.
@ -44,6 +44,12 @@ const (
ErrEndpointAccessDenied = Error("Access denied to endpoint")
)
// Endpoint group errors.
const (
ErrEndpointGroupNotFound = Error("Endpoint group not found")
ErrCannotRemoveDefaultGroup = Error("Cannot remove the default endpoint group")
)
// Registry errors.
const (
ErrRegistryNotFound = Error("Registry not found")

View File

@ -20,6 +20,7 @@ type DockerHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
@ -64,9 +65,17 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
return
}
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
if tokenData.Role != portainer.AdministratorRole {
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
var proxy http.Handler

View File

@ -28,6 +28,7 @@ type EndpointHandler struct {
Logger *log.Logger
authorizeEndpointManagement bool
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
FileService portainer.FileService
ProxyManager *proxy.Manager
}
@ -75,6 +76,7 @@ type (
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
GroupID int `valid:"-"`
TLS bool `valid:"-"`
TLSSkipVerify bool `valid:"-"`
TLSSkipClientVerify bool `valid:"-"`
@ -84,6 +86,7 @@ type (
name string
url string
publicURL string
groupID int
useTLS bool
skipTLSServerVerification bool
skipTLSClientVerification bool
@ -107,7 +110,13 @@ func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *htt
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, securityContext)
groups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpoints, err := security.FilterEndpoints(endpoints, groups, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@ -154,6 +163,7 @@ func (handler *EndpointHandler) createTLSSecuredEndpoint(payload *postEndpointPa
endpoint := &portainer.Endpoint{
Name: payload.name,
URL: payload.url,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: payload.useTLS,
@ -225,6 +235,7 @@ func (handler *EndpointHandler) createUnsecuredEndpoint(payload *postEndpointPay
endpoint := &portainer.Endpoint{
Name: payload.name,
URL: payload.url,
GroupID: portainer.EndpointGroupID(payload.groupID),
PublicURL: payload.publicURL,
TLSConfig: portainer.TLSConfiguration{
TLS: false,
@ -259,6 +270,17 @@ func convertPostEndpointRequestToPayload(r *http.Request) (*postEndpointPayload,
return nil, ErrInvalidRequestFormat
}
rawGroupID := r.FormValue("GroupID")
if rawGroupID == "" {
payload.groupID = 1
} else {
groupID, err := strconv.Atoi(rawGroupID)
if err != nil {
return nil, err
}
payload.groupID = groupID
}
payload.useTLS = r.FormValue("TLS") == "true"
if payload.useTLS {
@ -439,6 +461,10 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
endpoint.PublicURL = req.PublicURL
}
if req.GroupID != 0 {
endpoint.GroupID = portainer.EndpointGroupID(req.GroupID)
}
folder := strconv.Itoa(int(endpoint.ID))
if req.TLS {
endpoint.TLSConfig.TLS = true

View File

@ -0,0 +1,364 @@
package handler
import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
"encoding/json"
"log"
"net/http"
"os"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/gorilla/mux"
)
// EndpointGroupHandler represents an HTTP API handler for managing endpoint groups.
type EndpointGroupHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
}
// NewEndpointGroupHandler returns a new instance of EndpointGroupHandler.
func NewEndpointGroupHandler(bouncer *security.RequestBouncer) *EndpointGroupHandler {
h := &EndpointGroupHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/endpoint_groups",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostEndpointGroups))).Methods(http.MethodPost)
h.Handle("/endpoint_groups",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetEndpointGroups))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetEndpointGroup))).Methods(http.MethodGet)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroup))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}/access",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutEndpointGroupAccess))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteEndpointGroup))).Methods(http.MethodDelete)
return h
}
type (
postEndpointGroupsResponse struct {
ID int `json:"Id"`
}
postEndpointGroupsRequest struct {
Name string `valid:"required"`
Description string `valid:"-"`
Labels []portainer.Pair `valid:""`
AssociatedEndpoints []portainer.EndpointID `valid:""`
}
putEndpointGroupAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putEndpointGroupsRequest struct {
Name string `valid:"-"`
Description string `valid:"-"`
Labels []portainer.Pair `valid:""`
AssociatedEndpoints []portainer.EndpointID `valid:""`
}
)
// handleGetEndpointGroups handles GET requests on /endpoint_groups
func (handler *EndpointGroupHandler) handleGetEndpointGroups(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpointGroups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredEndpointGroups, err := security.FilterEndpointGroups(endpointGroups, securityContext)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, filteredEndpointGroups, handler.Logger)
}
// handlePostEndpointGroups handles POST requests on /endpoint_groups
func (handler *EndpointGroupHandler) handlePostEndpointGroups(w http.ResponseWriter, r *http.Request) {
var req postEndpointGroupsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup := &portainer.EndpointGroup{
Name: req.Name,
Description: req.Description,
Labels: req.Labels,
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
}
err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, endpoint := range endpoints {
if endpoint.GroupID == portainer.EndpointGroupID(1) {
err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, req.AssociatedEndpoints)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
encodeJSON(w, &postEndpointGroupsResponse{ID: int(endpointGroup.ID)}, handler.Logger)
}
// handleGetEndpointGroup handles GET requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handleGetEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, endpointGroup, handler.Logger)
}
// handlePutEndpointGroupAccess handles PUT requests on /endpoint_groups/:id/access
func (handler *EndpointGroupHandler) handlePutEndpointGroupAccess(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointGroupAccessRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.AuthorizedUsers != nil {
authorizedUserIDs := []portainer.UserID{}
for _, value := range req.AuthorizedUsers {
authorizedUserIDs = append(authorizedUserIDs, portainer.UserID(value))
}
endpointGroup.AuthorizedUsers = authorizedUserIDs
}
if req.AuthorizedTeams != nil {
authorizedTeamIDs := []portainer.TeamID{}
for _, value := range req.AuthorizedTeams {
authorizedTeamIDs = append(authorizedTeamIDs, portainer.TeamID(value))
}
endpointGroup.AuthorizedTeams = authorizedTeamIDs
}
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
// handlePutEndpointGroup handles PUT requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handlePutEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
var req putEndpointGroupsRequest
if err = json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err = govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
groupID := portainer.EndpointGroupID(endpointGroupID)
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(groupID)
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if req.Name != "" {
endpointGroup.Name = req.Name
}
if req.Description != "" {
endpointGroup.Description = req.Description
}
endpointGroup.Labels = req.Labels
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, endpoint := range endpoints {
err = handler.updateEndpointGroup(endpoint, groupID, req.AssociatedEndpoints)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
func (handler *EndpointGroupHandler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
if endpoint.GroupID == groupID {
return handler.checkForGroupUnassignment(endpoint, associatedEndpoints)
} else if endpoint.GroupID == portainer.EndpointGroupID(1) {
return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints)
}
return nil
}
func (handler *EndpointGroupHandler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
return nil
}
}
endpoint.GroupID = portainer.EndpointGroupID(1)
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
func (handler *EndpointGroupHandler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
endpoint.GroupID = groupID
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
}
return nil
}
// handleDeleteEndpointGroup handles DELETE requests on /endpoint_groups/:id
func (handler *EndpointGroupHandler) handleDeleteEndpointGroup(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
endpointGroupID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
if endpointGroupID == 1 {
httperror.WriteErrorResponse(w, portainer.ErrCannotRemoveDefaultGroup, http.StatusForbidden, handler.Logger)
return
}
groupID := portainer.EndpointGroupID(endpointGroupID)
_, err = handler.EndpointGroupService.EndpointGroup(groupID)
if err == portainer.ErrEndpointGroupNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.EndpointGroupService.DeleteEndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
for _, endpoint := range endpoints {
if endpoint.GroupID == groupID {
endpoint.GroupID = portainer.EndpointGroupID(1)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
}
}

View File

@ -20,6 +20,7 @@ type StoridgeHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
@ -64,9 +65,17 @@ func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter
return
}
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
if tokenData.Role != portainer.AdministratorRole {
group, err := handler.EndpointGroupService.EndpointGroup(endpoint.GroupID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedEndpointAccess(endpoint, group, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
}
var storidgeExtension *portainer.EndpointExtension

View File

@ -19,6 +19,7 @@ type Handler struct {
TeamHandler *TeamHandler
TeamMembershipHandler *TeamMembershipHandler
EndpointHandler *EndpointHandler
EndpointGroupHandler *EndpointGroupHandler
RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler
ExtensionHandler *ExtensionHandler
@ -51,6 +52,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoint_groups"):
http.StripPrefix("/api", h.EndpointGroupHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
switch {
case strings.Contains(r.URL.Path, "/docker/"):

View File

@ -124,34 +124,37 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams of the endpoint and the associated group.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
groupAccess := authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
if !groupAccess {
return authorizedAccess(userID, memberships, endpoint.AuthorizedUsers, endpoint.AuthorizedTeams)
}
return true
}
// AuthorizedEndpointGroupAccess ensure that the user can access the specified endpoint group.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
func AuthorizedEndpointGroupAccess(endpointGroup *portainer.EndpointGroup, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
return authorizedAccess(userID, memberships, endpointGroup.AuthorizedUsers, endpointGroup.AuthorizedTeams)
}
// AuthorizedRegistryAccess ensure that the user can access the specified registry.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedRegistryAccess(registry *portainer.Registry, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range registry.AuthorizedUsers {
return authorizedAccess(userID, memberships, registry.AuthorizedUsers, registry.AuthorizedTeams)
}
func authorizedAccess(userID portainer.UserID, memberships []portainer.TeamMembership, authorizedUsers []portainer.UserID, authorizedTeams []portainer.TeamID) bool {
for _, authorizedUserID := range authorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range registry.AuthorizedTeams {
for _, authorizedTeamID := range authorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}

View File

@ -79,15 +79,17 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
}
// FilterEndpoints filters endpoints based on user role and team memberships.
// Non administrator users only have access to authorized endpoints.
func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.Endpoint, error) {
filteredEndpoints := endpoints
if !context.IsAdmin {
filteredEndpoints = make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if AuthorizedEndpointAccess(&endpoint, context.UserID, context.UserMemberships) {
endpointGroup := getAssociatedGroup(&endpoint, groups)
if AuthorizedEndpointAccess(&endpoint, endpointGroup, context.UserID, context.UserMemberships) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
@ -95,3 +97,30 @@ func FilterEndpoints(endpoints []portainer.Endpoint, context *RestrictedRequestC
return filteredEndpoints, nil
}
// FilterEndpointGroups filters endpoint groups based on user role and team memberships.
// Non administrator users only have access to authorized endpoint groups.
func FilterEndpointGroups(endpointGroups []portainer.EndpointGroup, context *RestrictedRequestContext) ([]portainer.EndpointGroup, error) {
filteredEndpointGroups := endpointGroups
if !context.IsAdmin {
filteredEndpointGroups = make([]portainer.EndpointGroup, 0)
for _, group := range endpointGroups {
if AuthorizedEndpointGroupAccess(&group, context.UserID, context.UserMemberships) {
filteredEndpointGroups = append(filteredEndpointGroups, group)
}
}
}
return filteredEndpointGroups, nil
}
func getAssociatedGroup(endpoint *portainer.Endpoint, groups []portainer.EndpointGroup) *portainer.EndpointGroup {
for _, group := range groups {
if group.ID == endpoint.GroupID {
return &group
}
}
return nil
}

View File

@ -22,6 +22,7 @@ type Server struct {
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService
CryptoService portainer.CryptoService
@ -72,14 +73,19 @@ func (server *Server) Start() error {
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)
dockerHandler.EndpointService = server.EndpointService
dockerHandler.EndpointGroupService = server.EndpointGroupService
dockerHandler.TeamMembershipService = server.TeamMembershipService
dockerHandler.ProxyManager = proxyManager
var websocketHandler = handler.NewWebSocketHandler()
websocketHandler.EndpointService = server.EndpointService
var endpointHandler = handler.NewEndpointHandler(requestBouncer, server.EndpointManagement)
endpointHandler.EndpointService = server.EndpointService
endpointHandler.EndpointGroupService = server.EndpointGroupService
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
var endpointGroupHandler = handler.NewEndpointGroupHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
endpointGroupHandler.EndpointService = server.EndpointService
var registryHandler = handler.NewRegistryHandler(requestBouncer)
registryHandler.RegistryService = server.RegistryService
var dockerHubHandler = handler.NewDockerHubHandler(requestBouncer)
@ -102,6 +108,7 @@ func (server *Server) Start() error {
extensionHandler.ProxyManager = proxyManager
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
storidgeHandler.EndpointService = server.EndpointService
storidgeHandler.EndpointGroupService = server.EndpointGroupService
storidgeHandler.TeamMembershipService = server.TeamMembershipService
storidgeHandler.ProxyManager = proxyManager
@ -111,6 +118,7 @@ func (server *Server) Start() error {
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
EndpointHandler: endpointHandler,
EndpointGroupHandler: endpointGroupHandler,
RegistryHandler: registryHandler,
DockerHubHandler: dockerHubHandler,
ResourceHandler: resourceHandler,

View File

@ -174,6 +174,7 @@ type (
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
GroupID EndpointGroupID `json:"GroupId"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
@ -188,6 +189,19 @@ type (
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// EndpointGroupID represents an endpoint group identifier.
EndpointGroupID int
// EndpointGroup represents a group of endpoints.
EndpointGroup struct {
ID EndpointGroupID `json:"Id"`
Name string `json:"Name"`
Description string `json:"Description"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Labels []Pair `json:"Labels"`
}
// EndpointExtension represents a extension associated to an endpoint.
EndpointExtension struct {
Type EndpointExtensionType `json:"Type"`
@ -248,6 +262,7 @@ type (
// DataStore defines the interface to manage the data.
DataStore interface {
Open() error
Init() error
Close() error
MigrateData() error
}
@ -301,6 +316,15 @@ type (
Synchronize(toCreate, toUpdate, toDelete []*Endpoint) error
}
// EndpointGroupService represents a service for managing endpoint group data.
EndpointGroupService interface {
EndpointGroup(ID EndpointGroupID) (*EndpointGroup, error)
EndpointGroups() ([]EndpointGroup, error)
CreateEndpointGroup(group *EndpointGroup) error
UpdateEndpointGroup(ID EndpointGroupID, group *EndpointGroup) error
DeleteEndpointGroup(ID EndpointGroupID) error
}
// RegistryService represents a service for managing registry data.
RegistryService interface {
Registry(ID RegistryID) (*Registry, error)
@ -403,7 +427,7 @@ const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.16.5"
// DBVersion is the version number of the Portainer database.
DBVersion = 8
DBVersion = 9
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
)

View File

@ -2,6 +2,7 @@ angular.module('portainer')
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')

View File

@ -132,6 +132,50 @@ angular.module('portainer.app', [])
}
};
var groups = {
name: 'portainer.groups',
url: '/groups',
views: {
'content@': {
templateUrl: 'app/portainer/views/groups/groups.html',
controller: 'GroupsController'
}
}
};
var group = {
name: 'portainer.groups.group',
url: '/:id',
views: {
'content@': {
templateUrl: 'app/portainer/views/groups/edit/group.html',
controller: 'GroupController'
}
}
};
var groupCreation = {
name: 'portainer.groups.new',
url: '/new',
views: {
'content@': {
templateUrl: 'app/portainer/views/groups/create/creategroup.html',
controller: 'CreateGroupController'
}
}
};
var groupAccess = {
name: 'portainer.groups.group.access',
url: '/access',
views: {
'content@': {
templateUrl: 'app/portainer/views/groups/access/groupAccess.html',
controller: 'GroupAccessController'
}
}
};
var registries = {
name: 'portainer.registries',
url: '/registries',
@ -253,6 +297,10 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(endpoints);
$stateRegistryProvider.register(endpoint);
$stateRegistryProvider.register(endpointAccess);
$stateRegistryProvider.register(groups);
$stateRegistryProvider.register(group);
$stateRegistryProvider.register(groupAccess);
$stateRegistryProvider.register(groupCreation);
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryAccess);

View File

@ -0,0 +1,21 @@
angular.module('portainer.app').component('accessTable', {
templateUrl: 'app/portainer/components/access-table/accessTable.html',
controller: function() {
this.state = {
orderBy: 'Name',
reverseOrder: false,
paginatedItemLimit: '10',
textFilter: ''
};
this.changeOrderBy = function(orderField) {
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
this.state.orderBy = orderField;
};
},
bindings: {
dataset: '<',
entryClick: '<',
emptyDatasetMessage: '@'
}
});

View File

@ -0,0 +1,64 @@
<div class="datatable">
<table class="table table-hover">
<div class="col-sm-12">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search...">
</div>
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<span ng-show="$ctrl.state.orderBy == 'Name' && !$ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.orderBy == 'Name' && $ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Type')">
Type
<span ng-show="$ctrl.state.orderBy == 'Type' && !$ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.orderBy == 'Type' && $ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="!item.Inherited && $ctrl.entryClick(item)" ng-class="{ 'interactive': !item.Inherited }" dir-paginate="item in $ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit">
<td>
{{ item.Name }}
<!-- <span class="image-tag label label-">inherited</span> -->
<span ng-if="item.Inherited" class="text-muted small" style="margin-left: 2px;"><code style="font-size: 85% !important;">inherited</code></span>
</td>
<td>
<i class="fa" ng-class="item.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
{{ item.Type }}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.dataset.length === 0 || ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit).length === 0">
<td colspan="2" class="text-center text-muted">{{ $ctrl.emptyDatasetMessage }}</td>
</tr>
</tbody>
</table>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select ng-model="$ctrl.state.paginatedItemLimit">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -3,6 +3,8 @@ angular.module('portainer.app').component('porAccessManagement', {
controller: 'porAccessManagementController',
bindings: {
accessControlledEntity: '<',
inheritFrom: '<',
entityType: '@',
updateAccess: '&'
}
});

View File

@ -1,134 +1,46 @@
<div class="row">
<div class="col-sm-6">
<rd-widget>
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Users and teams">
<div class="pull-md-right pull-lg-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count_accesses" ng-change="$ctrl.changePaginationCountAccesses()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="$ctrl.authorizeAllAccesses()" ng-disabled="$ctrl.accesses.length === 0 || $ctrl.filteredUsers.length === 0"><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Authorize all</button>
</div>
<div class="col-sm-12 col-md-6 nopadding">
<input type="text" id="filter" ng-model="$ctrl.state.filterUsers" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.orderAccesses('Name')">
Name
<span ng-show="$ctrl.state.sortAccessesBy == 'Name' && !$ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAccessesBy == 'Name' && $ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.orderAccesses('Type')">
Type
<span ng-show="$ctrl.state.sortAccessesBy == 'Type' && !$ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAccessesBy == 'Type' && $ctrl.state.sortAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="$ctrl.authorizeAccess(user)" class="interactive" dir-paginate="user in $ctrl.accesses | filter:$ctrl.state.filterUsers | orderBy:$ctrl.state.sortAccessesBy:$ctrl.state.sortAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_accesses">
<td>{{ user.Name }}</td>
<td>
<i class="fa" ng-class="user.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
{{ user.Type }}
</td>
</tr>
<tr ng-if="!$ctrl.accesses">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.accesses.length === 0 || ($ctrl.accesses | filter:$ctrl.state.filterUsers | orderBy:$ctrl.state.sortAccessesBy:$ctrl.state.sortAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_accesses).length === 0">
<td colspan="2" class="text-center text-muted">No user or team available.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.accesses" class="pull-left pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
<rd-widget>
<rd-widget-header icon="fa-users" title="Access management"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="small text-muted">
<p>You can select which user or team can access this {{ $ctrl.entityType }} by moving them to the authorized accesses table. Simply click
on a user or team entry to move it from one table to the other.</p>
<p ng-if="$ctrl.inheritFrom">
<b>Note</b>: accesses tagged as <code>inherited</code> are inherited from the group accesses and cannot be remove at the endpoint level.
</p>
</div>
<div class="form-group" style="margin-top: 20px;">
<!-- available-endpoints -->
<div class="col-sm-6">
<div class="text-center small text-muted">Users and teams</div>
<div class="text-center small text-muted" style="margin-top: 5px;">
<button class="btn btn-primary btn-sm" ng-click="$ctrl.authorizeAllAccesses()" ng-disabled="$ctrl.accesses.length === 0"><i class="fa fa-user-plus space-right" aria-hidden="true"></i>Authorize all</button>
</div>
<div style="margin-top: 10px;">
<access-table
dataset="$ctrl.accesses"
entry-click="$ctrl.authorizeAccess"
empty-dataset-message="No user or team available"
></access-table>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
<div class="col-sm-6">
<rd-widget>
<rd-widget-header classes="col-sm-12 col-md-6 nopadding" icon="fa-users" title="Authorized users and teams">
<div class="pull-md-right pull-lg-right">
Items per page:
<select ng-model="$ctrl.state.pagination_count_authorizedAccesses" ng-change="$ctrl.changePaginationCountAuthorizedAccesses()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
</rd-widget-header>
<rd-widget-taskbar classes="col-sm-12 nopadding">
<div class="col-sm-12 col-md-6 nopadding">
<button class="btn btn-primary btn-sm" ng-click="$ctrl.unauthorizeAllAccesses()" ng-disabled="$ctrl.authorizedAccesses.length === 0 || $ctrl.filteredAuthorizedUsers.length === 0"><i class="fa fa-user-times space-right" aria-hidden="true"></i>Deny all</button>
</div>
<div class="col-sm-12 col-md-6 nopadding">
<input type="text" id="filter" ng-model="$ctrl.state.filterAuthorizedUsers" placeholder="Filter..." class="form-control input-sm" />
</div>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<a ng-click="$ctrl.orderAuthorizedAccesses('Name')">
Name
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Name' && !$ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Name' && $ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="$ctrl.orderAuthorizedAccesses('Type')">
Type
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Type' && !$ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.sortAuthorizedAccessesBy == 'Type' && $ctrl.state.sortAuthorizedAccessesReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="$ctrl.unauthorizeAccess(user)" class="interactive" pagination-id="table_authaccess" dir-paginate="user in $ctrl.authorizedAccesses | filter:$ctrl.state.filterAuthorizedUsers | orderBy:$ctrl.state.sortAuthorizedAccessesBy:$ctrl.state.sortAuthorizedAccessesReverse | itemsPerPage: $ctrl.state.pagination_count_authorizedAccesses">
<td>{{ user.Name }}</td>
<td>
<i class="fa" ng-class="user.Type === 'user' ? 'fa-user' : 'fa-users'" aria-hidden="true" style="margin-right: 2px;"></i>
{{ user.Type }}
</td>
</tr>
<tr ng-if="!$ctrl.authorizedAccesses">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.authorizedAccesses.length === 0 || (authorizedAccesses | filter:state.filterAuthorizedUsers | orderBy:sortTypeAuthorizedAccesses:sortReverseAuthorizedAccesses | itemsPerPage: state.pagination_count_authorizedAccesses).length === 0">
<td colspan="2" class="text-center text-muted">No authorized user or team.</td>
</tr>
</tbody>
</table>
<div ng-if="$ctrl.authorizedAccesses" class="pull-left pagination-controls">
<dir-pagination-controls pagination-id="table_authaccess"></dir-pagination-controls>
<!-- !available-endpoints -->
<!-- associated-endpoints -->
<div class="col-sm-6">
<div class="text-center small text-muted">Authorized users and teams</div>
<div class="text-center small text-muted" style="margin-top: 5px;">
<button class="btn btn-primary btn-sm" ng-click="$ctrl.unauthorizeAllAccesses()" ng-disabled="$ctrl.authorizedAccesses.length === 0"><i class="fa fa-user-times space-right" aria-hidden="true"></i>Deny all</button>
</div>
<div style="margin-top: 10px;">
<access-table
dataset="$ctrl.authorizedAccesses"
entry-click="$ctrl.unauthorizeAccess"
empty-dataset-message="No authorized user or team"
></access-table>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<!-- !associated-endpoints -->
</div>
</form>
</rd-widget-body>
</rd-widget>

View File

@ -1,40 +1,13 @@
angular.module('portainer.app')
.controller('porAccessManagementController', ['AccessService', 'PaginationService', 'Notifications',
function (AccessService, PaginationService, Notifications) {
.controller('porAccessManagementController', ['AccessService', 'Notifications',
function (AccessService, Notifications) {
var ctrl = this;
ctrl.state = {
pagination_count_accesses: PaginationService.getPaginationLimit('access_management_accesses'),
pagination_count_authorizedAccesses: PaginationService.getPaginationLimit('access_management_AuthorizedAccesses'),
sortAccessesBy: 'Type',
sortAccessesReverse: false,
sortAuthorizedAccessesBy: 'Type',
sortAuthorizedAccessesReverse: false
};
ctrl.orderAccesses = function(sortBy) {
ctrl.state.sortAccessesReverse = (ctrl.state.sortAccessesBy === sortBy) ? !ctrl.state.sortAccessesReverse : false;
ctrl.state.sortAccessesBy = sortBy;
};
ctrl.orderAuthorizedAccesses = function(sortBy) {
ctrl.state.sortAuthorizedAccessesReverse = (ctrl.state.sortAuthorizedAccessesBy === sortBy) ? !ctrl.state.sortAuthorizedAccessesReverse : false;
ctrl.state.sortAuthorizedAccessesBy = sortBy;
};
ctrl.changePaginationCountAuthorizedAccesses = function() {
PaginationService.setPaginationLimit('access_management_AuthorizedAccesses', ctrl.state.pagination_count_authorizedAccesses);
};
ctrl.changePaginationCountAccesses = function() {
PaginationService.setPaginationLimit('access_management_accesses', ctrl.state.pagination_count_accesses);
};
function dispatchUserAndTeamIDs(accesses, users, teams) {
angular.forEach(accesses, function (access) {
if (access.Type === 'user') {
if (access.Type === 'user' && !access.Inherited) {
users.push(access.Id);
} else if (access.Type === 'team') {
} else if (access.Type === 'team' && !access.Inherited) {
teams.push(access.Id);
}
});
@ -111,11 +84,20 @@ function (AccessService, PaginationService, Notifications) {
});
};
function moveAccesses(source, target) {
for (var i = 0; i < source.length; i++) {
var access = source[i];
if (!access.Inherited) {
target.push(access);
source.splice(i, 1);
}
}
}
ctrl.unauthorizeAllAccesses = function() {
ctrl.updateAccess({ userAccesses: [], teamAccesses: [] })
.then(function success(data) {
ctrl.accesses = ctrl.accesses.concat(ctrl.authorizedAccesses);
ctrl.authorizedAccesses = [];
moveAccesses(ctrl.authorizedAccesses, ctrl.accesses);
Notifications.success('Accesses successfully updated');
})
.catch(function error(err) {
@ -130,8 +112,7 @@ function (AccessService, PaginationService, Notifications) {
ctrl.updateAccess({ userAccesses: authorizedUserIDs, teamAccesses: authorizedTeamIDs })
.then(function success(data) {
ctrl.authorizedAccesses = ctrl.authorizedAccesses.concat(ctrl.accesses);
ctrl.accesses = [];
moveAccesses(ctrl.accesses, ctrl.authorizedAccesses);
Notifications.success('Accesses successfully updated');
})
.catch(function error(err) {
@ -141,7 +122,8 @@ function (AccessService, PaginationService, Notifications) {
function initComponent() {
var entity = ctrl.accessControlledEntity;
AccessService.accesses(entity.AuthorizedUsers, entity.AuthorizedTeams)
var parent = ctrl.inheritFrom;
AccessService.accesses(entity.AuthorizedUsers, entity.AuthorizedTeams, parent ? parent.AuthorizedUsers: [], parent ? parent.AuthorizedTeams : [])
.then(function success(data) {
ctrl.accesses = data.accesses;
ctrl.authorizedAccesses = data.authorizedAccesses;

View File

@ -43,6 +43,13 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'URL' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('GroupName')">
Group
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'GroupName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'GroupName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th>
</tr>
</thead>
@ -57,6 +64,7 @@
<span ng-if="!$ctrl.endpointManagement">{{ item.Name }}</span>
</td>
<td>{{ item.URL | stripprotocol }}</td>
<td>{{ item.GroupName }}</td>
<td>
<a ui-sref="portainer.endpoints.endpoint.access({id: item.Id})" ng-if="$ctrl.accessManagement">
<i class="fa fa-users" aria-hidden="true"></i> Manage access

View File

@ -0,0 +1,93 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.title }}
</div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.state.displayTextFilter }" ng-click="$ctrl.updateDisplayTextFilter()" ng-if="$ctrl.showTextFilter">
<i class="fa fa-search" aria-hidden="true"></i> Search
</span>
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-danger"
ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
</button>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.groups.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add group
</button>
</div>
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<span class="md-checkbox">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label>
</span>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td>
<span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/>
<label for="select_{{ $index }}"></label>
</span>
<a ui-sref="portainer.groups.group({id: item.Id})">{{ item.Name }}</a>
</td>
<td>
<a ui-sref="portainer.groups.group.access({id: item.Id})" ng-if="$ctrl.accessManagement">
<i class="fa fa-users" aria-hidden="true"></i> Manage access
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="3" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="3" class="text-center text-muted">No group available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
{{ $ctrl.state.selectedItemCount }} item(s) selected
</div>
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -0,0 +1,15 @@
angular.module('portainer.app').component('groupsDatatable', {
templateUrl: 'app/portainer/components/datatables/groups-datatable/groupsDatatable.html',
controller: 'GenericDatatableController',
bindings: {
title: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
showTextFilter: '<',
accessManagement: '<',
removeAction: '<'
}
});

View File

@ -0,0 +1,9 @@
angular.module('portainer.app').component('endpointSelector', {
templateUrl: 'app/portainer/components/endpoint-selector/endpointSelector.html',
controller: 'EndpointSelectorController',
bindings: {
'endpoints': '<',
'groups': '<',
'selectEndpoint': '<'
}
});

View File

@ -0,0 +1,27 @@
<div ng-if="$ctrl.endpoints.length > 1">
<div ng-if="!$ctrl.state.show">
<li class="sidebar-title">
<span class="interactive" style="color: #fff;" ng-click="$ctrl.state.show = true;">
<span class="fa fa-plug space-right"></span>Change environment
</span>
</li>
</div>
<div ng-if="$ctrl.state.show">
<div ng-if="$ctrl.availableGroups.length > 1">
<li class="sidebar-title"><span>Group</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="group.Name for group in $ctrl.availableGroups" ng-model="$ctrl.state.selectedGroup" ng-change="$ctrl.selectGroup()">
<option value="">Select a group</option>
</select>
</li>
</div>
<div ng-if="$ctrl.state.selectedGroup || $ctrl.availableGroups.length <= 1">
<li class="sidebar-title"><span>Endpoint</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in $ctrl.availableEndpoints" ng-model="$ctrl.state.selectedEndpoint" ng-change="$ctrl.selectEndpoint($ctrl.state.selectedEndpoint)">
<option value="">Select an endpoint</option>
</select>
</li>
</div>
</div>
</div>

View File

@ -0,0 +1,34 @@
angular.module('portainer.app')
.controller('EndpointSelectorController', function () {
var ctrl = this;
this.state = {
show: false,
selectedGroup: null,
selectedEndpoint: null
};
this.selectGroup = function() {
this.availableEndpoints = this.endpoints.filter(function f(endpoint) {
return endpoint.GroupId === ctrl.state.selectedGroup.Id;
});
};
this.$onInit = function() {
this.availableGroups = filterEmptyGroups(this.groups, this.endpoints);
this.availableEndpoints = this.endpoints;
};
function filterEmptyGroups(groups, endpoints) {
return groups.filter(function f(group) {
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];
if (endpoint.GroupId === group.Id) {
return true;
}
}
return false;
});
}
});

View File

@ -0,0 +1,31 @@
angular.module('portainer.app').component('groupForm', {
templateUrl: 'app/portainer/components/forms/group-form/groupForm.html',
controller: function() {
var ctrl = this;
this.associateEndpoint = function(endpoint) {
ctrl.associatedEndpoints.push(endpoint);
_.remove(ctrl.availableEndpoints, function(n) {
return n.Id === endpoint.Id;
});
};
this.dissociateEndpoint = function(endpoint) {
ctrl.availableEndpoints.push(endpoint);
_.remove(ctrl.associatedEndpoints, function(n) {
return n.Id === endpoint.Id;
});
};
},
bindings: {
model: '=',
availableEndpoints: '=',
associatedEndpoints: '=',
addLabelAction: '<',
removeLabelAction: '<',
formAction: '<',
formActionLabel: '@',
actionInProgress: '<'
}
});

View File

@ -0,0 +1,120 @@
<form class="form-horizontal" name="endpointGroupForm">
<!-- name-input -->
<div class="form-group" ng-class="{ 'has-error': endpointGroupForm.group_name.$invalid }">
<label for="group_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" name="group_name" ng-model="$ctrl.model.Name" placeholder="e.g. my-group" required auto-focus>
</div>
</div>
<div class="form-group" ng-show="endpointGroupForm.group_name.$invalid">
<div class="col-sm-12 small text-danger">
<div ng-messages="endpointGroupForm.group_name.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !name-input -->
<!-- description-input -->
<div class="form-group">
<label for="group_description" class="col-sm-3 col-lg-2 control-label text-left">Description</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="group_description" ng-model="$ctrl.model.Description" placeholder="e.g. production environments...">
</div>
</div>
<!-- !description-input -->
<!-- labels -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Labels</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addLabelAction()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
</span>
</div>
<!-- labels-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="label in $ctrl.model.Labels" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">name</span>
<input type="text" class="form-control" ng-model="label.name" placeholder="e.g. organization">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. acme">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLabelAction($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- !labels-input-list -->
</div>
<!-- !labels -->
<!-- endpoints -->
<div ng-if="$ctrl.model.Id !== 1">
<div class="col-sm-12 form-section-title">
Associated endpoints
</div>
<div class="form-group">
<div class="col-sm-12 small text-muted">
You can select which endpoint should be part of this group by moving them to the associated endpoints table. Simply click
on any endpoint entry to move it from one table to the other.
</div>
<div class="col-sm-12" style="margin-top: 20px;">
<!-- available-endpoints -->
<div class="col-sm-6">
<div class="text-center small text-muted">Available endpoints</div>
<div style="margin-top: 10px;">
<group-association-table
dataset="$ctrl.availableEndpoints"
entry-click="$ctrl.associateEndpoint"
empty-dataset-message="No endpoint available"
></group-association-table>
</div>
</div>
<!-- !available-endpoints -->
<!-- associated-endpoints -->
<div class="col-sm-6">
<div class="text-center small text-muted">Associated endpoints</div>
<div style="margin-top: 10px;">
<group-association-table
dataset="$ctrl.associatedEndpoints"
entry-click="$ctrl.dissociateEndpoint"
empty-dataset-message="No associated endpoint"
></group-association-table>
</div>
</div>
<!-- !associated-endpoints -->
</div>
</div>
</div>
<div ng-if="$ctrl.model.Id === 1">
<div class="col-sm-12 form-section-title">
Unassociated endpoints
</div>
<div ng-if="$ctrl.associatedEndpoints.length > 0">
<div style="margin-top: 10px;">
<group-association-table
dataset="$ctrl.associatedEndpoints"
empty-dataset-message="No endpoint available"
></group-association-table>
</div>
</div>
<div class="col-sm-12">
<span class="text-muted small">All the endpoints are assigned to a group.</span>
</div>
</div>
<!-- !endpoints -->
<!-- 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" ng-click="$ctrl.formAction()" ng-disabled="$ctrl.actionInProgress || !endpointGroupForm.$valid" button-spinner="$ctrl.actionInProgress">
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
<span ng-show="$ctrl.actionInProgress">In progress...</span>
</button>
</div>
</div>
<!-- !actions -->
</form>

View File

@ -0,0 +1,21 @@
angular.module('portainer.app').component('groupAssociationTable', {
templateUrl: 'app/portainer/components/group-association-table/groupAssociationTable.html',
controller: function() {
this.state = {
orderBy: 'Name',
reverseOrder: false,
paginatedItemLimit: '10',
textFilter: ''
};
this.changeOrderBy = function(orderField) {
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
this.state.orderBy = orderField;
};
},
bindings: {
dataset: '<',
entryClick: '<',
emptyDatasetMessage: '@'
}
});

View File

@ -0,0 +1,49 @@
<div class="datatable">
<table class="table table-hover">
<div class="col-sm-12">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search...">
</div>
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Name')">
Name
<span ng-show="$ctrl.state.orderBy == 'Name' && !$ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="$ctrl.state.orderBy == 'Name' && $ctrl.state.reverseOrder" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</tr>
</thead>
<tbody>
<tr ng-click="$ctrl.entryClick(item)" class="interactive" dir-paginate="item in $ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit">
<td>{{ item.Name }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.dataset.length === 0 || ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit).length === 0">
<td colspan="2" class="text-center text-muted">{{ $ctrl.emptyDatasetMessage }}</td>
</tr>
</tbody>
</table>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select ng-model="$ctrl.state.paginatedItemLimit">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,23 @@
angular.module('portainer.app')
.factory('EndpointHelper', [function EndpointHelperFactory() {
'use strict';
var helper = {};
function findAssociatedGroup(endpoint, groups) {
return _.find(groups, function(group) {
return group.Id === endpoint.GroupId;
});
}
helper.mapGroupNameToEndpoint = function(endpoints, groups) {
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];
var group = findAssociatedGroup(endpoint, groups);
if (group) {
endpoint.GroupName = group.Name;
}
}
};
return helper;
}]);

View File

@ -2,10 +2,12 @@ function UserAccessViewModel(data) {
this.Id = data.Id;
this.Name = data.Username;
this.Type = 'user';
this.Inherited = false;
}
function TeamAccessViewModel(data) {
this.Id = data.Id;
this.Name = data.Name;
this.Type = 'team';
this.Inherited = false;
}

View File

@ -0,0 +1,29 @@
function EndpointGroupDefaultModel() {
this.Name = '';
this.Description = '';
this.Labels = [];
}
function EndpointGroupModel(data) {
this.Id = data.Id;
this.Name = data.Name;
this.Description = data.Description;
this.Labels = data.Labels;
this.AuthorizedUsers = data.AuthorizedUsers;
this.AuthorizedTeams = data.AuthorizedTeams;
}
function EndpointGroupCreateRequest(model, endpoints) {
this.Name = model.Name;
this.Description = model.Description;
this.Labels = model.Labels;
this.AssociatedEndpoints = endpoints;
}
function EndpointGroupUpdateRequest(model, endpoints) {
this.id = model.Id;
this.Name = model.Name;
this.Description = model.Description;
this.Labels = model.Labels;
this.AssociatedEndpoints = endpoints;
}

View File

@ -0,0 +1,12 @@
angular.module('portainer.app')
.factory('EndpointGroups', ['$resource', 'API_ENDPOINT_ENDPOINT_GROUPS', function EndpointGroupsFactory($resource, API_ENDPOINT_ENDPOINT_GROUPS) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINT_GROUPS + '/:id/:action', {}, {
create: { method: 'POST', ignoreLoadingBar: true },
query: { method: 'GET', isArray: true },
get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', params: { id: '@id' } },
updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } },
remove: { method: 'DELETE', params: { id: '@id'} }
});
}]);

View File

@ -3,33 +3,30 @@ angular.module('portainer.app')
'use strict';
var service = {};
function mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs) {
var accesses = [];
function mapAccessData(accesses, authorizedIDs, inheritedIDs) {
var availableAccesses = [];
var authorizedAccesses = [];
angular.forEach(userAccesses, function(access) {
if (_.includes(authorizedUserIDs, access.Id)) {
authorizedAccesses.push(access);
} else {
accesses.push(access);
}
});
for (var i = 0; i < accesses.length; i++) {
angular.forEach(teamAccesses, function(access) {
if (_.includes(authorizedTeamIDs, access.Id)) {
var access = accesses[i];
if (_.includes(inheritedIDs, access.Id)) {
access.Inherited = true;
authorizedAccesses.push(access);
} else if (_.includes(authorizedIDs, access.Id)) {
authorizedAccesses.push(access);
} else {
accesses.push(access);
availableAccesses.push(access);
}
});
}
return {
accesses: accesses,
accesses: availableAccesses,
authorizedAccesses: authorizedAccesses
};
}
service.accesses = function(authorizedUserIDs, authorizedTeamIDs) {
service.accesses = function(authorizedUserIDs, authorizedTeamIDs, inheritedUserIDs, inheritedTeamIDs) {
var deferred = $q.defer();
$q.all({
@ -44,7 +41,14 @@ angular.module('portainer.app')
return new TeamAccessViewModel(team);
});
var accessData = mapAccessDataFromAuthorizedIDs(userAccesses, teamAccesses, authorizedUserIDs, authorizedTeamIDs);
var userAccessData = mapAccessData(userAccesses, authorizedUserIDs, inheritedUserIDs);
var teamAccessData = mapAccessData(teamAccesses, authorizedTeamIDs, inheritedTeamIDs);
var accessData = {
accesses: userAccessData.accesses.concat(teamAccessData.accesses),
authorizedAccesses: userAccessData.authorizedAccesses.concat(teamAccessData.authorizedAccesses)
};
deferred.resolve(accessData);
})
.catch(function error(err) {

View File

@ -12,6 +12,23 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return Endpoints.query({}).$promise;
};
service.endpointsByGroup = function(groupId) {
var deferred = $q.defer();
Endpoints.query({}).$promise
.then(function success(data) {
var endpoints = data.filter(function (endpoint) {
return endpoint.GroupId === groupId;
});
deferred.resolve(endpoints);
})
.catch(function error(err) {
deferred.reject({msg: 'Unable to retrieve endpoints', err: err});
});
return deferred.promise;
};
service.updateAccess = function(id, authorizedUserIDs, authorizedTeamIDs) {
return Endpoints.updateAccess({id: id}, {authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs}).$promise;
};
@ -20,6 +37,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
var query = {
name: endpointParams.name,
PublicURL: endpointParams.PublicURL,
GroupId: endpointParams.GroupId,
TLS: endpointParams.TLS,
TLSSkipVerify: endpointParams.TLSSkipVerify,
TLSSkipClientVerify: endpointParams.TLSSkipClientVerify,
@ -49,10 +67,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return Endpoints.remove({id: endpointID}).$promise;
};
service.createLocalEndpoint = function(name, URL, TLS, active) {
service.createLocalEndpoint = function() {
var deferred = $q.defer();
FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', false)
FileUploadService.createEndpoint('local', 'unix:///var/run/docker.sock', '', 1, false)
.then(function success(response) {
deferred.resolve(response.data);
})
@ -63,10 +81,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return deferred.promise;
};
service.createRemoteEndpoint = function(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
service.createRemoteEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
var deferred = $q.defer();
FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
FileUploadService.createEndpoint(name, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success(response) {
deferred.resolve(response.data);
})

View File

@ -0,0 +1,45 @@
angular.module('portainer.app')
.factory('GroupService', ['$q', 'EndpointGroups',
function GroupService($q, EndpointGroups) {
'use strict';
var service = {};
service.group = function(groupId) {
var deferred = $q.defer();
EndpointGroups.get({ id: groupId }).$promise
.then(function success(data) {
var group = new EndpointGroupModel(data);
deferred.resolve(group);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve group', err: err });
});
return deferred.promise;
};
service.groups = function() {
return EndpointGroups.query({}).$promise;
};
service.createGroup = function(model, endpoints) {
var payload = new EndpointGroupCreateRequest(model, endpoints);
return EndpointGroups.create(payload).$promise;
};
service.updateGroup = function(model, endpoints) {
var payload = new EndpointGroupUpdateRequest(model, endpoints);
return EndpointGroups.update(payload).$promise;
};
service.updateAccess = function(groupId, authorizedUserIDs, authorizedTeamIDs) {
return EndpointGroups.updateAccess({ id: groupId }, { authorizedUsers: authorizedUserIDs, authorizedTeams: authorizedTeamIDs }).$promise;
};
service.deleteGroup = function(groupId) {
return EndpointGroups.remove({ id: groupId }).$promise;
};
return service;
}]);

View File

@ -42,13 +42,14 @@ angular.module('portainer.app')
});
};
service.createEndpoint = function(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
service.createEndpoint = function(name, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
return Upload.upload({
url: 'api/endpoints',
data: {
Name: name,
URL: URL,
PublicURL: PublicURL,
GroupID: groupID,
TLS: TLS,
TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify,

View File

@ -6,7 +6,7 @@
</rd-header>
<div class="row" ng-if="endpoint">
<div class="col-lg-12 col-md-12 col-xs-12">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-plug" title="Endpoint"></rd-widget-header>
<rd-widget-body classes="no-padding">
@ -25,11 +25,9 @@
</td>
</tr>
<tr>
<td colspan="2">
<span class="small text-muted">
You can select which user or team can access this endpoint by moving them to the authorized accesses table. Simply click
on a user or team entry to move it from one table to the other.
</span>
<td>Group</td>
<td>
<a ui-sref="portainer.groups.group({ id: group.Id })">{{ group.Name }}</a>
</td>
</tr>
</tbody>
@ -39,5 +37,10 @@
</div>
</div>
<por-access-management ng-if="endpoint" access-controlled-entity="endpoint" update-access="updateAccess(userAccesses, teamAccesses)">
</por-access-management>
<div class="row" ng-if="endpoint && group">
<div class="col-sm-12">
<por-access-management
access-controlled-entity="endpoint" entity-type="endpoint" inherit-from="group" update-access="updateAccess(userAccesses, teamAccesses)"
></por-access-management>
</div>
</div>

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'Notifications',
function ($scope, $transition$, EndpointService, Notifications) {
.controller('EndpointAccessController', ['$scope', '$transition$', 'EndpointService', 'GroupService', 'Notifications',
function ($scope, $transition$, EndpointService, GroupService, Notifications) {
$scope.updateAccess = function(authorizedUsers, authorizedTeams) {
return EndpointService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams);
@ -9,7 +9,12 @@ function ($scope, $transition$, EndpointService, Notifications) {
function initView() {
EndpointService.endpoint($transition$.params().id)
.then(function success(data) {
$scope.endpoint = data;
var endpoint = data;
$scope.endpoint = endpoint;
return GroupService.group(endpoint.GroupId);
})
.then(function success(data) {
$scope.group = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint details');

View File

@ -43,6 +43,19 @@
</div>
</div>
<!-- !endpoint-public-url-input -->
<div class="col-sm-12 form-section-title">
Grouping
</div>
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left">
Group
</label>
<div class="col-sm-9 col-lg-10">
<select ng-options="group.Id as group.Name for group in groups" ng-model="endpoint.GroupId" id="endpoint_group" class="form-control"></select>
</div>
</div>
<!-- !group -->
<!-- endpoint-security -->
<div ng-if="endpointType === 'remote'">
<div class="col-sm-12 form-section-title">

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('EndpointController', ['$scope', '$state', '$transition$', '$filter', 'EndpointService', 'Notifications',
function ($scope, $state, $transition$, $filter, EndpointService, Notifications) {
.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications',
function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) {
if (!$scope.applicationState.application.endpointManagement) {
$state.go('portainer.endpoints');
@ -27,6 +27,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
name: endpoint.Name,
URL: endpoint.URL,
PublicURL: endpoint.PublicURL,
GroupId: endpoint.GroupId,
TLS: TLS,
TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify,
@ -40,7 +41,8 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
EndpointService.updateEndpoint(endpoint.Id, endpointParams)
.then(function success(data) {
Notifications.success('Endpoint updated', $scope.endpoint.Name);
$state.go('portainer.endpoints');
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
$state.go('portainer.endpoints', {}, {reload: true});
}, function error(err) {
Notifications.error('Failure', err, 'Unable to update endpoint');
$scope.state.actionInProgress = false;
@ -52,9 +54,12 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
};
function initView() {
EndpointService.endpoint($transition$.params().id)
$q.all({
endpoint: EndpointService.endpoint($transition$.params().id),
groups: GroupService.groups()
})
.then(function success(data) {
var endpoint = data;
var endpoint = data.endpoint;
if (endpoint.URL.indexOf('unix://') === 0) {
$scope.endpointType = 'local';
} else {
@ -62,6 +67,7 @@ function ($scope, $state, $transition$, $filter, EndpointService, Notifications)
}
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
$scope.endpoint = endpoint;
$scope.groups = data.groups;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint details');

View File

@ -59,6 +59,16 @@
</div>
</div>
<!-- !endpoint-public-url-input -->
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left">
Group
</label>
<div class="col-sm-9 col-lg-10">
<select ng-options="group.Id as group.Name for group in groups" ng-model="formValues.GroupId" id="endpoint_group" class="form-control"></select>
</div>
</div>
<!-- !group -->
<!-- endpoint-security -->
<por-endpoint-security form-data="formValues.SecurityFormData"></por-endpoint-security>
<!-- !endpoint-security -->

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('EndpointsController', ['$scope', '$state', '$filter', 'EndpointService', 'Notifications',
function ($scope, $state, $filter, EndpointService, Notifications) {
.controller('EndpointsController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'EndpointHelper', 'Notifications',
function ($q, $scope, $state, $filter, EndpointService, GroupService, EndpointHelper, Notifications) {
$scope.state = {
uploadInProgress: false,
actionInProgress: false
@ -10,6 +10,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
Name: '',
URL: '',
PublicURL: '',
GroupId: 1,
SecurityFormData: new EndpointSecurityFormData()
};
@ -20,6 +21,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
if (PublicURL === '') {
PublicURL = URL.split(':')[0];
}
var groupId = $scope.formValues.GroupId;
var securityData = $scope.formValues.SecurityFormData;
var TLS = securityData.TLS;
@ -31,7 +33,7 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey;
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(name, URL, PublicURL, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
EndpointService.createRemoteEndpoint(name, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success() {
Notifications.success('Endpoint created', name);
$state.reload();
@ -65,16 +67,22 @@ function ($scope, $state, $filter, EndpointService, Notifications) {
});
};
function fetchEndpoints() {
EndpointService.endpoints()
function initView() {
$q.all({
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
$scope.endpoints = data;
var endpoints = data.endpoints;
var groups = data.groups;
EndpointHelper.mapGroupNameToEndpoint(endpoints, groups);
$scope.groups = groups;
$scope.endpoints = endpoints;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
$scope.endpoints = [];
Notifications.error('Failure', err, 'Unable to load view');
});
}
fetchEndpoints();
initView();
}]);

View File

@ -0,0 +1,34 @@
<rd-header>
<rd-header-title title="Endpoint group access"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.groups">Groups</a> &gt; <a ui-sref="portainer.groups.group({id: group.Id})">{{ group.Name }}</a> &gt; Access management
</rd-header-content>
</rd-header>
<div class="row" ng-if="group">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-object-group" title="Group"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
{{ group.Name }}
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="group">
<div class="col-sm-12">
<por-access-management
ng-if="group" access-controlled-entity="group" entity-type="group" update-access="updateAccess(userAccesses, teamAccesses)"
></por-access-management>
</div>
</div>

View File

@ -0,0 +1,22 @@
angular.module('portainer.app')
.controller('GroupAccessController', ['$scope', '$transition$', 'GroupService', 'Notifications',
function ($scope, $transition$, GroupService, Notifications) {
$scope.updateAccess = function(authorizedUsers, authorizedTeams) {
return GroupService.updateAccess($transition$.params().id, authorizedUsers, authorizedTeams);
};
function initView() {
var groupId = $transition$.params().id;
GroupService.group(groupId)
.then(function success(data) {
$scope.group = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load view');
});
}
initView();
}]);

View File

@ -0,0 +1,54 @@
angular.module('portainer.app')
.controller('CreateGroupController', ['$scope', '$state', 'GroupService', 'EndpointService', 'Notifications',
function ($scope, $state, GroupService, EndpointService, Notifications) {
$scope.state = {
actionInProgress: false
};
$scope.addLabel = function() {
$scope.model.Labels.push({ name: '', value: '' });
};
$scope.removeLabel = function(index) {
$scope.model.Labels.splice(index, 1);
};
$scope.create = function() {
var model = $scope.model;
var associatedEndpoints = [];
for (var i = 0; i < $scope.associatedEndpoints.length; i++) {
var endpoint = $scope.associatedEndpoints[i];
associatedEndpoints.push(endpoint.Id);
}
$scope.state.actionInProgress = true;
GroupService.createGroup(model, associatedEndpoints)
.then(function success() {
Notifications.success('Group successfully created');
$state.go('portainer.groups', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create group');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function initView() {
$scope.model = new EndpointGroupDefaultModel();
EndpointService.endpointsByGroup(1)
.then(function success(data) {
$scope.availableEndpoints = data;
$scope.associatedEndpoints = [];
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
});
}
initView();
}]);

View File

@ -0,0 +1,25 @@
<rd-header>
<rd-header-title title="Create endpoint group"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.groups">Endpoint groups</a> &gt; Add group
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
model="model"
available-endpoints="availableEndpoints"
associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"
form-action="create"
form-action-label="Create the group"
action-in-progress="state.actionInProgress"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,25 @@
<rd-header>
<rd-header-title title="Endpoint group details"></rd-header-title>
<rd-header-content>
<a ui-sref="portainer.groups">Groups</a> &gt; <a ui-sref="portainer.groups.group({id: group.Id})">{{ group.Name }}</a>
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<group-form
model="group"
available-endpoints="availableEndpoints"
associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"
form-action="update"
form-action-label="Update the group"
action-in-progress="state.actionInProgress"
></group-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,70 @@
angular.module('portainer.app')
.controller('GroupController', ['$q', '$scope', '$state', '$transition$', 'GroupService', 'EndpointService', 'Notifications',
function ($q, $scope, $state, $transition$, GroupService, EndpointService, Notifications) {
$scope.state = {
actionInProgress: false
};
$scope.addLabel = function() {
$scope.group.Labels.push({ name: '', value: '' });
};
$scope.removeLabel = function(index) {
$scope.group.Labels.splice(index, 1);
};
$scope.update = function() {
var model = $scope.group;
var associatedEndpoints = [];
for (var i = 0; i < $scope.associatedEndpoints.length; i++) {
var endpoint = $scope.associatedEndpoints[i];
associatedEndpoints.push(endpoint.Id);
}
$scope.state.actionInProgress = true;
GroupService.updateGroup(model, associatedEndpoints)
.then(function success(data) {
Notifications.success('Group successfully updated');
$state.go('portainer.groups', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update group');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
function initView() {
var groupId = $transition$.params().id;
$q.all({
group: GroupService.group(groupId),
endpoints: EndpointService.endpoints()
})
.then(function success(data) {
$scope.group = data.group;
var availableEndpoints = [];
var associatedEndpoints = [];
for (var i = 0; i < data.endpoints.length; i++) {
var endpoint = data.endpoints[i];
if (endpoint.GroupId === +groupId) {
associatedEndpoints.push(endpoint);
} else if (endpoint.GroupId === 1) {
availableEndpoints.push(endpoint);
}
}
$scope.availableEndpoints = availableEndpoints;
$scope.associatedEndpoints = associatedEndpoints;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load view');
});
}
initView();
}]);

View File

@ -0,0 +1,20 @@
<rd-header>
<rd-header-title title="Endpoint groups">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.groups" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Endpoint group management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<groups-datatable
title="Endpoint groups" title-icon="fa-object-group"
dataset="groups" table-key="groups"
order-by="Name" show-text-filter="true"
access-management="applicationState.application.authentication"
remove-action="removeAction"
></groups-datatable>
</div>
</div>

View File

@ -0,0 +1,38 @@
angular.module('portainer.app')
.controller('GroupsController', ['$scope', '$state', '$filter', 'GroupService', 'Notifications',
function ($scope, $state, $filter, GroupService, Notifications) {
$scope.removeAction = function (selectedItems) {
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (group) {
GroupService.deleteGroup(group.Id)
.then(function success() {
Notifications.success('Endpoint group successfully removed', group.Name);
var index = $scope.groups.indexOf(group);
$scope.groups.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to remove group');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
};
function initView() {
GroupService.groups()
.then(function success(data) {
$scope.groups = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint groups');
$scope.groups = [];
});
}
initView();
}]);

View File

@ -31,7 +31,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
var endpointID = 1;
$scope.state.actionInProgress = true;
EndpointService.createLocalEndpoint(name, URL, false, true)
EndpointService.createLocalEndpoint()
.then(function success(data) {
endpointID = data.Id;
EndpointProvider.setEndpointID(endpointID);

View File

@ -9,12 +9,12 @@
</div>
<div class="sidebar-content">
<ul class="sidebar">
<li class="sidebar-title"><span>Active endpoint</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in endpoints" ng-model="activeEndpoint" ng-change="switchEndpoint(activeEndpoint)">
</select>
</li>
<li class="sidebar-title"><span>Endpoint actions</span></li>
<endpoint-selector ng-if="endpoints && groups"
endpoints="endpoints"
groups="groups"
select-endpoint="switchEndpoint"
></endpoint-selector>
<li class="sidebar-title"><span>{{ activeEndpoint.Name }}</span></li>
<docker-sidebar-content
endpoint-api-version="applicationState.endpoint.apiVersion"
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
@ -47,6 +47,9 @@
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="portainer.endpoints" ui-sref-active="active">Endpoints <span class="menu-icon fa fa-plug fa-fw"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'portainer.endpoints' || $state.current.name === 'portainer.endpoints.endpoint' || $state.current.name === 'portainer.endpoints.endpoint.access' || $state.current.name === 'portainer.groups' || $state.current.name === 'portainer.groups.group' || $state.current.name === 'portainer.groups.group.access' || $state.current.name === 'portainer.groups.new')">
<a ui-sref="portainer.groups" ui-sref-active="active">Groups</a>
</div>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="portainer.registries" ui-sref-active="active">Registries <span class="menu-icon fa fa-database fa-fw"></span></a>

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('SidebarController', ['$q', '$scope', '$state', 'Settings', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager',
function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) {
.controller('SidebarController', ['$q', '$scope', '$state', 'EndpointService', 'GroupService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager',
function ($q, $scope, $state, EndpointService, GroupService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) {
$scope.switchEndpoint = function(endpoint) {
var activeEndpointID = EndpointProvider.endpointID();
@ -25,16 +25,6 @@ function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointP
});
};
function setActiveEndpoint(endpoints) {
var activeEndpointID = EndpointProvider.endpointID();
angular.forEach(endpoints, function (endpoint) {
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
}
});
}
function checkPermissions(memberships) {
var isLeader = false;
angular.forEach(memberships, function(membership) {
@ -49,13 +39,24 @@ function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointP
$scope.uiVersion = StateManager.getState().application.version;
$scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors;
$scope.logo = StateManager.getState().application.logo;
$scope.endpoints = [];
EndpointService.endpoints()
$q.all({
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
var endpoints = data;
$scope.endpoints = _.sortBy(endpoints, ['Name']);
setActiveEndpoint(endpoints);
var endpoints = data.endpoints;
$scope.groups = data.groups;
$scope.endpoints = endpoints;
var activeEndpointID = EndpointProvider.endpointID();
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
break;
}
}
if (StateManager.getState().application.authentication) {
var userDetails = Authentication.getUserDetails();