feat(tags): add the ability to manage tags (#1971)

* feat(tags): add the ability to manage tags

* feat(tags): update tag selector UX

* refactor(app): remove unused ui-select library
pull/1978/head
Anthony Lapenna 2018-06-15 09:18:25 +02:00 committed by GitHub
parent b349f16090
commit 5e73a49473
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 942 additions and 118 deletions

View File

@ -27,6 +27,7 @@ type Store struct {
RegistryService *RegistryService
DockerHubService *DockerHubService
StackService *StackService
TagService *TagService
db *bolt.DB
checkForDataMigration bool
@ -45,6 +46,7 @@ const (
registryBucketName = "registries"
dockerhubBucketName = "dockerhub"
stackBucketName = "stacks"
tagBucketName = "tags"
)
// NewStore initializes a new Store and the associated services
@ -62,6 +64,7 @@ func NewStore(storePath string) (*Store, error) {
RegistryService: &RegistryService{},
DockerHubService: &DockerHubService{},
StackService: &StackService{},
TagService: &TagService{},
}
store.UserService.store = store
store.TeamService.store = store
@ -74,6 +77,7 @@ func NewStore(storePath string) (*Store, error) {
store.RegistryService.store = store
store.DockerHubService.store = store
store.StackService.store = store
store.TagService.store = store
_, err := os.Stat(storePath + "/" + databaseFileName)
if err != nil && os.IsNotExist(err) {
@ -99,7 +103,7 @@ func (store *Store) Open() error {
bucketsToCreate := []string{versionBucketName, userBucketName, teamBucketName, endpointBucketName,
endpointGroupBucketName, resourceControlBucketName, teamMembershipBucketName, settingsBucketName,
registryBucketName, dockerhubBucketName, stackBucketName}
registryBucketName, dockerhubBucketName, stackBucketName, tagBucketName}
return db.Update(func(tx *bolt.Tx) error {
@ -128,6 +132,7 @@ func (store *Store) Init() error {
Labels: []portainer.Pair{},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Tags: []string{},
}
return store.EndpointGroupService.CreateEndpointGroup(unassignedGroup)

View File

@ -107,6 +107,16 @@ func UnmarshalDockerHub(data []byte, settings *portainer.DockerHub) error {
return json.Unmarshal(data, settings)
}
// MarshalTag encodes a Tag object to binary format.
func MarshalTag(tag *portainer.Tag) ([]byte, error) {
return json.Marshal(tag)
}
// UnmarshalTag decodes a Tag object from a binary data.
func UnmarshalTag(data []byte, tag *portainer.Tag) error {
return json.Unmarshal(data, tag)
}
// Itob returns an 8-byte big endian representation of v.
// This function is typically used for encoding integer IDs to byte slices
// so that they can be used as BoltDB keys.

View File

@ -0,0 +1,37 @@
package bolt
func (m *Migrator) updateEndpointsToVersion12() error {
legacyEndpoints, err := m.EndpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range legacyEndpoints {
endpoint.Tags = []string{}
err = m.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
func (m *Migrator) updateEndpointGroupsToVersion12() error {
legacyEndpointGroups, err := m.EndpointGroupService.EndpointGroups()
if err != nil {
return err
}
for _, group := range legacyEndpointGroups {
group.Tags = []string{}
err = m.EndpointGroupService.UpdateEndpointGroup(group.ID, &group)
if err != nil {
return err
}
}
return nil
}

View File

@ -6,6 +6,7 @@ import "github.com/portainer/portainer"
type Migrator struct {
UserService *UserService
EndpointService *EndpointService
EndpointGroupService *EndpointGroupService
ResourceControlService *ResourceControlService
SettingsService *SettingsService
VersionService *VersionService
@ -18,6 +19,7 @@ func NewMigrator(store *Store, version int) *Migrator {
return &Migrator{
UserService: store.UserService,
EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
VersionService: store.VersionService,
@ -120,6 +122,18 @@ func (m *Migrator) Migrate() error {
}
}
if m.CurrentDBVersion < 12 {
err := m.updateEndpointsToVersion12()
if err != nil {
return err
}
err = m.updateEndpointGroupsToVersion12()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err

71
api/bolt/tag_service.go Normal file
View File

@ -0,0 +1,71 @@
package bolt
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/internal"
"github.com/boltdb/bolt"
)
// TagService represents a service for managing tags.
type TagService struct {
store *Store
}
// Tags return an array containing all the tags.
func (service *TagService) Tags() ([]portainer.Tag, error) {
var tags = make([]portainer.Tag, 0)
err := service.store.db.View(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(tagBucketName))
cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var tag portainer.Tag
err := internal.UnmarshalTag(v, &tag)
if err != nil {
return err
}
tags = append(tags, tag)
}
return nil
})
if err != nil {
return nil, err
}
return tags, nil
}
// CreateTag creates a new tag.
func (service *TagService) CreateTag(tag *portainer.Tag) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(tagBucketName))
id, _ := bucket.NextSequence()
tag.ID = portainer.TagID(id)
data, err := internal.MarshalTag(tag)
if err != nil {
return err
}
err = bucket.Put(internal.Itob(int(tag.ID)), data)
if err != nil {
return err
}
return nil
})
}
// DeleteTag deletes a tag.
func (service *TagService) DeleteTag(ID portainer.TagID) error {
return service.store.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(tagBucketName))
err := bucket.Delete(internal.Itob(int(ID)))
if err != nil {
return err
}
return nil
})
}

View File

@ -236,6 +236,7 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: []string{},
}
if strings.HasPrefix(endpoint.URL, "tcp://") {
@ -274,6 +275,7 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: []string{},
}
return endpointService.CreateEndpoint(endpoint)
@ -401,6 +403,7 @@ func main() {
RegistryService: store.RegistryService,
DockerHubService: store.DockerHubService,
StackService: store.StackService,
TagService: store.TagService,
SwarmStackManager: swarmStackManager,
ComposeStackManager: composeStackManager,
CryptoService: cryptoService,

View File

@ -68,6 +68,11 @@ const (
ErrStackNotExternal = Error("Not an external stack")
)
// Tag errors
const (
ErrTagAlreadyExists = Error("A tag already exists with this name")
)
// Endpoint extensions error
const (
ErrEndpointExtensionNotSupported = Error("This extension is not supported")

View File

@ -13,14 +13,17 @@ import (
type endpointGroupCreatePayload struct {
Name string
Description string
Labels []portainer.Pair
AssociatedEndpoints []portainer.EndpointID
Tags []string
}
func (payload *endpointGroupCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return portainer.Error("Invalid endpoint group name")
}
if payload.Tags == nil {
payload.Tags = []string{}
}
return nil
}
@ -35,9 +38,9 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque
endpointGroup := &portainer.EndpointGroup{
Name: payload.Name,
Description: payload.Description,
Labels: payload.Labels,
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Tags: payload.Tags,
}
err = handler.EndpointGroupService.CreateEndpointGroup(endpointGroup)

View File

@ -12,8 +12,8 @@ import (
type endpointGroupUpdatePayload struct {
Name string
Description string
Labels []portainer.Pair
AssociatedEndpoints []portainer.EndpointID
Tags []string
}
func (payload *endpointGroupUpdatePayload) Validate(r *http.Request) error {
@ -48,7 +48,9 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque
endpointGroup.Description = payload.Description
}
endpointGroup.Labels = payload.Labels
if payload.Tags != nil {
endpointGroup.Tags = payload.Tags
}
err = handler.EndpointGroupService.UpdateEndpointGroup(endpointGroup.ID, endpointGroup)
if err != nil {

View File

@ -28,6 +28,7 @@ type endpointCreatePayload struct {
AzureApplicationID string
AzureTenantID string
AzureAuthenticationKey string
Tags []string
}
func (payload *endpointCreatePayload) Validate(r *http.Request) error {
@ -49,6 +50,13 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
}
payload.GroupID = groupID
var tags []string
err = request.RetrieveMultiPartFormJSONValue(r, "Tags", &tags, true)
if err != nil {
return portainer.Error("Invalid Tags parameter")
}
payload.Tags = tags
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
payload.TLS = useTLS
@ -168,6 +176,7 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
AzureCredentials: credentials,
Tags: payload.Tags,
}
err = handler.EndpointService.CreateEndpoint(endpoint)
@ -203,6 +212,7 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: payload.Tags,
}
err := handler.EndpointService.CreateEndpoint(endpoint)
@ -242,6 +252,7 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: payload.Tags,
}
err = handler.EndpointService.CreateEndpoint(endpoint)

View File

@ -22,6 +22,7 @@ type endpointUpdatePayload struct {
AzureApplicationID string
AzureTenantID string
AzureAuthenticationKey string
Tags []string
}
func (payload *endpointUpdatePayload) Validate(r *http.Request) error {
@ -68,6 +69,10 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) *
endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID)
}
if payload.Tags != nil {
endpoint.Tags = payload.Tags
}
if endpoint.Type == portainer.AzureEnvironment {
credentials := endpoint.AzureCredentials
if payload.AzureApplicationID != "" {

View File

@ -15,6 +15,7 @@ import (
"github.com/portainer/portainer/http/handler/settings"
"github.com/portainer/portainer/http/handler/stacks"
"github.com/portainer/portainer/http/handler/status"
"github.com/portainer/portainer/http/handler/tags"
"github.com/portainer/portainer/http/handler/teammemberships"
"github.com/portainer/portainer/http/handler/teams"
"github.com/portainer/portainer/http/handler/templates"
@ -37,16 +38,13 @@ type Handler struct {
SettingsHandler *settings.Handler
StackHandler *stacks.Handler
StatusHandler *status.Handler
TagHandler *tags.Handler
TeamMembershipHandler *teammemberships.Handler
TeamHandler *teams.Handler
TemplatesHandler *templates.Handler
UploadHandler *upload.Handler
UserHandler *users.Handler
WebSocketHandler *websocket.Handler
// StoridgeHandler *extensions.StoridgeHandler
// AzureHandler *azure.Handler
// DockerHandler *docker.Handler
}
// ServeHTTP delegates a request to the appropriate subhandler.
@ -79,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.StackHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/status"):
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/tags"):
http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/templates"):
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/upload"):

View File

@ -0,0 +1,31 @@
package tags
import (
"net/http"
"github.com/gorilla/mux"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
)
// Handler is the HTTP handler used to handle tag operations.
type Handler struct {
*mux.Router
TagService portainer.TagService
}
// NewHandler creates a handler to manage tag operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/tags",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagCreate))).Methods(http.MethodPost)
h.Handle("/tags",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagList))).Methods(http.MethodGet)
h.Handle("/tags/{id}",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.tagDelete))).Methods(http.MethodDelete)
return h
}

View File

@ -0,0 +1,53 @@
package tags
import (
"net/http"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/request"
"github.com/portainer/portainer/http/response"
)
type tagCreatePayload struct {
Name string
}
func (payload *tagCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return portainer.Error("Invalid tag name")
}
return nil
}
// POST request on /api/tags
func (handler *Handler) tagCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload tagCreatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
tags, err := handler.TagService.Tags()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err}
}
for _, tag := range tags {
if tag.Name == payload.Name {
return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a tag", portainer.ErrTagAlreadyExists}
}
}
tag := &portainer.Tag{
Name: payload.Name,
}
err = handler.TagService.CreateTag(tag)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the tag inside the database", err}
}
return response.JSON(w, tag)
}

View File

@ -0,0 +1,25 @@
package tags
import (
"net/http"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/request"
"github.com/portainer/portainer/http/response"
)
// DELETE request on /api/tags/:name
func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid tag identifier route variable", err}
}
err = handler.TagService.DeleteTag(portainer.TagID(id))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the tag from the database", err}
}
return response.Empty(w)
}

View File

@ -0,0 +1,18 @@
package tags
import (
"net/http"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/response"
)
// GET request on /api/tags
func (handler *Handler) tagList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
tags, err := handler.TagService.Tags()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve tags from the database", err}
}
return response.JSON(w, tags)
}

View File

@ -16,6 +16,7 @@ import (
"github.com/portainer/portainer/http/handler/settings"
"github.com/portainer/portainer/http/handler/stacks"
"github.com/portainer/portainer/http/handler/status"
"github.com/portainer/portainer/http/handler/tags"
"github.com/portainer/portainer/http/handler/teammemberships"
"github.com/portainer/portainer/http/handler/teams"
"github.com/portainer/portainer/http/handler/templates"
@ -36,24 +37,25 @@ type Server struct {
AuthDisabled bool
EndpointManagement bool
Status *portainer.Status
UserService portainer.UserService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
SignatureService portainer.DigitalSignatureService
DockerHubService portainer.DockerHubService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
FileService portainer.FileService
GitService portainer.GitService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
RegistryService portainer.RegistryService
ResourceControlService portainer.ResourceControlService
SettingsService portainer.SettingsService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
FileService portainer.FileService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
StackService portainer.StackService
SwarmStackManager portainer.SwarmStackManager
ComposeStackManager portainer.ComposeStackManager
LDAPService portainer.LDAPService
GitService portainer.GitService
SignatureService portainer.DigitalSignatureService
TagService portainer.TagService
TeamService portainer.TeamService
TeamMembershipService portainer.TeamMembershipService
UserService portainer.UserService
Handler *handler.Handler
SSL bool
SSLCert string
@ -126,6 +128,9 @@ func (server *Server) Start() error {
stackHandler.RegistryService = server.RegistryService
stackHandler.DockerHubService = server.DockerHubService
var tagHandler = tags.NewHandler(requestBouncer)
tagHandler.TagService = server.TagService
var teamHandler = teams.NewHandler(requestBouncer)
teamHandler.TeamService = server.TeamService
teamHandler.TeamMembershipService = server.TeamMembershipService
@ -164,6 +169,7 @@ func (server *Server) Start() error {
SettingsHandler: settingsHandler,
StatusHandler: statusHandler,
StackHandler: stackHandler,
TagHandler: tagHandler,
TeamHandler: teamHandler,
TeamMembershipHandler: teamMembershipHandler,
TemplatesHandler: templatesHandler,

View File

@ -190,6 +190,7 @@ type (
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Extensions []EndpointExtension `json:"Extensions"`
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
Tags []string `json:"Tags"`
// Deprecated fields
// Deprecated in DBVersion == 4
@ -217,6 +218,9 @@ type (
Description string `json:"Description"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Tags []string `json:"Tags"`
// Deprecated fields
Labels []Pair `json:"Labels"`
}
@ -264,6 +268,15 @@ type (
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
// TagID represents a tag identifier.
TagID int
// Tag represents a tag that can be associated to a resource.
Tag struct {
ID TagID
Name string `json:"Name"`
}
// ResourceAccessLevel represents the level of control associated to a resource.
ResourceAccessLevel int
@ -390,6 +403,13 @@ type (
DeleteResourceControl(ID ResourceControlID) error
}
// TagService represents a service for managing tag data.
TagService interface {
Tags() ([]Tag, error)
CreateTag(tag *Tag) error
DeleteTag(ID TagID) error
}
// CryptoService represents a service for encrypting/hashing data.
CryptoService interface {
Hash(data string) (string, error)
@ -463,7 +483,7 @@ const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.17.1-dev"
// DBVersion is the version number of the Portainer database.
DBVersion = 11
DBVersion = 12
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
// PortainerAgentHeader represents the name of the header available in any agent response

View File

@ -81,6 +81,8 @@ tags:
description: "Manage Docker stacks"
- name: "users"
description: "Manage users"
- name: "tags"
description: "Manage tags"
- name: "teams"
description: "Manage teams"
- name: "team_memberships"
@ -1958,6 +1960,99 @@ paths:
schema:
$ref: "#/definitions/GenericError"
/tags:
get:
tags:
- "tags"
summary: "List tags"
description: |
List tags.
**Access policy**: administrator
operationId: "TagList"
produces:
- "application/json"
parameters: []
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/TagListResponse"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
post:
tags:
- "tags"
summary: "Create a new tag"
description: |
Create a new tag.
**Access policy**: administrator
operationId: "TagCreate"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Tag details"
required: true
schema:
$ref: "#/definitions/TagCreateRequest"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/Tag"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
409:
description: "Conflict"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "A tag with the specified name already exists"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/tags/{id}:
delete:
tags:
- "tags"
summary: "Remove a tag"
description: |
Remove a tag.
**Access policy**: administrator
operationId: "TagDelete"
parameters:
- name: "id"
in: "path"
description: "Tag identifier"
required: true
type: "integer"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/teams:
get:
tags:
@ -2411,6 +2506,17 @@ securityDefinitions:
name: "Authorization"
in: "header"
definitions:
Tag:
type: "object"
properties:
Id:
type: "integer"
example: 1
description: "Tag identifier"
Name:
type: "string"
example: "org/acme"
description: "Tag name"
Team:
type: "object"
properties:
@ -3334,6 +3440,19 @@ definitions:
type: "boolean"
example: true
description: "Is the password valid"
TagListResponse:
type: "array"
items:
$ref: "#/definitions/Tag"
TagCreateRequest:
type: "object"
required:
- "Name"
properties:
Name:
type: "string"
example: "org/acme"
description: "Name"
TeamCreateRequest:
type: "object"
required:

View File

@ -9,6 +9,7 @@ angular.module('portainer')
.constant('API_ENDPOINT_STACKS', 'api/stacks')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_TAGS', 'api/tags')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')

View File

@ -296,6 +296,17 @@ angular.module('portainer.app', [])
}
};
var tags = {
name: 'portainer.tags',
url: '/tags',
views: {
'content@': {
templateUrl: 'app/portainer/views/tags/tags.html',
controller: 'TagsController'
}
}
};
var users = {
name: 'portainer.users',
url: '/users',
@ -366,6 +377,7 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(stack);
$stateRegistryProvider.register(stackCreation);
$stateRegistryProvider.register(support);
$stateRegistryProvider.register(tags);
$stateRegistryProvider.register(users);
$stateRegistryProvider.register(user);
$stateRegistryProvider.register(teams);

View File

@ -0,0 +1,84 @@
<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.titleText }}
</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>
</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>
</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>
{{ item.Name }}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="1" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="1" class="text-center text-muted">No tag 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,14 @@
angular.module('portainer.app').component('tagsDatatable', {
templateUrl: 'app/portainer/components/datatables/tags-datatable/tagsDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
showTextFilter: '<',
removeAction: '<'
}
});

View File

@ -21,6 +21,7 @@ angular.module('portainer.app').component('groupForm', {
bindings: {
model: '=',
availableEndpoints: '=',
availableTags: '<',
associatedEndpoints: '=',
addLabelAction: '<',
removeLabelAction: '<',

View File

@ -22,33 +22,17 @@
</div>
</div>
<!-- !description-input -->
<!-- labels -->
<div class="col-sm-12 form-section-title">
Metadata
</div>
<!-- tags -->
<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>
<tag-selector
tags="$ctrl.availableTags"
model="$ctrl.model.Tags"
></tag-selector>
</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 -->
<!-- !tags -->
<!-- endpoints -->
<div ng-if="$ctrl.model.Id !== 1">
<div class="col-sm-12 form-section-title">

View File

@ -0,0 +1,8 @@
angular.module('portainer.app').component('tagSelector', {
templateUrl: 'app/portainer/components/tag-selector/tagSelector.html',
controller: 'TagSelectorController',
bindings: {
tags: '<',
model: '='
}
});

View File

@ -0,0 +1,38 @@
<div ng-show="$ctrl.model.length > 0" class="col-sm-12" style="padding: 0; margin-bottom: 15px;">
<label class="col-sm-3 col-lg-2 control-label text-left">
Selected tags
</label>
<div class="col-sm-9 col-lg-10" style="padding-top: 4px;">
<span class="tag space-right interactive" ng-repeat="tag in $ctrl.model" ng-click="$ctrl.removeTag(tag)">
{{ tag }}
<a title="Remove tag" ng-click="$ctrl.removeTag(tag)" style="margin-left: 2px;">
<span class="fa fa-trash-alt white-icon" aria-hidden="true"></span>
</a>
</span>
</div>
</div>
<div class="col-sm-12" style="padding: 0">
<label for="tags" class="col-sm-3 col-lg-2 control-label text-left">
Tags
</label>
<div class="col-sm-9 col-lg-10" ng-if="$ctrl.tags.length > 0">
<input
type="text" ng-model="$ctrl.state.selectedValue"
id="tags" class="form-control"
placeholder="Select tags..."
uib-typeahead="tag for tag in $ctrl.tags | filter:$viewValue | limitTo:7"
typeahead-on-select="$ctrl.selectTag($item, $model, $label)"
typeahead-no-results="$ctrl.state.noResult"
typeahead-show-hint="true" typeahead-min-length="0" />
</div>
<div class="col-sm-9 col-lg-10" ng-if="$ctrl.tags.length === 0">
<span class="small text-muted">
No tags available.
</span>
</div>
</div>
<div class="col-sm-offset-3 col-lg-offset-2 col-sm-12" ng-if="$ctrl.state.noResult" style="margin-top: 2px;">
<span class="small text-muted">
No tags matching your filter.
</span>
</div>

View File

@ -0,0 +1,32 @@
angular.module('portainer.app')
.controller('TagSelectorController', function () {
var ctrl = this;
this.$onChanges = function(changes) {
if(angular.isDefined(changes.tags.currentValue)) {
this.tags = _.difference(changes.tags.currentValue, this.model);
}
};
this.state = {
selectedValue: '',
noResult: false
};
this.selectTag = function($item, $model, $label) {
this.state.selectedValue = '';
this.model.push($item);
this.tags = _.remove(this.tags, function(item) {
return item !== $item;
});
};
this.removeTag = function(tag) {
var idx = this.model.indexOf(tag);
if (idx > -1) {
this.model.splice(idx, 1);
this.tags.push(tag);
}
};
});

View File

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

View File

@ -0,0 +1,4 @@
function TagViewModel(data) {
this.Id = data.ID;
this.Name = data.Name;
}

View File

@ -0,0 +1,9 @@
angular.module('portainer.app')
.factory('Tags', ['$resource', 'API_ENDPOINT_TAGS', function TagsFactory($resource, API_ENDPOINT_TAGS) {
'use strict';
return $resource(API_ENDPOINT_TAGS + '/:id', {}, {
create: { method: 'POST' },
query: { method: 'GET', isArray: true },
remove: { method: 'DELETE', params: { id: '@id'} }
});
}]);

View File

@ -57,7 +57,7 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
service.createLocalEndpoint = function() {
var deferred = $q.defer();
FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, false)
FileUploadService.createEndpoint('local', 1, 'unix:///var/run/docker.sock', '', 1, [], false)
.then(function success(response) {
deferred.resolve(response.data);
})
@ -68,10 +68,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return deferred.promise;
};
service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
service.createRemoteEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
var deferred = $q.defer();
FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
FileUploadService.createEndpoint(name, type, 'tcp://' + URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success(response) {
deferred.resolve(response.data);
})
@ -82,10 +82,10 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return deferred.promise;
};
service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) {
service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey, groupId, tags) {
var deferred = $q.defer();
FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey)
FileUploadService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags)
.then(function success(response) {
deferred.resolve(response.data);
})

View File

@ -0,0 +1,49 @@
angular.module('portainer.app')
.factory('TagService', ['$q', 'Tags', function TagServiceFactory($q, Tags) {
'use strict';
var service = {};
service.tags = function() {
var deferred = $q.defer();
Tags.query().$promise
.then(function success(data) {
var tags = data.map(function (item) {
return new TagViewModel(item);
});
deferred.resolve(tags);
})
.catch(function error(err) {
deferred.reject({msg: 'Unable to retrieve tags', err: err});
});
return deferred.promise;
};
service.tagNames = function() {
var deferred = $q.defer();
Tags.query().$promise
.then(function success(data) {
var tags = data.map(function (item) {
return item.Name;
});
deferred.resolve(tags);
})
.catch(function error(err) {
deferred.reject({msg: 'Unable to retrieve tags', err: err});
});
return deferred.promise;
};
service.createTag = function(name) {
var payload = {
Name: name
};
return Tags.create({}, payload).$promise;
};
service.deleteTag = function(id) {
return Tags.remove({id: id}).$promise;
};
return service;
}]);

View File

@ -52,7 +52,7 @@ angular.module('portainer.app')
});
};
service.createEndpoint = function(name, type, URL, PublicURL, groupID, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
service.createEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
return Upload.upload({
url: 'api/endpoints',
data: {
@ -61,6 +61,7 @@ angular.module('portainer.app')
URL: URL,
PublicURL: PublicURL,
GroupID: groupID,
Tags: Upload.json(tags),
TLS: TLS,
TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify,
@ -72,12 +73,14 @@ angular.module('portainer.app')
});
};
service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey) {
service.createAzureEndpoint = function(name, applicationId, tenantId, authenticationKey, groupId, tags) {
return Upload.upload({
url: 'api/endpoints',
data: {
Name: name,
EndpointType: 3,
GroupID: groupID,
Tags: Upload.json(tags),
AzureApplicationID: applicationId,
AzureTenantID: tenantId,
AzureAuthenticationKey: authenticationKey

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('CreateEndpointController', ['$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'Notifications',
function ($scope, $state, $filter, EndpointService, GroupService, Notifications) {
.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'TagService', 'Notifications',
function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService, Notifications) {
$scope.state = {
EnvironmentType: 'docker',
@ -15,7 +15,8 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
SecurityFormData: new EndpointSecurityFormData(),
AzureApplicationId: '',
AzureTenantId: '',
AzureAuthenticationKey: ''
AzureAuthenticationKey: '',
Tags: []
};
$scope.addDockerEndpoint = function() {
@ -23,6 +24,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
var URL = $filter('stripprotocol')($scope.formValues.URL);
var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL;
var groupId = $scope.formValues.GroupId;
var tags = $scope.formValues.Tags;
var securityData = $scope.formValues.SecurityFormData;
var TLS = securityData.TLS;
@ -33,7 +35,7 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
var TLSCertFile = TLSSkipClientVerify ? null : securityData.TLSCert;
var TLSKeyFile = TLSSkipClientVerify ? null : securityData.TLSKey;
addEndpoint(name, 1, URL, publicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile);
addEndpoint(name, 1, URL, publicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile);
};
$scope.addAgentEndpoint = function() {
@ -41,8 +43,9 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
var URL = $filter('stripprotocol')($scope.formValues.URL);
var publicURL = $scope.formValues.PublicURL === '' ? URL.split(':')[0] : $scope.formValues.PublicURL;
var groupId = $scope.formValues.GroupId;
var tags = $scope.formValues.Tags;
addEndpoint(name, 2, URL, publicURL, groupId, true, true, true, null, null, null);
addEndpoint(name, 2, URL, publicURL, groupId, tags, true, true, true, null, null, null);
};
$scope.addAzureEndpoint = function() {
@ -50,15 +53,17 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
var applicationId = $scope.formValues.AzureApplicationId;
var tenantId = $scope.formValues.AzureTenantId;
var authenticationKey = $scope.formValues.AzureAuthenticationKey;
var groupId = $scope.formValues.GroupId;
var tags = $scope.formValues.Tags;
createAzureEndpoint(name, applicationId, tenantId, authenticationKey);
createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags);
};
function createAzureEndpoint(name, applicationId, tenantId, authenticationKey) {
function createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags) {
var endpoint;
$scope.state.actionInProgress = true;
EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey)
EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, groupId, tags)
.then(function success() {
Notifications.success('Endpoint created', name);
$state.go('portainer.endpoints', {}, {reload: true});
@ -71,9 +76,9 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
});
}
function addEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
function addEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, groupId, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success() {
Notifications.success('Endpoint created', name);
$state.go('portainer.endpoints', {}, {reload: true});
@ -87,9 +92,13 @@ function ($scope, $state, $filter, EndpointService, GroupService, Notifications)
}
function initView() {
GroupService.groups()
$q.all({
groups: GroupService.groups(),
tags: TagService.tagNames()
})
.then(function success(data) {
$scope.groups = data;
$scope.groups = data.groups;
$scope.availableTags = data.tags;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load groups');

View File

@ -192,6 +192,12 @@
<!-- !authenticationkey-input -->
</div>
<!-- !azure-details -->
<!-- endpoint-security -->
<por-endpoint-security ng-if="state.EnvironmentType === 'docker'" form-data="formValues.SecurityFormData"></por-endpoint-security>
<!-- !endpoint-security -->
<div class="col-sm-12 form-section-title">
Metadata
</div>
<!-- group -->
<div class="form-group">
<label for="endpoint_group" class="col-sm-3 col-lg-2 control-label text-left">
@ -202,9 +208,17 @@
</div>
</div>
<!-- !group -->
<!-- endpoint-security -->
<por-endpoint-security ng-if="state.EnvironmentType === 'docker'" form-data="formValues.SecurityFormData"></por-endpoint-security>
<!-- !endpoint-security -->
<!-- tags -->
<div class="form-group">
<tag-selector
tags="availableTags"
model="formValues.Tags"
></tag-selector>
</div>
<!-- !tags -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">

View File

@ -49,7 +49,7 @@
></azure-endpoint-config>
<!-- !endpoint-public-url-input -->
<div class="col-sm-12 form-section-title">
Grouping
Metadata
</div>
<!-- group -->
<div class="form-group">
@ -61,6 +61,14 @@
</div>
</div>
<!-- !group -->
<!-- tags -->
<div class="form-group">
<tag-selector
tags="availableTags"
model="endpoint.Tags"
></tag-selector>
</div>
<!-- !tags -->
<!-- endpoint-security -->
<div ng-if="endpointType === 'remote' && endpoint.Type !== 3">
<div class="col-sm-12 form-section-title">

View File

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'EndpointProvider', 'Notifications',
function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, EndpointProvider, Notifications) {
.controller('EndpointController', ['$q', '$scope', '$state', '$transition$', '$filter', 'EndpointService', 'GroupService', 'TagService', 'EndpointProvider', 'Notifications',
function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupService, TagService, EndpointProvider, Notifications) {
if (!$scope.applicationState.application.endpointManagement) {
$state.go('portainer.endpoints');
@ -27,6 +27,7 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi
Name: endpoint.Name,
PublicURL: endpoint.PublicURL,
GroupID: endpoint.GroupId,
Tags: endpoint.Tags,
TLS: TLS,
TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify,
@ -61,7 +62,8 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi
function initView() {
$q.all({
endpoint: EndpointService.endpoint($transition$.params().id),
groups: GroupService.groups()
groups: GroupService.groups(),
tags: TagService.tagNames()
})
.then(function success(data) {
var endpoint = data.endpoint;
@ -73,6 +75,7 @@ function ($q, $scope, $state, $transition$, $filter, EndpointService, GroupServi
endpoint.URL = $filter('stripprotocol')(endpoint.URL);
$scope.endpoint = endpoint;
$scope.groups = data.groups;
$scope.availableTags = data.tags;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint details');

View File

@ -1,19 +1,11 @@
angular.module('portainer.app')
.controller('CreateGroupController', ['$scope', '$state', 'GroupService', 'EndpointService', 'Notifications',
function ($scope, $state, GroupService, EndpointService, Notifications) {
.controller('CreateGroupController', ['$q', '$scope', '$state', 'GroupService', 'EndpointService', 'TagService', 'Notifications',
function ($q, $scope, $state, GroupService, EndpointService, TagService, 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;
@ -40,10 +32,14 @@ function ($scope, $state, GroupService, EndpointService, Notifications) {
function initView() {
$scope.model = new EndpointGroupDefaultModel();
EndpointService.endpointsByGroup(1)
$q.all({
endpoints: EndpointService.endpointsByGroup(1),
tags: TagService.tagNames()
})
.then(function success(data) {
$scope.availableEndpoints = data;
$scope.availableEndpoints = data.endpoints;
$scope.associatedEndpoints = [];
$scope.availableTags = data.tags;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');

View File

@ -12,6 +12,7 @@
<group-form
model="model"
available-endpoints="availableEndpoints"
available-tags="availableTags"
associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"

View File

@ -12,6 +12,7 @@
<group-form
model="group"
available-endpoints="availableEndpoints"
available-tags="availableTags"
associated-endpoints="associatedEndpoints"
add-label-action="addLabel"
remove-label-action="removeLabel"

View File

@ -1,19 +1,11 @@
angular.module('portainer.app')
.controller('GroupController', ['$q', '$scope', '$state', '$transition$', 'GroupService', 'EndpointService', 'Notifications',
function ($q, $scope, $state, $transition$, GroupService, EndpointService, Notifications) {
.controller('GroupController', ['$q', '$scope', '$state', '$transition$', 'GroupService', 'EndpointService', 'TagService', 'Notifications',
function ($q, $scope, $state, $transition$, GroupService, EndpointService, TagService, 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;
@ -42,7 +34,8 @@ function ($q, $scope, $state, $transition$, GroupService, EndpointService, Notif
$q.all({
group: GroupService.group(groupId),
endpoints: EndpointService.endpoints()
endpoints: EndpointService.endpoints(),
tags: TagService.tagNames()
})
.then(function success(data) {
$scope.group = data.group;
@ -60,6 +53,7 @@ function ($q, $scope, $state, $transition$, GroupService, EndpointService, Notif
$scope.availableEndpoints = availableEndpoints;
$scope.associatedEndpoints = associatedEndpoints;
$scope.availableTags = data.tags;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to load view');

View File

@ -90,7 +90,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
var endpoint;
$scope.state.actionInProgress = true;
EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey)
EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, 1, [])
.then(function success(data) {
endpoint = data;
EndpointProvider.setEndpointID(endpoint.Id);
@ -110,7 +110,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
function createRemoteEndpoint(name, type, URL, PublicURL, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
var endpoint;
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, [], TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success(data) {
endpoint = data;
EndpointProvider.setEndpointID(endpoint.Id);

View File

@ -49,9 +49,12 @@
</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')">
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'portainer.endpoints' || $state.current.name === 'portainer.endpoints.endpoint' || $state.current.name === 'portainer.endpoints.new' || $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' || $state.current.name === 'portainer.tags')">
<a ui-sref="portainer.groups" ui-sref-active="active">Groups</a>
</div>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'portainer.endpoints' || $state.current.name === 'portainer.endpoints.endpoint' || $state.current.name === 'portainer.endpoints.new' || $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' || $state.current.name === 'portainer.tags')">
<a ui-sref="portainer.tags" ui-sref-active="active">Tags</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

@ -0,0 +1,58 @@
<rd-header>
<rd-header-title title-text="Tags">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.tags" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Tag management</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-plus" title-text="Add a new tag">
</rd-widget-header>
<rd-widget-body>
<form class="form-horizontal" name="tagCreationForm" ng-submit="createTag()">
<!-- name-input -->
<div class="form-group">
<label for="name" class="col-sm-2 control-label text-left">
Name
</label>
<div class="col-sm-8">
<input type="text" class="form-control" name="name" ng-model="formValues.Name" placeholder="org/acme" required auto-focus />
</div>
</div>
<div class="form-group" ng-show="tagCreationForm.name.$invalid">
<div class="col-sm-12 small text-danger">
<div ng-messages="tagCreationForm.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 -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !tagCreationForm.$valid" ng-click="createTag()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress"><i class="fa fa-plus" aria-hidden="true"></i> Create tag</span>
<span ng-show="state.actionInProgress">Creating tag...</span>
</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<tags-datatable
title-text="Tags" title-icon="fa-tags"
dataset="tags" table-key="tags"
order-by="Name" show-text-filter="true"
remove-action="removeAction"
></tags-datatable>
</div>
</div>

View File

@ -0,0 +1,58 @@
angular.module('portainer.app')
.controller('TagsController', ['$scope', '$state', 'TagService', 'Notifications',
function ($scope, $state, TagService, Notifications) {
$scope.state = {
actionInProgress: false
};
$scope.formValues = {
Name: ''
};
$scope.removeAction = function (selectedItems) {
var actionCount = selectedItems.length;
angular.forEach(selectedItems, function (tag) {
TagService.deleteTag(tag.Id)
.then(function success() {
Notifications.success('Tag successfully removed', tag.Name);
var index = $scope.tags.indexOf(tag);
$scope.tags.splice(index, 1);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to tag');
})
.finally(function final() {
--actionCount;
if (actionCount === 0) {
$state.reload();
}
});
});
};
$scope.createTag = function() {
var tagName = $scope.formValues.Name;
TagService.createTag(tagName)
.then(function success(data) {
Notifications.success('Tag successfully created', tagName);
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create tag');
});
};
function initView() {
TagService.tags()
.then(function success(data) {
$scope.tags = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve tags');
$scope.tags = [];
});
}
initView();
}]);

View File

@ -729,6 +729,18 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
background-color: #C5CAE9;
}
.row.header .meta .page {
padding-top: 7px;
}
.tag {
padding: 2px 6px;
color: white;
background-color: #337ab7;
border: 1px solid #2e6da4;
border-radius: 4px;
}
/*bootbox override*/
.modal-open {
padding-right: 0 !important;
@ -787,12 +799,12 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
font-weight: 600;
}
/* json-tree */
/* json-tree override */
json-tree {
font-size: 13px;
color: #30426a;
}
json-tree .key {
color: #738bc0;
padding-right: 5px;
@ -803,7 +815,4 @@ json-tree .branch-preview {
font-size: 11px;
opacity: .5;
}
.row.header .meta .page {
padding-top: 7px;
}
/* !json-tree override */

View File

@ -55,7 +55,6 @@
"rdash-ui": "1.0.*",
"splitargs": "github:deviantony/splitargs#~0.2.0",
"toastr": "github:CodeSeven/toastr#~2.1.3",
"ui-select": "~0.19.6",
"xterm": "^3.1.0"
},
"devDependencies": {

View File

@ -21,7 +21,6 @@ css:
- 'bootstrap/dist/css/bootstrap.css'
- 'rdash-ui/dist/css/rdash.css'
- 'isteven-angular-multiselect/isteven-multi-select.css'
- 'ui-select/dist/select.css'
- '@fortawesome/fontawesome-free-webfonts/css/fa-brands.css'
- '@fortawesome/fontawesome-free-webfonts/css/fa-solid.css'
- '@fortawesome/fontawesome-free-webfonts/css/fontawesome.css'
@ -42,7 +41,6 @@ angular:
- 'angular-messages/angular-messages.js'
- 'angular-resource/angular-resource.js'
- 'angular-sanitize/angular-sanitize.js'
- 'ui-select/dist/select.js'
- '@uirouter/angularjs/release/angular-ui-router.js'
- 'angular-utils-pagination/dirPagination.js'
- 'ng-file-upload/dist/ng-file-upload.js'

View File

@ -4238,10 +4238,6 @@ uglify-to-browserify@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz#6e0924d6bda6b5afe349e39a6d632850a0f882b7"
ui-select@~0.19.6:
version "0.19.8"
resolved "https://registry.yarnpkg.com/ui-select/-/ui-select-0.19.8.tgz#74860848a7fd8bc494d9856d2f62776ea98637c1"
uid-safe@2.1.4:
version "2.1.4"
resolved "https://registry.yarnpkg.com/uid-safe/-/uid-safe-2.1.4.tgz#3ad6f38368c6d4c8c75ec17623fb79aa1d071d81"