mirror of
https://github.com/portainer/portainer.git
synced 2025-07-21 14:29:40 +02:00
feat(templates): introduce templates management (#2017)
This commit is contained in:
parent
e7939a5384
commit
61c285bd2e
63 changed files with 3489 additions and 637 deletions
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/portainer/portainer/bolt/tag"
|
"github.com/portainer/portainer/bolt/tag"
|
||||||
"github.com/portainer/portainer/bolt/team"
|
"github.com/portainer/portainer/bolt/team"
|
||||||
"github.com/portainer/portainer/bolt/teammembership"
|
"github.com/portainer/portainer/bolt/teammembership"
|
||||||
|
"github.com/portainer/portainer/bolt/template"
|
||||||
"github.com/portainer/portainer/bolt/user"
|
"github.com/portainer/portainer/bolt/user"
|
||||||
"github.com/portainer/portainer/bolt/version"
|
"github.com/portainer/portainer/bolt/version"
|
||||||
)
|
)
|
||||||
|
@ -43,6 +44,7 @@ type Store struct {
|
||||||
TagService *tag.Service
|
TagService *tag.Service
|
||||||
TeamMembershipService *teammembership.Service
|
TeamMembershipService *teammembership.Service
|
||||||
TeamService *team.Service
|
TeamService *team.Service
|
||||||
|
TemplateService *template.Service
|
||||||
UserService *user.Service
|
UserService *user.Service
|
||||||
VersionService *version.Service
|
VersionService *version.Service
|
||||||
}
|
}
|
||||||
|
@ -212,6 +214,12 @@ func (store *Store) initServices() error {
|
||||||
}
|
}
|
||||||
store.TeamService = teamService
|
store.TeamService = teamService
|
||||||
|
|
||||||
|
templateService, err := template.NewService(store.db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
store.TemplateService = templateService
|
||||||
|
|
||||||
userService, err := user.NewService(store.db)
|
userService, err := user.NewService(store.db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
95
api/bolt/template/template.go
Normal file
95
api/bolt/template/template.go
Normal file
|
@ -0,0 +1,95 @@
|
||||||
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/portainer/portainer/bolt/internal"
|
||||||
|
|
||||||
|
"github.com/boltdb/bolt"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// BucketName represents the name of the bucket where this service stores data.
|
||||||
|
BucketName = "templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service represents a service for managing endpoint data.
|
||||||
|
type Service struct {
|
||||||
|
db *bolt.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new instance of a service.
|
||||||
|
func NewService(db *bolt.DB) (*Service, error) {
|
||||||
|
err := internal.CreateBucket(db, BucketName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Service{
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Templates return an array containing all the templates.
|
||||||
|
func (service *Service) Templates() ([]portainer.Template, error) {
|
||||||
|
var templates = make([]portainer.Template, 0)
|
||||||
|
|
||||||
|
err := service.db.View(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
|
cursor := bucket.Cursor()
|
||||||
|
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||||
|
var template portainer.Template
|
||||||
|
err := internal.UnmarshalObject(v, &template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
templates = append(templates, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template returns a template by ID.
|
||||||
|
func (service *Service) Template(ID portainer.TemplateID) (*portainer.Template, error) {
|
||||||
|
var template portainer.Template
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
|
||||||
|
err := internal.GetObject(service.db, BucketName, identifier, &template)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &template, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTemplate creates a new template.
|
||||||
|
func (service *Service) CreateTemplate(template *portainer.Template) error {
|
||||||
|
return service.db.Update(func(tx *bolt.Tx) error {
|
||||||
|
bucket := tx.Bucket([]byte(BucketName))
|
||||||
|
|
||||||
|
id, _ := bucket.NextSequence()
|
||||||
|
template.ID = portainer.TemplateID(id)
|
||||||
|
|
||||||
|
data, err := internal.MarshalObject(template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket.Put(internal.Itob(int(template.ID)), data)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTemplate saves a template.
|
||||||
|
func (service *Service) UpdateTemplate(ID portainer.TemplateID, template *portainer.Template) error {
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
return internal.UpdateObject(service.db, BucketName, identifier, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTemplate deletes a template.
|
||||||
|
func (service *Service) DeleteTemplate(ID portainer.TemplateID) error {
|
||||||
|
identifier := internal.Itob(int(ID))
|
||||||
|
return internal.DeleteObject(service.db, BucketName, identifier)
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ const (
|
||||||
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
errInvalidEndpointProtocol = portainer.Error("Invalid endpoint protocol: Portainer only supports unix:// or tcp://")
|
||||||
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
errSocketNotFound = portainer.Error("Unable to locate Unix socket")
|
||||||
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
|
||||||
|
errTemplateFileNotFound = portainer.Error("Unable to locate template file on disk")
|
||||||
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
|
||||||
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
|
||||||
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
|
||||||
|
@ -50,7 +51,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
|
||||||
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
|
||||||
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
|
||||||
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
Logo: kingpin.Flag("logo", "URL for the logo displayed in the UI").String(),
|
||||||
Templates: kingpin.Flag("templates", "URL to the templates (apps) definitions").Short('t').String(),
|
Templates: kingpin.Flag("templates", "URL to the templates definitions.").Short('t').String(),
|
||||||
|
TemplateFile: kingpin.Flag("template-file", "Path to the templates (app) definitions on the filesystem").Default(defaultTemplateFile).String(),
|
||||||
}
|
}
|
||||||
|
|
||||||
kingpin.Parse()
|
kingpin.Parse()
|
||||||
|
@ -73,7 +75,12 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
|
||||||
return errEndpointExcludeExternal
|
return errEndpointExcludeExternal
|
||||||
}
|
}
|
||||||
|
|
||||||
err := validateEndpointURL(*flags.EndpointURL)
|
err := validateTemplateFile(*flags.TemplateFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateEndpointURL(*flags.EndpointURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
@ -130,6 +137,16 @@ func validateExternalEndpoints(externalEndpoints string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateTemplateFile(templateFile string) error {
|
||||||
|
if _, err := os.Stat(templateFile); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return errTemplateFileNotFound
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateSyncInterval(syncInterval string) error {
|
func validateSyncInterval(syncInterval string) error {
|
||||||
if syncInterval != defaultSyncInterval {
|
if syncInterval != defaultSyncInterval {
|
||||||
_, err := time.ParseDuration(syncInterval)
|
_, err := time.ParseDuration(syncInterval)
|
||||||
|
|
|
@ -17,4 +17,5 @@ const (
|
||||||
defaultSSLCertPath = "/certs/portainer.crt"
|
defaultSSLCertPath = "/certs/portainer.crt"
|
||||||
defaultSSLKeyPath = "/certs/portainer.key"
|
defaultSSLKeyPath = "/certs/portainer.key"
|
||||||
defaultSyncInterval = "60s"
|
defaultSyncInterval = "60s"
|
||||||
|
defaultTemplateFile = "/templates.json"
|
||||||
)
|
)
|
||||||
|
|
|
@ -15,4 +15,5 @@ const (
|
||||||
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
defaultSSLCertPath = "C:\\certs\\portainer.crt"
|
||||||
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
defaultSSLKeyPath = "C:\\certs\\portainer.key"
|
||||||
defaultSyncInterval = "60s"
|
defaultSyncInterval = "60s"
|
||||||
|
defaultTemplateFile = "C:\\templates.json"
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package main // import "github.com/portainer/portainer"
|
package main // import "github.com/portainer/portainer"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/portainer/portainer"
|
"github.com/portainer/portainer"
|
||||||
|
@ -144,7 +145,6 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||||
if err == portainer.ErrObjectNotFound {
|
if err == portainer.ErrObjectNotFound {
|
||||||
settings := &portainer.Settings{
|
settings := &portainer.Settings{
|
||||||
LogoURL: *flags.Logo,
|
LogoURL: *flags.Logo,
|
||||||
DisplayExternalContributors: false,
|
|
||||||
AuthenticationMethod: portainer.AuthenticationInternal,
|
AuthenticationMethod: portainer.AuthenticationInternal,
|
||||||
LDAPSettings: portainer.LDAPSettings{
|
LDAPSettings: portainer.LDAPSettings{
|
||||||
TLSConfig: portainer.TLSConfiguration{},
|
TLSConfig: portainer.TLSConfiguration{},
|
||||||
|
@ -156,12 +156,6 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||||
AllowPrivilegedModeForRegularUsers: true,
|
AllowPrivilegedModeForRegularUsers: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
if *flags.Templates != "" {
|
|
||||||
settings.TemplatesURL = *flags.Templates
|
|
||||||
} else {
|
|
||||||
settings.TemplatesURL = portainer.DefaultTemplatesURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if *flags.Labels != nil {
|
if *flags.Labels != nil {
|
||||||
settings.BlackListedLabels = *flags.Labels
|
settings.BlackListedLabels = *flags.Labels
|
||||||
} else {
|
} else {
|
||||||
|
@ -176,6 +170,58 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initTemplates(templateService portainer.TemplateService, fileService portainer.FileService, templateURL, templateFile string) error {
|
||||||
|
|
||||||
|
existingTemplates, err := templateService.Templates()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(existingTemplates) != 0 {
|
||||||
|
log.Printf("Templates already registered inside the database. Skipping template import.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var templatesJSON []byte
|
||||||
|
if templateURL == "" {
|
||||||
|
return loadTemplatesFromFile(fileService, templateService, templateFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
templatesJSON, err = client.Get(templateURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve templates via HTTP")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return unmarshalAndPersistTemplates(templateService, templatesJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTemplatesFromFile(fileService portainer.FileService, templateService portainer.TemplateService, templateFile string) error {
|
||||||
|
templatesJSON, err := fileService.GetFileContent(templateFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to retrieve template via filesystem")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return unmarshalAndPersistTemplates(templateService, templatesJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarshalAndPersistTemplates(templateService portainer.TemplateService, templateData []byte) error {
|
||||||
|
var templates []portainer.Template
|
||||||
|
err := json.Unmarshal(templateData, &templates)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("Unable to parse templates file. Please review your template definition file.")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, template := range templates {
|
||||||
|
err := templateService.CreateTemplate(&template)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
|
func retrieveFirstEndpointFromDatabase(endpointService portainer.EndpointService) *portainer.Endpoint {
|
||||||
endpoints, err := endpointService.Endpoints()
|
endpoints, err := endpointService.Endpoints()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -334,6 +380,11 @@ func main() {
|
||||||
|
|
||||||
composeStackManager := initComposeStackManager(*flags.Data)
|
composeStackManager := initComposeStackManager(*flags.Data)
|
||||||
|
|
||||||
|
err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
err = initSettings(store.SettingsService, flags)
|
err = initSettings(store.SettingsService, flags)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
|
@ -357,7 +408,7 @@ func main() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
adminPasswordHash, err = cryptoService.Hash(content)
|
adminPasswordHash, err = cryptoService.Hash(string(content))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
@ -404,6 +455,7 @@ func main() {
|
||||||
DockerHubService: store.DockerHubService,
|
DockerHubService: store.DockerHubService,
|
||||||
StackService: store.StackService,
|
StackService: store.StackService,
|
||||||
TagService: store.TagService,
|
TagService: store.TagService,
|
||||||
|
TemplateService: store.TemplateService,
|
||||||
SwarmStackManager: swarmStackManager,
|
SwarmStackManager: swarmStackManager,
|
||||||
ComposeStackManager: composeStackManager,
|
ComposeStackManager: composeStackManager,
|
||||||
CryptoService: cryptoService,
|
CryptoService: cryptoService,
|
||||||
|
|
|
@ -169,7 +169,7 @@ func (manager *SwarmStackManager) retrieveConfigurationFromDisk(path string) (ma
|
||||||
return make(map[string]interface{}), nil
|
return make(map[string]interface{}), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal([]byte(raw), &config)
|
err = json.Unmarshal(raw, &config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,14 +176,14 @@ func (service *Service) DeleteTLSFile(folder string, fileType portainer.TLSFileT
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFileContent returns a string content from file.
|
// GetFileContent returns the content of a file as bytes.
|
||||||
func (service *Service) GetFileContent(filePath string) (string, error) {
|
func (service *Service) GetFileContent(filePath string) ([]byte, error) {
|
||||||
content, err := ioutil.ReadFile(filePath)
|
content, err := ioutil.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return string(content), nil
|
return content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename renames a file or directory
|
// Rename renames a file or directory
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -61,6 +62,27 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain
|
||||||
return &token, nil
|
return &token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get executes a simple HTTP GET to the specified URL and returns
|
||||||
|
// the content of the response body.
|
||||||
|
func Get(url string) ([]byte, error) {
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: time.Second * 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(response.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
|
// ExecutePingOperation will send a SystemPing operation HTTP request to a Docker environment
|
||||||
// using the specified host and optional TLS configuration.
|
// using the specified host and optional TLS configuration.
|
||||||
// It uses a new Http.Client for each operation.
|
// It uses a new Http.Client for each operation.
|
||||||
|
|
|
@ -10,7 +10,6 @@ import (
|
||||||
|
|
||||||
type publicSettingsResponse struct {
|
type publicSettingsResponse struct {
|
||||||
LogoURL string `json:"LogoURL"`
|
LogoURL string `json:"LogoURL"`
|
||||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
|
||||||
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
|
||||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||||
|
@ -25,7 +24,6 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
|
||||||
|
|
||||||
publicSettings := &publicSettingsResponse{
|
publicSettings := &publicSettingsResponse{
|
||||||
LogoURL: settings.LogoURL,
|
LogoURL: settings.LogoURL,
|
||||||
DisplayExternalContributors: settings.DisplayExternalContributors,
|
|
||||||
AuthenticationMethod: settings.AuthenticationMethod,
|
AuthenticationMethod: settings.AuthenticationMethod,
|
||||||
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
|
||||||
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
|
||||||
|
|
|
@ -12,10 +12,8 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type settingsUpdatePayload struct {
|
type settingsUpdatePayload struct {
|
||||||
TemplatesURL string
|
|
||||||
LogoURL string
|
LogoURL string
|
||||||
BlackListedLabels []portainer.Pair
|
BlackListedLabels []portainer.Pair
|
||||||
DisplayExternalContributors bool
|
|
||||||
AuthenticationMethod int
|
AuthenticationMethod int
|
||||||
LDAPSettings portainer.LDAPSettings
|
LDAPSettings portainer.LDAPSettings
|
||||||
AllowBindMountsForRegularUsers bool
|
AllowBindMountsForRegularUsers bool
|
||||||
|
@ -23,9 +21,6 @@ type settingsUpdatePayload struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||||
if govalidator.IsNull(payload.TemplatesURL) || !govalidator.IsURL(payload.TemplatesURL) {
|
|
||||||
return portainer.Error("Invalid templates URL. Must correspond to a valid URL format")
|
|
||||||
}
|
|
||||||
if payload.AuthenticationMethod == 0 {
|
if payload.AuthenticationMethod == 0 {
|
||||||
return portainer.Error("Invalid authentication method")
|
return portainer.Error("Invalid authentication method")
|
||||||
}
|
}
|
||||||
|
@ -47,10 +42,8 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
|
||||||
}
|
}
|
||||||
|
|
||||||
settings := &portainer.Settings{
|
settings := &portainer.Settings{
|
||||||
TemplatesURL: payload.TemplatesURL,
|
|
||||||
LogoURL: payload.LogoURL,
|
LogoURL: payload.LogoURL,
|
||||||
BlackListedLabels: payload.BlackListedLabels,
|
BlackListedLabels: payload.BlackListedLabels,
|
||||||
DisplayExternalContributors: payload.DisplayExternalContributors,
|
|
||||||
LDAPSettings: payload.LDAPSettings,
|
LDAPSettings: payload.LDAPSettings,
|
||||||
AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers,
|
AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers,
|
||||||
AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers,
|
AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers,
|
||||||
|
|
|
@ -54,5 +54,5 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, &stackFileResponse{StackFileContent: stackFileContent})
|
return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)})
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/portainer/portainer/http/response"
|
"github.com/portainer/portainer/http/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DELETE request on /api/tags/:name
|
// DELETE request on /api/tags/:id
|
||||||
func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) tagDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -9,14 +9,10 @@ import (
|
||||||
"github.com/portainer/portainer/http/security"
|
"github.com/portainer/portainer/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler represents an HTTP API handler for managing templates.
|
// Handler represents an HTTP API handler for managing templates.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
SettingsService portainer.SettingsService
|
TemplateService portainer.TemplateService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler returns a new instance of Handler.
|
// NewHandler returns a new instance of Handler.
|
||||||
|
@ -25,6 +21,14 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
Router: mux.NewRouter(),
|
Router: mux.NewRouter(),
|
||||||
}
|
}
|
||||||
h.Handle("/templates",
|
h.Handle("/templates",
|
||||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
bouncer.RestrictedAccess(httperror.LoggerHandler(h.templateList))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/templates",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateCreate))).Methods(http.MethodPost)
|
||||||
|
h.Handle("/templates/{id}",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateInspect))).Methods(http.MethodGet)
|
||||||
|
h.Handle("/templates/{id}",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateUpdate))).Methods(http.MethodPut)
|
||||||
|
h.Handle("/templates/{id}",
|
||||||
|
bouncer.AdministratorAccess(httperror.LoggerHandler(h.templateDelete))).Methods(http.MethodDelete)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
122
api/http/handler/templates/template_create.go
Normal file
122
api/http/handler/templates/template_create.go
Normal file
|
@ -0,0 +1,122 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/asaskevich/govalidator"
|
||||||
|
"github.com/portainer/portainer"
|
||||||
|
"github.com/portainer/portainer/filesystem"
|
||||||
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
|
"github.com/portainer/portainer/http/request"
|
||||||
|
"github.com/portainer/portainer/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateCreatePayload struct {
|
||||||
|
// Mandatory
|
||||||
|
Type int
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
AdministratorOnly bool
|
||||||
|
|
||||||
|
// Opt stack/container
|
||||||
|
Name string
|
||||||
|
Logo string
|
||||||
|
Note string
|
||||||
|
Platform string
|
||||||
|
Categories []string
|
||||||
|
Env []portainer.TemplateEnv
|
||||||
|
|
||||||
|
// Mandatory container
|
||||||
|
Image string
|
||||||
|
|
||||||
|
// Mandatory stack
|
||||||
|
Repository portainer.TemplateRepository
|
||||||
|
|
||||||
|
// Opt container
|
||||||
|
Registry string
|
||||||
|
Command string
|
||||||
|
Network string
|
||||||
|
Volumes []portainer.TemplateVolume
|
||||||
|
Ports []string
|
||||||
|
Labels []portainer.Pair
|
||||||
|
Privileged bool
|
||||||
|
Interactive bool
|
||||||
|
RestartPolicy string
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *templateCreatePayload) Validate(r *http.Request) error {
|
||||||
|
if payload.Type == 0 || (payload.Type != 1 && payload.Type != 2 && payload.Type != 3) {
|
||||||
|
return portainer.Error("Invalid template type. Valid values are: 1 (container), 2 (Swarm stack template) or 3 (Compose stack template).")
|
||||||
|
}
|
||||||
|
if govalidator.IsNull(payload.Title) {
|
||||||
|
return portainer.Error("Invalid template title")
|
||||||
|
}
|
||||||
|
if govalidator.IsNull(payload.Description) {
|
||||||
|
return portainer.Error("Invalid template description")
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Type == 1 {
|
||||||
|
if govalidator.IsNull(payload.Image) {
|
||||||
|
return portainer.Error("Invalid template image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Type == 2 || payload.Type == 3 {
|
||||||
|
if govalidator.IsNull(payload.Repository.URL) {
|
||||||
|
return portainer.Error("Invalid template repository URL")
|
||||||
|
}
|
||||||
|
if govalidator.IsNull(payload.Repository.StackFile) {
|
||||||
|
payload.Repository.StackFile = filesystem.ComposeFileDefaultName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST request on /api/templates
|
||||||
|
func (handler *Handler) templateCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
var payload templateCreatePayload
|
||||||
|
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &portainer.Template{
|
||||||
|
Type: portainer.TemplateType(payload.Type),
|
||||||
|
Title: payload.Title,
|
||||||
|
Description: payload.Description,
|
||||||
|
AdministratorOnly: payload.AdministratorOnly,
|
||||||
|
Name: payload.Name,
|
||||||
|
Logo: payload.Logo,
|
||||||
|
Note: payload.Note,
|
||||||
|
Platform: payload.Platform,
|
||||||
|
Categories: payload.Categories,
|
||||||
|
Env: payload.Env,
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.Type == portainer.ContainerTemplate {
|
||||||
|
template.Image = payload.Image
|
||||||
|
template.Registry = payload.Registry
|
||||||
|
template.Command = payload.Command
|
||||||
|
template.Network = payload.Network
|
||||||
|
template.Volumes = payload.Volumes
|
||||||
|
template.Ports = payload.Ports
|
||||||
|
template.Labels = payload.Labels
|
||||||
|
template.Privileged = payload.Privileged
|
||||||
|
template.Interactive = payload.Interactive
|
||||||
|
template.RestartPolicy = payload.RestartPolicy
|
||||||
|
template.Hostname = payload.Hostname
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate {
|
||||||
|
template.Repository = payload.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.TemplateService.CreateTemplate(template)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the template inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, template)
|
||||||
|
}
|
25
api/http/handler/templates/template_delete.go
Normal file
25
api/http/handler/templates/template_delete.go
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
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/templates/:id
|
||||||
|
func (handler *Handler) templateDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.TemplateService.DeleteTemplate(portainer.TemplateID(id))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the template from the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Empty(w)
|
||||||
|
}
|
27
api/http/handler/templates/template_inspect.go
Normal file
27
api/http/handler/templates/template_inspect.go
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET request on /api/templates/:id
|
||||||
|
func (handler *Handler) templateInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
templateID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := handler.TemplateService.Template(portainer.TemplateID(templateID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, template)
|
||||||
|
}
|
|
@ -1,50 +1,26 @@
|
||||||
package templates
|
package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/portainer/http/error"
|
httperror "github.com/portainer/portainer/http/error"
|
||||||
"github.com/portainer/portainer/http/request"
|
|
||||||
"github.com/portainer/portainer/http/response"
|
"github.com/portainer/portainer/http/response"
|
||||||
|
"github.com/portainer/portainer/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GET request on /api/templates?key=<key>
|
// GET request on /api/templates
|
||||||
func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
key, err := request.RetrieveQueryParameter(r, "key", false)
|
templates, err := handler.TemplateService.Templates()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: key", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
templatesURL, templateErr := handler.retrieveTemplateURLFromKey(key)
|
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||||
if templateErr != nil {
|
|
||||||
return templateErr
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := http.Get(templatesURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve templates via the network", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
body, err := ioutil.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to read template response", err}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Bytes(w, body, "application/json")
|
filteredTemplates := security.FilterTemplates(templates, securityContext)
|
||||||
}
|
|
||||||
|
|
||||||
func (handler *Handler) retrieveTemplateURLFromKey(key string) (string, *httperror.HandlerError) {
|
return response.JSON(w, filteredTemplates)
|
||||||
switch key {
|
|
||||||
case "containers":
|
|
||||||
settings, err := handler.SettingsService.Settings()
|
|
||||||
if err != nil {
|
|
||||||
return "", &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
|
|
||||||
}
|
|
||||||
return settings.TemplatesURL, nil
|
|
||||||
case "linuxserver.io":
|
|
||||||
return containerTemplatesURLLinuxServerIo, nil
|
|
||||||
}
|
|
||||||
return "", &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: key. Value must be one of: containers or linuxserver.io", request.ErrInvalidQueryParameter}
|
|
||||||
}
|
}
|
||||||
|
|
164
api/http/handler/templates/template_update.go
Normal file
164
api/http/handler/templates/template_update.go
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
package templates
|
||||||
|
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type templateUpdatePayload struct {
|
||||||
|
Title *string
|
||||||
|
Description *string
|
||||||
|
AdministratorOnly *bool
|
||||||
|
Name *string
|
||||||
|
Logo *string
|
||||||
|
Note *string
|
||||||
|
Platform *string
|
||||||
|
Categories []string
|
||||||
|
Env []portainer.TemplateEnv
|
||||||
|
Image *string
|
||||||
|
Registry *string
|
||||||
|
Repository portainer.TemplateRepository
|
||||||
|
Command *string
|
||||||
|
Network *string
|
||||||
|
Volumes []portainer.TemplateVolume
|
||||||
|
Ports []string
|
||||||
|
Labels []portainer.Pair
|
||||||
|
Privileged *bool
|
||||||
|
Interactive *bool
|
||||||
|
RestartPolicy *string
|
||||||
|
Hostname *string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (payload *templateUpdatePayload) Validate(r *http.Request) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT request on /api/templates/:id
|
||||||
|
func (handler *Handler) templateUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||||
|
templateID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid template identifier route variable", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
template, err := handler.TemplateService.Template(portainer.TemplateID(templateID))
|
||||||
|
if err == portainer.ErrObjectNotFound {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
} else if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a template with the specified identifier inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload templateUpdatePayload
|
||||||
|
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplate(template, &payload)
|
||||||
|
|
||||||
|
err = handler.TemplateService.UpdateTemplate(template.ID, template)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to persist template changes inside the database", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.JSON(w, template)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateContainerProperties(template *portainer.Template, payload *templateUpdatePayload) {
|
||||||
|
if payload.Image != nil {
|
||||||
|
template.Image = *payload.Image
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Registry != nil {
|
||||||
|
template.Registry = *payload.Registry
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Command != nil {
|
||||||
|
template.Command = *payload.Command
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Network != nil {
|
||||||
|
template.Network = *payload.Network
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Volumes != nil {
|
||||||
|
template.Volumes = payload.Volumes
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Ports != nil {
|
||||||
|
template.Ports = payload.Ports
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Labels != nil {
|
||||||
|
template.Labels = payload.Labels
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Privileged != nil {
|
||||||
|
template.Privileged = *payload.Privileged
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Interactive != nil {
|
||||||
|
template.Interactive = *payload.Interactive
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.RestartPolicy != nil {
|
||||||
|
template.RestartPolicy = *payload.RestartPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Hostname != nil {
|
||||||
|
template.Hostname = *payload.Hostname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStackProperties(template *portainer.Template, payload *templateUpdatePayload) {
|
||||||
|
if payload.Repository.URL != "" && payload.Repository.StackFile != "" {
|
||||||
|
template.Repository = payload.Repository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateTemplate(template *portainer.Template, payload *templateUpdatePayload) {
|
||||||
|
if payload.Title != nil {
|
||||||
|
template.Title = *payload.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Description != nil {
|
||||||
|
template.Description = *payload.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Name != nil {
|
||||||
|
template.Name = *payload.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Logo != nil {
|
||||||
|
template.Logo = *payload.Logo
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Note != nil {
|
||||||
|
template.Note = *payload.Note
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Platform != nil {
|
||||||
|
template.Platform = *payload.Platform
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Categories != nil {
|
||||||
|
template.Categories = payload.Categories
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Env != nil {
|
||||||
|
template.Env = payload.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.AdministratorOnly != nil {
|
||||||
|
template.AdministratorOnly = *payload.AdministratorOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
if template.Type == portainer.ContainerTemplate {
|
||||||
|
updateContainerProperties(template, payload)
|
||||||
|
} else if template.Type == portainer.SwarmStackTemplate || template.Type == portainer.ComposeStackTemplate {
|
||||||
|
updateStackProperties(template, payload)
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,10 +23,3 @@ func Empty(rw http.ResponseWriter) *httperror.HandlerError {
|
||||||
rw.WriteHeader(http.StatusNoContent)
|
rw.WriteHeader(http.StatusNoContent)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bytes write data into rw. It also allows to set the Content-Type header.
|
|
||||||
func Bytes(rw http.ResponseWriter, data []byte, contentType string) *httperror.HandlerError {
|
|
||||||
rw.Header().Set("Content-Type", contentType)
|
|
||||||
rw.Write(data)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
|
@ -77,6 +77,24 @@ func FilterRegistries(registries []portainer.Registry, context *RestrictedReques
|
||||||
return filteredRegistries
|
return filteredRegistries
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FilterTemplates filters templates based on the user role.
|
||||||
|
// Non-administrato template do not have access to templates where the AdministratorOnly flag is set to true.
|
||||||
|
func FilterTemplates(templates []portainer.Template, context *RestrictedRequestContext) []portainer.Template {
|
||||||
|
filteredTemplates := templates
|
||||||
|
|
||||||
|
if !context.IsAdmin {
|
||||||
|
filteredTemplates = make([]portainer.Template, 0)
|
||||||
|
|
||||||
|
for _, template := range templates {
|
||||||
|
if !template.AdministratorOnly {
|
||||||
|
filteredTemplates = append(filteredTemplates, template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredTemplates
|
||||||
|
}
|
||||||
|
|
||||||
// FilterEndpoints filters endpoints based on user role and team memberships.
|
// FilterEndpoints filters endpoints based on user role and team memberships.
|
||||||
// Non administrator users only have access to authorized endpoints (can be inherited via endoint groups).
|
// 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 {
|
func FilterEndpoints(endpoints []portainer.Endpoint, groups []portainer.EndpointGroup, context *RestrictedRequestContext) []portainer.Endpoint {
|
||||||
|
|
|
@ -55,6 +55,7 @@ type Server struct {
|
||||||
TagService portainer.TagService
|
TagService portainer.TagService
|
||||||
TeamService portainer.TeamService
|
TeamService portainer.TeamService
|
||||||
TeamMembershipService portainer.TeamMembershipService
|
TeamMembershipService portainer.TeamMembershipService
|
||||||
|
TemplateService portainer.TemplateService
|
||||||
UserService portainer.UserService
|
UserService portainer.UserService
|
||||||
Handler *handler.Handler
|
Handler *handler.Handler
|
||||||
SSL bool
|
SSL bool
|
||||||
|
@ -143,7 +144,7 @@ func (server *Server) Start() error {
|
||||||
var statusHandler = status.NewHandler(requestBouncer, server.Status)
|
var statusHandler = status.NewHandler(requestBouncer, server.Status)
|
||||||
|
|
||||||
var templatesHandler = templates.NewHandler(requestBouncer)
|
var templatesHandler = templates.NewHandler(requestBouncer)
|
||||||
templatesHandler.SettingsService = server.SettingsService
|
templatesHandler.TemplateService = server.TemplateService
|
||||||
|
|
||||||
var uploadHandler = upload.NewHandler(requestBouncer)
|
var uploadHandler = upload.NewHandler(requestBouncer)
|
||||||
uploadHandler.FileService = server.FileService
|
uploadHandler.FileService = server.FileService
|
||||||
|
|
101
api/portainer.go
101
api/portainer.go
|
@ -21,6 +21,7 @@ type (
|
||||||
NoAuth *bool
|
NoAuth *bool
|
||||||
NoAnalytics *bool
|
NoAnalytics *bool
|
||||||
Templates *string
|
Templates *string
|
||||||
|
TemplateFile *string
|
||||||
TLS *bool
|
TLS *bool
|
||||||
TLSSkipVerify *bool
|
TLSSkipVerify *bool
|
||||||
TLSCacert *string
|
TLSCacert *string
|
||||||
|
@ -68,16 +69,16 @@ type (
|
||||||
|
|
||||||
// Settings represents the application settings.
|
// Settings represents the application settings.
|
||||||
Settings struct {
|
Settings struct {
|
||||||
TemplatesURL string `json:"TemplatesURL"`
|
|
||||||
LogoURL string `json:"LogoURL"`
|
LogoURL string `json:"LogoURL"`
|
||||||
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
BlackListedLabels []Pair `json:"BlackListedLabels"`
|
||||||
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
|
|
||||||
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
|
||||||
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
LDAPSettings LDAPSettings `json:"LDAPSettings"`
|
||||||
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
|
||||||
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
|
||||||
// Deprecated fields
|
// Deprecated fields
|
||||||
DisplayDonationHeader bool
|
DisplayDonationHeader bool
|
||||||
|
DisplayExternalContributors bool
|
||||||
|
TemplatesURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
// User represents a user account.
|
// User represents a user account.
|
||||||
|
@ -277,6 +278,79 @@ type (
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TemplateID represents a template identifier.
|
||||||
|
TemplateID int
|
||||||
|
|
||||||
|
// TemplateType represents the type of a template.
|
||||||
|
TemplateType int
|
||||||
|
|
||||||
|
// Template represents an application template.
|
||||||
|
Template struct {
|
||||||
|
// Mandatory container/stack fields
|
||||||
|
ID TemplateID `json:"Id"`
|
||||||
|
Type TemplateType `json:"type"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
AdministratorOnly bool `json:"administrator_only"`
|
||||||
|
|
||||||
|
// Mandatory container fields
|
||||||
|
Image string `json:"image"`
|
||||||
|
|
||||||
|
// Mandatory stack fields
|
||||||
|
Repository TemplateRepository `json:"repository"`
|
||||||
|
|
||||||
|
// Optional stack/container fields
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Logo string `json:"logo,omitempty"`
|
||||||
|
Env []TemplateEnv `json:"env,omitempty"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
Categories []string `json:"categories,omitempty"`
|
||||||
|
|
||||||
|
// Optional container fields
|
||||||
|
Registry string `json:"registry,omitempty"`
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Network string `json:"network,omitempty"`
|
||||||
|
Volumes []TemplateVolume `json:"volumes,omitempty"`
|
||||||
|
Ports []string `json:"ports,omitempty"`
|
||||||
|
Labels []Pair `json:"labels,omitempty"`
|
||||||
|
Privileged bool `json:"privileged,omitempty"`
|
||||||
|
Interactive bool `json:"interactive,omitempty"`
|
||||||
|
RestartPolicy string `json:"restart_policy,omitempty"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateEnv represents a template environment variable configuration.
|
||||||
|
TemplateEnv struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label,omitempty"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Default string `json:"default,omitempty"`
|
||||||
|
Preset bool `json:"preset,omitempty"`
|
||||||
|
Select []TemplateEnvSelect `json:"select,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateVolume represents a template volume configuration.
|
||||||
|
TemplateVolume struct {
|
||||||
|
Container string `json:"container"`
|
||||||
|
Bind string `json:"bind,omitempty"`
|
||||||
|
ReadOnly bool `json:"readonly,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateRepository represents the git repository configuration for a template.
|
||||||
|
TemplateRepository struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
StackFile string `json:"stackfile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemplateEnvSelect represents text/value pair that will be displayed as a choice for the
|
||||||
|
// template user.
|
||||||
|
TemplateEnvSelect struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Default bool `json:"default"`
|
||||||
|
}
|
||||||
|
|
||||||
// ResourceAccessLevel represents the level of control associated to a resource.
|
// ResourceAccessLevel represents the level of control associated to a resource.
|
||||||
ResourceAccessLevel int
|
ResourceAccessLevel int
|
||||||
|
|
||||||
|
@ -411,6 +485,15 @@ type (
|
||||||
DeleteTag(ID TagID) error
|
DeleteTag(ID TagID) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TemplateService represents a service for managing template data.
|
||||||
|
TemplateService interface {
|
||||||
|
Templates() ([]Template, error)
|
||||||
|
Template(ID TemplateID) (*Template, error)
|
||||||
|
CreateTemplate(template *Template) error
|
||||||
|
UpdateTemplate(ID TemplateID, template *Template) error
|
||||||
|
DeleteTemplate(ID TemplateID) error
|
||||||
|
}
|
||||||
|
|
||||||
// CryptoService represents a service for encrypting/hashing data.
|
// CryptoService represents a service for encrypting/hashing data.
|
||||||
CryptoService interface {
|
CryptoService interface {
|
||||||
Hash(data string) (string, error)
|
Hash(data string) (string, error)
|
||||||
|
@ -434,7 +517,7 @@ type (
|
||||||
|
|
||||||
// FileService represents a service for managing files.
|
// FileService represents a service for managing files.
|
||||||
FileService interface {
|
FileService interface {
|
||||||
GetFileContent(filePath string) (string, error)
|
GetFileContent(filePath string) ([]byte, error)
|
||||||
Rename(oldPath, newPath string) error
|
Rename(oldPath, newPath string) error
|
||||||
RemoveDirectory(directoryPath string) error
|
RemoveDirectory(directoryPath string) error
|
||||||
StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
|
StoreTLSFileFromBytes(folder string, fileType TLSFileType, data []byte) (string, error)
|
||||||
|
@ -487,8 +570,6 @@ const (
|
||||||
APIVersion = "1.18.2-dev"
|
APIVersion = "1.18.2-dev"
|
||||||
// DBVersion is the version number of the Portainer database.
|
// DBVersion is the version number of the Portainer database.
|
||||||
DBVersion = 12
|
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
|
// PortainerAgentHeader represents the name of the header available in any agent response
|
||||||
PortainerAgentHeader = "Portainer-Agent"
|
PortainerAgentHeader = "Portainer-Agent"
|
||||||
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
|
// PortainerAgentTargetHeader represent the name of the header containing the target node name.
|
||||||
|
@ -582,3 +663,13 @@ const (
|
||||||
// DockerComposeStack represents a stack managed via docker-compose
|
// DockerComposeStack represents a stack managed via docker-compose
|
||||||
DockerComposeStack
|
DockerComposeStack
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
_ TemplateType = iota
|
||||||
|
// ContainerTemplate represents a container template
|
||||||
|
ContainerTemplate
|
||||||
|
// SwarmStackTemplate represents a template used to deploy a Swarm stack
|
||||||
|
SwarmStackTemplate
|
||||||
|
// ComposeStackTemplate represents a template used to deploy a Compose stack
|
||||||
|
ComposeStackTemplate
|
||||||
|
)
|
||||||
|
|
529
api/swagger.yaml
529
api/swagger.yaml
|
@ -2530,32 +2530,189 @@ paths:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
- "templates"
|
- "templates"
|
||||||
summary: "Retrieve App templates"
|
summary: "List available templates"
|
||||||
description: |
|
description: |
|
||||||
Retrieve App templates.
|
List available templates.
|
||||||
You can find more information about the format at http://portainer.readthedocs.io/en/stable/templates.html
|
Administrator templates will not be listed for non-administrator users.
|
||||||
**Access policy**: authenticated
|
**Access policy**: restricted
|
||||||
operationId: "TemplateList"
|
operationId: "TemplateList"
|
||||||
produces:
|
produces:
|
||||||
- "application/json"
|
- "application/json"
|
||||||
parameters:
|
parameters:
|
||||||
- name: "key"
|
|
||||||
in: "query"
|
|
||||||
required: true
|
|
||||||
description: "Templates key. Valid values are 'container' or 'linuxserver.io'."
|
|
||||||
type: "string"
|
|
||||||
responses:
|
responses:
|
||||||
200:
|
200:
|
||||||
description: "Success"
|
description: "Success"
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/TemplateListResponse"
|
$ref: "#/definitions/TemplateListResponse"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
post:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Create a new template"
|
||||||
|
description: |
|
||||||
|
Create a new template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateCreate"
|
||||||
|
consumes:
|
||||||
|
- "application/json"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
parameters:
|
||||||
|
- in: "body"
|
||||||
|
name: "body"
|
||||||
|
description: "Template details"
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/TemplateCreateRequest"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Success"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/Template"
|
||||||
400:
|
400:
|
||||||
description: "Invalid request"
|
description: "Invalid request"
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/definitions/GenericError"
|
$ref: "#/definitions/GenericError"
|
||||||
examples:
|
examples:
|
||||||
application/json:
|
application/json:
|
||||||
err: "Invalid query format"
|
err: "Invalid request data format"
|
||||||
|
403:
|
||||||
|
description: "Unauthorized"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Access denied to resource"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
/templates/{id}:
|
||||||
|
get:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Inspect a template"
|
||||||
|
description: |
|
||||||
|
Retrieve details about a template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateInspect"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
parameters:
|
||||||
|
- name: "id"
|
||||||
|
in: "path"
|
||||||
|
description: "Template identifier"
|
||||||
|
required: true
|
||||||
|
type: "integer"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Success"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/Template"
|
||||||
|
400:
|
||||||
|
description: "Invalid request"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Invalid request"
|
||||||
|
403:
|
||||||
|
description: "Unauthorized"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Access denied to resource"
|
||||||
|
404:
|
||||||
|
description: "Template not found"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Template not found"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
put:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Update a template"
|
||||||
|
description: |
|
||||||
|
Update a template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateUpdate"
|
||||||
|
consumes:
|
||||||
|
- "application/json"
|
||||||
|
produces:
|
||||||
|
- "application/json"
|
||||||
|
parameters:
|
||||||
|
- name: "id"
|
||||||
|
in: "path"
|
||||||
|
description: "Template identifier"
|
||||||
|
required: true
|
||||||
|
type: "integer"
|
||||||
|
- in: "body"
|
||||||
|
name: "body"
|
||||||
|
description: "Template details"
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/TemplateUpdateRequest"
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: "Success"
|
||||||
|
400:
|
||||||
|
description: "Invalid request"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Invalid request data format"
|
||||||
|
403:
|
||||||
|
description: "Unauthorized"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Access denied to resource"
|
||||||
|
404:
|
||||||
|
description: "Template not found"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Template not found"
|
||||||
|
500:
|
||||||
|
description: "Server error"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
delete:
|
||||||
|
tags:
|
||||||
|
- "templates"
|
||||||
|
summary: "Remove a template"
|
||||||
|
description: |
|
||||||
|
Remove a template.
|
||||||
|
**Access policy**: administrator
|
||||||
|
operationId: "TemplateDelete"
|
||||||
|
parameters:
|
||||||
|
- name: "id"
|
||||||
|
in: "path"
|
||||||
|
description: "Template identifier"
|
||||||
|
required: true
|
||||||
|
type: "integer"
|
||||||
|
responses:
|
||||||
|
204:
|
||||||
|
description: "Success"
|
||||||
|
400:
|
||||||
|
description: "Invalid request"
|
||||||
|
schema:
|
||||||
|
$ref: "#/definitions/GenericError"
|
||||||
|
examples:
|
||||||
|
application/json:
|
||||||
|
err: "Invalid request"
|
||||||
500:
|
500:
|
||||||
description: "Server error"
|
description: "Server error"
|
||||||
schema:
|
schema:
|
||||||
|
@ -3602,9 +3759,17 @@ definitions:
|
||||||
type: "array"
|
type: "array"
|
||||||
items:
|
items:
|
||||||
$ref: "#/definitions/Template"
|
$ref: "#/definitions/Template"
|
||||||
Template:
|
TemplateCreateRequest:
|
||||||
type: "object"
|
type: "object"
|
||||||
|
required:
|
||||||
|
- "type"
|
||||||
|
- "title"
|
||||||
|
- "description"
|
||||||
properties:
|
properties:
|
||||||
|
type:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)"
|
||||||
title:
|
title:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "Nginx"
|
example: "Nginx"
|
||||||
|
@ -3613,14 +3778,354 @@ definitions:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "High performance web server"
|
example: "High performance web server"
|
||||||
description: "Description of the template"
|
description: "Description of the template"
|
||||||
|
administrator_only:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the template should be available to administrators only"
|
||||||
|
image:
|
||||||
|
type: "string"
|
||||||
|
example: "nginx:latest"
|
||||||
|
description: "Image associated to a container template. Mandatory for a container template"
|
||||||
|
repository:
|
||||||
|
$ref: "#/definitions/TemplateRepository"
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "mystackname"
|
||||||
|
description: "Default name for the stack/container to be used on deployment"
|
||||||
logo:
|
logo:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
||||||
description: "URL of the template's logo"
|
description: "URL of the template's logo"
|
||||||
|
env:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of environment variables used during the template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateEnv"
|
||||||
|
note:
|
||||||
|
type: "string"
|
||||||
|
example: "This is my <b>custom</b> template"
|
||||||
|
description: "A note that will be displayed in the UI. Supports HTML content"
|
||||||
|
platform:
|
||||||
|
type: "string"
|
||||||
|
example: "linux"
|
||||||
|
description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform"
|
||||||
|
categories:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of categories associated to the template"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
exampe: "database"
|
||||||
|
registry:
|
||||||
|
type: "string"
|
||||||
|
example: "quay.io"
|
||||||
|
description: "The URL of a registry associated to the image for a container template"
|
||||||
|
command:
|
||||||
|
type: "string"
|
||||||
|
example: "ls -lah"
|
||||||
|
description: "The command that will be executed in a container template"
|
||||||
|
network:
|
||||||
|
type: "string"
|
||||||
|
example: "mynet"
|
||||||
|
description: "Name of a network that will be used on container deployment if it exists inside the environment"
|
||||||
|
volumes:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of volumes used during the container template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateVolume"
|
||||||
|
ports:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of ports exposed by the container"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
example: "8080:80/tcp"
|
||||||
|
labels:
|
||||||
|
type: "array"
|
||||||
|
description: "Container labels"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Pair'
|
||||||
|
privileged:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in privileged mode"
|
||||||
|
interactive:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)"
|
||||||
|
restart_policy:
|
||||||
|
type: "string"
|
||||||
|
example: "on-failure"
|
||||||
|
description: "Container restart policy"
|
||||||
|
hostname:
|
||||||
|
type: "string"
|
||||||
|
example: "mycontainer"
|
||||||
|
description: "Container hostname"
|
||||||
|
TemplateUpdateRequest:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)"
|
||||||
|
title:
|
||||||
|
type: "string"
|
||||||
|
example: "Nginx"
|
||||||
|
description: "Title of the template"
|
||||||
|
description:
|
||||||
|
type: "string"
|
||||||
|
example: "High performance web server"
|
||||||
|
description: "Description of the template"
|
||||||
|
administrator_only:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the template should be available to administrators only"
|
||||||
image:
|
image:
|
||||||
type: "string"
|
type: "string"
|
||||||
example: "nginx:latest"
|
example: "nginx:latest"
|
||||||
description: "The Docker image associated to the template"
|
description: "Image associated to a container template. Mandatory for a container template"
|
||||||
|
repository:
|
||||||
|
$ref: "#/definitions/TemplateRepository"
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "mystackname"
|
||||||
|
description: "Default name for the stack/container to be used on deployment"
|
||||||
|
logo:
|
||||||
|
type: "string"
|
||||||
|
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
||||||
|
description: "URL of the template's logo"
|
||||||
|
env:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of environment variables used during the template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateEnv"
|
||||||
|
note:
|
||||||
|
type: "string"
|
||||||
|
example: "This is my <b>custom</b> template"
|
||||||
|
description: "A note that will be displayed in the UI. Supports HTML content"
|
||||||
|
platform:
|
||||||
|
type: "string"
|
||||||
|
example: "linux"
|
||||||
|
description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform"
|
||||||
|
categories:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of categories associated to the template"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
exampe: "database"
|
||||||
|
registry:
|
||||||
|
type: "string"
|
||||||
|
example: "quay.io"
|
||||||
|
description: "The URL of a registry associated to the image for a container template"
|
||||||
|
command:
|
||||||
|
type: "string"
|
||||||
|
example: "ls -lah"
|
||||||
|
description: "The command that will be executed in a container template"
|
||||||
|
network:
|
||||||
|
type: "string"
|
||||||
|
example: "mynet"
|
||||||
|
description: "Name of a network that will be used on container deployment if it exists inside the environment"
|
||||||
|
volumes:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of volumes used during the container template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateVolume"
|
||||||
|
ports:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of ports exposed by the container"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
example: "8080:80/tcp"
|
||||||
|
labels:
|
||||||
|
type: "array"
|
||||||
|
description: "Container labels"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Pair'
|
||||||
|
privileged:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in privileged mode"
|
||||||
|
interactive:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)"
|
||||||
|
restart_policy:
|
||||||
|
type: "string"
|
||||||
|
example: "on-failure"
|
||||||
|
description: "Container restart policy"
|
||||||
|
hostname:
|
||||||
|
type: "string"
|
||||||
|
example: "mycontainer"
|
||||||
|
description: "Container hostname"
|
||||||
|
Template:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template identifier"
|
||||||
|
type:
|
||||||
|
type: "integer"
|
||||||
|
example: 1
|
||||||
|
description: "Template type. Valid values are: 1 (container), 2 (Swarm stack) or 3 (Compose stack)"
|
||||||
|
title:
|
||||||
|
type: "string"
|
||||||
|
example: "Nginx"
|
||||||
|
description: "Title of the template"
|
||||||
|
description:
|
||||||
|
type: "string"
|
||||||
|
example: "High performance web server"
|
||||||
|
description: "Description of the template"
|
||||||
|
administrator_only:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the template should be available to administrators only"
|
||||||
|
image:
|
||||||
|
type: "string"
|
||||||
|
example: "nginx:latest"
|
||||||
|
description: "Image associated to a container template. Mandatory for a container template"
|
||||||
|
repository:
|
||||||
|
$ref: "#/definitions/TemplateRepository"
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "mystackname"
|
||||||
|
description: "Default name for the stack/container to be used on deployment"
|
||||||
|
logo:
|
||||||
|
type: "string"
|
||||||
|
example: "https://cloudinovasi.id/assets/img/logos/nginx.png"
|
||||||
|
description: "URL of the template's logo"
|
||||||
|
env:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of environment variables used during the template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateEnv"
|
||||||
|
note:
|
||||||
|
type: "string"
|
||||||
|
example: "This is my <b>custom</b> template"
|
||||||
|
description: "A note that will be displayed in the UI. Supports HTML content"
|
||||||
|
platform:
|
||||||
|
type: "string"
|
||||||
|
example: "linux"
|
||||||
|
description: "Platform associated to the template. Valid values are: 'linux', 'windows' or leave empty for multi-platform"
|
||||||
|
categories:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of categories associated to the template"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
exampe: "database"
|
||||||
|
registry:
|
||||||
|
type: "string"
|
||||||
|
example: "quay.io"
|
||||||
|
description: "The URL of a registry associated to the image for a container template"
|
||||||
|
command:
|
||||||
|
type: "string"
|
||||||
|
example: "ls -lah"
|
||||||
|
description: "The command that will be executed in a container template"
|
||||||
|
network:
|
||||||
|
type: "string"
|
||||||
|
example: "mynet"
|
||||||
|
description: "Name of a network that will be used on container deployment if it exists inside the environment"
|
||||||
|
volumes:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of volumes used during the container template deployment"
|
||||||
|
items:
|
||||||
|
$ref: "#/definitions/TemplateVolume"
|
||||||
|
ports:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of ports exposed by the container"
|
||||||
|
items:
|
||||||
|
type: "string"
|
||||||
|
example: "8080:80/tcp"
|
||||||
|
labels:
|
||||||
|
type: "array"
|
||||||
|
description: "Container labels"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/Pair'
|
||||||
|
privileged:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in privileged mode"
|
||||||
|
interactive:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the container should be started in interactive mode (-i -t equivalent on the CLI)"
|
||||||
|
restart_policy:
|
||||||
|
type: "string"
|
||||||
|
example: "on-failure"
|
||||||
|
description: "Container restart policy"
|
||||||
|
hostname:
|
||||||
|
type: "string"
|
||||||
|
example: "mycontainer"
|
||||||
|
description: "Container hostname"
|
||||||
|
TemplateVolume:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
container:
|
||||||
|
type: "string"
|
||||||
|
example: "/data"
|
||||||
|
description: "Path inside the container"
|
||||||
|
bind:
|
||||||
|
type: "string"
|
||||||
|
example: "/tmp"
|
||||||
|
description: "Path on the host"
|
||||||
|
readonly:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Whether the volume used should be readonly"
|
||||||
|
TemplateEnv:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: "string"
|
||||||
|
example: "MYSQL_ROOT_PASSWORD"
|
||||||
|
description: "name of the environment variable"
|
||||||
|
label:
|
||||||
|
type: "string"
|
||||||
|
example: "Root password"
|
||||||
|
description: "Text for the label that will be generated in the UI"
|
||||||
|
description:
|
||||||
|
type: "string"
|
||||||
|
example: "MySQL root account password"
|
||||||
|
description: "Content of the tooltip that will be generated in the UI"
|
||||||
|
default:
|
||||||
|
type: "string"
|
||||||
|
example: "default_value"
|
||||||
|
description: "Default value that will be set for the variable"
|
||||||
|
preset:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "If set to true, will not generate any input for this variable in the UI"
|
||||||
|
select:
|
||||||
|
type: "array"
|
||||||
|
description: "A list of name/value that will be used to generate a dropdown in the UI"
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/TemplateEnvSelect'
|
||||||
|
TemplateEnvSelect:
|
||||||
|
type: "object"
|
||||||
|
properties:
|
||||||
|
text:
|
||||||
|
type: "string"
|
||||||
|
example: "text value"
|
||||||
|
description: "Some text that will displayed as a choice"
|
||||||
|
value:
|
||||||
|
type: "string"
|
||||||
|
example: "value"
|
||||||
|
description: "A value that will be associated to the choice"
|
||||||
|
default:
|
||||||
|
type: "boolean"
|
||||||
|
example: true
|
||||||
|
description: "Will set this choice as the default choice"
|
||||||
|
TemplateRepository:
|
||||||
|
type: "object"
|
||||||
|
required:
|
||||||
|
- "URL"
|
||||||
|
properties:
|
||||||
|
URL:
|
||||||
|
type: "string"
|
||||||
|
example: "https://github.com/portainer/portainer-compose"
|
||||||
|
description: "URL of a git repository used to deploy a stack template. Mandatory for a Swarm/Compose stack template"
|
||||||
|
stackfile:
|
||||||
|
type: "string"
|
||||||
|
example: "./subfolder/docker-compose.yml"
|
||||||
|
description: "Path to the stack file inside the git repository"
|
||||||
StackMigrateRequest:
|
StackMigrateRequest:
|
||||||
type: "object"
|
type: "object"
|
||||||
required:
|
required:
|
||||||
|
|
|
@ -361,36 +361,6 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var templates = {
|
|
||||||
name: 'docker.templates',
|
|
||||||
url: '/templates',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: 'app/docker/views/templates/templates.html',
|
|
||||||
controller: 'TemplatesController'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
key: 'containers',
|
|
||||||
hide_descriptions: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var templatesLinuxServer = {
|
|
||||||
name: 'docker.templates.linuxserver',
|
|
||||||
url: '/linuxserver',
|
|
||||||
views: {
|
|
||||||
'content@': {
|
|
||||||
templateUrl: 'app/docker/views/templates/templates.html',
|
|
||||||
controller: 'TemplatesController'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
key: 'linuxserver.io',
|
|
||||||
hide_descriptions: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var volumes = {
|
var volumes = {
|
||||||
name: 'docker.volumes',
|
name: 'docker.volumes',
|
||||||
url: '/volumes',
|
url: '/volumes',
|
||||||
|
@ -458,8 +428,6 @@ angular.module('portainer.docker', ['portainer.app'])
|
||||||
$stateRegistryProvider.register(tasks);
|
$stateRegistryProvider.register(tasks);
|
||||||
$stateRegistryProvider.register(task);
|
$stateRegistryProvider.register(task);
|
||||||
$stateRegistryProvider.register(taskLogs);
|
$stateRegistryProvider.register(taskLogs);
|
||||||
$stateRegistryProvider.register(templates);
|
|
||||||
$stateRegistryProvider.register(templatesLinuxServer);
|
|
||||||
$stateRegistryProvider.register(volumes);
|
$stateRegistryProvider.register(volumes);
|
||||||
$stateRegistryProvider.register(volume);
|
$stateRegistryProvider.register(volume);
|
||||||
$stateRegistryProvider.register(volumeCreation);
|
$stateRegistryProvider.register(volumeCreation);
|
||||||
|
|
|
@ -2,10 +2,7 @@
|
||||||
<a ui-sref="docker.dashboard" ui-sref-active="active">Dashboard <span class="menu-icon fa fa-tachometer-alt fa-fw"></span></a>
|
<a ui-sref="docker.dashboard" ui-sref-active="active">Dashboard <span class="menu-icon fa fa-tachometer-alt fa-fw"></span></a>
|
||||||
</li>
|
</li>
|
||||||
<li class="sidebar-list">
|
<li class="sidebar-list">
|
||||||
<a ui-sref="docker.templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket fa-fw"></span></a>
|
<a ui-sref="portainer.templates" ui-sref-active="active">App Templates <span class="menu-icon fa fa-rocket fa-fw"></span></a>
|
||||||
<div class="sidebar-sublist" ng-if="$ctrl.sidebarToggledOn && $ctrl.externalContributions && ($ctrl.currentState === 'docker.templates' || $ctrl.currentState === 'docker.templates.linuxserver')">
|
|
||||||
<a ui-sref="docker.templates.linuxserver" ui-sref-active="active">LinuxServer.io</a>
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li class="sidebar-list">
|
<li class="sidebar-list">
|
||||||
<a ui-sref="portainer.stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a>
|
<a ui-sref="portainer.stacks" ui-sref-active="active">Stacks <span class="menu-icon fa fa-th-list fa-fw"></span></a>
|
||||||
|
|
|
@ -4,6 +4,11 @@ angular.module('portainer.docker').component('porImageRegistry', {
|
||||||
bindings: {
|
bindings: {
|
||||||
'image': '=',
|
'image': '=',
|
||||||
'registry': '=',
|
'registry': '=',
|
||||||
'autoComplete': '<'
|
'autoComplete': '<',
|
||||||
|
'labelClass': '@',
|
||||||
|
'inputClass': '@'
|
||||||
|
},
|
||||||
|
require: {
|
||||||
|
form: '^form'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,14 +1,21 @@
|
||||||
<div>
|
<div class="form-group">
|
||||||
<label for="image_name" class="col-sm-1 control-label text-left">Name</label>
|
<label for="image_name" ng-class="$ctrl.labelClass" class="control-label text-left">Image</label>
|
||||||
<div class="col-sm-11 col-md-6">
|
<div ng-class="$ctrl.inputClass">
|
||||||
<input type="text" class="form-control" uib-typeahead="image for image in $ctrl.availableImages | filter:$viewValue | limitTo:5"
|
<input type="text" class="form-control" uib-typeahead="image for image in $ctrl.availableImages | filter:$viewValue | limitTo:5"
|
||||||
ng-model="$ctrl.image" id="image_name" placeholder="e.g. myImage:myTag">
|
ng-model="$ctrl.image" name="image_name" placeholder="e.g. myImage:myTag" required>
|
||||||
</div>
|
</div>
|
||||||
<label for="image_registry" class="col-sm-2 col-md-1 margin-sm-top control-label text-left">
|
<label for="image_registry" class="margin-sm-top control-label text-right" ng-class="$ctrl.labelClass">
|
||||||
Registry
|
Registry
|
||||||
</label>
|
</label>
|
||||||
<div class="col-sm-10 col-md-4 margin-sm-top">
|
<div ng-class="$ctrl.inputClass" class="margin-sm-top">
|
||||||
<select ng-options="registry as registry.Name for registry in $ctrl.availableRegistries" ng-model="$ctrl.registry" id="image_registry"
|
<select ng-options="registry as registry.Name for registry in $ctrl.availableRegistries" ng-model="$ctrl.registry" id="image_registry"
|
||||||
class="form-control"></select>
|
class="form-control"></select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" ng-show="$ctrl.form.image_name.$invalid">
|
||||||
|
<div class="col-sm-12 small text-danger">
|
||||||
|
<div ng-messages="$ctrl.form.image_name.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Image name is required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
function StackTemplateViewModel(data) {
|
|
||||||
this.Type = data.type;
|
|
||||||
this.Name = data.name;
|
|
||||||
this.Title = data.title;
|
|
||||||
this.Description = data.description;
|
|
||||||
this.Note = data.note;
|
|
||||||
this.Categories = data.categories ? data.categories : [];
|
|
||||||
this.Platform = data.platform ? data.platform : 'undefined';
|
|
||||||
this.Logo = data.logo;
|
|
||||||
this.Repository = data.repository;
|
|
||||||
this.Env = data.env ? data.env : [];
|
|
||||||
}
|
|
|
@ -1,51 +0,0 @@
|
||||||
function TemplateViewModel(data) {
|
|
||||||
this.Type = data.type;
|
|
||||||
this.Name = data.name;
|
|
||||||
this.Hostname = data.hostname;
|
|
||||||
this.Title = data.title;
|
|
||||||
this.Description = data.description;
|
|
||||||
this.Note = data.note;
|
|
||||||
this.Categories = data.categories ? data.categories : [];
|
|
||||||
this.Platform = data.platform ? data.platform : 'undefined';
|
|
||||||
this.Logo = data.logo;
|
|
||||||
this.Image = data.image;
|
|
||||||
this.Registry = data.registry ? data.registry : '';
|
|
||||||
this.Command = data.command ? data.command : '';
|
|
||||||
this.Network = data.network ? data.network : '';
|
|
||||||
this.Env = data.env ? data.env : [];
|
|
||||||
this.Privileged = data.privileged ? data.privileged : false;
|
|
||||||
this.Interactive = data.interactive ? data.interactive : false;
|
|
||||||
this.RestartPolicy = data.restart_policy ? data.restart_policy : 'always';
|
|
||||||
this.Labels = data.labels ? data.labels : [];
|
|
||||||
this.Volumes = [];
|
|
||||||
|
|
||||||
if (data.volumes) {
|
|
||||||
this.Volumes = data.volumes.map(function (v) {
|
|
||||||
// @DEPRECATED: New volume definition introduced
|
|
||||||
// via https://github.com/portainer/portainer/pull/1154
|
|
||||||
var volume = {
|
|
||||||
readOnly: v.readonly || false,
|
|
||||||
containerPath: v.container || v,
|
|
||||||
type: 'auto'
|
|
||||||
};
|
|
||||||
|
|
||||||
if (v.bind) {
|
|
||||||
volume.name = v.bind;
|
|
||||||
volume.type = 'bind';
|
|
||||||
}
|
|
||||||
|
|
||||||
return volume;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.Ports = [];
|
|
||||||
if (data.ports) {
|
|
||||||
this.Ports = data.ports.map(function (p) {
|
|
||||||
var portAndProtocol = _.split(p, '/');
|
|
||||||
return {
|
|
||||||
containerPort: portAndProtocol[0],
|
|
||||||
protocol: portAndProtocol[1]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.Hosts = data.hosts ? data.hosts : [];
|
|
||||||
}
|
|
|
@ -1,36 +0,0 @@
|
||||||
function TemplateLSIOViewModel(data) {
|
|
||||||
this.Type = data.type;
|
|
||||||
this.Title = data.title;
|
|
||||||
this.Note = data.description;
|
|
||||||
this.Categories = data.category ? data.category : [];
|
|
||||||
this.Platform = data.platform ? data.platform : 'linux';
|
|
||||||
this.Logo = data.logo;
|
|
||||||
this.Image = data.image;
|
|
||||||
this.Registry = data.registry ? data.registry : '';
|
|
||||||
this.Command = data.command ? data.command : '';
|
|
||||||
this.Network = data.network ? data.network : '';
|
|
||||||
this.Env = data.env ? data.env : [];
|
|
||||||
this.Privileged = data.privileged ? data.privileged : false;
|
|
||||||
this.Interactive = data.interactive ? data.interactive : false;
|
|
||||||
this.RestartPolicy = data.restart_policy ? data.restart_policy : 'always';
|
|
||||||
this.Volumes = [];
|
|
||||||
if (data.volumes) {
|
|
||||||
this.Volumes = data.volumes.map(function (v) {
|
|
||||||
return {
|
|
||||||
readOnly: false,
|
|
||||||
containerPath: v,
|
|
||||||
type: 'auto'
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this.Ports = [];
|
|
||||||
if (data.ports) {
|
|
||||||
this.Ports = data.ports.map(function (p) {
|
|
||||||
var portAndProtocol = _.split(p, '/');
|
|
||||||
return {
|
|
||||||
containerPort: portAndProtocol[0],
|
|
||||||
protocol: portAndProtocol[1]
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -27,9 +27,13 @@
|
||||||
</div>
|
</div>
|
||||||
<div ng-if="formValues.Registry || !fromContainer">
|
<div ng-if="formValues.Registry || !fromContainer">
|
||||||
<!-- image-and-registry -->
|
<!-- image-and-registry -->
|
||||||
<div class="form-group">
|
<por-image-registry
|
||||||
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry" auto-complete="true"></por-image-registry>
|
image="config.Image"
|
||||||
</div>
|
registry="formValues.Registry"
|
||||||
|
ng-if="formValues.Registry"
|
||||||
|
auto-complete="true"
|
||||||
|
label-class="col-sm-1" input-class="col-sm-11 col-md-5"
|
||||||
|
></por-image-registry>
|
||||||
<!-- !image-and-registry -->
|
<!-- !image-and-registry -->
|
||||||
<!-- always-pull -->
|
<!-- always-pull -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -153,9 +153,12 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !tag-description -->
|
<!-- !tag-description -->
|
||||||
<!-- image-and-registry -->
|
<!-- image-and-registry -->
|
||||||
<div class="form-group">
|
<por-image-registry
|
||||||
<por-image-registry image="config.Image" registry="config.Registry"></por-image-registry>
|
image="config.Image"
|
||||||
</div>
|
registry="config.Registry"
|
||||||
|
auto-complete="true"
|
||||||
|
label-class="col-sm-1" input-class="col-sm-11 col-md-5"
|
||||||
|
></por-image-registry>
|
||||||
<!-- !image-and-registry -->
|
<!-- !image-and-registry -->
|
||||||
<!-- tag-note -->
|
<!-- tag-note -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -63,9 +63,11 @@
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal">
|
<form class="form-horizontal">
|
||||||
<!-- image-and-registry -->
|
<!-- image-and-registry -->
|
||||||
<div class="form-group">
|
<por-image-registry
|
||||||
<por-image-registry image="formValues.Image" registry="formValues.Registry"></por-image-registry>
|
image="formValues.Image"
|
||||||
</div>
|
registry="formValues.Registry"
|
||||||
|
label-class="col-sm-1" input-class="col-sm-11 col-md-5"
|
||||||
|
></por-image-registry>
|
||||||
<!-- !image-and-registry -->
|
<!-- !image-and-registry -->
|
||||||
<!-- tag-note -->
|
<!-- tag-note -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -15,9 +15,11 @@
|
||||||
<rd-widget-body>
|
<rd-widget-body>
|
||||||
<form class="form-horizontal">
|
<form class="form-horizontal">
|
||||||
<!-- image-and-registry -->
|
<!-- image-and-registry -->
|
||||||
<div class="form-group">
|
<por-image-registry
|
||||||
<por-image-registry image="formValues.Image" registry="formValues.Registry"></por-image-registry>
|
image="formValues.Image"
|
||||||
</div>
|
registry="formValues.Registry"
|
||||||
|
label-class="col-sm-1" input-class="col-sm-11 col-md-5"
|
||||||
|
></por-image-registry>
|
||||||
<!-- !image-and-registry -->
|
<!-- !image-and-registry -->
|
||||||
<!-- tag-note -->
|
<!-- tag-note -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
|
@ -22,9 +22,12 @@
|
||||||
Image configuration
|
Image configuration
|
||||||
</div>
|
</div>
|
||||||
<!-- image-and-registry -->
|
<!-- image-and-registry -->
|
||||||
<div class="form-group">
|
<por-image-registry
|
||||||
<por-image-registry image="formValues.Image" registry="formValues.Registry" auto-complete="true"></por-image-registry>
|
image="formValues.Image"
|
||||||
</div>
|
registry="formValues.Registry"
|
||||||
|
auto-complete="true"
|
||||||
|
label-class="col-sm-1" input-class="col-sm-11 col-md-5"
|
||||||
|
></por-image-registry>
|
||||||
<!-- !image-and-registry -->
|
<!-- !image-and-registry -->
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Scheduling
|
Scheduling
|
||||||
|
|
|
@ -351,6 +351,43 @@ angular.module('portainer.app', [])
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
var templates = {
|
||||||
|
name: 'portainer.templates',
|
||||||
|
url: '/templates',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: 'app/portainer/views/templates/templates.html',
|
||||||
|
controller: 'TemplatesController'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
key: 'containers',
|
||||||
|
hide_descriptions: false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var template = {
|
||||||
|
name: 'portainer.templates.template',
|
||||||
|
url: '/:id',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: 'app/portainer/views/templates/edit/template.html',
|
||||||
|
controller: 'TemplateController'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var templateCreation = {
|
||||||
|
name: 'portainer.templates.new',
|
||||||
|
url: '/new',
|
||||||
|
views: {
|
||||||
|
'content@': {
|
||||||
|
templateUrl: 'app/portainer/views/templates/create/createtemplate.html',
|
||||||
|
controller: 'CreateTemplateController'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
$stateRegistryProvider.register(root);
|
$stateRegistryProvider.register(root);
|
||||||
$stateRegistryProvider.register(portainer);
|
$stateRegistryProvider.register(portainer);
|
||||||
$stateRegistryProvider.register(about);
|
$stateRegistryProvider.register(about);
|
||||||
|
@ -382,4 +419,7 @@ angular.module('portainer.app', [])
|
||||||
$stateRegistryProvider.register(user);
|
$stateRegistryProvider.register(user);
|
||||||
$stateRegistryProvider.register(teams);
|
$stateRegistryProvider.register(teams);
|
||||||
$stateRegistryProvider.register(team);
|
$stateRegistryProvider.register(team);
|
||||||
|
$stateRegistryProvider.register(templates);
|
||||||
|
$stateRegistryProvider.register(template);
|
||||||
|
$stateRegistryProvider.register(templateCreation);
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
angular.module('portainer.app').component('templateForm', {
|
||||||
|
templateUrl: 'app/portainer/components/forms/template-form/templateForm.html',
|
||||||
|
controller: function() {
|
||||||
|
this.state = {
|
||||||
|
collapseTemplate: false,
|
||||||
|
collapseContainer: false,
|
||||||
|
collapseStack: false,
|
||||||
|
collapseEnv: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addPortBinding = function() {
|
||||||
|
this.model.Ports.push({ containerPort: '', protocol: 'tcp' });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.removePortBinding = function(index) {
|
||||||
|
this.model.Ports.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addVolume = function () {
|
||||||
|
this.model.Volumes.push({ container: '', bind: '', readonly: false, type: 'auto' });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.removeVolume = function(index) {
|
||||||
|
this.model.Volumes.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addLabel = function () {
|
||||||
|
this.model.Labels.push({ name: '', value: ''});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.removeLabel = function(index) {
|
||||||
|
this.model.Labels.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addEnvVar = function() {
|
||||||
|
this.model.Env.push({ type: 1, name: '', label: '', description: '', default: '', preset: true, select: [] });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.removeEnvVar = function(index) {
|
||||||
|
this.model.Env.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.addEnvVarValue = function(env) {
|
||||||
|
env.select.push({ name: '', value: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
this.removeEnvVarValue = function(env, index) {
|
||||||
|
env.select.splice(index, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.changeEnvVarType = function(env) {
|
||||||
|
if (env.type === 1) {
|
||||||
|
env.preset = true;
|
||||||
|
} else if (env.type === 2) {
|
||||||
|
env.preset = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
model: '=',
|
||||||
|
categories: '<',
|
||||||
|
networks: '<',
|
||||||
|
formAction: '<',
|
||||||
|
formActionLabel: '@',
|
||||||
|
actionInProgress: '<',
|
||||||
|
showTypeSelector: '<'
|
||||||
|
}
|
||||||
|
});
|
569
app/portainer/components/forms/template-form/templateForm.html
Normal file
569
app/portainer/components/forms/template-form/templateForm.html
Normal file
|
@ -0,0 +1,569 @@
|
||||||
|
<form class="form-horizontal" name="templateForm">
|
||||||
|
<!-- title-input -->
|
||||||
|
<div class="form-group" ng-class="{ 'has-error': templateForm.template_title.$invalid }">
|
||||||
|
<label for="template_title" class="col-sm-3 col-lg-2 control-label text-left">Title</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_title" ng-model="$ctrl.model.Title" placeholder="e.g. my-template" required auto-focus>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-show="templateForm.template_title.$invalid">
|
||||||
|
<div class="col-sm-12 small text-danger">
|
||||||
|
<div ng-messages="templateForm.template_title.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !title-input -->
|
||||||
|
<!-- description-input -->
|
||||||
|
<div class="form-group" ng-class="{ 'has-error': templateForm.template_description.$invalid }">
|
||||||
|
<label for="template_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" name="template_description" ng-model="$ctrl.model.Description" placeholder="e.g. template description..." required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-show="templateForm.template_description.$invalid">
|
||||||
|
<div class="col-sm-12 small text-danger">
|
||||||
|
<div ng-messages="templateForm.template_description.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !description-input -->
|
||||||
|
<div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseTemplate = !$ctrl.state.collapseTemplate">
|
||||||
|
Template
|
||||||
|
<span class="small space-left">
|
||||||
|
<a ng-if="$ctrl.state.collapseTemplate"><i class="fa fa-plus" aria-hidden="true"></i> expand</a>
|
||||||
|
<a ng-if="!$ctrl.state.collapseTemplate"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- template-details -->
|
||||||
|
<div uib-collapse="$ctrl.state.collapseTemplate">
|
||||||
|
|
||||||
|
<div ng-if="$ctrl.showTypeSelector">
|
||||||
|
<div class="form-group"></div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0">
|
||||||
|
<div class="boxselector_wrapper">
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="template_container" ng-model="$ctrl.model.Type" ng-value="1">
|
||||||
|
<label for="template_container">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-server" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Container
|
||||||
|
</div>
|
||||||
|
<p>Container template</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="template_swarm_stack" ng-model="$ctrl.model.Type" ng-value="2">
|
||||||
|
<label for="template_swarm_stack">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-th-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Swarm stack
|
||||||
|
</div>
|
||||||
|
<p>Stack template (Swarm)</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="template_compose_stack" ng-model="$ctrl.model.Type" ng-value="3">
|
||||||
|
<label for="template_compose_stack">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-th-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Compose stack
|
||||||
|
</div>
|
||||||
|
<p>Stack template (Compose)</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- name -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_name" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Name
|
||||||
|
<portainer-tooltip position="bottom" message="Default name that will be associated to the template"></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_name" ng-model="$ctrl.model.Name" placeholder="e.g. myApp">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !name -->
|
||||||
|
<!-- logo -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_logo" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Logo URL
|
||||||
|
<portainer-tooltip position="bottom" message="Recommended size: 60x60"></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_logo" ng-model="$ctrl.model.Logo" placeholder="e.g. https://portainer.io/images/logos/nginx.png">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !logo -->
|
||||||
|
<!-- note -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_note" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Note
|
||||||
|
<portainer-tooltip position="bottom" message="Usage / extra information about the template. Supports HTML."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<textarea class="form-control" name="template_note" ng-model="$ctrl.model.Note" placeholder='You can use this field to specify extra information. <br/> It supports <b>HTML</b>.'></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !note -->
|
||||||
|
<!-- platform -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_platform" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Platform
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<select class="form-control" name="template_platform" ng-model="$ctrl.model.Platform">
|
||||||
|
<option value="">Multi-platform</option>
|
||||||
|
<option value="linux">Linux</option>
|
||||||
|
<option value="windows">Windows</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !platform -->
|
||||||
|
<!-- categories -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_categories" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Categories
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<ui-select multiple tagging tagging-label="(new category)" ng-model="$ctrl.model.Categories" sortable="true" style="width: 300px;" title="Choose a category">
|
||||||
|
<ui-select-match placeholder="Select categories...">{{ $item }}</ui-select-match>
|
||||||
|
<ui-select-choices repeat="category in $ctrl.categories | filter:$select.search">
|
||||||
|
{{ category }}
|
||||||
|
</ui-select-choices>
|
||||||
|
</ui-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !categories -->
|
||||||
|
<!-- administrator-only -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<label for="tls" class="control-label text-left">
|
||||||
|
Administrator template
|
||||||
|
<portainer-tooltip position="bottom" message="Should this template be only available to administrator users."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<label class="switch" style="margin-left: 20px;">
|
||||||
|
<input type="checkbox" ng-model="$ctrl.model.AdministratorOnly"><i></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- administrator-only -->
|
||||||
|
</div>
|
||||||
|
<!-- !template-details -->
|
||||||
|
<div ng-if="$ctrl.model.Type === 2 || $ctrl.model.Type === 3">
|
||||||
|
<div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseStack = !$ctrl.state.collapseStack">
|
||||||
|
Stack
|
||||||
|
<span class="small space-left">
|
||||||
|
<a ng-if="$ctrl.state.collapseStack"><i class="fa fa-plus" aria-hidden="true"></i> expand</a>
|
||||||
|
<a ng-if="!$ctrl.state.collapseStack"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- stack-details -->
|
||||||
|
<div uib-collapse="$ctrl.state.collapseStack">
|
||||||
|
<!-- repository-url -->
|
||||||
|
<div class="form-group" ng-class="{ 'has-error': templateForm.template_repository_url.$invalid }">
|
||||||
|
<label for="template_repository_url" class="col-sm-3 col-lg-2 control-label text-left">Repository URL</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_repository_url" ng-model="$ctrl.model.Repository.url" placeholder="https://github.com/portainer/portainer-compose" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-show="templateForm.template_repository_url.$invalid">
|
||||||
|
<div class="col-sm-12 small text-danger">
|
||||||
|
<div ng-messages="templateForm.template_repository_url.$error">
|
||||||
|
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !repository-url -->
|
||||||
|
<!-- composefile-path -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_repository_path" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Compose file path
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_repository_path" ng-model="$ctrl.model.Repository.stackfile" placeholder='docker-compose.yml'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !composefile-path -->
|
||||||
|
</div>
|
||||||
|
<!-- !stack-details -->
|
||||||
|
</div>
|
||||||
|
<div ng-if="$ctrl.model.Type === 1">
|
||||||
|
<div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseContainer = !$ctrl.state.collapseContainer">
|
||||||
|
Container
|
||||||
|
<span class="small space-left">
|
||||||
|
<a ng-if="$ctrl.state.collapseContainer"><i class="fa fa-plus" aria-hidden="true"></i> expand</a>
|
||||||
|
<a ng-if="!$ctrl.state.collapseContainer"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- container-details -->
|
||||||
|
<div uib-collapse="$ctrl.state.collapseContainer">
|
||||||
|
<por-image-registry
|
||||||
|
image="$ctrl.model.Image"
|
||||||
|
registry="$ctrl.model.Registry"
|
||||||
|
auto-complete="true"
|
||||||
|
label-class="col-sm-2" input-class="col-sm-10 col-md-4"
|
||||||
|
></por-image-registry>
|
||||||
|
<!-- command -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_command" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Command
|
||||||
|
<portainer-tooltip position="bottom" message="The command to run in the container. If not specified, the container will use the default command specified in its Dockerfile."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_command" ng-model="$ctrl.model.Command" placeholder='/bin/bash -c \"echo hello\" && exit 777'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !command -->
|
||||||
|
<!-- hostname -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_hostname" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Hostname
|
||||||
|
<portainer-tooltip position="bottom" message="Set the hostname of the container. Will use Docker default if not specified."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<input type="text" class="form-control" name="template_hostname" ng-model="$ctrl.model.Hostname" placeholder='mycontainername'>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !hostname -->
|
||||||
|
<!-- network -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_network" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Network
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" ng-options="net.Name for net in $ctrl.networks" ng-model="$ctrl.model.Network">
|
||||||
|
<option disabled hidden value="">Select a network</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !network -->
|
||||||
|
<!-- port-mapping -->
|
||||||
|
<div class="form-group" >
|
||||||
|
<div class="col-sm-12" style="margin-top: 5px;">
|
||||||
|
<label class="control-label text-left">Port mapping</label>
|
||||||
|
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addPortBinding()">
|
||||||
|
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional port
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12" style="margin-top: 10px" ng-if="$ctrl.model.Ports.length > 0">
|
||||||
|
<span class="small text-muted">Portainer will automatically assign a port if you leave the host port empty.</span>
|
||||||
|
</div>
|
||||||
|
<!-- port-mapping-input-list -->
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||||
|
<div ng-repeat="portBinding in $ctrl.model.Ports" style="margin-top: 2px;">
|
||||||
|
<!-- host-port -->
|
||||||
|
<div class="input-group col-sm-4 input-group-sm">
|
||||||
|
<span class="input-group-addon">host</span>
|
||||||
|
<input type="text" class="form-control" ng-model="portBinding.hostPort" placeholder="e.g. 80 or 1.2.3.4:80 (optional)">
|
||||||
|
</div>
|
||||||
|
<!-- !host-port -->
|
||||||
|
<span style="margin: 0 10px 0 10px;">
|
||||||
|
<i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<!-- container-port -->
|
||||||
|
<div class="input-group col-sm-4 input-group-sm">
|
||||||
|
<span class="input-group-addon">container</span>
|
||||||
|
<input type="text" class="form-control" ng-model="portBinding.containerPort" placeholder="e.g. 80">
|
||||||
|
</div>
|
||||||
|
<!-- !container-port -->
|
||||||
|
<!-- protocol-actions -->
|
||||||
|
<div class="input-group col-sm-3 input-group-sm">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'tcp'">TCP</label>
|
||||||
|
<label class="btn btn-primary" ng-model="portBinding.protocol" uib-btn-radio="'udp'">UDP</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removePortBinding($index)">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- !protocol-actions -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !port-mapping-input-list -->
|
||||||
|
</div>
|
||||||
|
<!-- !port-mapping -->
|
||||||
|
<!-- volumes -->
|
||||||
|
<div class="form-group" >
|
||||||
|
<div class="col-sm-12" style="margin-top: 5px;">
|
||||||
|
<label class="control-label text-left">Volume mapping</label>
|
||||||
|
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addVolume()">
|
||||||
|
<i class="fa fa-plus-circle" aria-hidden="true"></i> map additional volume
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12" style="margin-top: 10px" ng-if="$ctrl.model.Volumes.length > 0">
|
||||||
|
<span class="small text-muted">Portainer will automatically create and map a local volume when using the <b>auto</b> option.</span>
|
||||||
|
</div>
|
||||||
|
<div ng-repeat="volume in $ctrl.model.Volumes">
|
||||||
|
<div class="col-sm-12" style="margin-top: 10px;">
|
||||||
|
<!-- volume-line1 -->
|
||||||
|
<div class="col-sm-12 form-inline">
|
||||||
|
<!-- container-path -->
|
||||||
|
<div class="input-group input-group-sm col-sm-6">
|
||||||
|
<span class="input-group-addon">container</span>
|
||||||
|
<input type="text" class="form-control" ng-model="volume.container" placeholder="e.g. /path/in/container">
|
||||||
|
</div>
|
||||||
|
<!-- !container-path -->
|
||||||
|
<!-- volume-type -->
|
||||||
|
<div class="input-group col-sm-5" style="margin-left: 5px;">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.bind = ''">Auto</label>
|
||||||
|
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.bind = ''">Bind</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeVolume($index)">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- !volume-type -->
|
||||||
|
</div>
|
||||||
|
<!-- !volume-line1 -->
|
||||||
|
<!-- volume-line2 -->
|
||||||
|
<div class="col-sm-12 form-inline" style="margin-top: 5px;" ng-if="volume.type !== 'auto'">
|
||||||
|
<i class="fa fa-long-arrow-alt-right" aria-hidden="true"></i>
|
||||||
|
<!-- bind -->
|
||||||
|
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
|
||||||
|
<span class="input-group-addon">host</span>
|
||||||
|
<input type="text" class="form-control" ng-model="volume.bind" placeholder="e.g. /path/on/host">
|
||||||
|
</div>
|
||||||
|
<!-- !bind -->
|
||||||
|
<!-- read-only -->
|
||||||
|
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<label class="btn btn-primary" ng-model="volume.readonly" uib-btn-radio="false">Writable</label>
|
||||||
|
<label class="btn btn-primary" ng-model="volume.readonly" uib-btn-radio="true">Read-only</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !read-only -->
|
||||||
|
</div>
|
||||||
|
<!-- !volume-line2 -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !volumes -->
|
||||||
|
<!-- 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.addLabel()">
|
||||||
|
<i class="fa fa-plus-circle" aria-hidden="true"></i> add label
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- labels-input-list -->
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<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. com.example.foo">
|
||||||
|
</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. bar">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLabel($index)">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !labels-input-list -->
|
||||||
|
</div>
|
||||||
|
<!-- !labels -->
|
||||||
|
<!-- restart_policy -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="template_restart_policy" class="col-sm-3 col-lg-2 control-label text-left">
|
||||||
|
Restart policy
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-9 col-lg-10">
|
||||||
|
<select class="form-control" name="template_platform" ng-model="$ctrl.model.RestartPolicy">
|
||||||
|
<option value="always">Always</option>
|
||||||
|
<option value="unless-stopped">Unless stopped</option>
|
||||||
|
<option value="on-failure">On failure</option>
|
||||||
|
<option value="no">None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !restart_policy -->
|
||||||
|
<!-- privileged-mode -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<label for="tls" class="control-label text-left">
|
||||||
|
Privileged mode
|
||||||
|
<portainer-tooltip position="bottom" message="Start the container in privileged mode."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<label class="switch" style="margin-left: 20px;">
|
||||||
|
<input type="checkbox" ng-model="$ctrl.model.Privileged"><i></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !privileged-mode -->
|
||||||
|
<!-- interactive-mode -->
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<label for="tls" class="control-label text-left">
|
||||||
|
Interactive mode
|
||||||
|
<portainer-tooltip position="bottom" message="Start the container in foreground (equivalent of -i -t flags)."></portainer-tooltip>
|
||||||
|
</label>
|
||||||
|
<label class="switch" style="margin-left: 20px;">
|
||||||
|
<input type="checkbox" ng-model="$ctrl.model.Interactive"><i></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !interactive-mode -->
|
||||||
|
</div>
|
||||||
|
<!-- !container-details -->
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12 form-section-title interactive" ng-click="$ctrl.state.collapseEnv = !$ctrl.state.collapseEnv">
|
||||||
|
Environment
|
||||||
|
<span class="small space-left">
|
||||||
|
<a ng-if="$ctrl.state.collapseEnv"><i class="fa fa-plus" aria-hidden="true"></i> expand</a>
|
||||||
|
<a ng-if="!$ctrl.state.collapseEnv"><i class="fa fa-minus" aria-hidden="true"></i> collapse</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- environment-details -->
|
||||||
|
<div uib-collapse="$ctrl.state.collapseEnv">
|
||||||
|
<!-- env -->
|
||||||
|
<div class="form-group" >
|
||||||
|
<div class="col-sm-12" style="margin-top: 5px;">
|
||||||
|
<label class="control-label text-left">Environment variables</label>
|
||||||
|
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addEnvVar()">
|
||||||
|
<i class="fa fa-plus-circle" aria-hidden="true"></i> add variable
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- env-var-list -->
|
||||||
|
<div style="margin-top: 10px;">
|
||||||
|
<div class="col-sm-12 template-envvar" ng-repeat="var in $ctrl.model.Env" style="margin-top: 10px;">
|
||||||
|
<div class="form-group"></div>
|
||||||
|
<div class="form-group" style="margin-bottom: 0">
|
||||||
|
<div class="boxselector_wrapper">
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="preset_var_{{$index}}" ng-model="var.type" ng-value="1" ng-change="$ctrl.changeEnvVarType(var)">
|
||||||
|
<label for="preset_var_{{$index}}">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-user-slash" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Preset
|
||||||
|
</div>
|
||||||
|
<p>Preset variable</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="text_var_{{$index}}" ng-model="var.type" ng-value="2" ng-change="$ctrl.changeEnvVarType(var)">
|
||||||
|
<label for="text_var_{{$index}}">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Text
|
||||||
|
</div>
|
||||||
|
<p>Free text value</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="radio" id="select_var_{{$index}}" ng-model="var.type" ng-value="3">
|
||||||
|
<label for="select_var_{{$index}}">
|
||||||
|
<div class="boxselector_header">
|
||||||
|
<i class="fa fa-list-ol" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
Select
|
||||||
|
</div>
|
||||||
|
<p>Choose value from list</p>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label text-left">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-8">
|
||||||
|
<input type="text" class="form-control" ng-model="var.name" placeholder="env_var">
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-2">
|
||||||
|
<button class="btn btn-sm btn-danger space-left" type="button" ng-click="$ctrl.removeEnvVar($index)">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ng-if="var.type == 2 || var.type == 3">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label text-left">
|
||||||
|
Label
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" ng-model="var.label" placeholder="Choose a label">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-sm-2 control-label text-left" style="margin-top: 2px;">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-10" style="margin-top: 2px;">
|
||||||
|
<input type="text" class="form-control" ng-model="var.description" placeholder="Tooltip">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-if="var.type === 1 || var.type === 2">
|
||||||
|
<label class="col-sm-2 control-label text-left">
|
||||||
|
Default value
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" ng-model="var.default" placeholder="default_value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ng-if="var.type === 3" style="margin-bottom: 5px;" class="form-group">
|
||||||
|
<div class="col-sm-12" style="margin-top: 5px;">
|
||||||
|
<label class="control-label text-left">Values</label>
|
||||||
|
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="$ctrl.addEnvVarValue(var)">
|
||||||
|
<i class="fa fa-plus-circle" aria-hidden="true"></i> add allowed value
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- envvar-values-list -->
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
|
||||||
|
<div ng-repeat="val in var.select" 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="val.text" placeholder="Yes, I agree">
|
||||||
|
</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="val.value" placeholder="Y">
|
||||||
|
</div>
|
||||||
|
<div class="input-group col-sm-1 input-group-sm">
|
||||||
|
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeEnvVarValue(var, $index)">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<input style="margin-left: 5px;" type="checkbox" ng-model="val.default" id="val_default_{{$index}}"><label for="val_default_{{$index}}" class="space-left">Default</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- envvar-values-list -->
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-12" ng-show="$ctrl.model.Env.length > 1">
|
||||||
|
<div class="line-separator"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !env-var-list -->
|
||||||
|
</div>
|
||||||
|
<!-- !env -->
|
||||||
|
</div>
|
||||||
|
<!-- !environment-details -->
|
||||||
|
<!-- 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 || !templateForm.$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>
|
|
@ -0,0 +1,10 @@
|
||||||
|
angular.module('portainer.app').component('templateItem', {
|
||||||
|
templateUrl: 'app/portainer/components/template-list/template-item/templateItem.html',
|
||||||
|
bindings: {
|
||||||
|
model: '=',
|
||||||
|
onSelect: '<',
|
||||||
|
onDelete: '<',
|
||||||
|
showUpdateAction: '<',
|
||||||
|
showDeleteAction: '<'
|
||||||
|
}
|
||||||
|
});
|
|
@ -0,0 +1,56 @@
|
||||||
|
<!-- template -->
|
||||||
|
<div ng-class="{ 'template-container--selected': $ctrl.model.Selected }" class="template-container" ng-click="$ctrl.onSelect($ctrl.model)">
|
||||||
|
<div class="template-main">
|
||||||
|
<!-- template-image -->
|
||||||
|
<span ng-if="$ctrl.model.Logo">
|
||||||
|
<img class="template-logo" ng-src="{{ $ctrl.model.Logo }}" />
|
||||||
|
</span>
|
||||||
|
<span class="template-logo" ng-if="!$ctrl.model.Logo">
|
||||||
|
<i class="fa fa-rocket fa-4x blue-icon" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
|
<!-- !template-image -->
|
||||||
|
<!-- template-details -->
|
||||||
|
<span class="col-sm-12">
|
||||||
|
<!-- template-line1 -->
|
||||||
|
<div class="template-line">
|
||||||
|
<span>
|
||||||
|
<span class="template-title">
|
||||||
|
{{ $ctrl.model.Title }}
|
||||||
|
</span>
|
||||||
|
<span class="space-left template-type">
|
||||||
|
<span>
|
||||||
|
<i class="fab fa-linux" aria-hidden="true" ng-if="$ctrl.model.Platform === 'linux' || !$ctrl.model.Platform"></i>
|
||||||
|
<span ng-if="!$ctrl.model.Platform"> & </span>
|
||||||
|
<i class="fab fa-windows" aria-hidden="true" ng-if="$ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"></i>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{{ $ctrl.model.Type === 1 ? 'container' : 'stack' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-small">
|
||||||
|
<a ui-sref="portainer.templates.template({ id: $ctrl.model.Id })" class="btn btn-xs btn-primary" ng-click="$event.stopPropagation();" ng-if="$ctrl.showUpdateAction">
|
||||||
|
<i class="fa fa-edit" aria-hidden="true"></i>
|
||||||
|
Update
|
||||||
|
</a>
|
||||||
|
<btn class="btn btn-xs btn-danger" ng-click="$event.stopPropagation(); $ctrl.onDelete($ctrl.model)" ng-if="$ctrl.showDeleteAction">
|
||||||
|
<i class="fa fa-trash" aria-hidden="true"></i> Delete
|
||||||
|
</btn>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- !template-line1 -->
|
||||||
|
<!-- template-line2 -->
|
||||||
|
<div class="template-line">
|
||||||
|
<span class="template-description">
|
||||||
|
{{ $ctrl.model.Description }}
|
||||||
|
</span>
|
||||||
|
<span class="small text-muted" ng-if="$ctrl.model.Categories.length > 0">
|
||||||
|
{{ $ctrl.model.Categories.join(', ') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- !template-line2 -->
|
||||||
|
</span>
|
||||||
|
<!-- !template-details -->
|
||||||
|
</div>
|
||||||
|
<!-- !template -->
|
||||||
|
</div>
|
59
app/portainer/components/template-list/template-list.js
Normal file
59
app/portainer/components/template-list/template-list.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
angular.module('portainer.app').component('templateList', {
|
||||||
|
templateUrl: 'app/portainer/components/template-list/templateList.html',
|
||||||
|
controller: function() {
|
||||||
|
var ctrl = this;
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
textFilter: '',
|
||||||
|
selectedCategory: '',
|
||||||
|
categories: [],
|
||||||
|
showContainerTemplates: true
|
||||||
|
};
|
||||||
|
|
||||||
|
this.updateCategories = function() {
|
||||||
|
var availableCategories = [];
|
||||||
|
|
||||||
|
for (var i = 0; i < ctrl.templates.length; i++) {
|
||||||
|
var template = ctrl.templates[i];
|
||||||
|
if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) {
|
||||||
|
availableCategories = availableCategories.concat(template.Categories);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.categories = _.sortBy(_.uniq(availableCategories));
|
||||||
|
};
|
||||||
|
|
||||||
|
this.filterByCategory = function(item) {
|
||||||
|
if (!ctrl.state.selectedCategory) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _.includes(item.Categories, ctrl.state.selectedCategory);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.filterByType = function(item) {
|
||||||
|
if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.$onInit = function() {
|
||||||
|
if (this.showSwarmStacks) {
|
||||||
|
this.state.showContainerTemplates = false;
|
||||||
|
}
|
||||||
|
this.updateCategories();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
titleText: '@',
|
||||||
|
titleIcon: '@',
|
||||||
|
templates: '<',
|
||||||
|
selectAction: '<',
|
||||||
|
deleteAction: '<',
|
||||||
|
showSwarmStacks: '<',
|
||||||
|
showAddAction: '<',
|
||||||
|
showUpdateAction: '<',
|
||||||
|
showDeleteAction: '<'
|
||||||
|
}
|
||||||
|
});
|
62
app/portainer/components/template-list/templateList.html
Normal file
62
app/portainer/components/template-list/templateList.html
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div class="actionBar">
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.templates.new" ng-if="$ctrl.showAddAction">
|
||||||
|
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add template
|
||||||
|
</button>
|
||||||
|
<span ng-class="{ 'pull-right': $ctrl.showAddAction }" style="width: 25%;">
|
||||||
|
<ui-select ng-model="$ctrl.state.selectedCategory">
|
||||||
|
<ui-select-match placeholder="Select a category" allow-clear="true">
|
||||||
|
<span>{{ $select.selected }}</span>
|
||||||
|
</ui-select-match>
|
||||||
|
<ui-select-choices repeat="category in ($ctrl.state.categories | filter: $select.search)">
|
||||||
|
<span>{{ category }}</span>
|
||||||
|
</ui-select-choices>
|
||||||
|
</ui-select>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="small text-muted" style="margin: 15px 0 0 5px;">
|
||||||
|
<label for="show_stacks" class="control-label text-left">
|
||||||
|
Show container templates
|
||||||
|
</label>
|
||||||
|
<label class="switch" style="margin-left: 20px;">
|
||||||
|
<input type="checkbox" name="show_stacks" ng-model="$ctrl.state.showContainerTemplates" ng-change="$ctrl.updateCategories()"><i></i>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="searchBar" style="border-top: 2px solid #f6f6f6;">
|
||||||
|
<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="template-list">
|
||||||
|
<template-item
|
||||||
|
ng-repeat="template in $ctrl.templates | filter: $ctrl.filterByType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter"
|
||||||
|
model="template"
|
||||||
|
show-update-action="$ctrl.showUpdateAction"
|
||||||
|
show-delete-action="$ctrl.showDeleteAction"
|
||||||
|
on-select="$ctrl.selectAction"
|
||||||
|
on-delete="$ctrl.deleteAction"
|
||||||
|
></template-item>
|
||||||
|
<div ng-if="!$ctrl.templates" class="text-center text-muted">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div ng-if="($ctrl.templates | filter: $ctrl.filterByType | filter:$ctrl.filterByCategory | filter:$ctrl.state.textFilter).length == 0" class="text-center text-muted">
|
||||||
|
No templates available.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
|
@ -7,7 +7,7 @@ angular.module('portainer.app')
|
||||||
icon: '='
|
icon: '='
|
||||||
},
|
},
|
||||||
transclude: true,
|
transclude: true,
|
||||||
template: '<div class="widget-header"><div class="row"><span class="pull-left"><img class="custom-header-ico" ng-src="{{icon}}"></img> <span class="text-muted"> {{titleText}} </span> </span><span class="pull-right col-xs-6 col-sm-4" ng-transclude></span></div></div>',
|
template: '<div class="widget-header"><div class="row"><span class="pull-left"><img class="custom-header-ico" ng-src="{{icon}}" ng-if="icon"></img><i class="fa fa-rocket" aria-hidden="true" ng-if="!icon"></i> <span class="text-muted"> {{titleText}} </span> </span><span class="pull-right col-xs-6 col-sm-4" ng-transclude></span></div></div>',
|
||||||
restrict: 'E'
|
restrict: 'E'
|
||||||
};
|
};
|
||||||
return directive;
|
return directive;
|
||||||
|
|
|
@ -62,14 +62,6 @@ angular.module('portainer.app')
|
||||||
templateEnvironment.forEach(function(envvar) {
|
templateEnvironment.forEach(function(envvar) {
|
||||||
if (envvar.value || envvar.set) {
|
if (envvar.value || envvar.set) {
|
||||||
var value = envvar.set ? envvar.set : envvar.value;
|
var value = envvar.set ? envvar.set : envvar.value;
|
||||||
if (envvar.type && envvar.type === 'container') {
|
|
||||||
if (containerMapping === 'BY_CONTAINER_IP') {
|
|
||||||
var container = envvar.value;
|
|
||||||
value = container.NetworkSettings.Networks[Object.keys(container.NetworkSettings.Networks)[0]].IPAddress;
|
|
||||||
} else if (containerMapping === 'BY_CONTAINER_NAME') {
|
|
||||||
value = $filter('containername')(envvar.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
env.push(envvar.name + '=' + value);
|
env.push(envvar.name + '=' + value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -90,14 +82,14 @@ angular.module('portainer.app')
|
||||||
|
|
||||||
helper.createVolumeBindings = function(volumes, generatedVolumesPile) {
|
helper.createVolumeBindings = function(volumes, generatedVolumesPile) {
|
||||||
volumes.forEach(function (volume) {
|
volumes.forEach(function (volume) {
|
||||||
if (volume.containerPath) {
|
if (volume.container) {
|
||||||
var binding;
|
var binding;
|
||||||
if (volume.type === 'auto') {
|
if (volume.type === 'auto') {
|
||||||
binding = generatedVolumesPile.pop().Id + ':' + volume.containerPath;
|
binding = generatedVolumesPile.pop().Id + ':' + volume.container;
|
||||||
} else if (volume.type !== 'auto' && volume.name) {
|
} else if (volume.type !== 'auto' && volume.bind) {
|
||||||
binding = volume.name + ':' + volume.containerPath;
|
binding = volume.bind + ':' + volume.container;
|
||||||
}
|
}
|
||||||
if (volume.readOnly) {
|
if (volume.readonly) {
|
||||||
binding += ':ro';
|
binding += ':ro';
|
||||||
}
|
}
|
||||||
volume.binding = binding;
|
volume.binding = binding;
|
||||||
|
@ -115,21 +107,13 @@ angular.module('portainer.app')
|
||||||
return count;
|
return count;
|
||||||
};
|
};
|
||||||
|
|
||||||
helper.filterLinuxServerIOTemplates = function(templates) {
|
helper.getUniqueCategories = function(templates) {
|
||||||
return templates.filter(function f(template) {
|
var categories = [];
|
||||||
var valid = false;
|
for (var i = 0; i < templates.length; i++) {
|
||||||
if (template.Categories) {
|
var template = templates[i];
|
||||||
angular.forEach(template.Categories, function(category) {
|
categories = categories.concat(template.Categories);
|
||||||
if (_.startsWith(category, 'Network')) {
|
|
||||||
valid = true;
|
|
||||||
}
|
}
|
||||||
});
|
return _.uniq(categories);
|
||||||
}
|
|
||||||
return valid;
|
|
||||||
}).map(function(template, idx) {
|
|
||||||
template.index = idx;
|
|
||||||
return template;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return helper;
|
return helper;
|
||||||
|
|
|
@ -1,8 +1,6 @@
|
||||||
function SettingsViewModel(data) {
|
function SettingsViewModel(data) {
|
||||||
this.TemplatesURL = data.TemplatesURL;
|
|
||||||
this.LogoURL = data.LogoURL;
|
this.LogoURL = data.LogoURL;
|
||||||
this.BlackListedLabels = data.BlackListedLabels;
|
this.BlackListedLabels = data.BlackListedLabels;
|
||||||
this.DisplayExternalContributors = data.DisplayExternalContributors;
|
|
||||||
this.AuthenticationMethod = data.AuthenticationMethod;
|
this.AuthenticationMethod = data.AuthenticationMethod;
|
||||||
this.LDAPSettings = data.LDAPSettings;
|
this.LDAPSettings = data.LDAPSettings;
|
||||||
this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers;
|
this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers;
|
||||||
|
|
145
app/portainer/models/template.js
Normal file
145
app/portainer/models/template.js
Normal file
|
@ -0,0 +1,145 @@
|
||||||
|
function TemplateDefaultModel() {
|
||||||
|
this.Type = 1;
|
||||||
|
this.AdministratorOnly = false;
|
||||||
|
this.Title = '';
|
||||||
|
this.Image = '';
|
||||||
|
this.Description = '';
|
||||||
|
this.Volumes = [];
|
||||||
|
this.Ports = [];
|
||||||
|
this.Env = [];
|
||||||
|
this.Labels = [];
|
||||||
|
this.RestartPolicy = 'always';
|
||||||
|
this.Registry = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateCreateRequest(model) {
|
||||||
|
this.Type = model.Type;
|
||||||
|
this.Name = model.Name;
|
||||||
|
this.Hostname = model.Hostname;
|
||||||
|
this.Title = model.Title;
|
||||||
|
this.Description = model.Description;
|
||||||
|
this.Note = model.Note;
|
||||||
|
this.Categories = model.Categories;
|
||||||
|
this.Platform = model.Platform;
|
||||||
|
this.Logo = model.Logo;
|
||||||
|
this.Image = model.Image;
|
||||||
|
this.Registry = model.Registry.URL;
|
||||||
|
this.Command = model.Command;
|
||||||
|
this.Network = model.Network;
|
||||||
|
this.Privileged = model.Privileged;
|
||||||
|
this.Interactive = model.Interactive;
|
||||||
|
this.RestartPolicy = model.RestartPolicy;
|
||||||
|
this.Labels = model.Labels;
|
||||||
|
this.Repository = model.Repository;
|
||||||
|
this.Env = model.Env;
|
||||||
|
this.AdministratorOnly = model.AdministratorOnly;
|
||||||
|
|
||||||
|
this.Ports = [];
|
||||||
|
for (var i = 0; i < model.Ports.length; i++) {
|
||||||
|
var binding = model.Ports[i];
|
||||||
|
if (binding.containerPort && binding.protocol) {
|
||||||
|
var port = binding.hostPort ? binding.hostPort + ':' + binding.containerPort + '/' + binding.protocol: binding.containerPort + '/' + binding.protocol;
|
||||||
|
this.Ports.push(port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.Volumes = model.Volumes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateUpdateRequest(model) {
|
||||||
|
TemplateCreateRequest.call(this, model);
|
||||||
|
this.id = model.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateViewModel(data) {
|
||||||
|
this.Id = data.Id;
|
||||||
|
this.Title = data.title;
|
||||||
|
this.Type = data.type;
|
||||||
|
this.Description = data.description;
|
||||||
|
this.AdministratorOnly = data.AdministratorOnly;
|
||||||
|
this.Name = data.name;
|
||||||
|
this.Note = data.note;
|
||||||
|
this.Categories = data.categories ? data.categories : [];
|
||||||
|
this.Platform = data.platform ? data.platform : '';
|
||||||
|
this.Logo = data.logo;
|
||||||
|
this.Repository = data.repository;
|
||||||
|
this.Hostname = data.hostname;
|
||||||
|
this.Registry = data.registry ? { URL: data.registry } : {};
|
||||||
|
this.Image = data.image;
|
||||||
|
this.Registry = data.registry ? data.registry : '';
|
||||||
|
this.Command = data.command ? data.command : '';
|
||||||
|
this.Network = data.network ? data.network : '';
|
||||||
|
this.Privileged = data.privileged ? data.privileged : false;
|
||||||
|
this.Interactive = data.interactive ? data.interactive : false;
|
||||||
|
this.RestartPolicy = data.restart_policy ? data.restart_policy : 'always';
|
||||||
|
this.Labels = data.labels ? data.labels : [];
|
||||||
|
this.Hosts = data.hosts ? data.hosts : [];
|
||||||
|
this.Env = templateEnv(data);
|
||||||
|
this.Volumes = templateVolumes(data);
|
||||||
|
this.Ports = templatePorts(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function templatePorts(data) {
|
||||||
|
var ports = [];
|
||||||
|
|
||||||
|
if (data.ports) {
|
||||||
|
ports = data.ports.map(function (p) {
|
||||||
|
var portAndProtocol = _.split(p, '/');
|
||||||
|
var hostAndContainerPort = _.split(portAndProtocol[0], ':');
|
||||||
|
|
||||||
|
return {
|
||||||
|
hostPort: hostAndContainerPort.length > 1 ? hostAndContainerPort[0] : undefined,
|
||||||
|
containerPort: hostAndContainerPort.length > 1 ? hostAndContainerPort[1] : hostAndContainerPort[0],
|
||||||
|
protocol: portAndProtocol[1]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return ports;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateVolumes(data) {
|
||||||
|
var volumes = [];
|
||||||
|
|
||||||
|
if (data.volumes) {
|
||||||
|
volumes = data.volumes.map(function (v) {
|
||||||
|
return {
|
||||||
|
container: v.container,
|
||||||
|
readonly: v.readonly || false,
|
||||||
|
type: v.bind ? 'bind' : 'auto',
|
||||||
|
bind : v.bind ? v.bind : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return volumes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function templateEnv(data) {
|
||||||
|
var env = [];
|
||||||
|
|
||||||
|
if (data.env) {
|
||||||
|
env = data.env.map(function(envvar) {
|
||||||
|
envvar.type = 2;
|
||||||
|
envvar.value = envvar.default ? envvar.default : '';
|
||||||
|
|
||||||
|
if (envvar.preset) {
|
||||||
|
envvar.type = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envvar.select) {
|
||||||
|
envvar.type = 3;
|
||||||
|
for (var i = 0; i < envvar.select.length; i++) {
|
||||||
|
var allowedValue = envvar.select[i];
|
||||||
|
if (allowedValue.default) {
|
||||||
|
envvar.value = allowedValue.value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return envvar;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
|
@ -1,6 +1,10 @@
|
||||||
angular.module('portainer.app')
|
angular.module('portainer.app')
|
||||||
.factory('Template', ['$resource', 'API_ENDPOINT_TEMPLATES', function TemplateFactory($resource, API_ENDPOINT_TEMPLATES) {
|
.factory('Templates', ['$resource', 'API_ENDPOINT_TEMPLATES', function TemplatesFactory($resource, API_ENDPOINT_TEMPLATES) {
|
||||||
return $resource(API_ENDPOINT_TEMPLATES, {}, {
|
return $resource(API_ENDPOINT_TEMPLATES + '/:id', {}, {
|
||||||
get: {method: 'GET', isArray: true}
|
create: { method: 'POST' },
|
||||||
|
query: { method: 'GET', isArray: true },
|
||||||
|
get: { method: 'GET', params: { id: '@id'} },
|
||||||
|
update: { method: 'PUT', params: { id: '@id'} },
|
||||||
|
remove: { method: 'DELETE', params: { id: '@id'} }
|
||||||
});
|
});
|
||||||
}]);
|
}]);
|
||||||
|
|
|
@ -1,43 +1,64 @@
|
||||||
angular.module('portainer.app')
|
angular.module('portainer.app')
|
||||||
.factory('TemplateService', ['$q', 'Template', 'TemplateHelper', 'ImageHelper', 'ContainerHelper', function TemplateServiceFactory($q, Template, TemplateHelper, ImageHelper, ContainerHelper) {
|
.factory('TemplateService', ['$q', 'Templates', 'TemplateHelper', 'ImageHelper', 'ContainerHelper',
|
||||||
|
function TemplateServiceFactory($q, Templates, TemplateHelper, ImageHelper, ContainerHelper) {
|
||||||
'use strict';
|
'use strict';
|
||||||
var service = {};
|
var service = {};
|
||||||
|
|
||||||
service.getTemplates = function(key) {
|
service.templates = function() {
|
||||||
var deferred = $q.defer();
|
var deferred = $q.defer();
|
||||||
Template.get({key: key}).$promise
|
|
||||||
|
Templates.query().$promise
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
var templates = data.map(function (tpl, idx) {
|
var templates = data.map(function (item) {
|
||||||
var template;
|
return new TemplateViewModel(item);
|
||||||
if (tpl.type === 'stack') {
|
|
||||||
template = new StackTemplateViewModel(tpl);
|
|
||||||
} else if (tpl.type === 'container' && key === 'linuxserver.io') {
|
|
||||||
template = new TemplateLSIOViewModel(tpl);
|
|
||||||
} else {
|
|
||||||
template = new TemplateViewModel(tpl);
|
|
||||||
}
|
|
||||||
template.index = idx;
|
|
||||||
return template;
|
|
||||||
});
|
});
|
||||||
if (key === 'linuxserver.io') {
|
|
||||||
templates = TemplateHelper.filterLinuxServerIOTemplates(templates);
|
|
||||||
}
|
|
||||||
deferred.resolve(templates);
|
deferred.resolve(templates);
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
deferred.reject({ msg: 'Unable to retrieve templates', err: err });
|
deferred.reject({ msg: 'Unable to retrieve templates', err: err });
|
||||||
});
|
});
|
||||||
|
|
||||||
return deferred.promise;
|
return deferred.promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createTemplateConfiguration = function(template, containerName, network, containerMapping) {
|
service.template = function(id) {
|
||||||
|
var deferred = $q.defer();
|
||||||
|
|
||||||
|
Templates.get({ id: id }).$promise
|
||||||
|
.then(function success(data) {
|
||||||
|
var template = new TemplateViewModel(data);
|
||||||
|
deferred.resolve(template);
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
deferred.reject({ msg: 'Unable to retrieve template details', err: err });
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return deferred.promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.delete = function(id) {
|
||||||
|
return Templates.remove({ id: id }).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.create = function(model) {
|
||||||
|
var payload = new TemplateCreateRequest(model);
|
||||||
|
return Templates.create(payload).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.update = function(model) {
|
||||||
|
var payload = new TemplateUpdateRequest(model);
|
||||||
|
return Templates.update(payload).$promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
service.createTemplateConfiguration = function(template, containerName, network) {
|
||||||
var imageConfiguration = ImageHelper.createImageConfigForContainer(template.Image, template.Registry);
|
var imageConfiguration = ImageHelper.createImageConfigForContainer(template.Image, template.Registry);
|
||||||
var containerConfiguration = service.createContainerConfiguration(template, containerName, network, containerMapping);
|
var containerConfiguration = service.createContainerConfiguration(template, containerName, network);
|
||||||
containerConfiguration.Image = imageConfiguration.fromImage + ':' + imageConfiguration.tag;
|
containerConfiguration.Image = imageConfiguration.fromImage + ':' + imageConfiguration.tag;
|
||||||
return containerConfiguration;
|
return containerConfiguration;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createContainerConfiguration = function(template, containerName, network, containerMapping) {
|
service.createContainerConfiguration = function(template, containerName, network) {
|
||||||
var configuration = TemplateHelper.getDefaultContainerConfiguration();
|
var configuration = TemplateHelper.getDefaultContainerConfiguration();
|
||||||
configuration.HostConfig.NetworkMode = network.Name;
|
configuration.HostConfig.NetworkMode = network.Name;
|
||||||
configuration.HostConfig.Privileged = template.Privileged;
|
configuration.HostConfig.Privileged = template.Privileged;
|
||||||
|
@ -46,7 +67,7 @@ angular.module('portainer.app')
|
||||||
configuration.name = containerName;
|
configuration.name = containerName;
|
||||||
configuration.Hostname = template.Hostname;
|
configuration.Hostname = template.Hostname;
|
||||||
configuration.Image = template.Image;
|
configuration.Image = template.Image;
|
||||||
configuration.Env = TemplateHelper.EnvToStringArray(template.Env, containerMapping);
|
configuration.Env = TemplateHelper.EnvToStringArray(template.Env);
|
||||||
configuration.Cmd = ContainerHelper.commandStringToArray(template.Command);
|
configuration.Cmd = ContainerHelper.commandStringToArray(template.Command);
|
||||||
var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports);
|
var portConfiguration = TemplateHelper.portArrayToPortConfiguration(template.Ports);
|
||||||
configuration.HostConfig.PortBindings = portConfiguration.bindings;
|
configuration.HostConfig.PortBindings = portConfiguration.bindings;
|
||||||
|
@ -63,7 +84,7 @@ angular.module('portainer.app')
|
||||||
TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile);
|
TemplateHelper.createVolumeBindings(volumes, generatedVolumesPile);
|
||||||
volumes.forEach(function (volume) {
|
volumes.forEach(function (volume) {
|
||||||
if (volume.binding) {
|
if (volume.binding) {
|
||||||
configuration.Volumes[volume.containerPath] = {};
|
configuration.Volumes[volume.container] = {};
|
||||||
configuration.HostConfig.Binds.push(volume.binding);
|
configuration.HostConfig.Binds.push(volume.binding);
|
||||||
}
|
}
|
||||||
});
|
});
|
|
@ -25,18 +25,12 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
|
||||||
LocalStorage.storeApplicationState(state.application);
|
LocalStorage.storeApplicationState(state.application);
|
||||||
};
|
};
|
||||||
|
|
||||||
manager.updateExternalContributions = function(displayExternalContributors) {
|
|
||||||
state.application.displayExternalContributors = displayExternalContributors;
|
|
||||||
LocalStorage.storeApplicationState(state.application);
|
|
||||||
};
|
|
||||||
|
|
||||||
function assignStateFromStatusAndSettings(status, settings) {
|
function assignStateFromStatusAndSettings(status, settings) {
|
||||||
state.application.authentication = status.Authentication;
|
state.application.authentication = status.Authentication;
|
||||||
state.application.analytics = status.Analytics;
|
state.application.analytics = status.Analytics;
|
||||||
state.application.endpointManagement = status.EndpointManagement;
|
state.application.endpointManagement = status.EndpointManagement;
|
||||||
state.application.version = status.Version;
|
state.application.version = status.Version;
|
||||||
state.application.logo = settings.LogoURL;
|
state.application.logo = settings.LogoURL;
|
||||||
state.application.displayExternalContributors = settings.DisplayExternalContributors;
|
|
||||||
state.application.validity = moment().unix();
|
state.application.validity = moment().unix();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<rd-header>
|
<rd-header>
|
||||||
<rd-header-title title-text="Endpoint group details"></rd-header-title>
|
<rd-header-title title-text="Endpoint group details"></rd-header-title>
|
||||||
<rd-header-content>
|
<rd-header-content>
|
||||||
<a ui-sref="portainer.groups">Groups</a> > <a ui-sref="portainer.groups.group({id: group.Id})">{{ group.Name }}</a>
|
<a ui-sref="portainer.groups">Groups</a> > {{ ::group.Name }}
|
||||||
</rd-header-content>
|
</rd-header-content>
|
||||||
</rd-header>
|
</rd-header>
|
||||||
|
|
||||||
|
|
|
@ -63,47 +63,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- security -->
|
<!-- security -->
|
||||||
<!-- app-templates -->
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
App Templates
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label for="toggle_templates" class="control-label text-left">
|
|
||||||
Use custom templates
|
|
||||||
</label>
|
|
||||||
<label class="switch" style="margin-left: 20px;">
|
|
||||||
<input type="checkbox" name="toggle_templates" ng-model="formValues.customTemplates"><i></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div ng-if="formValues.customTemplates">
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
You can specify the URL to your own template definitions file here. See <a href="https://portainer.readthedocs.io/en/stable/templates.html" target="_blank">Portainer documentation</a> for more details.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group" >
|
|
||||||
<label for="templates_url" class="col-sm-1 control-label text-left">
|
|
||||||
URL
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<input type="text" class="form-control" ng-model="settings.TemplatesURL" id="templates_url" placeholder="https://myserver.mydomain/templates.json">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<label for="toggle_external_contrib" class="control-label text-left">
|
|
||||||
Hide external contributions
|
|
||||||
<portainer-tooltip position="bottom" message="When enabled, external contributions such as LinuxServer.io will not be displayed in the sidebar."></portainer-tooltip>
|
|
||||||
</label>
|
|
||||||
<label class="switch" style="margin-left: 20px;">
|
|
||||||
<input type="checkbox" name="toggle_external_contrib" ng-model="formValues.externalContributions"><i></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !app-templates -->
|
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
|
|
|
@ -8,8 +8,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
customLogo: false,
|
customLogo: false,
|
||||||
customTemplates: false,
|
|
||||||
externalContributions: false,
|
|
||||||
restrictBindMounts: false,
|
restrictBindMounts: false,
|
||||||
restrictPrivilegedMode: false,
|
restrictPrivilegedMode: false,
|
||||||
labelName: '',
|
labelName: '',
|
||||||
|
@ -41,11 +39,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
|
||||||
settings.LogoURL = '';
|
settings.LogoURL = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$scope.formValues.customTemplates) {
|
|
||||||
settings.TemplatesURL = DEFAULT_TEMPLATES_URL;
|
|
||||||
}
|
|
||||||
|
|
||||||
settings.DisplayExternalContributors = !$scope.formValues.externalContributions;
|
|
||||||
settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts;
|
settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts;
|
||||||
settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode;
|
settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode;
|
||||||
|
|
||||||
|
@ -58,7 +51,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
Notifications.success('Settings updated');
|
Notifications.success('Settings updated');
|
||||||
StateManager.updateLogo(settings.LogoURL);
|
StateManager.updateLogo(settings.LogoURL);
|
||||||
StateManager.updateExternalContributions(settings.DisplayExternalContributors);
|
|
||||||
$state.reload();
|
$state.reload();
|
||||||
})
|
})
|
||||||
.catch(function error(err) {
|
.catch(function error(err) {
|
||||||
|
@ -77,10 +69,6 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
|
||||||
if (settings.LogoURL !== '') {
|
if (settings.LogoURL !== '') {
|
||||||
$scope.formValues.customLogo = true;
|
$scope.formValues.customLogo = true;
|
||||||
}
|
}
|
||||||
if (settings.TemplatesURL !== DEFAULT_TEMPLATES_URL) {
|
|
||||||
$scope.formValues.customTemplates = true;
|
|
||||||
}
|
|
||||||
$scope.formValues.externalContributions = !settings.DisplayExternalContributors;
|
|
||||||
$scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers;
|
$scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers;
|
||||||
$scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers;
|
$scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers;
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
angular.module('portainer.app')
|
||||||
|
.controller('CreateTemplateController', ['$q', '$scope', '$state', 'TemplateService', 'TemplateHelper', 'NetworkService', 'Notifications',
|
||||||
|
function ($q, $scope, $state, TemplateService, TemplateHelper, NetworkService, Notifications) {
|
||||||
|
|
||||||
|
$scope.state = {
|
||||||
|
actionInProgress: false
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.create = function() {
|
||||||
|
var model = $scope.model;
|
||||||
|
|
||||||
|
$scope.state.actionInProgress = true;
|
||||||
|
TemplateService.create(model)
|
||||||
|
.then(function success() {
|
||||||
|
Notifications.success('Template successfully created', model.Title);
|
||||||
|
$state.go('portainer.templates');
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to create template');
|
||||||
|
})
|
||||||
|
.finally(function final() {
|
||||||
|
$scope.state.actionInProgress = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function initView() {
|
||||||
|
$scope.model = new TemplateDefaultModel();
|
||||||
|
var provider = $scope.applicationState.endpoint.mode.provider;
|
||||||
|
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||||
|
|
||||||
|
$q.all({
|
||||||
|
templates: TemplateService.templates(),
|
||||||
|
networks: NetworkService.networks(
|
||||||
|
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
|
||||||
|
false,
|
||||||
|
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.then(function success(data) {
|
||||||
|
$scope.categories = TemplateHelper.getUniqueCategories(data.templates);
|
||||||
|
$scope.networks = data.networks;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to retrieve template details');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initView();
|
||||||
|
}]);
|
24
app/portainer/views/templates/create/createtemplate.html
Normal file
24
app/portainer/views/templates/create/createtemplate.html
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Create template"></rd-header-title>
|
||||||
|
<rd-header-content>
|
||||||
|
<a ui-sref="portainer.templates">Templates</a> > Add template
|
||||||
|
</rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body>
|
||||||
|
<template-form
|
||||||
|
model="model"
|
||||||
|
categories="categories"
|
||||||
|
networks="networks"
|
||||||
|
form-action="create"
|
||||||
|
show-type-selector="true"
|
||||||
|
form-action-label="Create the template"
|
||||||
|
action-in-progress="state.actionInProgress"
|
||||||
|
></template-form>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
28
app/portainer/views/templates/edit/template.html
Normal file
28
app/portainer/views/templates/edit/template.html
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Template details">
|
||||||
|
<a data-toggle="tooltip" title-text="Refresh" ui-sref="portainer.templates.template({id: template.Id})" ui-sref-opts="{reload: true}">
|
||||||
|
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</rd-header-title>
|
||||||
|
<rd-header-content>
|
||||||
|
<a ui-sref="portainer.templates">Templates</a> > {{ ::template.Title }}</a>
|
||||||
|
</rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body>
|
||||||
|
<template-form
|
||||||
|
model="template"
|
||||||
|
categories="categories"
|
||||||
|
networks="networks"
|
||||||
|
form-action="update"
|
||||||
|
show-type-selector="false"
|
||||||
|
form-action-label="Update the template"
|
||||||
|
action-in-progress="state.actionInProgress"
|
||||||
|
></template-form>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
51
app/portainer/views/templates/edit/templateController.js
Normal file
51
app/portainer/views/templates/edit/templateController.js
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
angular.module('portainer.app')
|
||||||
|
.controller('TemplateController', ['$q', '$scope', '$state', '$transition$', 'TemplateService', 'TemplateHelper', 'NetworkService', 'Notifications',
|
||||||
|
function ($q, $scope, $state, $transition$, TemplateService, TemplateHelper, NetworkService, Notifications) {
|
||||||
|
|
||||||
|
$scope.state = {
|
||||||
|
actionInProgress: false
|
||||||
|
};
|
||||||
|
|
||||||
|
$scope.update = function() {
|
||||||
|
var model = $scope.template;
|
||||||
|
|
||||||
|
$scope.state.actionInProgress = true;
|
||||||
|
TemplateService.update(model)
|
||||||
|
.then(function success() {
|
||||||
|
Notifications.success('Template successfully updated', model.Title);
|
||||||
|
$state.go('portainer.templates');
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to update template');
|
||||||
|
})
|
||||||
|
.finally(function final() {
|
||||||
|
$scope.state.actionInProgress = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function initView() {
|
||||||
|
var provider = $scope.applicationState.endpoint.mode.provider;
|
||||||
|
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||||
|
|
||||||
|
var templateId = $transition$.params().id;
|
||||||
|
$q.all({
|
||||||
|
templates: TemplateService.templates(),
|
||||||
|
template: TemplateService.template(templateId),
|
||||||
|
networks: NetworkService.networks(
|
||||||
|
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
|
||||||
|
false,
|
||||||
|
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.then(function success(data) {
|
||||||
|
$scope.categories = TemplateHelper.getUniqueCategories(data.templates);
|
||||||
|
$scope.template = data.template;
|
||||||
|
$scope.networks = data.networks;
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to retrieve template details');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initView();
|
||||||
|
}]);
|
|
@ -1,6 +1,6 @@
|
||||||
<rd-header id="view-top">
|
<rd-header id="view-top">
|
||||||
<rd-header-title title-text="Application templates list">
|
<rd-header-title title-text="Application templates list">
|
||||||
<a data-toggle="tooltip" title="Refresh" ui-sref="docker.templates" ui-sref-opts="{reload: true}">
|
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.templates" ui-sref-opts="{reload: true}">
|
||||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
</rd-header-title>
|
</rd-header-title>
|
||||||
|
@ -9,13 +9,9 @@
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<!-- stack-form -->
|
<!-- stack-form -->
|
||||||
<div class="col-sm-12" ng-if="state.selectedTemplate && state.filters.Type === 'stack'">
|
<div class="col-sm-12" ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)">
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title-text="state.selectedTemplate.Title">
|
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title-text="state.selectedTemplate.Title"></rd-widget-custom-header>
|
||||||
<div class="pull-right">
|
|
||||||
<button type="button" class="btn btn-sm btn-primary" ng-click="unselectTemplate()">Hide</button>
|
|
||||||
</div>
|
|
||||||
</rd-widget-custom-header>
|
|
||||||
<rd-widget-body classes="padding">
|
<rd-widget-body classes="padding">
|
||||||
|
|
||||||
<form class="form-horizontal">
|
<form class="form-horizontal">
|
||||||
|
@ -43,10 +39,12 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !name-input -->
|
<!-- !name-input -->
|
||||||
<!-- env -->
|
<!-- env -->
|
||||||
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="var.label && !var.set" class="form-group">
|
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.preset || var.select" class="form-group">
|
||||||
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">{{ var.label }}<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip></label>
|
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
|
||||||
|
{{ var.label }}
|
||||||
|
<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip>
|
||||||
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<!-- <input ng-if="!var.values && (!var.type || !var.type === 'container')" type="text" class="form-control" ng-model="var.value" id="field_{{ $index }}"> -->
|
|
||||||
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}">
|
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}">
|
||||||
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
|
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
|
||||||
<option selected disabled hidden value="">Select value</option>
|
<option selected disabled hidden value="">Select value</option>
|
||||||
|
@ -68,6 +66,7 @@
|
||||||
<span ng-hide="state.actionInProgress">Deploy the stack</span>
|
<span ng-hide="state.actionInProgress">Deploy the stack</span>
|
||||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-default" ng-click="unselectTemplate(state.selectedTemplate)">Hide</button>
|
||||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -79,13 +78,9 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !stack-form -->
|
<!-- !stack-form -->
|
||||||
<!-- container-form -->
|
<!-- container-form -->
|
||||||
<div class="col-sm-12" ng-if="state.selectedTemplate && state.filters.Type === 'container'">
|
<div class="col-sm-12" ng-if="state.selectedTemplate && state.selectedTemplate.Type === 1">
|
||||||
<rd-widget>
|
<rd-widget>
|
||||||
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title-text="state.selectedTemplate.Image">
|
<rd-widget-custom-header icon="state.selectedTemplate.Logo" title-text="state.selectedTemplate.Image"></rd-widget-custom-header>
|
||||||
<div class="pull-right">
|
|
||||||
<button type="button" class="btn btn-sm btn-primary" ng-click="unselectTemplate()">Hide</button>
|
|
||||||
</div>
|
|
||||||
</rd-widget-custom-header>
|
|
||||||
<rd-widget-body classes="padding">
|
<rd-widget-body classes="padding">
|
||||||
|
|
||||||
<form class="form-horizontal">
|
<form class="form-horizontal">
|
||||||
|
@ -123,13 +118,17 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !network-input -->
|
<!-- !network-input -->
|
||||||
<!-- env -->
|
<!-- env -->
|
||||||
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.set" class="form-group">
|
<div ng-repeat="var in state.selectedTemplate.Env" ng-if="!var.preset || var.select" class="form-group">
|
||||||
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">{{ var.label }}</label>
|
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
|
||||||
|
{{ var.label }}
|
||||||
|
<portainer-tooltip ng-if="var.description" position="bottom" message="{{ var.description }}"></portainer-tooltip>
|
||||||
|
</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<select ng-if="var.type === 'container'" ng-options="container|containername for container in runningContainers" class="form-control" ng-model="var.value">
|
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}">
|
||||||
<option selected disabled hidden value="">Select a container</option>
|
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
|
||||||
|
<option selected disabled hidden value="">Select value</option>
|
||||||
|
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
|
||||||
</select>
|
</select>
|
||||||
<input ng-if="!var.type || !var.type === 'container'" type="text" class="form-control" ng-model="var.value" id="field_{{ $index }}">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !env -->
|
<!-- !env -->
|
||||||
|
@ -212,15 +211,15 @@
|
||||||
<!-- container-path -->
|
<!-- container-path -->
|
||||||
<div class="input-group input-group-sm col-sm-6">
|
<div class="input-group input-group-sm col-sm-6">
|
||||||
<span class="input-group-addon">container</span>
|
<span class="input-group-addon">container</span>
|
||||||
<input type="text" class="form-control" ng-model="volume.containerPath" placeholder="e.g. /path/in/container">
|
<input type="text" class="form-control" ng-model="volume.container" placeholder="e.g. /path/in/container">
|
||||||
</div>
|
</div>
|
||||||
<!-- !container-path -->
|
<!-- !container-path -->
|
||||||
<!-- volume-type -->
|
<!-- volume-type -->
|
||||||
<div class="input-group col-sm-5" style="margin-left: 5px;">
|
<div class="input-group col-sm-5" style="margin-left: 5px;">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.name = ''">Auto</label>
|
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'auto'" ng-click="volume.bind = ''">Auto</label>
|
||||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.name = ''">Volume</label>
|
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'volume'" ng-click="volume.bind = ''">Volume</label>
|
||||||
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.name = ''" ng-if="isAdmin || allowBindMounts">Bind</label>
|
<label class="btn btn-primary" ng-model="volume.type" uib-btn-radio="'bind'" ng-click="volume.bind = ''" ng-if="isAdmin || allowBindMounts">Bind</label>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
|
<button class="btn btn-sm btn-danger" type="button" ng-click="removeVolume($index)">
|
||||||
<i class="fa fa-trash" aria-hidden="true"></i>
|
<i class="fa fa-trash" aria-hidden="true"></i>
|
||||||
|
@ -235,7 +234,7 @@
|
||||||
<!-- volume -->
|
<!-- volume -->
|
||||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
|
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'volume'">
|
||||||
<span class="input-group-addon">volume</span>
|
<span class="input-group-addon">volume</span>
|
||||||
<select class="form-control" ng-model="volume.name">
|
<select class="form-control" ng-model="volume.bind">
|
||||||
<option selected disabled hidden value="">Select a volume</option>
|
<option selected disabled hidden value="">Select a volume</option>
|
||||||
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
|
<option ng-repeat="vol in availableVolumes" ng-value="vol.Name">{{ vol.Name|truncate:30}}</option>
|
||||||
</select>
|
</select>
|
||||||
|
@ -244,14 +243,14 @@
|
||||||
<!-- bind -->
|
<!-- bind -->
|
||||||
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
|
<div class="input-group input-group-sm col-sm-6" ng-if="volume.type === 'bind'">
|
||||||
<span class="input-group-addon">host</span>
|
<span class="input-group-addon">host</span>
|
||||||
<input type="text" class="form-control" ng-model="volume.name" placeholder="e.g. /path/on/host">
|
<input type="text" class="form-control" ng-model="volume.bind" placeholder="e.g. /path/on/host">
|
||||||
</div>
|
</div>
|
||||||
<!-- !bind -->
|
<!-- !bind -->
|
||||||
<!-- read-only -->
|
<!-- read-only -->
|
||||||
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
|
<div class="input-group input-group-sm col-sm-5" style="margin-left: 5px;">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="false">Writable</label>
|
<label class="btn btn-primary" ng-model="volume.readonly" uib-btn-radio="false">Writable</label>
|
||||||
<label class="btn btn-primary" ng-model="volume.readOnly" uib-btn-radio="true">Read-only</label>
|
<label class="btn btn-primary" ng-model="volume.readonly" uib-btn-radio="true">Read-only</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !read-only -->
|
<!-- !read-only -->
|
||||||
|
@ -285,7 +284,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !extra-host -->
|
<!-- !extra-host -->
|
||||||
<!-- Label -->
|
<!-- labels -->
|
||||||
<div class="form-group" >
|
<div class="form-group" >
|
||||||
<div class="col-sm-12" style="margin-top: 5px;">
|
<div class="col-sm-12" style="margin-top: 5px;">
|
||||||
<label class="control-label text-left">Labels</label>
|
<label class="control-label text-left">Labels</label>
|
||||||
|
@ -313,7 +312,7 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !labels-input-list -->
|
<!-- !labels-input-list -->
|
||||||
</div>
|
</div>
|
||||||
<!-- !Label -->
|
<!-- !labels -->
|
||||||
<!-- hostname -->
|
<!-- hostname -->
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="container_hostname" class="col-sm-2 control-label text-left">Hostname</label>
|
<label for="container_hostname" class="col-sm-2 control-label text-left">Hostname</label>
|
||||||
|
@ -334,6 +333,7 @@
|
||||||
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
<span ng-hide="state.actionInProgress">Deploy the container</span>
|
||||||
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
<span ng-show="state.actionInProgress">Deployment in progress...</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-default" ng-click="unselectTemplate(state.selectedTemplate)">Hide</button>
|
||||||
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -346,124 +346,17 @@
|
||||||
<!-- container-form -->
|
<!-- container-form -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-12" style="height: 100%">
|
<div class="col-sm-12">
|
||||||
<rd-template-widget>
|
<template-list ng-if="templates"
|
||||||
<rd-widget-header icon="fa-rocket" title-text="Templates">
|
title-text="Templates" title-icon="fa-rocket"
|
||||||
<div ng-if="availableCategories.length > 0" class="pull-right">
|
templates="templates"
|
||||||
Category
|
select-action="selectTemplate"
|
||||||
<select ng-model="state.filters.Categories">
|
delete-action="deleteTemplate"
|
||||||
<option value="!">All</option>
|
show-add-action="isAdmin"
|
||||||
<option ng-repeat="category in availableCategories" value="{{ category }}">{{ category }}</option>
|
show-update-action="isAdmin"
|
||||||
</select>
|
show-delete-action="isAdmin"
|
||||||
</div>
|
show-swarm-stacks="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER' && applicationState.endpoint.apiVersion >= 1.25"
|
||||||
</rd-widget-header>
|
></template-list>
|
||||||
<rd-widget-taskbar>
|
|
||||||
<div>
|
|
||||||
<!-- Platform -->
|
|
||||||
<span class="btn-group btn-group-sm" style="margin-right: 15px;">
|
|
||||||
<label class="btn btn-primary" ng-model="state.filters.Platform" uib-btn-radio="'!'">
|
|
||||||
All
|
|
||||||
</label>
|
|
||||||
<label class="btn btn-primary" ng-model="state.filters.Platform" uib-btn-radio="'windows'">
|
|
||||||
<i class="fab fa-windows" aria-hidden="true"></i>
|
|
||||||
Windows
|
|
||||||
</label>
|
|
||||||
<label class="btn btn-primary" ng-model="state.filters.Platform" uib-btn-radio="'linux'">
|
|
||||||
<i class="fab fa-linux" aria-hidden="true"></i>
|
|
||||||
Linux
|
|
||||||
</label>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</rd-widget-taskbar>
|
|
||||||
<rd-widget-body classes="padding template-widget-body">
|
|
||||||
<form class="form-horizontal">
|
|
||||||
<div ng-if="templatesKey !== 'linuxserver.io' && state.showDeploymentSelector">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Deployment method
|
|
||||||
</div>
|
|
||||||
<div class="form-group"></div>
|
|
||||||
<div class="form-group" style="margin-bottom: 0">
|
|
||||||
<div class="boxselector_wrapper">
|
|
||||||
<div ng-click="updateCategories(templates, state.filters.Type)">
|
|
||||||
<input type="radio" id="template_stack" ng-model="state.filters.Type" value="stack">
|
|
||||||
<label for="template_stack">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fa fa-th-list" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Stack
|
|
||||||
</div>
|
|
||||||
<p>Multi-containers deployment</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div ng-click="updateCategories(templates, state.filters.Type)">
|
|
||||||
<input type="radio" id="template_container" ng-model="state.filters.Type" value="container">
|
|
||||||
<label for="template_container">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fa fa-server" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Container
|
|
||||||
</div>
|
|
||||||
<p>Single container deployment</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ng-if="templatesKey !== 'linuxserver.io' && state.showDeploymentSelector">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Templates
|
|
||||||
</div>
|
|
||||||
<div class="form-group"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="template-list">
|
|
||||||
<!-- template -->
|
|
||||||
<div ng-repeat="tpl in templates | filter:state.filters:true" class="template-container" id="template_{{ tpl.index }}" ng-click="selectTemplate(tpl.index, $index)">
|
|
||||||
<div class="template-main">
|
|
||||||
<!-- template-image -->
|
|
||||||
<span class="">
|
|
||||||
<img class="template-logo" ng-src="{{ tpl.Logo }}" />
|
|
||||||
</span>
|
|
||||||
<!-- !template-image -->
|
|
||||||
<!-- template-details -->
|
|
||||||
<span class="col-sm-12">
|
|
||||||
<!-- template-line1 -->
|
|
||||||
<div class="template-line">
|
|
||||||
<span class="template-title">
|
|
||||||
{{ tpl.Title }}
|
|
||||||
</span>
|
|
||||||
<span>
|
|
||||||
<i class="fab fa-windows" aria-hidden="true" ng-if="tpl.Platform === 'windows'"></i>
|
|
||||||
<i class="fab fa-linux" aria-hidden="true" ng-if="tpl.Platform === 'linux'"></i>
|
|
||||||
<!-- Arch / Platform -->
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- !template-line1 -->
|
|
||||||
<!-- template-line2 -->
|
|
||||||
<div class="template-line">
|
|
||||||
<span class="template-description">
|
|
||||||
{{ tpl.Description }}
|
|
||||||
</span>
|
|
||||||
<span class="small text-muted" ng-if="tpl.Categories.length > 0">
|
|
||||||
{{ tpl.Categories.join(', ') }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- !template-line2 -->
|
|
||||||
</span>
|
|
||||||
<!-- !template-details -->
|
|
||||||
</div>
|
|
||||||
<!-- !template -->
|
|
||||||
</div>
|
|
||||||
<div ng-if="!templates" class="text-center text-muted">
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
<div ng-if="(templates | filter:state.filters:true).length == 0" class="text-center text-muted">
|
|
||||||
No templates available.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-template-widget>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
|
@ -1,18 +1,11 @@
|
||||||
angular.module('portainer.docker')
|
angular.module('portainer.app')
|
||||||
.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'PaginationService', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', 'StackService', 'EndpointProvider',
|
.controller('TemplatesController', ['$scope', '$q', '$state', '$transition$', '$anchorScroll', 'ContainerService', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'ResourceControlService', 'Authentication', 'FormValidator', 'SettingsService', 'StackService', 'EndpointProvider', 'ModalService',
|
||||||
function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, PaginationService, ResourceControlService, Authentication, FormValidator, SettingsService, StackService, EndpointProvider) {
|
function ($scope, $q, $state, $transition$, $anchorScroll, ContainerService, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, ResourceControlService, Authentication, FormValidator, SettingsService, StackService, EndpointProvider, ModalService) {
|
||||||
$scope.state = {
|
$scope.state = {
|
||||||
selectedTemplate: null,
|
selectedTemplate: null,
|
||||||
showAdvancedOptions: false,
|
showAdvancedOptions: false,
|
||||||
hideDescriptions: $transition$.params().hide_descriptions,
|
|
||||||
formValidationError: '',
|
formValidationError: '',
|
||||||
showDeploymentSelector: false,
|
actionInProgress: false
|
||||||
actionInProgress: false,
|
|
||||||
filters: {
|
|
||||||
Categories: '!',
|
|
||||||
Platform: '!',
|
|
||||||
Type: 'container'
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.formValues = {
|
$scope.formValues = {
|
||||||
|
@ -22,7 +15,7 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.addVolume = function () {
|
$scope.addVolume = function () {
|
||||||
$scope.state.selectedTemplate.Volumes.push({ containerPath: '', name: '', readOnly: false, type: 'auto' });
|
$scope.state.selectedTemplate.Volumes.push({ containerPath: '', bind: '', readonly: false, type: 'auto' });
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.removeVolume = function(index) {
|
$scope.removeVolume = function(index) {
|
||||||
|
@ -98,6 +91,31 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createComposeStackFromTemplate(template, userId, accessControlData) {
|
||||||
|
var stackName = $scope.formValues.name;
|
||||||
|
|
||||||
|
var repositoryOptions = {
|
||||||
|
RepositoryURL: template.Repository.url,
|
||||||
|
ComposeFilePathInRepository: template.Repository.stackfile
|
||||||
|
};
|
||||||
|
|
||||||
|
var endpointId = EndpointProvider.endpointID();
|
||||||
|
StackService.createComposeStackFromGitRepository(stackName, repositoryOptions, endpointId)
|
||||||
|
.then(function success(data) {
|
||||||
|
return ResourceControlService.applyResourceControl('stack', stackName, userId, accessControlData, []);
|
||||||
|
})
|
||||||
|
.then(function success() {
|
||||||
|
Notifications.success('Stack successfully deployed');
|
||||||
|
$state.go('portainer.stacks');
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.warning('Deployment error', err.data.err);
|
||||||
|
})
|
||||||
|
.finally(function final() {
|
||||||
|
$scope.state.actionInProgress = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createStackFromTemplate(template, userId, accessControlData) {
|
function createStackFromTemplate(template, userId, accessControlData) {
|
||||||
var stackName = $scope.formValues.name;
|
var stackName = $scope.formValues.name;
|
||||||
|
|
||||||
|
@ -144,104 +162,88 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
|
||||||
var templatesKey = $scope.templatesKey;
|
var templatesKey = $scope.templatesKey;
|
||||||
|
|
||||||
$scope.state.actionInProgress = true;
|
$scope.state.actionInProgress = true;
|
||||||
if (template.Type === 'stack') {
|
if (template.Type === 2) {
|
||||||
createStackFromTemplate(template, userId, accessControlData);
|
createStackFromTemplate(template, userId, accessControlData);
|
||||||
|
} if (template.Type === 3) {
|
||||||
|
createComposeStackFromTemplate(template, userId, accessControlData);
|
||||||
} else {
|
} else {
|
||||||
createContainerFromTemplate(template, userId, accessControlData);
|
createContainerFromTemplate(template, userId, accessControlData);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.unselectTemplate = function() {
|
$scope.unselectTemplate = function(template) {
|
||||||
var currentTemplateIndex = $scope.state.selectedTemplate.index;
|
template.Selected = false;
|
||||||
$('#template_' + currentTemplateIndex).toggleClass('template-container--selected');
|
|
||||||
$scope.state.selectedTemplate = null;
|
$scope.state.selectedTemplate = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.selectTemplate = function(index, pos) {
|
$scope.selectTemplate = function(template) {
|
||||||
if ($scope.state.selectedTemplate && $scope.state.selectedTemplate.index !== index) {
|
if ($scope.state.selectedTemplate) {
|
||||||
$scope.unselectTemplate();
|
$scope.unselectTemplate($scope.state.selectedTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
var templates = $filter('filter')($scope.templates, $scope.state.filters, true);
|
template.Selected = true;
|
||||||
var template = templates[pos];
|
if (template.Network) {
|
||||||
if (template === $scope.state.selectedTemplate) {
|
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === template.Network; });
|
||||||
$scope.unselectTemplate();
|
|
||||||
} else {
|
|
||||||
selectTemplate(index, pos, templates);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function selectTemplate(index, pos, filteredTemplates) {
|
|
||||||
$('#template_' + index).toggleClass('template-container--selected');
|
|
||||||
var selectedTemplate = filteredTemplates[pos];
|
|
||||||
$scope.state.selectedTemplate = selectedTemplate;
|
|
||||||
|
|
||||||
if (selectedTemplate.Network) {
|
|
||||||
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === selectedTemplate.Network; });
|
|
||||||
} else {
|
} else {
|
||||||
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === 'bridge'; });
|
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === 'bridge'; });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTemplate.Name) {
|
$scope.formValues.name = template.Name ? template.Name : '';
|
||||||
$scope.formValues.name = selectedTemplate.Name;
|
$scope.state.selectedTemplate = template;
|
||||||
} else {
|
|
||||||
$scope.formValues.name = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$anchorScroll('view-top');
|
$anchorScroll('view-top');
|
||||||
}
|
};
|
||||||
|
|
||||||
function createTemplateConfiguration(template) {
|
function createTemplateConfiguration(template) {
|
||||||
var network = $scope.formValues.network;
|
var network = $scope.formValues.network;
|
||||||
var name = $scope.formValues.name;
|
var name = $scope.formValues.name;
|
||||||
var containerMapping = determineContainerMapping(network);
|
return TemplateService.createTemplateConfiguration(template, name, network);
|
||||||
return TemplateService.createTemplateConfiguration(template, name, network, containerMapping);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function determineContainerMapping(network) {
|
$scope.deleteTemplate = function(template) {
|
||||||
var containerMapping = 'BY_CONTAINER_IP';
|
ModalService.confirmDeletion(
|
||||||
if (network.Name !== 'bridge') {
|
'Do you want to delete this template?',
|
||||||
containerMapping = 'BY_CONTAINER_NAME';
|
function onConfirm(confirmed) {
|
||||||
|
if(!confirmed) { return; }
|
||||||
|
deleteTemplate(template);
|
||||||
}
|
}
|
||||||
return containerMapping;
|
);
|
||||||
}
|
|
||||||
|
|
||||||
$scope.updateCategories = function(templates, type) {
|
|
||||||
$scope.state.filters.Categories = '!';
|
|
||||||
updateCategories(templates, type);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateCategories(templates, type) {
|
function deleteTemplate(template) {
|
||||||
var availableCategories = [];
|
TemplateService.delete(template.Id)
|
||||||
angular.forEach(templates, function(template) {
|
.then(function success() {
|
||||||
if (template.Type === type) {
|
Notifications.success('Template successfully deleted');
|
||||||
availableCategories = availableCategories.concat(template.Categories);
|
var idx = $scope.templates.indexOf(template);
|
||||||
}
|
$scope.templates.splice(idx, 1);
|
||||||
|
})
|
||||||
|
.catch(function error(err) {
|
||||||
|
Notifications.error('Failure', err, 'Unable to remove template');
|
||||||
});
|
});
|
||||||
$scope.availableCategories = _.sortBy(_.uniq(availableCategories));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTemplates(templatesKey, type, provider, apiVersion) {
|
function initView() {
|
||||||
|
var userDetails = Authentication.getUserDetails();
|
||||||
|
$scope.isAdmin = userDetails.role === 1;
|
||||||
|
|
||||||
|
var endpointMode = $scope.applicationState.endpoint.mode;
|
||||||
|
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
||||||
|
|
||||||
$q.all({
|
$q.all({
|
||||||
templates: TemplateService.getTemplates(templatesKey),
|
templates: TemplateService.templates(),
|
||||||
containers: ContainerService.containers(0),
|
|
||||||
volumes: VolumeService.getVolumes(),
|
volumes: VolumeService.getVolumes(),
|
||||||
networks: NetworkService.networks(
|
networks: NetworkService.networks(
|
||||||
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
|
endpointMode.provider === 'DOCKER_STANDALONE' || endpointMode.provider === 'DOCKER_SWARM_MODE',
|
||||||
false,
|
false,
|
||||||
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
|
endpointMode.provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25
|
||||||
),
|
),
|
||||||
settings: SettingsService.publicSettings()
|
settings: SettingsService.publicSettings()
|
||||||
})
|
})
|
||||||
.then(function success(data) {
|
.then(function success(data) {
|
||||||
var templates = data.templates;
|
var templates = data.templates;
|
||||||
updateCategories(templates, type);
|
|
||||||
$scope.templates = templates;
|
$scope.templates = templates;
|
||||||
$scope.runningContainers = data.containers;
|
|
||||||
$scope.availableVolumes = data.volumes.Volumes;
|
$scope.availableVolumes = data.volumes.Volumes;
|
||||||
var networks = data.networks;
|
var networks = data.networks;
|
||||||
$scope.availableNetworks = networks;
|
$scope.availableNetworks = networks;
|
||||||
$scope.globalNetworkCount = networks.length;
|
|
||||||
var settings = data.settings;
|
var settings = data.settings;
|
||||||
$scope.allowBindMounts = settings.AllowBindMountsForRegularUsers;
|
$scope.allowBindMounts = settings.AllowBindMountsForRegularUsers;
|
||||||
})
|
})
|
||||||
|
@ -251,24 +253,5 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initView() {
|
|
||||||
var templatesKey = $transition$.params().key;
|
|
||||||
$scope.templatesKey = templatesKey;
|
|
||||||
|
|
||||||
var userDetails = Authentication.getUserDetails();
|
|
||||||
$scope.isAdmin = userDetails.role === 1;
|
|
||||||
|
|
||||||
var endpointMode = $scope.applicationState.endpoint.mode;
|
|
||||||
var apiVersion = $scope.applicationState.endpoint.apiVersion;
|
|
||||||
|
|
||||||
if (templatesKey !== 'linuxserver.io'
|
|
||||||
&& endpointMode.provider === 'DOCKER_SWARM_MODE' && endpointMode.role === 'MANAGER' && apiVersion >= 1.25) {
|
|
||||||
$scope.state.filters.Type = 'stack';
|
|
||||||
$scope.state.showDeploymentSelector = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
initTemplates(templatesKey, $scope.state.filters.Type, endpointMode.provider, apiVersion);
|
|
||||||
}
|
|
||||||
|
|
||||||
initView();
|
initView();
|
||||||
}]);
|
}]);
|
|
@ -152,6 +152,8 @@ a[ng-click]{
|
||||||
.template-list {
|
.template-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding: 10px;
|
||||||
|
border-top: 2px solid #e2e2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-logo {
|
.template-logo {
|
||||||
|
@ -195,6 +197,11 @@ a[ng-click]{
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-type {
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.template-description {
|
.template-description {
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
|
@ -741,6 +748,16 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-envvar {
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-separator{
|
||||||
|
border-bottom: 1px solid #777;
|
||||||
|
width: 50%;
|
||||||
|
margin: 20px auto 10px auto;
|
||||||
|
}
|
||||||
|
|
||||||
/*bootbox override*/
|
/*bootbox override*/
|
||||||
.modal-open {
|
.modal-open {
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
|
|
|
@ -31,6 +31,7 @@ module.exports = function (grunt) {
|
||||||
'clean:all',
|
'clean:all',
|
||||||
'before-copy',
|
'before-copy',
|
||||||
'copy:assets',
|
'copy:assets',
|
||||||
|
'copy:templates',
|
||||||
'after-copy'
|
'after-copy'
|
||||||
]);
|
]);
|
||||||
grunt.registerTask('build', [
|
grunt.registerTask('build', [
|
||||||
|
@ -78,6 +79,7 @@ module.exports = function (grunt) {
|
||||||
|
|
||||||
// Project configuration.
|
// Project configuration.
|
||||||
grunt.initConfig({
|
grunt.initConfig({
|
||||||
|
root: 'dist',
|
||||||
distdir: 'dist/public',
|
distdir: 'dist/public',
|
||||||
shippedDockerVersion: '18.03.1-ce',
|
shippedDockerVersion: '18.03.1-ce',
|
||||||
pkg: grunt.file.readJSON('package.json'),
|
pkg: grunt.file.readJSON('package.json'),
|
||||||
|
@ -163,6 +165,11 @@ gruntfile_cfg.copy = {
|
||||||
{dest: '<%= distdir %>/images/', src: '**', expand: true, cwd: 'assets/images/'},
|
{dest: '<%= distdir %>/images/', src: '**', expand: true, cwd: 'assets/images/'},
|
||||||
{dest: '<%= distdir %>/ico', src: '**', expand: true, cwd: 'assets/ico'}
|
{dest: '<%= distdir %>/ico', src: '**', expand: true, cwd: 'assets/ico'}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
templates: {
|
||||||
|
files: [
|
||||||
|
{ dest: '<%= root %>/', src: 'templates.json', cwd: '' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -269,7 +276,7 @@ function shell_buildBinary(p, a) {
|
||||||
function shell_run(arch) {
|
function shell_run(arch) {
|
||||||
return [
|
return [
|
||||||
'docker rm -f portainer',
|
'docker rm -f portainer',
|
||||||
'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics'
|
'docker run -d -p 9000:9000 -v $(pwd)/dist:/app -v /tmp/portainer:/data -v /var/run/docker.sock:/var/run/docker.sock:z --name portainer portainer/base /app/portainer-linux-' + arch + ' --no-analytics --template-file /app/templates.json'
|
||||||
].join(';');
|
].join(';');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
838
templates.json
Normal file
838
templates.json
Normal file
|
@ -0,0 +1,838 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Registry",
|
||||||
|
"description": "Docker image registry",
|
||||||
|
"categories": ["docker"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/registry.png",
|
||||||
|
"image": "registry:latest",
|
||||||
|
"ports": [
|
||||||
|
"5000/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{ "container": "/var/lib/registry"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Nginx",
|
||||||
|
"description": "High performance web server",
|
||||||
|
"categories": ["webserver"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/nginx.png",
|
||||||
|
"image": "nginx:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp",
|
||||||
|
"443/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/etc/nginx"}, {"container": "/usr/share/nginx/html"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Httpd",
|
||||||
|
"description": "Open-source HTTP server",
|
||||||
|
"categories": ["webserver"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/httpd.png",
|
||||||
|
"image": "httpd:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/usr/local/apache2/htdocs/"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Caddy",
|
||||||
|
"description": "HTTP/2 web server with automatic HTTPS",
|
||||||
|
"categories": ["webserver"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/caddy.png",
|
||||||
|
"image": "abiosoft/caddy:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp", "443/tcp", "2015/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/root/.caddy"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "MySQL",
|
||||||
|
"description": "The most popular open-source database",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/mysql.png",
|
||||||
|
"image": "mysql:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "MYSQL_ROOT_PASSWORD",
|
||||||
|
"label": "Root password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"3306/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/mysql"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "MariaDB",
|
||||||
|
"description": "Performance beyond MySQL",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/mariadb.png",
|
||||||
|
"image": "mariadb:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "MYSQL_ROOT_PASSWORD",
|
||||||
|
"label": "Root password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"3306/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/mysql"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "PostgreSQL",
|
||||||
|
"description": "The most advanced open-source database",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/postgres.png",
|
||||||
|
"image": "postgres:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "POSTGRES_USER",
|
||||||
|
"label": "Superuser"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "POSTGRES_PASSWORD",
|
||||||
|
"label": "Superuser password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"5432/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/postgresql/data"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Mongo",
|
||||||
|
"description": "Open-source document-oriented database",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/mongo.png",
|
||||||
|
"image": "mongo:latest",
|
||||||
|
"ports": [
|
||||||
|
"27017/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data/db"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "CockroachDB",
|
||||||
|
"description": "An open-source, survivable, strongly consistent, scale-out SQL database",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/cockroachdb.png",
|
||||||
|
"image": "cockroachdb/cockroach:latest",
|
||||||
|
"ports": [
|
||||||
|
"26257/tcp",
|
||||||
|
"8080/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/cockroach/cockroach-data"}],
|
||||||
|
"command": "start --insecure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "CrateDB",
|
||||||
|
"description": "An open-source distributed SQL database",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/cratedb.png",
|
||||||
|
"image": "crate:latest",
|
||||||
|
"ports": [
|
||||||
|
"4200/tcp",
|
||||||
|
"4300/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Elasticsearch",
|
||||||
|
"description": "Open-source search and analytics engine",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/elasticsearch.png",
|
||||||
|
"image": "elasticsearch:latest",
|
||||||
|
"ports": [
|
||||||
|
"9200/tcp",
|
||||||
|
"9300/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/usr/share/elasticsearch/data"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Gitlab CE",
|
||||||
|
"description": "Open-source end-to-end software development platform",
|
||||||
|
"note": "Default username is <b>root</b>. Check the <a href=\"https://docs.gitlab.com/omnibus/docker/README.html#after-starting-a-container\" target=\"_blank\">Gitlab documentation</a> to get started.",
|
||||||
|
"categories": ["development", "project-management"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/gitlab_ce.png",
|
||||||
|
"image": "gitlab/gitlab-ce:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp",
|
||||||
|
"443/tcp",
|
||||||
|
"22/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
{ "container": "/etc/gitlab" },
|
||||||
|
{ "container": "/var/log/gitlab" },
|
||||||
|
{ "container": "/var/opt/gitlab" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Minio",
|
||||||
|
"description": "A distributed object storage server built for cloud applications and devops",
|
||||||
|
"categories": ["storage"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/minio.png",
|
||||||
|
"image": "minio/minio:latest",
|
||||||
|
"ports": [
|
||||||
|
"9000/tcp"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "MINIO_ACCESS_KEY",
|
||||||
|
"label": "Minio access key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MINIO_SECRET_KEY",
|
||||||
|
"label": "Minio secret key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data"}, {"container": "/root/.minio"}],
|
||||||
|
"command": "server /data"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Scality S3",
|
||||||
|
"description": "Standalone AWS S3 protocol server",
|
||||||
|
"categories": ["storage"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/scality-s3.png",
|
||||||
|
"image": "scality/s3server",
|
||||||
|
"ports": [
|
||||||
|
"8000/tcp"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "SCALITY_ACCESS_KEY",
|
||||||
|
"label": "Scality S3 access key"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SCALITY_SECRET_KEY",
|
||||||
|
"label": "Scality S3 secret key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/usr/src/app/localData"}, {"container": "/usr/src/app/localMetadata"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "SQL Server",
|
||||||
|
"description": "Microsoft SQL Server on Linux",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"note": "Password needs to include at least 8 characters including uppercase, lowercase letters, base-10 digits and/or non-alphanumeric symbols.",
|
||||||
|
"logo": "https://portainer.io/images/logos/microsoft.png",
|
||||||
|
"image": "microsoft/mssql-server-linux:2017-GA",
|
||||||
|
"ports": [
|
||||||
|
"1433/tcp"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "ACCEPT_EULA",
|
||||||
|
"set": "Y"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SA_PASSWORD",
|
||||||
|
"label": "SA password"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "SQL Server",
|
||||||
|
"description": "Microsoft SQL Server Developer for Windows containers",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "windows",
|
||||||
|
"note": "Password needs to include at least 8 characters including uppercase, lowercase letters, base-10 digits and/or non-alphanumeric symbols.",
|
||||||
|
"logo": "https://portainer.io/images/logos/microsoft.png",
|
||||||
|
"image": "microsoft/mssql-server-windows-developer:latest",
|
||||||
|
"ports": [
|
||||||
|
"1433/tcp"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "ACCEPT_EULA",
|
||||||
|
"set": "Y"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sa_password",
|
||||||
|
"label": "SA password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "C:/temp/"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "SQL Server Express",
|
||||||
|
"description": "Microsoft SQL Server Express for Windows containers",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "windows",
|
||||||
|
"note": "Password needs to include at least 8 characters including uppercase, lowercase letters, base-10 digits and/or non-alphanumeric symbols.",
|
||||||
|
"logo": "https://portainer.io/images/logos/microsoft.png",
|
||||||
|
"image": "microsoft/mssql-server-windows-express:latest",
|
||||||
|
"ports": [
|
||||||
|
"1433/tcp"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "ACCEPT_EULA",
|
||||||
|
"set": "Y"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sa_password",
|
||||||
|
"label": "SA password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "C:/temp/"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "IronFunctions API",
|
||||||
|
"description": "Open-source serverless computing platform",
|
||||||
|
"categories": ["serverless"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/ironfunctions.png",
|
||||||
|
"image": "iron/functions:latest",
|
||||||
|
"ports": [
|
||||||
|
"8080/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/app/data"}],
|
||||||
|
"privileged": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "IronFunctions UI",
|
||||||
|
"description": "Open-source user interface for IronFunctions",
|
||||||
|
"categories": ["serverless"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/ironfunctions.png",
|
||||||
|
"image": "iron/functions-ui:latest",
|
||||||
|
"ports": [
|
||||||
|
"4000/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/app/data"}],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "API_URL",
|
||||||
|
"label": "API URL"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"privileged": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Solr",
|
||||||
|
"description": "Open-source enterprise search platform",
|
||||||
|
"categories": ["search-engine"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/solr.png",
|
||||||
|
"image": "solr:latest",
|
||||||
|
"ports": [
|
||||||
|
"8983/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/opt/solr/mydata"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Redis",
|
||||||
|
"description": "Open-source in-memory data structure store",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/redis.png",
|
||||||
|
"image": "redis:latest",
|
||||||
|
"ports": [
|
||||||
|
"6379/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "RabbitMQ",
|
||||||
|
"description": "Highly reliable enterprise messaging system",
|
||||||
|
"categories": ["messaging"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/rabbitmq.png",
|
||||||
|
"image": "rabbitmq:latest",
|
||||||
|
"ports": [
|
||||||
|
"5671/tcp",
|
||||||
|
"5672/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/rabbitmq"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Ghost",
|
||||||
|
"description": "Free and open-source blogging platform",
|
||||||
|
"categories": ["blog"],
|
||||||
|
"note": "Access the blog management interface under <code>/ghost/</code>.",
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/ghost.png",
|
||||||
|
"image": "ghost:latest",
|
||||||
|
"ports": [
|
||||||
|
"2368/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/ghost/content"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Plesk",
|
||||||
|
"description": "WebOps platform and hosting control panel",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"note": "Default credentials: admin / changeme",
|
||||||
|
"logo": "https://portainer.io/images/logos/plesk.png",
|
||||||
|
"image": "plesk/plesk:preview",
|
||||||
|
"ports": [
|
||||||
|
"21/tcp", "80/tcp", "443/tcp", "8880/tcp", "8443/tcp", "8447/tcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "WordPress",
|
||||||
|
"description": "A free and open-source CMS",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/wordpress.png",
|
||||||
|
"image": "wordpress:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "WORDPRESS_DB_HOST",
|
||||||
|
"label": "MySQL database host",
|
||||||
|
"type": "container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WORDPRESS_DB_PASSWORD",
|
||||||
|
"label": "Database password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"80/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/www/html"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Joomla",
|
||||||
|
"description": "Another free and open-source CMS",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/joomla.png",
|
||||||
|
"image": "joomla:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "JOOMLA_DB_HOST",
|
||||||
|
"label": "MySQL database host",
|
||||||
|
"type": "container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "JOOMLA_DB_PASSWORD",
|
||||||
|
"label": "Database password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"80/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/www/html"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Drupal",
|
||||||
|
"description": "Open-source content management framework",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/drupal.png",
|
||||||
|
"image": "drupal:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/www/html"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Plone",
|
||||||
|
"description": "A free and open-source CMS built on top of Zope",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/plone.png",
|
||||||
|
"image": "plone:latest",
|
||||||
|
"ports": [
|
||||||
|
"8080/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Magento 2",
|
||||||
|
"description": "Open-source e-commerce platform",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/magento.png",
|
||||||
|
"image": "alankent/gsd:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp",
|
||||||
|
"3000/tcp",
|
||||||
|
"3001/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/www/html/"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Sematext Docker Agent",
|
||||||
|
"description": "Collect logs, metrics and docker events",
|
||||||
|
"categories": ["Log Management", "Monitoring"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/sematext_agent.png",
|
||||||
|
"image": "sematext/sematext-agent-docker:latest",
|
||||||
|
"name": "sematext-agent",
|
||||||
|
"privileged": true,
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "LOGSENE_TOKEN",
|
||||||
|
"label": "Logs token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SPM_TOKEN",
|
||||||
|
"label": "SPM monitoring token"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"container": "/var/run/docker.sock",
|
||||||
|
"bind": "/var/run/docker.sock"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Mautic",
|
||||||
|
"description": "Open-source marketing automation platform",
|
||||||
|
"categories": ["marketing"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/mautic.png",
|
||||||
|
"image": "mautic/mautic:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "MAUTIC_DB_HOST",
|
||||||
|
"label": "MySQL database host",
|
||||||
|
"type": "container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MAUTIC_DB_PASSWORD",
|
||||||
|
"label": "Database password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"80/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/www/html"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Wowza",
|
||||||
|
"description": "Streaming media server",
|
||||||
|
"categories": ["streaming"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/wowza.png",
|
||||||
|
"image": "sameersbn/wowza:4.1.2-8",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "WOWZA_ACCEPT_LICENSE",
|
||||||
|
"label": "Agree to Wowza EULA",
|
||||||
|
"set": "yes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WOWZA_KEY",
|
||||||
|
"label": "License key"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"1935/tcp",
|
||||||
|
"8086/tcp",
|
||||||
|
"8087/tcp",
|
||||||
|
"8088/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/wowza"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Jenkins",
|
||||||
|
"description": "Open-source continuous integration tool",
|
||||||
|
"categories": ["continuous-integration"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/jenkins.png",
|
||||||
|
"image": "jenkins/jenkins:lts",
|
||||||
|
"ports": [
|
||||||
|
"8080/tcp",
|
||||||
|
"50000/tcp"
|
||||||
|
],
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "JENKINS_OPTS",
|
||||||
|
"label": "Jenkins options"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/jenkins_home"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Redmine",
|
||||||
|
"description": "Open-source project management tool",
|
||||||
|
"categories": ["project-management"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/redmine.png",
|
||||||
|
"image": "redmine:latest",
|
||||||
|
"ports": [
|
||||||
|
"3000/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/usr/src/redmine/files"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Odoo",
|
||||||
|
"description": "Open-source business apps",
|
||||||
|
"categories": ["project-management"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/odoo.png",
|
||||||
|
"image": "odoo:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "HOST",
|
||||||
|
"label": "PostgreSQL database host",
|
||||||
|
"type": "container"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "USER",
|
||||||
|
"label": "Database user"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "PASSWORD",
|
||||||
|
"label": "Database password"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"8069/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/lib/odoo"}, {"container": "/mnt/extra-addons"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "Urbackup",
|
||||||
|
"description": "Open-source network backup",
|
||||||
|
"categories": ["backup"],
|
||||||
|
"platform": "linux",
|
||||||
|
"note": "This application web interface is exposed on the port 55414 inside the container.",
|
||||||
|
"logo": "https://portainer.io/images/logos/urbackup.png",
|
||||||
|
"image": "cfstras/urbackup",
|
||||||
|
"ports": [
|
||||||
|
"55413/tcp", "55414/tcp", "55415/tcp", "35622/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/var/urbackup"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "File browser",
|
||||||
|
"description": "A web file manager",
|
||||||
|
"note": "Default credentials: admin/admin",
|
||||||
|
"categories": ["filesystem", "storage"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/filebrowser.png",
|
||||||
|
"image": "hacdias/filemanager:latest",
|
||||||
|
"ports": [
|
||||||
|
"80/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data"}, {"container": "/srv"}],
|
||||||
|
"command": "--port 80 --database /data/database.db --scope /srv"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "CommandBox",
|
||||||
|
"description": "ColdFusion (CFML) CLI",
|
||||||
|
"categories": ["development"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/ortussolutions-commandbox.png",
|
||||||
|
"image": "ortussolutions/commandbox:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "CFENGINE",
|
||||||
|
"set": "lucee@4.5"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"8080/tcp", "8443/tcp"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 1,
|
||||||
|
"title": "ContentBox",
|
||||||
|
"description": "Open-source modular CMS",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/ortussolutions-contentbox.png",
|
||||||
|
"image": "ortussolutions/contentbox:latest",
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "express",
|
||||||
|
"set": "true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "install",
|
||||||
|
"set": "true"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "CFENGINE",
|
||||||
|
"set": "lucee@4.5"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ports": [
|
||||||
|
"8080/tcp", "8443/tcp"
|
||||||
|
],
|
||||||
|
"volumes": [{"container": "/data/contentbox/db"}, {"container": "/app/includes/shared/media"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"title": "Portainer Agent",
|
||||||
|
"description": "Manage all the resources in your Swarm cluster",
|
||||||
|
"note": "The agent will be deployed globally inside your cluster and available on port 9001.",
|
||||||
|
"categories": ["portainer"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/portainer.png",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/portainer/templates",
|
||||||
|
"stackfile": "stacks/portainer-agent/docker-stack.yml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"title": "OpenFaaS",
|
||||||
|
"name": "func",
|
||||||
|
"description": "Serverless functions made simple",
|
||||||
|
"note": "Deploys the API gateway and sample functions. You can access the UI on port 8080. <b>Warning</b>: the name of the stack must be 'func'.",
|
||||||
|
"categories": ["serverless"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/openfaas.png",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/openfaas/faas",
|
||||||
|
"stackfile": "docker-compose.yml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"title": "IronFunctions",
|
||||||
|
"description": "Open-source serverless computing platform",
|
||||||
|
"note": "Deploys the IronFunctions API and UI.",
|
||||||
|
"categories": ["serverless"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/ironfunctions.png",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/portainer/templates",
|
||||||
|
"stackfile": "stacks/ironfunctions/docker-stack.yml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"title": "CockroachDB",
|
||||||
|
"description": "CockroachDB cluster",
|
||||||
|
"note": "Deploys an insecure CockroachDB cluster, please refer to <a href=\"https://www.cockroachlabs.com/docs/stable/orchestrate-cockroachdb-with-docker-swarm.html\" target=\"_blank\">CockroachDB documentation</a> for production deployments.",
|
||||||
|
"categories": ["database"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/cockroachdb.png",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/portainer/templates",
|
||||||
|
"stackfile": "stacks/cockroachdb/docker-stack.yml"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"title": "Wordpress",
|
||||||
|
"description": "Wordpress setup with a MySQL database",
|
||||||
|
"note": "Deploys a Wordpress instance connected to a MySQL database.",
|
||||||
|
"categories": ["CMS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/wordpress.png",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/portainer/templates",
|
||||||
|
"stackfile": "stacks/wordpress/docker-stack.yml"
|
||||||
|
},
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "MYSQL_ROOT_PASSWORD",
|
||||||
|
"label": "Database root password",
|
||||||
|
"description": "Password used by the MySQL root user."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": 2,
|
||||||
|
"title": "Microsoft OMS Agent",
|
||||||
|
"description": "Microsoft Operations Management Suite Linux agent.",
|
||||||
|
"categories": ["OPS"],
|
||||||
|
"platform": "linux",
|
||||||
|
"logo": "https://portainer.io/images/logos/microsoft.png",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/portainer/templates",
|
||||||
|
"stackfile": "stacks/microsoft-oms/docker-stack.yml"
|
||||||
|
},
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "AZURE_WORKSPACE_ID",
|
||||||
|
"label": "Workspace ID",
|
||||||
|
"description": "Azure Workspace ID"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "AZURE_PRIMARY_KEY",
|
||||||
|
"label": "Primary key",
|
||||||
|
"description": "Azure primary key"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Sematext Docker Agent",
|
||||||
|
"type": 2,
|
||||||
|
"categories": ["Log Management", "Monitoring"],
|
||||||
|
"description": "Collect logs, metrics and docker events",
|
||||||
|
"logo": "https://portainer.io/images/logos/sematext_agent.png",
|
||||||
|
"platform": "linux",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/portainer/templates",
|
||||||
|
"stackfile": "stacks/sematext-agent-docker/docker-stack.yml"
|
||||||
|
},
|
||||||
|
"env": [
|
||||||
|
{
|
||||||
|
"name": "LOGSENE_TOKEN",
|
||||||
|
"label": "Logs token"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SPM_TOKEN",
|
||||||
|
"label": "SPM monitoring token"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"volumes": [
|
||||||
|
{
|
||||||
|
"container": "/var/run/docker.sock",
|
||||||
|
"bind": "/var/run/docker.sock"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
Loading…
Add table
Add a link
Reference in a new issue