Compare commits

...

82 Commits

Author SHA1 Message Date
Anthony Lapenna 8e2dbd1775 Merge branch 'release/1.14.0' 2017-08-13 20:17:30 +02:00
Anthony Lapenna 27188f4dff chore(version): bump version number 2017-08-13 20:17:23 +02:00
Anthony Lapenna ef13f6fb3b feat(sidebar): do not display services and secrets when managing a worker node (#1114) 2017-08-13 16:55:02 +02:00
Anthony Lapenna 92391254bc feat(api): introduces swagger.yml (#1112) 2017-08-13 16:45:55 +02:00
Anthony Lapenna d3e87b2435 style(settings): fix typo 2017-08-13 15:04:24 +02:00
Anthony Lapenna e5666dfdf2 feat(vic): fix multiple issues when managing a VIC engine (#1069) 2017-08-13 13:31:50 +02:00
Anthony Lapenna e96e615761 feat(container-details): add the ability to specify if image should be pulled when re-creating a container 2017-08-13 12:55:52 +02:00
Thomas Krzero c85aa0739d feat(container-details): add the ability to re-create, duplicate and edit a container (#855) 2017-08-13 12:17:41 +02:00
Anthony Lapenna d814f3aaa4 fix(networks): review how networks are loaded for usage in multiple views (#1104) 2017-08-11 09:46:55 +02:00
Anthony Lapenna 3d5f9a76e4 fix(team-details): fix an issue when sorting columns (#1106) 2017-08-10 15:25:53 +02:00
Anthony Lapenna d27528a771 feat(authentication): add LDAP authentication support (#1093) 2017-08-10 10:35:23 +02:00
Anthony Lapenna 04ea81e7cd feat(service): support the Order field for Update Configuration (#1101) 2017-08-09 15:30:50 +02:00
Anthony Lapenna d7769dec33 fix(images): fix the way the registry and image name are extracted fr… (#1099)
* fix(images): fix the way the registry and image name are extracted from a repository
2017-08-09 10:40:46 +02:00
Liam Cottam 12adeadc94 fix(container-details): connected network section disappearing (#1092) 2017-08-06 10:42:38 +02:00
Anthony Lapenna b5429f7504 docs(README): add code climate badge 2017-08-04 08:09:29 +02:00
Liam Cottam cf5c3ee536 fix(container-console): fix an issue with scrollbar (#932) (#1086) 2017-08-04 08:02:26 +02:00
tfenster 86c450bd91 feat(templates): Use container name as hostname (#1084) 2017-08-04 07:54:03 +02:00
Anthony Lapenna 0d6ab099ac feat(templates): update LinuxServer.io templates feed URL (#1089) 2017-08-01 11:24:44 +02:00
Anthony Lapenna 5110f83fae fix(rest): fix an issue with rest factories using $http (#1077) 2017-07-27 10:46:29 +02:00
Anthony Lapenna 252e05e963 fix(container-details): add missing Created field from ContainerDetailsViewModel (#1075) 2017-07-26 17:12:02 +02:00
Dan Hlavenka 635ecdef72 style(sidebar): crop logo.png to fit in sidebar without scaling (#1072) 2017-07-26 07:52:44 +02:00
Anthony Lapenna b08d2b07bc feat(volume-creation): add plugin support (#1044)
* feat(volume-creation): add plugin support

* feat(plugins): only use systemInfo to retrieve plugins when API version < 1.25

* refactor(createVolume): remove unused dependencies
2017-07-25 16:21:32 +02:00
Anthony Lapenna 3919ad3ccf fix(images): show image usage only if endpoint API version >= 1.25 (#1067) 2017-07-24 19:11:12 +02:00
Konstantin Azizov aca4f5c286 fix(containers): Fix available buttons for created container (#1065) 2017-07-24 16:39:04 +02:00
Anthony Lapenna 387b4c66d9 fix(containers): fix an issue when only containers without ports are running (#1068) 2017-07-24 16:29:28 +02:00
Anthony Lapenna 7c40d2caa9 fix(services): use secrets with services only if endpoint API version >= 1.25 2017-07-24 11:59:09 +02:00
Anthony Lapenna 02203e7ce5 refactor(api): relocate /docker API endpoint under /endpoints (#1053) 2017-07-20 16:22:27 +02:00
Anthony Lapenna 53583741ba fix(UAC): fix the ability to update the ownership of a resource from public to another type (#1054) 2017-07-20 15:48:05 +02:00
1138-4EB 12eb9671de style(volumes): replace label 'Dangling' with 'Unused' (#1052) 2017-07-20 08:47:11 +02:00
Anthony Lapenna 29d66bfd97 fix(containers): add support for the 'dead' status (#1048) 2017-07-19 16:34:11 +02:00
Anthony Lapenna 57fde5ae7c feat(Dockerfile): use portainer/base image (#1045) 2017-07-18 12:17:31 +02:00
Anthony Lapenna 471f902171 Merge tag '1.13.6' into develop
Release 1.13.6
2017-07-17 16:00:47 +02:00
Anthony Lapenna 2e2aba1bbb Merge branch 'release/1.13.6' 2017-07-17 16:00:40 +02:00
Anthony Lapenna f2347b2f77 chore(version): bump version number 2017-07-17 15:59:43 +02:00
Anthony Lapenna a39645a297 fix(images): fix the system/df call to display unused images (#1037) 2017-07-17 15:58:53 +02:00
Anthony Lapenna 806a0b92a0 Merge tag '1.13.5' into develop
Release 1.13.5
2017-07-13 18:08:50 +02:00
Anthony Lapenna a438357b45 Merge branch 'release/1.13.5' 2017-07-13 18:08:46 +02:00
Anthony Lapenna 206eb0513d chore(version): bump version number 2017-07-13 18:08:39 +02:00
Anthony Lapenna 5ad6837547 feat(container-console): improve container console UX (#1031) 2017-07-13 18:04:58 +02:00
Anthony Lapenna 272a040c91 feat(volumes): add a label in front of dangling volumes (#1025) 2017-07-13 13:50:59 +02:00
Anthony Lapenna c04b9e5340 feat(volumes): new truncate method for volume paths (#1028) 2017-07-13 13:50:42 +02:00
Anthony Lapenna 3f085a977c fix(UAC): allow a team member to delete a resource control (#1030) 2017-07-13 09:12:06 +02:00
Anthony Lapenna a1dd12a947 feat(sidebar): sort available endpoints alphabetically (#1027) 2017-07-12 20:52:07 +02:00
Anthony Lapenna a7df43bd45 feat(container-details): show container ID (#1026) 2017-07-12 19:37:34 +02:00
Anthony Lapenna 5d749c2ebf feat(auth): use the same error message on invalid authentication (#1024) 2017-07-12 17:22:14 +02:00
Anthony Lapenna 536ca15e90 fix(swarm): fix multiple Swarm related issues (#1022)
* fix(containers): fix an issue where the containers would not be displayed

* fix(images): image usage filtering is not compliant with docker/swarm

* fix(volume-creation): do not load volume driver with docker/swarm
2017-07-12 16:11:11 +02:00
Anthony Lapenna 703e423e04 fix(external-endpoints): prevent the creation of an invalid file endpoint (#1021) 2017-07-12 15:15:42 +02:00
Anthony Lapenna 780fec8e36 fix(access): fix an issue where an access would disappear (#1018) 2017-07-12 14:13:51 +02:00
1138-4EB 0a436600f4 feat(build-system): dynamic vendoring (#994) 2017-07-12 11:28:51 +02:00
Anthony Lapenna 32c2ce90e2 feat(build-system): automatically remove binary build container 2017-07-12 10:13:00 +02:00
Anthony Lapenna a864641692 refactor(UAC): refactor common views to components (#1013) 2017-07-12 09:51:51 +02:00
Anthony Lapenna 344eee098d chore(deps): update xtermjs version (#1012) 2017-07-11 16:52:39 +02:00
Konstantin Azizov bc4b0a0b35 feat(images): display unused images tags (#1009) 2017-07-11 09:56:28 +02:00
1138-4EB b23943e30b refactor(build-system): reduce gruntfile verbosity, drop grunt-if, allow custom build (#939) 2017-07-11 09:30:25 +02:00
Glowbal 25ed6a71fb feat(services): add support for placement preferences (#1003) 2017-07-10 09:33:09 +02:00
Konstantin Azizov 8dc6d05ed6 feat(console): allow the user to specify a command in the console section (#259) (#1007) 2017-07-10 09:10:10 +02:00
Konstantin Azizov fe5a993fc9 feat(volumes): view dangling volumes (#993) 2017-07-09 18:49:36 +02:00
Thomas Krzero 6df5eb3787 feat(service-details) - add service logs (#671) 2017-07-08 11:34:21 +02:00
Konstantin Azizov bc3d5e97ea chore(build-system): update run-dev to mount assets (#997) 2017-07-08 10:42:41 +02:00
Glowbal 9909b6d481 feat(backend): make swarm api endpoint admin user protected (#991) 2017-07-08 10:34:04 +02:00
Glowbal 90a32d1b67 refactor(html): fix html tags and escape special characters (#987) 2017-07-08 10:23:00 +02:00
Konstantin Azizov 472834ac42 feat(containers): add buttons disabling based on cluster selection (#985) 2017-07-08 10:07:08 +02:00
Anthony Lapenna b3f4c6f751 refactor(image-details): place imageLayer model under models/docker 2017-07-08 09:22:39 +02:00
Anthony Lapenna 317303fc43 feat(image-details): image layer enhancements 2017-07-08 09:21:30 +02:00
Gábor Kovács b6b579d55d feat(image-details): simple image history (#425) 2017-07-08 08:59:32 +02:00
Anthony Lapenna 6d6f4f092d fix(secrets): fix an issue when removing a secret that is in use (#984) 2017-07-07 15:45:31 +02:00
Anthony Lapenna 7473681c5b fix(container-details): fix the ability to commit a container (#983) 2017-07-05 19:06:28 +02:00
Konstantin Azizov 54c8872d25 feat(container-console): add ability to specify the user (#976) 2017-07-05 07:16:57 +02:00
Konstantin Azizov c5ce45f588 chore(build-system): replace Recess with PostCSS (#975) 2017-07-04 14:30:22 +02:00
Anthony Lapenna 07a0c4dfe3 feat(endpoints): update information message (#974) 2017-07-03 08:36:18 +02:00
Anthony Lapenna 80bb94e745 docs(README): update README 2017-06-30 14:52:04 +02:00
Anthony Lapenna 6c89412f39 Merge tag '1.13.4' into develop
Release 1.13.4
2017-06-29 16:37:32 +02:00
Anthony Lapenna 034e29cd74 Merge branch 'release/1.13.4' 2017-06-29 16:37:28 +02:00
Anthony Lapenna 0e0764eff8 chore(version): bump version number 2017-06-29 16:37:22 +02:00
Anthony Lapenna e47db0b8c9 feat(volumes): display mount point for each volume (#967) 2017-06-29 16:14:17 +02:00
Anthony Lapenna 6d401dcd59 fix(templates): fix the ability to pull an image within an offline environment (#961) 2017-06-29 16:05:39 +02:00
Anthony Lapenna 6609c2e928 style(container-details): review responsiveness for the join network section 2017-06-29 16:04:49 +02:00
Adam Snodgrass a161d25d48 feat(container-details): add section to join networks (#927) 2017-06-29 15:49:35 +02:00
Anthony Lapenna 4adedf9436 fix(service-details): fix an issue where secret target would be overwritten (#964) 2017-06-29 08:37:05 +02:00
Anthony Lapenna 1168e94534 fix(service-creation): fix an issue when selecting a volume from available volumes (#963) 2017-06-29 07:41:37 +02:00
Anthony Lapenna b57bfe3eee Create CODE_OF_CONDUCT.md (#946) 2017-06-22 05:11:40 +02:00
Anthony Lapenna 3592e88e4f Merge tag '1.13.3' into develop
Release 1.13.3
2017-06-20 13:21:16 +02:00
181 changed files with 6149 additions and 1950 deletions
+46
View File
@@ -0,0 +1,46 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at anthony.lapenna@portainer.io. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
+5 -4
View File
@@ -7,15 +7,16 @@
[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size")
[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable)
[![Codefresh build status]( https://g.codefresh.io/api/badges/build?repoOwner=portainer&repoName=portainer&branch=develop&pipelineName=portainer-ci&accountName=deviantony&type=cf-1)]( https://g.codefresh.io/repositories/portainer/portainer/builds?filter=trigger:build;branch:develop;service:5922a08a3a1aab000116fcc6~portainer-ci)
[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer)
[![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/)
[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
[![Donate](https://img.shields.io/badge/Donate-PayPal-green.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6)
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your Docker host or Swarm cluster.
**_Portainer_** is a lightweight management UI which allows you to **easily** manage your different Docker environments (Docker hosts or Swarm clusters).
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (Docker for Linux and Docker for Windows are supported).
**_Portainer_** is meant to be as **simple** to deploy as it is to use. It consists of a single container that can run on any Docker engine (can be deployed as Linux container or a Windows native container).
**_Portainer_** allows you to manage your Docker containers, images, volumes, networks and more ! It is compatible with the *standalone Docker* engine and with *Docker Swarm*.
**_Portainer_** allows you to manage your Docker containers, images, volumes, networks and more ! It is compatible with the *standalone Docker* engine and with *Docker Swarm mode*.
## Demo
@@ -34,8 +35,8 @@ Please note that the public demo cluster is **reset every 15min**.
* Issues: https://github.com/portainer/portainer/issues
* FAQ: https://portainer.readthedocs.io/en/latest/faq.html
* Slack (chat): https://portainer.io/slack/
* Gitter (chat): https://gitter.im/portainer/Lobby
* Slack: https://portainer.io/slack/
## Reporting bugs and contributing
+25
View File
@@ -0,0 +1,25 @@
package bolt
import "github.com/portainer/portainer"
func (m *Migrator) updateSettingsToVersion3() error {
legacySettings, err := m.SettingsService.Settings()
if err != nil {
return err
}
legacySettings.AuthenticationMethod = portainer.AuthenticationInternal
legacySettings.LDAPSettings = portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{},
},
}
err = m.SettingsService.StoreSettings(legacySettings)
if err != nil {
return err
}
return nil
}
+10
View File
@@ -7,6 +7,7 @@ type Migrator struct {
UserService *UserService
EndpointService *EndpointService
ResourceControlService *ResourceControlService
SettingsService *SettingsService
VersionService *VersionService
CurrentDBVersion int
store *Store
@@ -18,6 +19,7 @@ func NewMigrator(store *Store, version int) *Migrator {
UserService: store.UserService,
EndpointService: store.EndpointService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
VersionService: store.VersionService,
CurrentDBVersion: version,
store: store,
@@ -47,6 +49,14 @@ func (m *Migrator) Migrate() error {
}
}
// Portainer 1.13.x
if m.CurrentDBVersion == 2 {
err := m.updateSettingsToVersion3()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err
+15
View File
@@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/file"
"github.com/portainer/portainer/http"
"github.com/portainer/portainer/jwt"
"github.com/portainer/portainer/ldap"
"log"
)
@@ -68,6 +69,10 @@ func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
func initLDAPService() portainer.LDAPService {
return &ldap.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true
if externalEnpointFile != "" {
@@ -113,6 +118,13 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
settings := &portainer.Settings{
LogoURL: *flags.Logo,
DisplayExternalContributors: true,
AuthenticationMethod: portainer.AuthenticationInternal,
LDAPSettings: portainer.LDAPSettings{
TLSConfig: portainer.TLSConfiguration{},
SearchSettings: []portainer.LDAPSearchSettings{
portainer.LDAPSearchSettings{},
},
},
}
if *flags.Templates != "" {
@@ -155,6 +167,8 @@ func main() {
cryptoService := initCryptoService()
ldapService := initLDAPService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
err := initSettings(store.SettingsService, flags)
@@ -225,6 +239,7 @@ func main() {
CryptoService: cryptoService,
JWTService: jwtService,
FileService: fileService,
LDAPService: ldapService,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
+1 -1
View File
@@ -117,7 +117,7 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
}
for idx, endpoint := range fileEndpoints {
if endpoint.Name == "" || endpoint.URL == "" {
if !isValidEndpoint(&endpoint) {
job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
continue
}
+21 -13
View File
@@ -7,20 +7,28 @@ import (
)
// CreateTLSConfiguration initializes a tls.Config using a CA certificate, a certificate and a key
func CreateTLSConfiguration(caCertPath, certPath, keyPath string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
func CreateTLSConfiguration(caCertPath, certPath, keyPath string, skipTLSVerify bool) (*tls.Config, error) {
config := &tls.Config{}
if certPath != "" && keyPath != "" {
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
config.Certificates = []tls.Certificate{cert}
}
caCert, err := ioutil.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
config := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: caCertPool,
if caCertPath != "" {
caCert, err := ioutil.ReadFile(caCertPath)
if err != nil {
return nil, err
}
caCertPool := x509.NewCertPool()
caCertPool.AppendCertsFromPEM(caCert)
config.RootCAs = caCertPool
}
config.InsecureSkipVerify = skipTLSVerify
return config, nil
}
+12 -14
View File
@@ -6,12 +6,13 @@ import (
"io"
"os"
"path"
"strconv"
)
const (
// TLSStorePath represents the subfolder where TLS files are stored in the file store folder.
TLSStorePath = "tls"
// LDAPStorePath represents the subfolder where LDAP TLS files are stored in the TLSStorePath.
LDAPStorePath = "ldap"
// TLSCACertFile represents the name on disk for a TLS CA file.
TLSCACertFile = "ca.pem"
// TLSCertFile represents the name on disk for a TLS certificate file.
@@ -50,11 +51,10 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
return service, nil
}
// StoreTLSFile creates a subfolder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType, r io.Reader) error {
ID := strconv.Itoa(int(endpointID))
endpointStorePath := path.Join(TLSStorePath, ID)
err := service.createDirectoryInStoreIfNotExist(endpointStorePath)
// StoreTLSFile creates a folder in the TLSStorePath and stores a new file with the content from r.
func (service *Service) StoreTLSFile(folder string, fileType portainer.TLSFileType, r io.Reader) error {
storePath := path.Join(TLSStorePath, folder)
err := service.createDirectoryInStoreIfNotExist(storePath)
if err != nil {
return err
}
@@ -71,7 +71,7 @@ func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType p
return portainer.ErrUndefinedTLSFileType
}
tlsFilePath := path.Join(endpointStorePath, fileName)
tlsFilePath := path.Join(storePath, fileName)
err = service.createFileInStore(tlsFilePath, r)
if err != nil {
return err
@@ -80,7 +80,7 @@ func (service *Service) StoreTLSFile(endpointID portainer.EndpointID, fileType p
}
// GetPathForTLSFile returns the absolute path to a specific TLS file for an endpoint.
func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileType portainer.TLSFileType) (string, error) {
func (service *Service) GetPathForTLSFile(folder string, fileType portainer.TLSFileType) (string, error) {
var fileName string
switch fileType {
case portainer.TLSFileCA:
@@ -92,15 +92,13 @@ func (service *Service) GetPathForTLSFile(endpointID portainer.EndpointID, fileT
default:
return "", portainer.ErrUndefinedTLSFileType
}
ID := strconv.Itoa(int(endpointID))
return path.Join(service.fileStorePath, TLSStorePath, ID, fileName), nil
return path.Join(service.fileStorePath, TLSStorePath, folder, fileName), nil
}
// DeleteTLSFiles deletes a folder containing the TLS files for an endpoint.
func (service *Service) DeleteTLSFiles(endpointID portainer.EndpointID) error {
ID := strconv.Itoa(int(endpointID))
endpointPath := path.Join(service.fileStorePath, TLSStorePath, ID)
err := os.RemoveAll(endpointPath)
func (service *Service) DeleteTLSFiles(folder string) error {
storePath := path.Join(service.fileStorePath, TLSStorePath, folder)
err := os.RemoveAll(storePath)
if err != nil {
return err
}
-8
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"log"
"net/http"
"strings"
)
// errorResponse is a generic response for sending a error.
@@ -21,10 +20,3 @@ func WriteErrorResponse(w http.ResponseWriter, err error, code int, logger *log.
w.WriteHeader(code)
json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()})
}
// WriteMethodNotAllowedResponse writes an error message to the response and sets the Allow header.
func WriteMethodNotAllowedResponse(w http.ResponseWriter, allowedMethods []string) {
w.Header().Set("Allow", strings.Join(allowedMethods, ", "))
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(&errorResponse{Err: http.StatusText(http.StatusMethodNotAllowed)})
}
+36 -22
View File
@@ -17,11 +17,13 @@ import (
// AuthHandler represents an HTTP API handler for managing authentication.
type AuthHandler struct {
*mux.Router
Logger *log.Logger
authDisabled bool
UserService portainer.UserService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
Logger *log.Logger
authDisabled bool
UserService portainer.UserService
CryptoService portainer.CryptoService
JWTService portainer.JWTService
LDAPService portainer.LDAPService
SettingsService portainer.SettingsService
}
const (
@@ -42,17 +44,23 @@ func NewAuthHandler(bouncer *security.RequestBouncer, authDisabled bool) *AuthHa
authDisabled: authDisabled,
}
h.Handle("/auth",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth)))
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAuth))).Methods(http.MethodPost)
return h
}
func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
type (
postAuthRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
}
postAuthResponse struct {
JWT string `json:"jwt"`
}
)
func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Request) {
if handler.authDisabled {
httperror.WriteErrorResponse(w, ErrAuthDisabled, http.StatusServiceUnavailable, handler.Logger)
return
@@ -75,24 +83,39 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
u, err := handler.UserService.UserByUsername(username)
if err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusBadRequest, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
err = handler.CryptoService.CompareHashAndData(u.Password, password)
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if settings.AuthenticationMethod == portainer.AuthenticationLDAP && u.ID != 1 {
err = handler.LDAPService.AuthenticateUser(username, password, &settings.LDAPSettings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
} else {
err = handler.CryptoService.CompareHashAndData(u.Password, password)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidCredentials, http.StatusUnprocessableEntity, handler.Logger)
return
}
}
tokenData := &portainer.TokenData{
ID: u.ID,
Username: u.Username,
Role: u.Role,
}
token, err := handler.JWTService.GenerateToken(tokenData)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@@ -101,12 +124,3 @@ func (handler *AuthHandler) handlePostAuth(w http.ResponseWriter, r *http.Reques
encodeJSON(w, &postAuthResponse{JWT: token}, handler.Logger)
}
type postAuthRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
}
type postAuthResponse struct {
JWT string `json:"jwt"`
}
+2 -2
View File
@@ -30,7 +30,7 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.PathPrefix("/{id}/").Handler(
h.PathPrefix("/{id}/docker").Handler(
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToDockerAPI)))
return h
}
@@ -90,5 +90,5 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
}
}
http.StripPrefix("/"+id, proxy).ServeHTTP(w, r)
http.StripPrefix("/"+id+"/docker", proxy).ServeHTTP(w, r)
}
+10 -8
View File
@@ -22,20 +22,28 @@ type DockerHubHandler struct {
DockerHubService portainer.DockerHubService
}
// NewDockerHubHandler returns a new instance of OldDockerHubHandler.
// NewDockerHubHandler returns a new instance of NewDockerHubHandler.
func NewDockerHubHandler(bouncer *security.RequestBouncer) *DockerHubHandler {
h := &DockerHubHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/dockerhub",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet)
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetDockerHub))).Methods(http.MethodGet)
h.Handle("/dockerhub",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutDockerHub))).Methods(http.MethodPut)
return h
}
type (
putDockerHubRequest struct {
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
)
// handleGetDockerHub handles GET requests on /dockerhub
func (handler *DockerHubHandler) handleGetDockerHub(w http.ResponseWriter, r *http.Request) {
dockerhub, err := handler.DockerHubService.DockerHub()
@@ -79,9 +87,3 @@ func (handler *DockerHubHandler) handlePutDockerHub(w http.ResponseWriter, r *ht
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
type putDockerHubRequest struct {
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
+35 -31
View File
@@ -55,6 +55,31 @@ func NewEndpointHandler(bouncer *security.RequestBouncer, authorizeEndpointManag
return h
}
type (
postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
}
postEndpointsResponse struct {
ID int `json:"Id"`
}
putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putEndpointsRequest struct {
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
TLS bool `valid:"-"`
}
)
// handleGetEndpoints handles GET requests on /endpoints
func (handler *EndpointHandler) handleGetEndpoints(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@@ -113,11 +138,12 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
}
if req.TLS {
caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA)
folder := strconv.Itoa(int(endpoint.ID))
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert)
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey)
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSKeyPath = keyPath
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
@@ -129,17 +155,6 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
encodeJSON(w, &postEndpointsResponse{ID: int(endpoint.ID)}, handler.Logger)
}
type postEndpointsRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
PublicURL string `valid:"-"`
TLS bool
}
type postEndpointsResponse struct {
ID int `json:"Id"`
}
// handleGetEndpoint handles GET requests on /endpoints/:id
func (handler *EndpointHandler) handleGetEndpoint(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -218,11 +233,6 @@ func (handler *EndpointHandler) handlePutEndpointAccess(w http.ResponseWriter, r
}
}
type putEndpointAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
// handlePutEndpoint handles PUT requests on /endpoints/:id
func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
@@ -272,20 +282,21 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
endpoint.PublicURL = req.PublicURL
}
folder := strconv.Itoa(int(endpoint.ID))
if req.TLS {
endpoint.TLS = true
caCertPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCA)
caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA)
endpoint.TLSCACertPath = caCertPath
certPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileCert)
certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert)
endpoint.TLSCertPath = certPath
keyPath, _ := handler.FileService.GetPathForTLSFile(endpoint.ID, portainer.TLSFileKey)
keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey)
endpoint.TLSKeyPath = keyPath
} else {
endpoint.TLS = false
endpoint.TLSCACertPath = ""
endpoint.TLSCertPath = ""
endpoint.TLSKeyPath = ""
err = handler.FileService.DeleteTLSFiles(endpoint.ID)
err = handler.FileService.DeleteTLSFiles(folder)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
@@ -305,13 +316,6 @@ func (handler *EndpointHandler) handlePutEndpoint(w http.ResponseWriter, r *http
}
}
type putEndpointsRequest struct {
Name string `valid:"-"`
URL string `valid:"-"`
PublicURL string `valid:"-"`
TLS bool `valid:"-"`
}
// handleDeleteEndpoint handles DELETE requests on /endpoints/:id
func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *http.Request) {
if !handler.authorizeEndpointManagement {
@@ -347,7 +351,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
}
if endpoint.TLS {
err = handler.FileService.DeleteTLSFiles(portainer.EndpointID(endpointID))
err = handler.FileService.DeleteTLSFiles(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
+26 -24
View File
@@ -36,46 +36,48 @@ const (
ErrInvalidRequestFormat = portainer.Error("Invalid request data format")
// ErrInvalidQueryFormat defines an error raised when the data sent in the query or the URL is invalid
ErrInvalidQueryFormat = portainer.Error("Invalid query format")
// ErrEmptyResponseBody defines an error raised when portainer excepts to parse the body of a HTTP response and there is nothing to parse
// ErrEmptyResponseBody = portainer.Error("Empty response body")
)
// ServeHTTP delegates a request to the appropriate subhandler.
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/auth") {
switch {
case strings.HasPrefix(r.URL.Path, "/api/auth"):
http.StripPrefix("/api", h.AuthHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/users") {
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/teams") {
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/team_memberships") {
http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/endpoints") {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/registries") {
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/dockerhub") {
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/resource_controls") {
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker") {
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else {
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/registries"):
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/resource_controls"):
http.StripPrefix("/api", h.ResourceHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/settings") {
case strings.HasPrefix(r.URL.Path, "/api/settings"):
http.StripPrefix("/api", h.SettingsHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/status") {
case strings.HasPrefix(r.URL.Path, "/api/status"):
http.StripPrefix("/api", h.StatusHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/templates") {
case strings.HasPrefix(r.URL.Path, "/api/templates"):
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/upload") {
case strings.HasPrefix(r.URL.Path, "/api/upload"):
http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/websocket") {
case strings.HasPrefix(r.URL.Path, "/api/users"):
http.StripPrefix("/api", h.UserHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/teams"):
http.StripPrefix("/api", h.TeamHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/team_memberships"):
http.StripPrefix("/api", h.TeamMembershipHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/websocket"):
http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/api/docker") {
http.StripPrefix("/api/docker", h.DockerHandler).ServeHTTP(w, r)
} else if strings.HasPrefix(r.URL.Path, "/") {
case strings.HasPrefix(r.URL.Path, "/"):
h.FileHandler.ServeHTTP(w, r)
}
}
// encodeJSON encodes v to w in JSON format. Error() is called if encoding fails.
// encodeJSON encodes v to w in JSON format. WriteErrorResponse() is called if encoding fails.
func encodeJSON(w http.ResponseWriter, v interface{}, logger *log.Logger) {
if err := json.NewEncoder(w).Encode(v); err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, logger)
+27 -25
View File
@@ -44,6 +44,33 @@ func NewRegistryHandler(bouncer *security.RequestBouncer) *RegistryHandler {
return h
}
type (
postRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
postRegistriesResponse struct {
ID int `json:"Id"`
}
putRegistryAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
putRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
)
// handleGetRegistries handles GET requests on /registries
func (handler *RegistryHandler) handleGetRegistries(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@@ -112,18 +139,6 @@ func (handler *RegistryHandler) handlePostRegistries(w http.ResponseWriter, r *h
encodeJSON(w, &postRegistriesResponse{ID: int(registry.ID)}, handler.Logger)
}
type postRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
type postRegistriesResponse struct {
ID int `json:"Id"`
}
// handleGetRegistry handles GET requests on /registries/:id
func (handler *RegistryHandler) handleGetRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -202,11 +217,6 @@ func (handler *RegistryHandler) handlePutRegistryAccess(w http.ResponseWriter, r
}
}
type putRegistryAccessRequest struct {
AuthorizedUsers []int `valid:"-"`
AuthorizedTeams []int `valid:"-"`
}
// handlePutRegistry handles PUT requests on /registries/:id
func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -276,14 +286,6 @@ func (handler *RegistryHandler) handlePutRegistry(w http.ResponseWriter, r *http
}
}
type putRegistriesRequest struct {
Name string `valid:"required"`
URL string `valid:"required"`
Authentication bool `valid:""`
Username string `valid:""`
Password string `valid:""`
}
// handleDeleteRegistry handles DELETE requests on /registries/:id
func (handler *RegistryHandler) handleDeleteRegistry(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
+18 -16
View File
@@ -39,6 +39,23 @@ func NewResourceHandler(bouncer *security.RequestBouncer) *ResourceHandler {
return h
}
type (
postResourcesRequest struct {
ResourceID string `valid:"required"`
Type string `valid:"required"`
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
SubResourceIDs []string `valid:"-"`
}
putResourcesRequest struct {
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
}
)
// handlePostResources handles POST requests on /resources
func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *http.Request) {
var req postResourcesRequest
@@ -121,22 +138,13 @@ func (handler *ResourceHandler) handlePostResources(w http.ResponseWriter, r *ht
err = handler.ResourceControlService.CreateResourceControl(&resourceControl)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
return
}
type postResourcesRequest struct {
ResourceID string `valid:"required"`
Type string `valid:"required"`
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
SubResourceIDs []string `valid:"-"`
}
// handlePutResources handles PUT requests on /resources/:id
func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -210,12 +218,6 @@ func (handler *ResourceHandler) handlePutResources(w http.ResponseWriter, r *htt
}
}
type putResourcesRequest struct {
AdministratorsOnly bool `valid:"-"`
Users []int `valid:"-"`
Teams []int `valid:"-"`
}
// handleDeleteResources handles DELETE requests on /resources/:id
func (handler *ResourceHandler) handleDeleteResources(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
+92 -6
View File
@@ -5,6 +5,7 @@ import (
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
"github.com/portainer/portainer/file"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/security"
@@ -20,6 +21,8 @@ type SettingsHandler struct {
*mux.Router
Logger *log.Logger
SettingsService portainer.SettingsService
LDAPService portainer.LDAPService
FileService portainer.FileService
}
// NewSettingsHandler returns a new instance of OldSettingsHandler.
@@ -29,13 +32,38 @@ func NewSettingsHandler(bouncer *security.RequestBouncer) *SettingsHandler {
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/settings",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
bouncer.AdministratorAccess(http.HandlerFunc(h.handleGetSettings))).Methods(http.MethodGet)
h.Handle("/settings",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettings))).Methods(http.MethodPut)
h.Handle("/settings/public",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetPublicSettings))).Methods(http.MethodGet)
h.Handle("/settings/authentication/checkLDAP",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePutSettingsLDAPCheck))).Methods(http.MethodPut)
return h
}
type (
publicSettingsResponse struct {
LogoURL string `json:"LogoURL"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
}
putSettingsRequest struct {
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayExternalContributors bool `valid:""`
AuthenticationMethod int `valid:"required"`
LDAPSettings portainer.LDAPSettings `valid:""`
}
putSettingsLDAPCheckRequest struct {
LDAPSettings portainer.LDAPSettings `valid:""`
}
)
// handleGetSettings handles GET requests on /settings
func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http.Request) {
settings, err := handler.SettingsService.Settings()
@@ -48,6 +76,24 @@ func (handler *SettingsHandler) handleGetSettings(w http.ResponseWriter, r *http
return
}
// handleGetPublicSettings handles GET requests on /settings/public
func (handler *SettingsHandler) handleGetPublicSettings(w http.ResponseWriter, r *http.Request) {
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
publicSettings := &publicSettingsResponse{
LogoURL: settings.LogoURL,
DisplayExternalContributors: settings.DisplayExternalContributors,
AuthenticationMethod: settings.AuthenticationMethod,
}
encodeJSON(w, publicSettings, handler.Logger)
return
}
// handlePutSettings handles PUT requests on /settings
func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http.Request) {
var req putSettingsRequest
@@ -67,6 +113,27 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
LogoURL: req.LogoURL,
BlackListedLabels: req.BlackListedLabels,
DisplayExternalContributors: req.DisplayExternalContributors,
LDAPSettings: req.LDAPSettings,
}
if req.AuthenticationMethod == 1 {
settings.AuthenticationMethod = portainer.AuthenticationInternal
} else if req.AuthenticationMethod == 2 {
settings.AuthenticationMethod = portainer.AuthenticationLDAP
} else {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
settings.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
} else {
settings.LDAPSettings.TLSConfig.TLSCACertPath = ""
err := handler.FileService.DeleteTLSFiles(file.LDAPStorePath)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
}
}
err = handler.SettingsService.StoreSettings(settings)
@@ -75,9 +142,28 @@ func (handler *SettingsHandler) handlePutSettings(w http.ResponseWriter, r *http
}
}
type putSettingsRequest struct {
TemplatesURL string `valid:"required"`
LogoURL string `valid:""`
BlackListedLabels []portainer.Pair `valid:""`
DisplayExternalContributors bool `valid:""`
// handlePutSettingsLDAPCheck handles PUT requests on /settings/ldap/check
func (handler *SettingsHandler) handlePutSettingsLDAPCheck(w http.ResponseWriter, r *http.Request) {
var req putSettingsLDAPCheckRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
return
}
_, err := govalidator.ValidateStruct(req)
if err != nil {
httperror.WriteErrorResponse(w, ErrInvalidRequestFormat, http.StatusBadRequest, handler.Logger)
return
}
if (req.LDAPSettings.TLSConfig.TLS || req.LDAPSettings.StartTLS) && !req.LDAPSettings.TLSConfig.TLSSkipVerify {
caCertPath, _ := handler.FileService.GetPathForTLSFile(file.LDAPStorePath, portainer.TLSFileCA)
req.LDAPSettings.TLSConfig.TLSCACertPath = caCertPath
}
err = handler.LDAPService.TestConnectivity(&req.LDAPSettings)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
+24 -14
View File
@@ -34,7 +34,7 @@ func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler {
h.Handle("/teams",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostTeams))).Methods(http.MethodPost)
h.Handle("/teams",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeam))).Methods(http.MethodGet)
h.Handle("/teams/{id}",
@@ -47,6 +47,20 @@ func NewTeamHandler(bouncer *security.RequestBouncer) *TeamHandler {
return h
}
type (
postTeamsRequest struct {
Name string `valid:"required"`
}
postTeamsResponse struct {
ID int `json:"Id"`
}
putTeamRequest struct {
Name string `valid:"-"`
}
)
// handlePostTeams handles POST requests on /teams
func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Request) {
var req postTeamsRequest
@@ -84,23 +98,23 @@ func (handler *TeamHandler) handlePostTeams(w http.ResponseWriter, r *http.Reque
encodeJSON(w, &postTeamsResponse{ID: int(team.ID)}, handler.Logger)
}
type postTeamsResponse struct {
ID int `json:"Id"`
}
type postTeamsRequest struct {
Name string `valid:"required"`
}
// handleGetTeams handles GET requests on /teams
func (handler *TeamHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
teams, err := handler.TeamService.Teams()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, teams, handler.Logger)
filteredTeams := security.FilterUserTeams(teams, securityContext)
encodeJSON(w, filteredTeams, handler.Logger)
}
// handleGetTeam handles GET requests on /teams/:id
@@ -181,10 +195,6 @@ func (handler *TeamHandler) handlePutTeam(w http.ResponseWriter, r *http.Request
}
}
type putTeamRequest struct {
Name string `valid:"-"`
}
// handleDeleteTeam handles DELETE requests on /teams/:id
func (handler *TeamHandler) handleDeleteTeam(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
+18 -16
View File
@@ -42,6 +42,24 @@ func NewTeamMembershipHandler(bouncer *security.RequestBouncer) *TeamMembershipH
return h
}
type (
postTeamMembershipsRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
postTeamMembershipsResponse struct {
ID int `json:"Id"`
}
putTeamMembershipRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
)
// handlePostTeamMemberships handles POST requests on /team_memberships
func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@@ -100,16 +118,6 @@ func (handler *TeamMembershipHandler) handlePostTeamMemberships(w http.ResponseW
encodeJSON(w, &postTeamMembershipsResponse{ID: int(membership.ID)}, handler.Logger)
}
type postTeamMembershipsResponse struct {
ID int `json:"Id"`
}
type postTeamMembershipsRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
// handleGetTeamsMemberships handles GET requests on /team_memberships
func (handler *TeamMembershipHandler) handleGetTeamsMemberships(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@@ -195,12 +203,6 @@ func (handler *TeamMembershipHandler) handlePutTeamMembership(w http.ResponseWri
}
}
type putTeamMembershipRequest struct {
UserID int `valid:"required"`
TeamID int `valid:"required"`
Role int `valid:"required"`
}
// handleDeleteTeamMembership handles DELETE requests on /team_memberships/:id
func (handler *TeamMembershipHandler) handleDeleteTeamMembership(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
+2 -7
View File
@@ -20,7 +20,7 @@ type TemplatesHandler struct {
}
const (
containerTemplatesURLLinuxServerIo = "http://tools.linuxserver.io/portainer.json"
containerTemplatesURLLinuxServerIo = "https://tools.linuxserver.io/portainer.json"
)
// NewTemplatesHandler returns a new instance of TemplatesHandler.
@@ -30,17 +30,12 @@ func NewTemplatesHandler(bouncer *security.RequestBouncer) *TemplatesHandler {
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/templates",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates)))
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetTemplates))).Methods(http.MethodGet)
return h
}
// handleGetTemplates handles GET requests on /templates?key=<key>
func (handler *TemplatesHandler) handleGetTemplates(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet})
return
}
key := r.FormValue("key")
if key == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
+8 -13
View File
@@ -8,7 +8,6 @@ import (
"log"
"net/http"
"os"
"strconv"
"github.com/gorilla/mux"
)
@@ -26,23 +25,19 @@ func NewUploadHandler(bouncer *security.RequestBouncer) *UploadHandler {
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/upload/tls/{endpointID}/{certificate:(?:ca|cert|key)}",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUploadTLS)))
h.Handle("/upload/tls/{certificate:(?:ca|cert|key)}",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostUploadTLS))).Methods(http.MethodPost)
return h
}
// handlePostUploadTLS handles POST requests on /upload/tls/{certificate:(?:ca|cert|key)}?folder=folder
func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
}
vars := mux.Vars(r)
endpointID := vars["endpointID"]
certificate := vars["certificate"]
ID, err := strconv.Atoi(endpointID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
folder := r.FormValue("folder")
if folder == "" {
httperror.WriteErrorResponse(w, ErrInvalidQueryFormat, http.StatusBadRequest, handler.Logger)
return
}
@@ -66,7 +61,7 @@ func (handler *UploadHandler) handlePostUploadTLS(w http.ResponseWriter, r *http
return
}
err = handler.FileService.StoreTLSFile(portainer.EndpointID(ID), fileType, file)
err = handler.FileService.StoreTLSFile(folder, fileType, file)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
+53 -92
View File
@@ -26,6 +26,7 @@ type UserHandler struct {
TeamMembershipService portainer.TeamMembershipService
ResourceControlService portainer.ResourceControlService
CryptoService portainer.CryptoService
SettingsService portainer.SettingsService
}
// NewUserHandler returns a new instance of UserHandler.
@@ -46,18 +47,45 @@ func NewUserHandler(bouncer *security.RequestBouncer) *UserHandler {
bouncer.AdministratorAccess(http.HandlerFunc(h.handleDeleteUser))).Methods(http.MethodDelete)
h.Handle("/users/{id}/memberships",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handleGetMemberships))).Methods(http.MethodGet)
h.Handle("/users/{id}/teams",
bouncer.RestrictedAccess(http.HandlerFunc(h.handleGetTeams))).Methods(http.MethodGet)
h.Handle("/users/{id}/passwd",
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd)))
bouncer.AuthenticatedAccess(http.HandlerFunc(h.handlePostUserPasswd))).Methods(http.MethodPost)
h.Handle("/users/admin/check",
bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck)))
bouncer.PublicAccess(http.HandlerFunc(h.handleGetAdminCheck))).Methods(http.MethodGet)
h.Handle("/users/admin/init",
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit)))
bouncer.PublicAccess(http.HandlerFunc(h.handlePostAdminInit))).Methods(http.MethodPost)
return h
}
type (
postUsersRequest struct {
Username string `valid:"required"`
Password string `valid:""`
Role int `valid:"required"`
}
postUsersResponse struct {
ID int `json:"Id"`
}
postUserPasswdRequest struct {
Password string `valid:"required"`
}
postUserPasswdResponse struct {
Valid bool `json:"valid"`
}
putUserRequest struct {
Password string `valid:"-"`
Role int `valid:"-"`
}
postAdminInitRequest struct {
Password string `valid:"required"`
}
)
// handlePostUsers handles POST requests on /users
func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Request) {
var req postUsersRequest
@@ -93,13 +121,6 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
return
}
var role portainer.UserRole
if req.Role == 1 {
role = portainer.AdministratorRole
} else {
role = portainer.StandardUserRole
}
user, err := handler.UserService.UserByUsername(req.Username)
if err != nil && err != portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@@ -110,16 +131,32 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
return
}
var role portainer.UserRole
if req.Role == 1 {
role = portainer.AdministratorRole
} else {
role = portainer.StandardUserRole
}
user = &portainer.User{
Username: req.Username,
Role: role,
}
user.Password, err = handler.CryptoService.Hash(req.Password)
settings, err := handler.SettingsService.Settings()
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if settings.AuthenticationMethod == portainer.AuthenticationInternal {
user.Password, err = handler.CryptoService.Hash(req.Password)
if err != nil {
httperror.WriteErrorResponse(w, portainer.ErrCryptoHashFailure, http.StatusBadRequest, handler.Logger)
return
}
}
err = handler.UserService.CreateUser(user)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@@ -129,16 +166,6 @@ func (handler *UserHandler) handlePostUsers(w http.ResponseWriter, r *http.Reque
encodeJSON(w, &postUsersResponse{ID: int(user.ID)}, handler.Logger)
}
type postUsersResponse struct {
ID int `json:"Id"`
}
type postUsersRequest struct {
Username string `valid:"required"`
Password string `valid:"required"`
Role int `valid:"required"`
}
// handleGetUsers handles GET requests on /users
func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Request) {
securityContext, err := security.RetrieveRestrictedRequestContext(r)
@@ -164,11 +191,6 @@ func (handler *UserHandler) handleGetUsers(w http.ResponseWriter, r *http.Reques
// handlePostUserPasswd handles POST requests on /users/:id/passwd
func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
}
vars := mux.Vars(r)
id := vars["id"]
@@ -210,14 +232,6 @@ func (handler *UserHandler) handlePostUserPasswd(w http.ResponseWriter, r *http.
encodeJSON(w, &postUserPasswdResponse{Valid: valid}, handler.Logger)
}
type postUserPasswdRequest struct {
Password string `valid:"required"`
}
type postUserPasswdResponse struct {
Valid bool `json:"valid"`
}
// handleGetUser handles GET requests on /users/:id
func (handler *UserHandler) handleGetUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -317,18 +331,8 @@ func (handler *UserHandler) handlePutUser(w http.ResponseWriter, r *http.Request
}
}
type putUserRequest struct {
Password string `valid:"-"`
Role int `valid:"-"`
}
// handlePostAdminInit handles GET requests on /users/admin/check
// handleGetAdminCheck handles GET requests on /users/admin/check
func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodGet})
return
}
users, err := handler.UserService.UsersByRole(portainer.AdministratorRole)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
@@ -342,11 +346,6 @@ func (handler *UserHandler) handleGetAdminCheck(w http.ResponseWriter, r *http.R
// handlePostAdminInit handles POST requests on /users/admin/init
func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httperror.WriteMethodNotAllowedResponse(w, []string{http.MethodPost})
return
}
var req postAdminInitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httperror.WriteErrorResponse(w, ErrInvalidJSON, http.StatusBadRequest, handler.Logger)
@@ -381,15 +380,11 @@ func (handler *UserHandler) handlePostAdminInit(w http.ResponseWriter, r *http.R
return
}
if user != nil {
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusForbidden, handler.Logger)
httperror.WriteErrorResponse(w, portainer.ErrAdminAlreadyInitialized, http.StatusConflict, handler.Logger)
return
}
}
type postAdminInitRequest struct {
Password string `valid:"required"`
}
// handleDeleteUser handles DELETE requests on /users/:id
func (handler *UserHandler) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
@@ -454,37 +449,3 @@ func (handler *UserHandler) handleGetMemberships(w http.ResponseWriter, r *http.
encodeJSON(w, memberships, handler.Logger)
}
// handleGetTeams handles GET requests on /users/:id/teams
func (handler *UserHandler) handleGetTeams(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
uid, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
userID := portainer.UserID(uid)
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if !security.AuthorizedUserManagement(userID, securityContext) {
httperror.WriteErrorResponse(w, portainer.ErrResourceAccessDenied, http.StatusForbidden, handler.Logger)
return
}
teams, err := handler.TeamService.Teams()
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
filteredTeams := security.FilterUserTeams(teams, securityContext)
encodeJSON(w, filteredTeams, handler.Logger)
}
+1 -3
View File
@@ -72,9 +72,7 @@ func (handler *WebSocketHandler) webSocketDockerExec(ws *websocket.Conn) {
// Should not be managed here
var tlsConfig *tls.Config
if endpoint.TLS {
tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath,
endpoint.TLSCertPath,
endpoint.TLSKeyPath)
tlsConfig, err = crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
if err != nil {
log.Fatalf("Unable to create TLS configuration: %s", err)
return
+1 -1
View File
@@ -24,7 +24,7 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
u.Scheme = "https"
proxy := factory.createReverseProxy(u)
config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath)
config, err := crypto.CreateTLSConfiguration(endpoint.TLSCACertPath, endpoint.TLSCertPath, endpoint.TLSKeyPath, false)
if err != nil {
return nil, err
}
+5
View File
@@ -85,6 +85,11 @@ func rewriteResponse(response *http.Response, newResponseData interface{}, statu
response.StatusCode = statusCode
response.Body = body
response.ContentLength = int64(len(jsonData))
if response.Header == nil {
response.Header = make(http.Header)
}
response.Header.Set("Content-Length", strconv.Itoa(len(jsonData)))
return nil
}
+6
View File
@@ -59,6 +59,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
return p.proxyServiceRequest(request)
} else if strings.HasPrefix(path, "/volumes") {
return p.proxyVolumeRequest(request)
} else if strings.HasPrefix(path, "/swarm") {
return p.proxySwarmRequest(request)
}
return p.executeDockerRequest(request)
@@ -143,6 +145,10 @@ func (p *proxyTransport) proxyVolumeRequest(request *http.Request) (*http.Respon
}
}
func (p *proxyTransport) proxySwarmRequest(request *http.Request) (*http.Response, error) {
return p.administratorOperation(request)
}
// restrictedOperation ensures that the current user has the required authorizations
// before executing the original request.
func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID string) (*http.Response, error) {
+1 -1
View File
@@ -22,7 +22,7 @@ func AuthorizedResourceControlDeletion(resourceControl *portainer.ResourceContro
if teamAccessesCount > 0 {
for _, access := range resourceControl.TeamAccesses {
for _, membership := range context.UserMemberships {
if membership.TeamID == access.TeamID && membership.Role == portainer.TeamLeader {
if membership.TeamID == access.TeamID {
return true
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ func (bouncer *RequestBouncer) AuthenticatedAccess(h http.Handler) http.Handler
return h
}
// RestrictedAccess defines defines a security check for restricted endpoints.
// RestrictedAccess defines a security check for restricted endpoints.
// Authentication is required to access these endpoints.
// The request context will be enhanced with a RestrictedRequestContext object
// that might be used later to authorize/filter access to resources.
+6
View File
@@ -27,6 +27,7 @@ type Server struct {
FileService portainer.FileService
RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService
LDAPService portainer.LDAPService
Handler *handler.Handler
SSL bool
SSLCert string
@@ -42,12 +43,15 @@ func (server *Server) Start() error {
authHandler.UserService = server.UserService
authHandler.CryptoService = server.CryptoService
authHandler.JWTService = server.JWTService
authHandler.LDAPService = server.LDAPService
authHandler.SettingsService = server.SettingsService
var userHandler = handler.NewUserHandler(requestBouncer)
userHandler.UserService = server.UserService
userHandler.TeamService = server.TeamService
userHandler.TeamMembershipService = server.TeamMembershipService
userHandler.CryptoService = server.CryptoService
userHandler.ResourceControlService = server.ResourceControlService
userHandler.SettingsService = server.SettingsService
var teamHandler = handler.NewTeamHandler(requestBouncer)
teamHandler.TeamService = server.TeamService
teamHandler.TeamMembershipService = server.TeamMembershipService
@@ -56,6 +60,8 @@ func (server *Server) Start() error {
var statusHandler = handler.NewStatusHandler(requestBouncer, server.Status)
var settingsHandler = handler.NewSettingsHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService
settingsHandler.FileService = server.FileService
var templatesHandler = handler.NewTemplatesHandler(requestBouncer)
templatesHandler.SettingsService = server.SettingsService
var dockerHandler = handler.NewDockerHandler(requestBouncer)
+123
View File
@@ -0,0 +1,123 @@
package ldap
import (
"fmt"
"strings"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
"gopkg.in/ldap.v2"
)
const (
// ErrUserNotFound defines an error raised when the user is not found via LDAP search
// or that too many entries (> 1) are returned.
ErrUserNotFound = portainer.Error("User not found or too many entries returned")
)
// Service represents a service used to authenticate users against a LDAP/AD.
type Service struct{}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
var userDN string
found := false
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username),
[]string{"dn"},
nil,
)
// Deliberately skip errors on the search request so that we can jump to other search settings
// if any issue arise with the current one.
sr, _ := conn.Search(searchRequest)
if len(sr.Entries) == 1 {
found = true
userDN = sr.Entries[0].DN
break
}
}
if !found {
return "", ErrUserNotFound
}
return userDN, nil
}
func createConnection(settings *portainer.LDAPSettings) (*ldap.Conn, error) {
if settings.TLSConfig.TLS || settings.StartTLS {
config, err := crypto.CreateTLSConfiguration(settings.TLSConfig.TLSCACertPath, "", "", settings.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
config.ServerName = strings.Split(settings.URL, ":")[0]
if settings.TLSConfig.TLS {
return ldap.DialTLS("tcp", settings.URL, config)
}
conn, err := ldap.Dial("tcp", settings.URL)
if err != nil {
return nil, err
}
err = conn.StartTLS(config)
if err != nil {
return nil, err
}
return conn, nil
}
return ldap.Dial("tcp", settings.URL)
}
// AuthenticateUser is used to authenticate a user against a LDAP/AD.
func (*Service) AuthenticateUser(username, password string, settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
userDN, err := searchUser(username, connection, settings.SearchSettings)
if err != nil {
return err
}
err = connection.Bind(userDN, password)
if err != nil {
return err
}
return nil
}
// TestConnectivity is used to test a connection against the LDAP server using the credentials
// specified in the LDAPSettings.
func (*Service) TestConnectivity(settings *portainer.LDAPSettings) error {
connection, err := createConnection(settings)
if err != nil {
return err
}
defer connection.Close()
err = connection.Bind(settings.ReaderDN, settings.Password)
if err != nil {
return err
}
return nil
}
+54 -9
View File
@@ -41,12 +41,40 @@ type (
Version string `json:"Version"`
}
// LDAPSettings represents the settings used to connect to a LDAP server.
LDAPSettings struct {
ReaderDN string `json:"ReaderDN"`
Password string `json:"Password"`
URL string `json:"URL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
StartTLS bool `json:"StartTLS"`
SearchSettings []LDAPSearchSettings `json:"SearchSettings"`
}
// TLSConfiguration represents a TLS configuration.
TLSConfiguration struct {
TLS bool `json:"TLS"`
TLSSkipVerify bool `json:"TLSSkipVerify"`
TLSCACertPath string `json:"TLSCACert,omitempty"`
TLSCertPath string `json:"TLSCert,omitempty"`
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// LDAPSearchSettings represents settings used to search for users in a LDAP server.
LDAPSearchSettings struct {
BaseDN string `json:"BaseDN"`
Filter string `json:"Filter"`
UserNameAttribute string `json:"UserNameAttribute"`
}
// Settings represents the application settings.
Settings struct {
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
TemplatesURL string `json:"TemplatesURL"`
LogoURL string `json:"LogoURL"`
BlackListedLabels []Pair `json:"BlackListedLabels"`
DisplayExternalContributors bool `json:"DisplayExternalContributors"`
AuthenticationMethod AuthenticationMethod `json:"AuthenticationMethod"`
LDAPSettings LDAPSettings `json:"LDAPSettings"`
}
// User represents a user account.
@@ -64,6 +92,9 @@ type (
// or a regular user
UserRole int
// AuthenticationMethod represents the authentication method used to authenticate a user.
AuthenticationMethod int
// Team represents a list of user accounts.
Team struct {
ID TeamID `json:"Id"`
@@ -292,22 +323,28 @@ type (
// FileService represents a service for managing files.
FileService interface {
StoreTLSFile(endpointID EndpointID, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(endpointID EndpointID, fileType TLSFileType) (string, error)
DeleteTLSFiles(endpointID EndpointID) error
StoreTLSFile(folder string, fileType TLSFileType, r io.Reader) error
GetPathForTLSFile(folder string, fileType TLSFileType) (string, error)
DeleteTLSFiles(folder string) error
}
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
EndpointWatcher interface {
WatchEndpointFile(endpointFilePath string) error
}
// LDAPService represents a service used to authenticate users against a LDAP/AD.
LDAPService interface {
AuthenticateUser(username, password string, settings *LDAPSettings) error
TestConnectivity(settings *LDAPSettings) error
}
)
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.13.3"
APIVersion = "1.14.0"
// DBVersion is the version number of the Portainer database.
DBVersion = 2
DBVersion = 3
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
)
@@ -337,6 +374,14 @@ const (
StandardUserRole
)
const (
_ AuthenticationMethod = iota
// AuthenticationInternal represents the internal authentication method (authentication against Portainer API)
AuthenticationInternal
// AuthenticationLDAP represents the LDAP authentication method (authentication against a LDAP server)
AuthenticationLDAP
)
const (
_ ResourceAccessLevel = iota
// ReadWriteAccessLevel represents an access level with read-write permissions on a resource
+2471
View File
File diff suppressed because it is too large Load Diff
+41 -17
View File
@@ -20,11 +20,10 @@ angular.module('portainer', [
'portainer.services',
'auth',
'dashboard',
'common.accesscontrol.panel',
'common.accesscontrol.form',
'container',
'containerConsole',
'containerLogs',
'serviceLogs',
'containers',
'createContainer',
'createNetwork',
@@ -52,6 +51,7 @@ angular.module('portainer', [
'service',
'services',
'settings',
'settingsAuthentication',
'sidebar',
'stats',
'swarm',
@@ -166,7 +166,7 @@ angular.module('portainer', [
}
}
})
.state('logs', {
.state('containerlogs', {
url: '^/containers/:id/logs',
views: {
'content@': {
@@ -179,6 +179,19 @@ angular.module('portainer', [
}
}
})
.state('servicelogs', {
url: '^/services/:id/logs',
views: {
'content@': {
templateUrl: 'app/components/serviceLogs/servicelogs.html',
controller: 'ServiceLogsController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('console', {
url: '^/containers/:id/console',
views: {
@@ -231,7 +244,7 @@ angular.module('portainer', [
}
})
.state('actions.create.container', {
url: '/container',
url: '/container/:from',
views: {
'content@': {
templateUrl: 'app/components/createContainer/createcontainer.html',
@@ -551,6 +564,19 @@ angular.module('portainer', [
}
}
})
.state('settings_authentication', {
url: '^/settings/authentication',
views: {
'content@': {
templateUrl: 'app/components/settingsAuthentication/settingsAuthentication.html',
controller: 'SettingsAuthenticationController'
},
'sidebar@': {
templateUrl: 'app/components/sidebar/sidebar.html',
controller: 'SidebarController'
}
}
})
.state('task', {
url: '^/task/:id',
views: {
@@ -732,18 +758,16 @@ angular.module('portainer', [
// This is your docker url that the api will use to make requests
// You need to set this to the api endpoint without the port i.e. http://192.168.1.9
// .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is required. If you have a port, prefix it with a ':' i.e. :4243
.constant('DOCKER_ENDPOINT', 'api/docker')
.constant('CONFIG_ENDPOINT', 'api/old_settings')
.constant('SETTINGS_ENDPOINT', 'api/settings')
.constant('STATUS_ENDPOINT', 'api/status')
.constant('AUTH_ENDPOINT', 'api/auth')
.constant('USERS_ENDPOINT', 'api/users')
.constant('TEAMS_ENDPOINT', 'api/teams')
.constant('TEAM_MEMBERSHIPS_ENDPOINT', 'api/team_memberships')
.constant('RESOURCE_CONTROL_ENDPOINT', 'api/resource_controls')
.constant('ENDPOINTS_ENDPOINT', 'api/endpoints')
.constant('DOCKERHUB_ENDPOINT', 'api/dockerhub')
.constant('REGISTRIES_ENDPOINT', 'api/registries')
.constant('TEMPLATES_ENDPOINT', 'api/templates')
.constant('API_ENDPOINT_AUTH', 'api/auth')
.constant('API_ENDPOINT_DOCKERHUB', 'api/dockerhub')
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
.constant('API_ENDPOINT_SETTINGS', 'api/settings')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')
.constant('API_ENDPOINT_TEMPLATES', 'api/templates')
.constant('DEFAULT_TEMPLATES_URL', 'https://raw.githubusercontent.com/portainer/templates/master/templates.json')
.constant('PAGINATION_MAX_ITEMS', 10);
@@ -1,126 +0,0 @@
<div ng-controller="AccessControlFormController">
<div class="col-sm-12 form-section-title">
Access control
</div>
<!-- access-control-switch -->
<div class="form-group">
<div class="col-sm-12">
<label for="ownership" class="control-label text-left">
Enable access control
<portainer-tooltip position="bottom" message="When enabled, you can restrict the access and management of this resource."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input name="ownership" type="checkbox" ng-model="formValues.enableAccessControl" ng-click="synchronizeFormData()"><i></i>
</label>
</div>
</div>
<!-- !access-control-switch -->
<!-- restricted-access -->
<div class="form-group" ng-if="formValues.enableAccessControl" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div ng-if="isAdmin">
<input type="radio" id="access_administrators" ng-model="formValues.Ownership" ng-click="synchronizeFormData()" value="administrators">
<label for="access_administrators">
<div class="boxselector_header">
<i ng-class="'administrators' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
Administrators
</div>
<p>I want to restrict the management of this resource to administrators only</p>
</label>
</div>
<div ng-if="isAdmin">
<input type="radio" id="access_restricted" ng-model="formValues.Ownership" ng-click="synchronizeFormData()" value="restricted">
<label for="access_restricted">
<div class="boxselector_header">
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
Restricted
</div>
<p>
I want to restrict the management of this resource to a set of users and/or teams
</p>
</label>
</div>
<div ng-if="!isAdmin">
<input type="radio" id="access_private" ng-model="formValues.Ownership" ng-click="synchronizeFormData()" value="private">
<label for="access_private">
<div class="boxselector_header">
<i ng-class="'private' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
Private
</div>
<p>
I want to this resource to be manageable by myself only
</p>
</label>
</div>
<div ng-if="!isAdmin && availableTeams.length > 0">
<input type="radio" id="access_restricted" ng-model="formValues.Ownership" ng-click="synchronizeFormData()" value="restricted">
<label for="access_restricted">
<div class="boxselector_header">
<i ng-class="'restricted' | ownershipicon" aria-hidden="true" style="margin-right: 2px;"></i>
Restricted
</div>
<p ng-if="availableTeams.length === 1">
I want any member of my team (<b>{{ availableTeams[0].Name }}</b>) to be able to manage this resource
</p>
<p ng-if="availableTeams.length > 1">
I want to restrict the management of this resource to one or more of my teams
</p>
</label>
</div>
</div>
</div>
<!-- restricted-access -->
<!-- authorized-teams -->
<div class="form-group" ng-if="formValues.enableAccessControl && formValues.Ownership === 'restricted' && (isAdmin || (!isAdmin && availableTeams.length > 1))" >
<div class="col-sm-12">
<label for="group-access" class="control-label text-left">
Authorized teams
<portainer-tooltip ng-if="isAdmin && availableTeams.length > 0" position="bottom" message="You can select which teams(s) will be able to manage this resource."></portainer-tooltip>
<portainer-tooltip ng-if="!isAdmin && availableTeams.length > 1" position="bottom" message="As you are a member of multiple teams, you can select which teams(s) will be able to manage this resource."></portainer-tooltip>
</label>
<span ng-if="isAdmin && availableTeams.length === 0" class="small text-muted" style="margin-left: 20px;">
You have not yet created any team. Head over the <a ui-sref="teams">teams view</a> to manage user teams.</span>
</span>
<span isteven-multi-select
ng-if="(isAdmin && availableTeams.length > 0) || (!isAdmin && availableTeams.length > 1)"
input-model="availableTeams"
output-model="formValues.Ownership_Teams"
button-label="Name"
item-label="Name"
tick-property="ticked"
helper-elements="filter"
search-property="Name"
on-item-click="synchronizeFormData()"
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
style="margin-left: 20px;"
</span>
</div>
</div>
<!-- !authorized-teams -->
<!-- authorized-users -->
<div class="form-group" ng-if="formValues.enableAccessControl && formValues.Ownership === 'restricted' && isAdmin">
<div class="col-sm-12">
<label for="group-access" class="control-label text-left">
Authorized users
<portainer-tooltip ng-if="isAdmin && availableUsers.length > 0" position="bottom" message="You can select which user(s) will be able to manage this resource."></portainer-tooltip>
</label>
<span ng-if="availableUsers.length === 0" class="small text-muted" style="margin-left: 20px;">
You have not yet created any user. Head over the <a ui-sref="users">users view</a> to manage users.</span>
</span>
<span isteven-multi-select
ng-if="availableUsers.length > 0"
input-model="availableUsers"
output-model="formValues.Ownership_Users"
button-label="Username"
item-label="Username"
tick-property="ticked"
helper-elements="filter"
search-property="Username"
on-item-click="synchronizeFormData()"
translation="{nothingSelected: 'Select one or more users', search: 'Search...'}"
style="margin-left: 20px;"
</span>
</div>
</div>
<!-- !authorized-users -->
</div>
@@ -1,55 +0,0 @@
angular.module('common.accesscontrol.form', [])
.controller('AccessControlFormController', ['$q', '$scope', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'ControllerDataPipeline',
function ($q, $scope, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, ControllerDataPipeline) {
$scope.availableTeams = [];
$scope.availableUsers = [];
$scope.formValues = {
enableAccessControl: true,
Ownership_Teams: [],
Ownership_Users: [],
Ownership: 'private'
};
$scope.synchronizeFormData = function() {
ControllerDataPipeline.setAccessControlFormData($scope.formValues.enableAccessControl,
$scope.formValues.Ownership, $scope.formValues.Ownership_Users, $scope.formValues.Ownership_Teams);
};
function initAccessControlForm() {
$('#loadingViewSpinner').show();
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true: false;
$scope.isAdmin = isAdmin;
if (isAdmin) {
$scope.formValues.Ownership = 'administrators';
}
$q.all({
availableTeams: UserService.userTeams(userDetails.ID),
availableUsers: isAdmin ? UserService.users(false) : []
})
.then(function success(data) {
$scope.availableUsers = data.availableUsers;
var availableTeams = data.availableTeams;
$scope.availableTeams = availableTeams;
if (!isAdmin && availableTeams.length === 1) {
$scope.formValues.Ownership_Teams = availableTeams;
}
$scope.synchronizeFormData();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve access control information');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initAccessControlForm();
}]);
@@ -1,158 +0,0 @@
angular.module('common.accesscontrol.panel', [])
.controller('AccessControlPanelController', ['$q', '$scope', '$state', 'UserService', 'ResourceControlService', 'Notifications', 'Authentication', 'ModalService', 'ControllerDataPipeline', 'FormValidator',
function ($q, $scope, $state, UserService, ResourceControlService, Notifications, Authentication, ModalService, ControllerDataPipeline, FormValidator) {
$scope.state = {
displayAccessControlPanel: false,
canEditOwnership: false,
editOwnership: false,
formValidationError: ''
};
$scope.formValues = {
Ownership: 'public',
Ownership_Users: [],
Ownership_Teams: []
};
$scope.authorizedUsers = [];
$scope.availableUsers = [];
$scope.authorizedTeams = [];
$scope.availableTeams = [];
$scope.confirmUpdateOwnership = function (force) {
if (!validateForm()) {
return;
}
ModalService.confirmAccessControlUpdate(function (confirmed) {
if(!confirmed) { return; }
updateOwnership();
});
};
function processOwnershipFormValues() {
var userIds = [];
angular.forEach($scope.formValues.Ownership_Users, function(user) {
userIds.push(user.Id);
});
var teamIds = [];
angular.forEach($scope.formValues.Ownership_Teams, function(team) {
teamIds.push(team.Id);
});
var administratorsOnly = $scope.formValues.Ownership === 'administrators' ? true : false;
return {
ownership: $scope.formValues.Ownership,
authorizedUserIds: administratorsOnly ? [] : userIds,
authorizedTeamIds: administratorsOnly ? [] : teamIds,
administratorsOnly: administratorsOnly
};
}
function validateForm() {
$scope.state.formValidationError = '';
var error = '';
var accessControlData = {
ownership: $scope.formValues.Ownership,
authorizedUsers: $scope.formValues.Ownership_Users,
authorizedTeams: $scope.formValues.Ownership_Teams
};
var isAdmin = $scope.isAdmin;
error = FormValidator.validateAccessControl(accessControlData, isAdmin);
if (error) {
$scope.state.formValidationError = error;
return false;
}
return true;
}
function updateOwnership() {
$('#loadingViewSpinner').show();
var accessControlData = ControllerDataPipeline.getAccessControlData();
var resourceId = accessControlData.resourceId;
var ownershipParameters = processOwnershipFormValues();
ResourceControlService.applyResourceControlChange(accessControlData.resourceType, resourceId,
$scope.resourceControl, ownershipParameters)
.then(function success(data) {
Notifications.success('Access control successfully updated');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update access control');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
function initAccessControlPanel() {
$('#loadingViewSpinner').show();
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true: false;
var userId = userDetails.ID;
$scope.isAdmin = isAdmin;
var accessControlData = ControllerDataPipeline.getAccessControlData();
var resourceControl = accessControlData.resourceControl;
$scope.resourceType = accessControlData.resourceType;
$scope.resourceControl = resourceControl;
if (isAdmin) {
if (resourceControl) {
$scope.formValues.Ownership = resourceControl.Ownership === 'private' ? 'restricted' : resourceControl.Ownership;
} else {
$scope.formValues.Ownership = 'public';
}
} else {
$scope.formValues.Ownership = 'public';
}
ResourceControlService.retrieveOwnershipDetails(resourceControl)
.then(function success(data) {
$scope.authorizedUsers = data.authorizedUsers;
$scope.authorizedTeams = data.authorizedTeams;
return ResourceControlService.retrieveUserPermissionsOnResource(userId, isAdmin, resourceControl);
})
.then(function success(data) {
$scope.state.canEditOwnership = data.isPartOfRestrictedUsers || data.isLeaderOfAnyRestrictedTeams;
$scope.state.canChangeOwnershipToTeam = data.isPartOfRestrictedUsers;
return $q.all({
availableUsers: isAdmin ? UserService.users(false) : [],
availableTeams: isAdmin || data.isPartOfRestrictedUsers ? UserService.userTeams(userId) : []
});
})
.then(function success(data) {
$scope.availableUsers = data.availableUsers;
angular.forEach($scope.availableUsers, function(user) {
var found = _.find($scope.authorizedUsers, { Id: user.Id });
if (found) {
user.selected = true;
}
});
$scope.availableTeams = data.availableTeams;
angular.forEach(data.availableTeams, function(team) {
var found = _.find($scope.authorizedTeams, { Id: team.Id });
if (found) {
team.selected = true;
}
});
if (data.availableTeams.length === 1) {
$scope.formValues.Ownership_Teams.push(data.availableTeams[0]);
}
$scope.state.displayAccessControlPanel = true;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve access control information');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initAccessControlPanel();
}]);
+37 -5
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a>
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a>
</rd-header-content>
</rd-header>
@@ -20,6 +20,8 @@
<button class="btn btn-primary" ng-click="pause()" ng-disabled="!container.State.Running || container.State.Paused"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button class="btn btn-primary" ng-click="unpause()" ng-disabled="!container.State.Paused"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button class="btn btn-danger" ng-click="confirmRemove()"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
<button class="btn btn-danger" ng-click="recreate()"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Recreate</button>
<button class="btn btn-primary" ng-click="duplicate()"><i class="fa fa-files-o space-right" aria-hidden="true"></i>Duplicate/Edit</button>
</div>
</rd-widget-body>
</rd-widget>
@@ -33,6 +35,10 @@
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>ID</td>
<td>{{ container.Id }}</td>
</tr>
<tr>
<td>Name</td>
<td ng-if="!container.edit">
@@ -75,7 +81,7 @@
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a class="btn btn-outline-secondary" type="button" ui-sref="stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i>Stats</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="logs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="containerlogs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
<a class="btn btn-outline-secondary" type="button" ui-sref="console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
</div>
</td>
@@ -87,7 +93,14 @@
</div>
</div>
<div ng-include="'app/components/common/accessControlPanel/accessControlPanel.html'" ng-if="container && applicationState.application.authentication"></div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="container && applicationState.application.authentication"
resource-id="container.Id"
resource-control="container.ResourceControl"
resource-type="'container'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div ng-if="container.State.Health" class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
@@ -114,7 +127,7 @@
</tbody>
</table>
</rd-widget-body>
</rd-widge>
</rd-widget>
</div>
</div>
@@ -251,7 +264,7 @@
</div>
</div>
<div class="row" ng-if="!(container.NetworkSettings.Networks | emptyobject)">
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-sitemap" title="Connected networks">
@@ -285,11 +298,30 @@
<button type="button" class="btn btn-xs btn-danger" ng-click="containerLeaveNetwork(container, value.NetworkID)"><i class="fa fa-trash space-right" aria-hidden="true"></i>Leave Network</button>
</td>
</tr>
<tr ng-if="(container.NetworkSettings.Networks | emptyobject)">
<td colspan="5" class="text-center text-muted">No networks connected.</td>
</tr>
</tbody>
</table>
<div class="pagination-controls">
<dir-pagination-controls></dir-pagination-controls>
</div>
<hr />
<form class="form-horizontal">
<!-- network-input -->
<div class="row">
<label for="container_network" class="col-sm-3 col-lg-2 control-label text-left">Join a Network</label>
<div class="col-sm-5 col-lg-4">
<select class="form-control" ng-model="selectedNetwork" id="container_network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Id">{{ net.Name }}</option>
</select>
</div>
<div class="col-sm-1">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!selectedNetwork" ng-click="containerJoinNetwork(container, selectedNetwork)">Join Network</button>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
+118 -4
View File
@@ -1,6 +1,6 @@
angular.module('container', [])
.controller('ContainerController', ['$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerService', 'ImageHelper', 'Network', 'Notifications', 'Pagination', 'ModalService', 'ControllerDataPipeline',
function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerService, ImageHelper, Network, Notifications, Pagination, ModalService, ControllerDataPipeline) {
.controller('ContainerController', ['$q', '$scope', '$state','$stateParams', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'Pagination', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, Pagination, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0;
$scope.portBindings = [];
$scope.config = {
@@ -19,7 +19,6 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con
Container.get({id: $stateParams.id}, function (d) {
var container = new ContainerDetailsViewModel(d);
$scope.container = container;
ControllerDataPipeline.setAccessControlData('container', $stateParams.id, container.ResourceControl);
$scope.container.edit = false;
$scope.container.newContainerName = $filter('trimcontainername')(container.Name);
@@ -86,7 +85,7 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con
$('#createImageSpinner').show();
var image = $scope.config.Image;
var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry);
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
ContainerCommit.commit({id: $stateParams.id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
$('#createImageSpinner').hide();
update();
@@ -197,5 +196,120 @@ function ($scope, $state, $stateParams, $filter, Container, ContainerCommit, Con
});
};
$scope.duplicate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
$state.go('actions.create.container', {from: $stateParams.id}, {reload: true});
});
};
$scope.confirmRemove = function () {
var title = 'You are about to remove a container.';
if ($scope.container.State.Running) {
title = 'You are about to remove a running container.';
}
ModalService.confirmContainerDeletion(
title,
function (result) {
if(!result) { return; }
var cleanAssociatedVolumes = false;
if (result[0]) {
cleanAssociatedVolumes = true;
}
$scope.remove(cleanAssociatedVolumes);
}
);
};
function recreateContainer(pullImage) {
$('#loadingViewSpinner').show();
var container = $scope.container;
var config = ContainerHelper.configFromContainer(container.Model);
ContainerService.remove(container, true)
.then(function success() {
return RegistryService.retrieveRegistryFromRepository(container.Config.Image);
})
.then(function success(data) {
return $q.when(!pullImage || ImageService.pullImage(container.Config.Image, data, true));
})
.then(function success() {
return ContainerService.createAndStartContainer(config);
})
.then(function success(data) {
if (!container.ResourceControl) {
return true;
} else {
var containerIdentifier = data.Id;
var resourceControl = container.ResourceControl;
var users = resourceControl.UserAccesses.map(function(u) {
return u.UserId;
});
var teams = resourceControl.TeamAccesses.map(function(t) {
return t.TeamId;
});
return ResourceControlService.createResourceControl(resourceControl.AdministratorsOnly,
users, teams, containerIdentifier, 'container', []);
}
})
.then(function success(data) {
Notifications.success('Container successfully re-created');
$state.go('containers', {}, {reload: true});
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to re-create container');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
$scope.recreate = function() {
ModalService.confirmExperimentalFeature(function (experimental) {
if(!experimental) { return; }
ModalService.confirmContainerRecreation(function (result) {
if(!result) { return; }
var pullImage = false;
if (result[0]) {
pullImage = true;
}
recreateContainer(pullImage);
});
});
};
$scope.containerJoinNetwork = function containerJoinNetwork(container, networkId) {
$('#joinNetworkSpinner').show();
Network.connect({id: networkId}, { Container: $stateParams.id }, function (d) {
if (container.message) {
$('#joinNetworkSpinner').hide();
Notifications.error('Error', d, 'Unable to connect container to network');
} else {
$('#joinNetworkSpinner').hide();
Notifications.success('Container joined network', $stateParams.id);
$state.go('container', {id: $stateParams.id}, {reload: true});
}
}, function (e) {
$('#joinNetworkSpinner').hide();
Notifications.error('Failure', e, 'Unable to connect container to network');
});
};
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM'
)
.then(function success(data) {
var networks = data;
$scope.availableNetworks = networks;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
update();
}]);
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content ng-if="state.loaded">
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Console
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Console
</rd-header-content>
</rd-header>
@@ -16,29 +16,53 @@
</div>
</rd-widget-header>
<rd-widget-body>
<form>
<div class="row">
<form class="form-horizontal">
<div ng-if="!state.connected">
<!-- command-list -->
<div class="col-sm-4">
<div class="input-group">
<span class="input-group-addon">
<i class="fa fa-linux" aria-hidden="true" ng-if="imageOS == 'linux'"></i>
<i class="fa fa-windows" aria-hidden="true" ng-if="imageOS == 'windows'"></i>
</span>
<select class="form-control" ng-model="state.command" id="command">
<option value="bash" ng-if="imageOS == 'linux'">/bin/bash</option>
<option value="sh" ng-if="imageOS == 'linux'">/bin/sh</option>
<option value="powershell" ng-if="imageOS == 'windows'">powershell</option>
<option value="cmd.exe" ng-if="imageOS == 'windows'">cmd.exe</option>
</select>
<div class="form-group">
<label for="command" class="col-lg-1 text-left col-sm-2 control-label">Command</label>
<div class="col-lg-11 col-sm-10">
<div class="input-group" ng-if="!formValues.isCustomCommand">
<span class="input-group-addon">
<i class="fa fa-linux" aria-hidden="true" ng-if="imageOS == 'linux'"></i>
<i class="fa fa-windows" aria-hidden="true" ng-if="imageOS == 'windows'"></i>
</span>
<select class="form-control" ng-model="formValues.command" id="command">
<option value="bash" ng-if="imageOS == 'linux'">/bin/bash</option>
<option value="sh" ng-if="imageOS == 'linux'">/bin/sh</option>
<option value="powershell" ng-if="imageOS == 'windows'">powershell</option>
<option value="cmd.exe" ng-if="imageOS == 'windows'">cmd.exe</option>
</select>
</div>
<input class="form-control" ng-if="formValues.isCustomCommand" type="text" name="custom-command" ng-model="formValues.customCommand" placeholder="e.g. ps aux">
</div>
</div>
<!-- !command-list -->
<div class="form-group col-lg-12">
<label for="command" class="text-left control-label">Use custom command</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="formValues.isCustomCommand"><i></i>
</label>
</div>
<div class="form-group">
<label for="username" class="col-lg-1 text-left col-sm-2 control-label">
User
<portainer-tooltip position="bottom" message="Format is one of: user, user:group, uid or uid:gid"></portainer-tooltip>
</label>
<div class="col-lg-11 col-sm-10">
<input class="form-control" type="text" name="username" ng-model="formValues.user" placeholder="root">
</div>
</div>
<!-- !command-list -->
<div class="col-sm-8">
<button type="button" class="btn btn-primary" ng-click="connect()" ng-disabled="state.connected">Connect</button>
<button type="button" class="btn btn-default" ng-click="disconnect()" ng-disabled="!state.connected">Disconnect</button>
<div class="form-group">
<div class="col-lg-offset-1 col-sm-offset-2 col-lg-11 col-sm-10">
<button type="button" class="btn btn-primary" ng-click="connect()">Connect</button>
</div>
</div>
</div>
<div ng-if="state.connected">
<label>Exec into container as <code>{{ ::formValues.user || 'default user' }}</code> using command <code>{{ formValues.isCustomCommand ? formValues.customCommand : formValues.command }}</code></label>
<button type="button" class="btn btn-default" ng-click="disconnect()">Disconnect</button>
</div>
</form>
</rd-widget-body>
</rd-widget>
@@ -1,9 +1,10 @@
angular.module('containerConsole', [])
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'Exec', '$timeout', 'EndpointProvider', 'Notifications',
function ($scope, $stateParams, Container, Image, Exec, $timeout, EndpointProvider, Notifications) {
.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Container', 'Image', 'EndpointProvider', 'Notifications', 'ContainerHelper', 'ContainerService', 'ExecService',
function ($scope, $stateParams, Container, Image, EndpointProvider, Notifications, ContainerHelper, ContainerService, ExecService) {
$scope.state = {};
$scope.state.loaded = false;
$scope.state.connected = false;
$scope.formValues = {};
var socket, term;
@@ -22,7 +23,7 @@ function ($scope, $stateParams, Container, Image, Exec, $timeout, EndpointProvid
} else {
Image.get({id: d.Image}, function(imgData) {
$scope.imageOS = imgData.Os;
$scope.state.command = imgData.Os === 'windows' ? 'powershell' : 'bash';
$scope.formValues.command = imgData.Os === 'windows' ? 'powershell' : 'bash';
$scope.state.loaded = true;
$('#loadingViewSpinner').hide();
}, function (e) {
@@ -37,35 +38,38 @@ function ($scope, $stateParams, Container, Image, Exec, $timeout, EndpointProvid
$scope.connect = function() {
$('#loadConsoleSpinner').show();
var termWidth = Math.round($('#terminal-container').width() / 8.2);
var termWidth = Math.floor(($('#terminal-container').width() - 20) / 8.39);
var termHeight = 30;
var command = $scope.formValues.isCustomCommand ?
$scope.formValues.customCommand : $scope.formValues.command;
var execConfig = {
id: $stateParams.id,
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: $scope.state.command.replace(' ', ',').split(',')
User: $scope.formValues.user,
Cmd: ContainerHelper.commandStringToArray(command)
};
Container.exec(execConfig, function(d) {
if (d.message) {
$('#loadConsoleSpinner').hide();
Notifications.error('Error', {}, d.message);
var execId;
ContainerService.createExec(execConfig)
.then(function success(data) {
execId = data.Id;
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID();
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {
var execId = d.Id;
resizeTTY(execId, termHeight, termWidth);
var url = window.location.href.split('#')[0] + 'api/websocket/exec?id=' + execId + '&endpointId=' + EndpointProvider.endpointID();
if (url.indexOf('https') > -1) {
url = url.replace('https://', 'wss://');
} else {
url = url.replace('http://', 'ws://');
}
initTerm(url, termHeight, termWidth);
url = url.replace('http://', 'ws://');
}
}, function (e) {
initTerm(url, termHeight, termWidth);
return ExecService.resizeTTY(execId, termHeight, termWidth, 2000);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to exec into container');
})
.finally(function final() {
$('#loadConsoleSpinner').hide();
Notifications.error('Failure', e, 'Unable to start an exec instance');
});
};
@@ -79,19 +83,6 @@ function ($scope, $stateParams, Container, Image, Exec, $timeout, EndpointProvid
}
};
function resizeTTY(execId, height, width) {
$timeout(function() {
Exec.resize({id: execId, height: height, width: width}, function (d) {
if (d.message) {
Notifications.error('Error', {}, 'Unable to resize TTY');
}
}, function (e) {
Notifications.error('Failure', {}, 'Unable to resize TTY');
});
}, 2000);
}
function initTerm(url, height, width) {
socket = new WebSocket(url);
@@ -103,9 +94,14 @@ function ($scope, $stateParams, Container, Image, Exec, $timeout, EndpointProvid
term.on('data', function (data) {
socket.send(data);
});
term.open(document.getElementById('terminal-container'));
term.open(document.getElementById('terminal-container'), true);
term.resize(width, height);
term.setOption('cursorBlink', true);
term.fit();
window.onresize = function() {
term.fit();
};
socket.onmessage = function (e) {
term.write(e.data);
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Logs
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Logs
</rd-header-content>
</rd-header>
+5 -5
View File
@@ -25,12 +25,12 @@
<rd-widget-taskbar classes="col-lg-12">
<div class="pull-left">
<div class="btn-group" role="group" aria-label="...">
<button type="button" class="btn btn-success btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button type="button" class="btn btn-success btn-responsive" ng-click="startAction()" ng-disabled="!state.selectedItemCount || state.noStoppedItemsSelected"><i class="fa fa-play space-right" aria-hidden="true"></i>Start</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="stopAction()" ng-disabled="!state.selectedItemCount || state.noRunningItemsSelected"><i class="fa fa-stop space-right" aria-hidden="true"></i>Stop</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="killAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-bomb space-right" aria-hidden="true"></i>Kill</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="restartAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Restart</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="pauseAction()" ng-disabled="!state.selectedItemCount || state.noRunningItemsSelected"><i class="fa fa-pause space-right" aria-hidden="true"></i>Pause</button>
<button type="button" class="btn btn-primary btn-responsive" ng-click="unpauseAction()" ng-disabled="!state.selectedItemCount || state.noPausedItemsSelected"><i class="fa fa-play space-right" aria-hidden="true"></i>Resume</button>
<button type="button" class="btn btn-danger btn-responsive" ng-click="confirmRemoveAction()" ng-disabled="!state.selectedItemCount"><i class="fa fa-trash space-right" aria-hidden="true"></i>Remove</button>
</div>
<a class="btn btn-primary" type="button" ui-sref="actions.create.container"><i class="fa fa-plus space-right" aria-hidden="true"></i>Add container</a>
@@ -137,5 +137,5 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
@@ -41,6 +41,7 @@ angular.module('containers', [])
}
return model;
});
updateSelectionFlags();
$('#loadContainersSpinner').hide();
}, function (e) {
$('#loadContainersSpinner').hide();
@@ -117,17 +118,15 @@ angular.module('containers', [])
angular.forEach($scope.state.filteredContainers, function (container) {
if (container.Checked !== allSelected) {
container.Checked = allSelected;
$scope.selectItem(container);
toggleItemSelection(container);
}
});
updateSelectionFlags();
};
$scope.selectItem = function (item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
toggleItemSelection(item);
updateSelectionFlags();
};
$scope.toggleGetAll = function () {
@@ -187,6 +186,34 @@ angular.module('containers', [])
);
};
function toggleItemSelection(item) {
if (item.Checked) {
$scope.state.selectedItemCount++;
} else {
$scope.state.selectedItemCount--;
}
}
function updateSelectionFlags() {
$scope.state.noStoppedItemsSelected = true;
$scope.state.noRunningItemsSelected = true;
$scope.state.noPausedItemsSelected = true;
$scope.containers.forEach(function(container) {
if(!container.Checked) {
return;
}
if(container.Status === 'paused') {
$scope.state.noPausedItemsSelected = false;
} else if(container.Status === 'stopped' ||
container.Status === 'created') {
$scope.state.noStoppedItemsSelected = false;
} else if(container.Status === 'running') {
$scope.state.noRunningItemsSelected = false;
}
} );
}
function retrieveSwarmHostsInfo(data) {
var swarm_hosts = {};
var systemStatus = data.SystemStatus;
@@ -207,7 +234,7 @@ angular.module('containers', [])
$q.when(provider !== 'DOCKER_SWARM' || SystemService.info())
.then(function success(data) {
if (provider === 'DOCKER_SWARM') {
$scope.swarm_hosts = retrieveSwarmHostsInfo(d);
$scope.swarm_hosts = retrieveSwarmHostsInfo(data);
}
update({all: $scope.state.displayAll ? 1 : 0});
})
@@ -1,19 +1,19 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createContainer', [])
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'Network', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'ControllerDataPipeline', 'FormValidator',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, Network, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, ControllerDataPipeline, FormValidator) {
.controller('CreateContainerController', ['$q', '$scope', '$state', '$stateParams', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService',
function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService) {
$scope.formValues = {
alwaysPull: true,
Console: 'none',
Volumes: [],
Registry: '',
NetworkContainer: '',
Labels: [],
ExtraHosts: [],
IPv4: '',
IPv6: ''
IPv6: '',
AccessControlData: new AccessControlFormData()
};
$scope.state = {
@@ -91,6 +91,8 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
$scope.config.HostConfig.Devices.splice(index, 1);
};
$scope.fromContainerMultipleNetworks = false;
function prepareImageConfig(config) {
var image = config.Image;
var registry = $scope.formValues.Registry;
@@ -178,6 +180,7 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
var networkMode = mode;
if (containerName) {
networkMode += ':' + containerName;
config.Hostname = '';
}
config.HostConfig.NetworkMode = networkMode;
@@ -232,6 +235,213 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
return config;
}
function confirmCreateContainer() {
var deferred = $q.defer();
Container.query({ all: 1, filters: {name: ['^/' + $scope.config.name + '$'] }}).$promise
.then(function success(data) {
var existingContainer = data[0];
if (existingContainer) {
ModalService.confirm({
title: 'Are you sure ?',
message: 'A container with the same name already exists. Portainer can automatically remove it and re-create one. Do you want to replace it?',
buttons: {
confirm: {
label: 'Replace',
className: 'btn-danger'
}
},
callback: function onConfirm(confirmed) {
if(!confirmed) { deferred.resolve(false); }
else {
// Remove old container
ContainerService.remove(existingContainer, true)
.then(function success(data) {
Notifications.success('Container Removed', existingContainer.Id);
deferred.resolve(true);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to remove container', err: err });
});
}
}
});
} else {
deferred.resolve(true);
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve containers');
return undefined;
});
return deferred.promise;
}
function loadFromContainerCmd(d) {
if ($scope.config.Cmd) {
$scope.config.Cmd = ContainerHelper.commandArrayToString($scope.config.Cmd);
} else {
$scope.config.Cmd = '';
}
}
function loadFromContainerPortBindings(d) {
var bindings = [];
for (var p in $scope.config.HostConfig.PortBindings) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) {
var hostPort = '';
if ($scope.config.HostConfig.PortBindings[p][0].HostIp) {
hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':';
}
hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort;
var b = {
'hostPort': hostPort,
'containerPort': p.split('/')[0],
'protocol': p.split('/')[1]
};
bindings.push(b);
}
}
$scope.config.HostConfig.PortBindings = bindings;
}
function loadFromContainerVolumes(d) {
for (var v in d.Mounts) {
if ({}.hasOwnProperty.call(d.Mounts, v)) {
var mount = d.Mounts[v];
var volume = {
'type': mount.Type,
'name': mount.Name || mount.Source,
'containerPath': mount.Destination,
'readOnly': mount.RW === false
};
$scope.formValues.Volumes.push(volume);
}
}
}
function loadFromContainerNetworkConfig(d) {
$scope.config.NetworkingConfig = {
EndpointsConfig: {}
};
var networkMode = d.HostConfig.NetworkMode;
if (networkMode === 'default') {
$scope.config.HostConfig.NetworkMode = 'bridge';
if (!_.find($scope.availableNetworks, {'Name': 'bridge'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
}
if ($scope.config.HostConfig.NetworkMode.indexOf('container:') === 0) {
var netContainer = $scope.config.HostConfig.NetworkMode.split(/^container:/)[1];
$scope.config.HostConfig.NetworkMode = 'container';
for (var c in $scope.runningContainers) {
if ($scope.runningContainers[c].Names && $scope.runningContainers[c].Names[0] === '/' + netContainer) {
$scope.formValues.NetworkContainer = $scope.runningContainers[c];
}
}
}
$scope.fromContainerMultipleNetworks = Object.keys(d.NetworkSettings.Networks).length >= 2;
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode]) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig) {
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address) {
$scope.formValues.IPv4 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv4Address;
}
if (d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address) {
$scope.formValues.IPv6 = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode].IPAMConfig.IPv6Address;
}
}
}
$scope.config.NetworkingConfig.EndpointsConfig[$scope.config.HostConfig.NetworkMode] = d.NetworkSettings.Networks[$scope.config.HostConfig.NetworkMode];
// ExtraHosts
for (var h in $scope.config.HostConfig.ExtraHosts) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.ExtraHosts, h)) {
$scope.formValues.ExtraHosts.push({'value': $scope.config.HostConfig.ExtraHosts[h]});
$scope.config.HostConfig.ExtraHosts = [];
}
}
}
function loadFromContainerEnvrionmentVariables(d) {
var envArr = [];
for (var e in $scope.config.Env) {
if ({}.hasOwnProperty.call($scope.config.Env, e)) {
var arr = $scope.config.Env[e].split(/\=(.+)/);
envArr.push({'name': arr[0], 'value': arr[1]});
}
}
$scope.config.Env = envArr;
}
function loadFromContainerLabels(d) {
for (var l in $scope.config.Labels) {
if ({}.hasOwnProperty.call($scope.config.Labels, l)) {
$scope.formValues.Labels.push({ name: l, value: $scope.config.Labels[l]});
}
}
}
function loadFromContainerConsole(d) {
if ($scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'both';
} else if (!$scope.config.OpenStdin && $scope.config.Tty) {
$scope.formValues.Console = 'tty';
} else if ($scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'interactive';
} else if (!$scope.config.OpenStdin && !$scope.config.Tty) {
$scope.formValues.Console = 'none';
}
}
function loadFromContainerDevices(d) {
var path = [];
for (var dev in $scope.config.HostConfig.Devices) {
if ({}.hasOwnProperty.call($scope.config.HostConfig.Devices, dev)) {
var device = $scope.config.HostConfig.Devices[dev];
path.push({'pathOnHost': device.PathOnHost, 'pathInContainer': device.PathInContainer});
}
}
$scope.config.HostConfig.Devices = path;
}
function loadFromContainerImageConfig(d) {
// If no registry found, we let default DockerHub and let full image path
var imageInfo = ImageHelper.extractImageAndRegistryFromRepository($scope.config.Image);
RegistryService.retrieveRegistryFromRepository($scope.config.Image)
.then(function success(data) {
if (data) {
$scope.config.Image = imageInfo.image;
$scope.formValues.Registry = data;
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrive registry');
});
}
function loadFromContainerSpec() {
// Get container
Container.get({ id: $stateParams.from }).$promise
.then(function success(d) {
var fromContainer = new ContainerDetailsViewModel(d);
if (!fromContainer.ResourceControl) {
$scope.formValues.AccessControlData.AccessControlEnabled = false;
}
$scope.fromContainer = fromContainer;
$scope.config = ContainerHelper.configFromContainer(fromContainer.Model);
loadFromContainerCmd(d);
loadFromContainerPortBindings(d);
loadFromContainerVolumes(d);
loadFromContainerNetworkConfig(d);
loadFromContainerEnvrionmentVariables(d);
loadFromContainerLabels(d);
loadFromContainerConsole(d);
loadFromContainerDevices(d);
loadFromContainerImageConfig(d);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container');
});
}
function initView() {
Volume.query({}, function (d) {
$scope.availableVolumes = d.Volumes;
@@ -239,31 +449,36 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
Notifications.error('Failure', e, 'Unable to retrieve volumes');
});
Network.query({}, function (d) {
var networks = d;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || $scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
networks = d.filter(function (network) {
if (network.Scope === 'global') {
return network;
}
});
$scope.globalNetworkCount = networks.length;
networks.push({Name: 'bridge'});
networks.push({Name: 'host'});
networks.push({Name: 'none'});
}
networks.push({Name: 'container'});
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM'
)
.then(function success(data) {
var networks = data;
networks.push({ Name: 'container' });
$scope.availableNetworks = networks;
if (!_.find(networks, {'Name': 'bridge'})) {
if (_.find(networks, {'Name': 'nat'})) {
$scope.config.HostConfig.NetworkMode = 'nat';
}
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve networks');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve networks');
});
Container.query({}, function (d) {
var containers = d;
$scope.runningContainers = containers;
if ($stateParams.from !== '') {
loadFromContainerSpec();
} else {
$scope.fromContainer = {};
$scope.formValues.Registry = {};
}
}, function(e) {
Notifications.error('Failure', e, 'Unable to retrieve running containers');
});
@@ -283,23 +498,31 @@ function ($q, $scope, $state, $stateParams, $filter, Container, ContainerHelper,
}
$scope.create = function () {
$('#createContainerSpinner').show();
confirmCreateContainer()
.then(function success(confirm) {
if (!confirm) {
return false;
}
$('#createContainerSpinner').show();
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
var accessControlData = ControllerDataPipeline.getAccessControlFormData();
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
if (!validateForm(accessControlData, isAdmin)) {
$('#createContainerSpinner').hide();
return;
}
var config = prepareConfiguration();
createContainer(config, accessControlData);
var config = prepareConfiguration();
createContainer(config, accessControlData);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create container');
});
};
function createContainer(config, accessControlData) {
$q.when(!$scope.formValues.alwaysPull || ImageService.pullImage($scope.config.Image, $scope.formValues.Registry))
$q.when(!$scope.formValues.alwaysPull || ImageService.pullImage($scope.config.Image, $scope.formValues.Registry, true))
.finally(function final() {
ContainerService.createAndStartContainer(config)
.then(function success(data) {
@@ -1,7 +1,7 @@
<rd-header>
<rd-header-title title="Create container"></rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> > Add container
<a ui-sref="containers">Containers</a> &gt; Add container
</rd-header-content>
</rd-header>
@@ -23,7 +23,7 @@
</div>
<!-- image-and-registry -->
<div class="form-group">
<por-image-registry image="config.Image" registry="formValues.Registry"></por-image-registry>
<por-image-registry image="config.Image" registry="formValues.Registry" ng-if="formValues.Registry"></por-image-registry>
</div>
<!-- !image-and-registry -->
<!-- always-pull -->
@@ -98,7 +98,7 @@
</div>
<!-- !port-mapping -->
<!-- access-control -->
<div ng-include="'app/components/common/accessControlForm/accessControlForm.html'" ng-if="applicationState.application.authentication"></div>
<por-access-control-form form-data="formValues.AccessControlData" resource-control="fromContainer.ResourceControl" ng-if="applicationState.application.authentication && fromContainer"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
@@ -110,6 +110,10 @@
<a type="button" class="btn btn-default btn-sm" ui-sref="containers">Cancel</a>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
<span ng-if="fromContainerMultipleNetworks" style="margin-left: 10px">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span class="small text-muted" style="margin-left: 5px;">This container is connected to multiple networks, only one network will be kept at creation time.</span>
</span>
</div>
</div>
<!-- !actions -->
@@ -1,6 +1,6 @@
angular.module('createNetwork', [])
.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network',
function ($scope, $state, Notifications, Network) {
.controller('CreateNetworkController', ['$scope', '$state', 'Notifications', 'Network', 'LabelHelper',
function ($scope, $state, Notifications, Network, LabelHelper) {
$scope.formValues = {
DriverOptions: [],
Subnet: '',
@@ -30,7 +30,7 @@ function ($scope, $state, Notifications, Network) {
};
$scope.addLabel = function() {
$scope.formValues.Labels.push({ name: '', value: ''});
$scope.formValues.Labels.push({ key: '', value: ''});
};
$scope.removeLabel = function(index) {
@@ -74,13 +74,7 @@ function ($scope, $state, Notifications, Network) {
}
function prepareLabelsConfig(config) {
var labels = {};
$scope.formValues.Labels.forEach(function (label) {
if (label.name && label.value) {
labels[label.name] = label.value;
}
});
config.Labels = labels;
config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels);
}
function prepareConfiguration() {
@@ -1,7 +1,7 @@
<rd-header>
<rd-header-title title="Create network"></rd-header-title>
<rd-header-content>
<a ui-sref="networks">Networks</a> > Add network
<a ui-sref="networks">Networks</a> &gt; Add network
</rd-header-content>
</rd-header>
@@ -90,7 +90,7 @@
<div ng-repeat="label in formValues.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">
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="display:none"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="registries">Registries</a> > Add registry
<a ui-sref="registries">Registries</a> &gt; Add registry
</rd-header-content>
</rd-header>
@@ -1,6 +1,6 @@
angular.module('createSecret', [])
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService',
function ($scope, $state, Notifications, SecretService) {
.controller('CreateSecretController', ['$scope', '$state', 'Notifications', 'SecretService', 'LabelHelper',
function ($scope, $state, Notifications, SecretService, LabelHelper) {
$scope.formValues = {
Name: '',
Data: '',
@@ -9,7 +9,7 @@ function ($scope, $state, Notifications, SecretService) {
};
$scope.addLabel = function() {
$scope.formValues.Labels.push({ name: '', value: ''});
$scope.formValues.Labels.push({ key: '', value: ''});
};
$scope.removeLabel = function(index) {
@@ -17,13 +17,7 @@ function ($scope, $state, Notifications, SecretService) {
};
function prepareLabelsConfig(config) {
var labels = {};
$scope.formValues.Labels.forEach(function (label) {
if (label.name && label.value) {
labels[label.name] = label.value;
}
});
config.Labels = labels;
config.Labels = LabelHelper.fromKeyValueToLabelHash($scope.formValues.Labels);
}
function prepareSecretData(config) {
@@ -1,7 +1,7 @@
<rd-header>
<rd-header-title title="Create secret"></rd-header-title>
<rd-header-content>
<a ui-sref="secrets">Secrets</a> > Add secret
<a ui-sref="secrets">Secrets</a> &gt; Add secret
</rd-header-content>
</rd-header>
@@ -52,7 +52,7 @@
<div ng-repeat="label in formValues.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">
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
@@ -1,8 +1,8 @@
// @@OLD_SERVICE_CONTROLLER: this service should be rewritten to use services.
// See app/components/templates/templatesController.js as a reference.
angular.module('createService', [])
.controller('CreateServiceController', ['$q', '$scope', '$state', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'ControllerDataPipeline', 'FormValidator', 'RegistryService', 'HttpRequestHelper',
function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, Authentication, ResourceControlService, Notifications, ControllerDataPipeline, FormValidator, RegistryService, HttpRequestHelper) {
.controller('CreateServiceController', ['$q', '$scope', '$state', 'Service', 'ServiceHelper', 'SecretHelper', 'SecretService', 'VolumeService', 'NetworkService', 'ImageHelper', 'LabelHelper', 'Authentication', 'ResourceControlService', 'Notifications', 'FormValidator', 'RegistryService', 'HttpRequestHelper',
function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretService, VolumeService, NetworkService, ImageHelper, LabelHelper, Authentication, ResourceControlService, Notifications, FormValidator, RegistryService, HttpRequestHelper) {
$scope.formValues = {
Name: '',
@@ -23,9 +23,12 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
Ports: [],
Parallelism: 1,
PlacementConstraints: [],
PlacementPreferences: [],
UpdateDelay: 0,
UpdateOrder: 'stop-first',
FailureAction: 'pause',
Secrets: []
Secrets: [],
AccessControlData: new AccessControlFormData()
};
$scope.state = {
@@ -81,7 +84,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
};
$scope.addPlacementPreference = function() {
$scope.formValues.PlacementPreferences.push({ key: '', operator: '==', value: '' });
$scope.formValues.PlacementPreferences.push({ strategy: 'spread', value: '' });
};
$scope.removePlacementPreference = function(index) {
@@ -89,7 +92,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
};
$scope.addLabel = function() {
$scope.formValues.Labels.push({ name: '', value: ''});
$scope.formValues.Labels.push({ key: '', value: ''});
};
$scope.removeLabel = function(index) {
@@ -97,7 +100,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
};
$scope.addContainerLabel = function() {
$scope.formValues.ContainerLabels.push({ name: '', value: ''});
$scope.formValues.ContainerLabels.push({ key: '', value: ''});
};
$scope.removeContainerLabel = function(index) {
@@ -170,21 +173,8 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
}
function prepareLabelsConfig(config, input) {
var labels = {};
input.Labels.forEach(function (label) {
if (label.name && label.value) {
labels[label.name] = label.value;
}
});
config.Labels = labels;
var containerLabels = {};
input.ContainerLabels.forEach(function (label) {
if (label.name && label.value) {
containerLabels[label.name] = label.value;
}
});
config.TaskTemplate.ContainerSpec.Labels = containerLabels;
config.Labels = LabelHelper.fromKeyValueToLabelHash(input.Labels);
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(input.ContainerLabels);
}
function prepareVolumes(config, input) {
@@ -210,11 +200,14 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
config.UpdateConfig = {
Parallelism: input.Parallelism || 0,
Delay: input.UpdateDelay || 0,
FailureAction: input.FailureAction
FailureAction: input.FailureAction,
Order: input.UpdateOrder
};
}
function preparePlacementConfig(config, input) {
config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(input.PlacementConstraints);
config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(input.PlacementPreferences);
}
function prepareSecretConfig(config, input) {
@@ -222,7 +215,9 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
var secrets = [];
angular.forEach(input.Secrets, function(secret) {
if (secret.model) {
secrets.push(SecretHelper.secretConfig(secret.model));
var s = SecretHelper.secretConfig(secret.model);
s.File.Name = s.SecretName;
secrets.push(s);
}
});
config.TaskTemplate.ContainerSpec.Secrets = secrets;
@@ -294,7 +289,7 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
$scope.create = function createService() {
$('#createServiceSpinner').show();
var accessControlData = ControllerDataPipeline.getAccessControlFormData();
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true : false;
@@ -309,10 +304,12 @@ function ($q, $scope, $state, Service, ServiceHelper, SecretHelper, SecretServic
function initView() {
$('#loadingViewSpinner').show();
var apiVersion = $scope.applicationState.endpoint.apiVersion;
$q.all({
volumes: VolumeService.volumes(),
networks: NetworkService.retrieveSwarmNetworks(),
secrets: SecretService.secrets()
secrets: apiVersion >= 1.25 ? SecretService.secrets() : [],
networks: NetworkService.networks(true, true, false, false)
})
.then(function success(data) {
$scope.availableVolumes = data.volumes;
+41 -17
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="services">Services</a> > Add service
<a ui-sref="services">Services</a> &gt; Add service
</rd-header-content>
</rd-header>
@@ -101,7 +101,7 @@
</div>
<!-- !port-mapping -->
<!-- access-control -->
<div ng-include="'app/components/common/accessControlForm/accessControlForm.html'" ng-if="applicationState.application.authentication"></div>
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
@@ -132,7 +132,7 @@
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="interactive"><a data-target="#labels" data-toggle="tab">Labels</a></li>
<li class="interactive"><a data-target="#update-config" data-toggle="tab">Update config</a></li>
<li class="interactive"><a data-target="#secrets" data-toggle="tab" ng-if="applicationState.endpoint.apiVersion >= 1.25">Secrets</a></li>
<li class="interactive" ng-if="applicationState.endpoint.apiVersion >= 1.25"><a data-target="#secrets" data-toggle="tab">Secrets</a></li>
<li class="interactive"><a data-target="#placement" data-toggle="tab">Placement</a></li>
</ul>
<!-- tab-content -->
@@ -242,7 +242,7 @@
<span class="input-group-addon">volume</span>
<select class="form-control" ng-model="volume.Source">
<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.Id">{{ vol.Id|truncate:30 }}</option>
</select>
</div>
<!-- !volume -->
@@ -328,7 +328,7 @@
<div ng-repeat="label in formValues.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">
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
@@ -355,7 +355,7 @@
<div ng-repeat="label in formValues.ContainerLabels" 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">
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
@@ -377,12 +377,12 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- parallelism-input -->
<div class="form-group">
<label for="parallelism" class="col-sm-2 col-lg-1 control-label text-left">Parallelism</label>
<div class="col-sm-2">
<label for="parallelism" class="col-sm-3 col-lg-1 control-label text-left">Parallelism</label>
<div class="col-sm-4 col-lg-3">
<input type="number" class="form-control" ng-model="formValues.Parallelism" id="parallelism" placeholder="e.g. 1">
</div>
<div class="col-sm-8">
<p class="small text-muted" style="margin-top: 10px;">
<div class="col-sm-5">
<p class="small text-muted">
Maximum number of tasks to be updated simultaneously (0 to update all at once).
</p>
</div>
@@ -390,12 +390,12 @@
<!-- !parallelism-input -->
<!-- delay-input -->
<div class="form-group">
<label for="update-delay" class="col-sm-2 col-lg-1 control-label text-left">Delay</label>
<div class="col-sm-2">
<label for="update-delay" class="col-sm-3 col-lg-1 control-label text-left">Delay</label>
<div class="col-sm-4 col-lg-3">
<input type="number" class="form-control" ng-model="formValues.UpdateDelay" id="update-delay" placeholder="e.g. 10">
</div>
<div class="col-sm-8">
<p class="small text-muted" style="margin-top: 10px;">
<div class="col-sm-5">
<p class="small text-muted">
Amount of time between updates.
</p>
</div>
@@ -403,15 +403,39 @@
<!-- !delay-input -->
<!-- failureAction-input -->
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Failure action</label>
<div class="btn-group btn-group-sm" style="margin-left: 20px;">
<label for="failure-action" class="col-sm-3 col-lg-1 control-label text-left">Failure action</label>
<div class="col-sm-4 col-lg-3">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="formValues.FailureAction" uib-btn-radio="'continue'">Continue</label>
<label class="btn btn-primary" ng-model="formValues.FailureAction" uib-btn-radio="'pause'">Pause</label>
</div>
</div>
<div class="col-sm-5">
<p class="small text-muted">
Action taken on failure to start after update.
</p>
</div>
</div>
<!-- !failureAction-input -->
<!-- order-input -->
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.29">
<label for="update-order" class="col-sm-3 col-lg-1 control-label text-left">Order</label>
<div class="col-sm-4 col-lg-3">
<div class="btn-group btn-group-sm">
<label class="btn btn-primary" ng-model="formValues.UpdateOrder" uib-btn-radio="'start-first'">start-first</label>
<label class="btn btn-primary" ng-model="formValues.UpdateOrder" uib-btn-radio="'stop-first'">stop-first</label>
</div>
</div>
<div class="col-sm-5">
<p class="small text-muted">
Operation order on failure.
</p>
</div>
</div>
<!-- !order-input -->
</form>
</div>
<!-- !tab-update-config -->
@@ -29,3 +29,29 @@
</div>
</div>
</form>
<form class="form-horizontal" style="margin-top: 15px;" ng-if="applicationState.endpoint.apiVersion >= 1.30">
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">Placement preferences</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addPlacementPreference()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement preference
</span>
</div>
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="preference in formValues.PlacementPreferences" style="margin-top: 2px;">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">strategy</span>
<input type="text" class="form-control" ng-model="preference.strategy" placeholder="e.g. spread">
</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="preference.value" placeholder="e.g. node.labels.datacenter">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementPreference($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</form>
@@ -1,10 +1,11 @@
angular.module('createVolume', [])
.controller('CreateVolumeController', ['$scope', '$state', 'VolumeService', 'SystemService', 'ResourceControlService', 'Authentication', 'Notifications', 'ControllerDataPipeline', 'FormValidator',
function ($scope, $state, VolumeService, SystemService, ResourceControlService, Authentication, Notifications, ControllerDataPipeline, FormValidator) {
.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator',
function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator) {
$scope.formValues = {
Driver: 'local',
DriverOptions: []
DriverOptions: [],
AccessControlData: new AccessControlFormData()
};
$scope.state = {
@@ -40,8 +41,8 @@ function ($scope, $state, VolumeService, SystemService, ResourceControlService,
var driver = $scope.formValues.Driver;
var driverOptions = $scope.formValues.DriverOptions;
var volumeConfiguration = VolumeService.createVolumeConfiguration(name, driver, driverOptions);
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var accessControlData = ControllerDataPipeline.getAccessControlFormData();
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
@@ -69,16 +70,20 @@ function ($scope, $state, VolumeService, SystemService, ResourceControlService,
function initView() {
$('#loadingViewSpinner').show();
SystemService.getVolumePlugins()
.then(function success(data) {
$scope.availableVolumeDrivers = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve volume drivers');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (endpointProvider !== 'DOCKER_SWARM') {
PluginService.volumePlugins(apiVersion < 1.25 || endpointProvider === 'VMWARE_VIC')
.then(function success(data) {
$scope.availableVolumeDrivers = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve volume drivers');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
}
initView();
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="volumes">Volumes</a> > Add volume
<a ui-sref="volumes">Volumes</a> &gt; Add volume
</rd-header-content>
</rd-header>
@@ -65,7 +65,7 @@
</div>
<!-- !driver-options -->
<!-- access-control -->
<div ng-include="'app/components/common/accessControlForm/accessControlForm.html'" ng-if="applicationState.application.authentication"></div>
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
+1 -1
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="endpoints">Endpoints</a> > <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a>
<a ui-sref="endpoints">Endpoints</a> &gt; <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a>
</rd-header-content>
</rd-header>
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="endpoints">Endpoints</a> > <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a> > Access management
<a ui-sref="endpoints">Endpoints</a> &gt; <a ui-sref="endpoint({id: endpoint.Id})">{{ endpoint.Name }}</a> &gt; Access management
</rd-header-content>
</rd-header>
+6 -3
View File
@@ -14,8 +14,11 @@
<rd-widget-header icon="fa-exclamation-triangle" title="Endpoint management is not available">
</rd-widget-header>
<rd-widget-body>
<span class="small text-muted">Portainer has been started using the <code>--external-endpoints</code> flag. Endpoint management via the UI is disabled. You can still manage endpoint access.</span>
</rd-wigdet-body>
<span class="small text-muted">Portainer has been started using the <code>--external-endpoints</code> flag.
Endpoint management via the UI is disabled.
<span ng-if="applicationState.application.authentication">You can still manage endpoint access.</span>
</span>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -203,6 +206,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
+1 -1
View File
@@ -69,6 +69,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
+56 -1
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="images">Images</a> > <a ui-sref="image({id: image.Id})">{{ image.Id }}</a>
<a ui-sref="images">Images</a> &gt; <a ui-sref="image({id: image.Id})">{{ image.Id }}</a>
</rd-header-content>
</rd-header>
@@ -167,3 +167,58 @@
</rd-widget>
</div>
</div>
<div class="row" ng-if="history.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-clone" title="Image layers"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table id="image-layers" class="table">
<thead>
<th>
<a ng-click="order('Size')">
Size
<span ng-show="sortType == 'Size' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Size' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('CreatedBy')">
Layer
<span ng-show="sortType == 'CreatedBy' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'CreatedBy' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
</thead>
<tbody>
<tr ng-repeat="layer in history | orderBy:sortType:sortReverse">
<td style="white-space:nowrap;">
{{ layer.Size | humansize }}
</td>
<td class="expand">
<div ng-if="layer.CreatedBy.length > 130">
<span id="layer-command-{{$index}}-full" style="display: none">
{{ layer.CreatedBy | imagelayercommand }}
</span>
<span id="layer-command-{{$index}}-short">
{{ layer.CreatedBy | imagelayercommand | truncate:130 }}
<span ng-if="layer.CreatedBy.length > 130" style="margin-left: 5px;">
<a id="layer-command-expander{{$index}}" class="btn" ng-click='toggleLayerCommand($index)'>
<i class="fa fa-plus-circle" aria-hidden="true"></i>
</a>
</span>
</span>
</div>
<div ng-if="layer.CreatedBy.length <= 130">
<span id="layer-command-{{$index}}-full">
{{ layer.CreatedBy | imagelayercommand }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
+26 -7
View File
@@ -1,11 +1,25 @@
angular.module('image', [])
.controller('ImageController', ['$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications',
function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) {
.controller('ImageController', ['$q', '$scope', '$stateParams', '$state', '$timeout', 'ImageService', 'RegistryService', 'Notifications',
function ($q, $scope, $stateParams, $state, $timeout, ImageService, RegistryService, Notifications) {
$scope.formValues = {
Image: '',
Registry: ''
};
$scope.sortType = 'Size';
$scope.sortReverse = true;
$scope.order = function(sortType) {
$scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false;
$scope.sortType = sortType;
};
$scope.toggleLayerCommand = function(layerId) {
$('#layer-command-expander'+layerId+' span').toggleClass('glyphicon-plus-sign glyphicon-minus-sign');
$('#layer-command-'+layerId+'-short').toggle();
$('#layer-command-'+layerId+'-full').toggle();
};
$scope.tagImage = function() {
$('#loadingViewSpinner').show();
var image = $scope.formValues.Image;
@@ -47,7 +61,7 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService,
RegistryService.retrieveRegistryFromRepository(repository)
.then(function success(data) {
var registry = data;
return ImageService.pullImage(repository, registry);
return ImageService.pullImage(repository, registry, false);
})
.then(function success(data) {
Notifications.success('Image successfully pulled', repository);
@@ -95,11 +109,16 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService,
});
};
function retrieveImageDetails() {
function initView() {
$('#loadingViewSpinner').show();
ImageService.image($stateParams.id)
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
$q.all({
image: ImageService.image($stateParams.id),
history: endpointProvider !== 'VMWARE_VIC' ? ImageService.history($stateParams.id) : []
})
.then(function success(data) {
$scope.image = data;
$scope.image = data.image;
$scope.history = data.history;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve image details');
@@ -110,5 +129,5 @@ function ($scope, $stateParams, $state, $timeout, ImageService, RegistryService,
});
}
retrieveImageDetails();
initView();
}]);
+23 -6
View File
@@ -70,6 +70,17 @@
<div class="pull-right">
<input type="text" id="filter" ng-model="state.filter" placeholder="Filter..." class="form-control input-sm" />
</div>
<span class="btn-group btn-group-sm pull-right" style="margin-right: 20px;" ng-if="applicationState.endpoint.mode.provider !== 'DOCKER_SWARM' && applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="undefined">
All
</label>
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="'!' + 0">
Used
</label>
<label class="btn btn-primary" ng-model="state.containersCountFilter" uib-btn-radio="0">
Unused
</label>
</span>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="table-responsive">
@@ -110,9 +121,15 @@
</tr>
</thead>
<tbody>
<tr dir-paginate="image in (state.filteredImages = (images | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<tr dir-paginate="image in (state.filteredImages = (images | filter:{ Containers: state.containersCountFilter } | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="image.Checked" ng-change="selectItem(image)" /></td>
<td><a ui-sref="image({id: image.Id})">{{ image.Id|truncate:20}}</a></td>
<td>
<a class="monospaced" ui-sref="image({id: image.Id})">{{ image.Id|truncate:20}}</a>
<span style="margin-left: 10px;" class="label label-warning image-tag"
ng-if="::image.Containers === 0 && applicationState.endpoint.mode.provider !== 'DOCKER_SWARM' && applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
Unused
</span>
</td>
<td>
<span class="label label-primary image-tag" ng-repeat="tag in (image|repotags)">{{ tag }}</span>
</td>
@@ -122,7 +139,7 @@
<tr ng-if="!images">
<td colspan="5" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="images.length == 0">
<tr ng-if="state.filteredImages.length === 0">
<td colspan="5" class="text-center text-muted">No images available.</td>
</tr>
</tbody>
@@ -132,6 +149,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</div>
</div>
</rd-widget>
</div>
</div>
+4 -2
View File
@@ -42,7 +42,7 @@ function ($scope, $state, ImageService, Notifications, Pagination, ModalService)
$('#pullImageSpinner').show();
var image = $scope.formValues.Image;
var registry = $scope.formValues.Registry;
ImageService.pullImage(image, registry)
ImageService.pullImage(image, registry, false)
.then(function success(data) {
Notifications.success('Image successfully pulled', image);
$state.reload();
@@ -93,7 +93,9 @@ function ($scope, $state, ImageService, Notifications, Pagination, ModalService)
function fetchImages() {
$('#loadImagesSpinner').show();
ImageService.images()
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
ImageService.images(apiVersion >= 1.25 && endpointProvider !== 'DOCKER_SWARM' && endpointProvider !== 'VMWARE_VIC')
.then(function success(data) {
$scope.images = data;
})
+2 -2
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="networks">Networks</a> > <a ui-sref="network({id: network.Id})">{{ network.Name }}</a>
<a ui-sref="networks">Networks</a> &gt; <a ui-sref="network({id: network.Id})">{{ network.Name }}</a>
</rd-header-content>
</rd-header>
@@ -67,7 +67,7 @@
</div>
<div class="row" ng-if="!(network.Containers | emptyobject)">
<div class="row" ng-if="containersInNetwork.length > 0">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-server" title="Containers in network"></rd-widget-header>
+13 -4
View File
@@ -51,8 +51,9 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe
}
function getContainersInNetwork(network) {
var apiVersion = $scope.applicationState.endpoint.apiVersion;
if (network.Containers) {
if ($scope.applicationState.endpoint.apiVersion < 1.24) {
if (apiVersion < 1.24) {
Container.query({}, function success(data) {
var containersInNetwork = data.filter(function filter(container) {
if (container.HostConfig.NetworkMode === network.Name) {
@@ -81,12 +82,20 @@ function ($scope, $state, $stateParams, $filter, Network, Container, ContainerHe
function initView() {
$('#loadingViewSpinner').show();
Network.get({id: $stateParams.id}, function success(data) {
Network.get({id: $stateParams.id}).$promise
.then(function success(data) {
$scope.network = data;
getContainersInNetwork(data);
}, function error(err) {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider !== 'VMWARE_VIC') {
getContainersInNetwork(data);
}
})
.catch(function error(err) {
$('#loadingViewSpinner').hide();
Notifications.error('Failure', err, 'Unable to retrieve network info');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
+3 -3
View File
@@ -29,7 +29,7 @@
<span class="small text-muted">Note: The network will be created using the overlay driver and will allow containers to communicate across the hosts of your cluster.</span>
</div>
</div>
<div class="form-group" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<div class="form-group" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<div class="col-sm-12">
<span class="small text-muted">Note: The network will be created using the bridge driver.</span>
</div>
@@ -134,7 +134,7 @@
<tr dir-paginate="network in ( state.filteredNetworks = (networks | filter:state.filter | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><input type="checkbox" ng-model="network.Checked" ng-change="selectItem(network)"/></td>
<td><a ui-sref="network({id: network.Id})">{{ network.Name|truncate:40}}</a></td>
<td>{{ network.Id }}</td>
<td class="monospaced">{{ network.Id|truncate:20 }}</td>
<td>{{ network.Scope }}</td>
<td>{{ network.Driver }}</td>
<td>{{ network.IPAM.Driver }}</td>
@@ -154,6 +154,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="swarm">Swarm nodes</a> > <a ui-sref="node({id: node.Id})">{{ node.Hostname }}</a>
<a ui-sref="swarm">Swarm nodes</a> &gt; <a ui-sref="node({id: node.Id})">{{ node.Hostname }}</a>
</rd-header-content>
</rd-header>
+3 -3
View File
@@ -133,10 +133,10 @@
</td>
</tr>
<tr ng-if="!registries">
<td colspan="3" class="text-center text-muted">Loading...</td>
<td colspan="4" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="registries.length == 0">
<td colspan="3" class="text-center text-muted">No registries available.</td>
<td colspan="4" class="text-center text-muted">No registries available.</td>
</tr>
</tbody>
</table>
@@ -145,6 +145,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="registries">Registries</a> > <a ui-sref="registry({id: registry.Id})">{{ registry.Name }}</a>
<a ui-sref="registries">Registries</a> &gt; <a ui-sref="registry({id: registry.Id})">{{ registry.Name }}</a>
</rd-header-content>
</rd-header>
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="registries">Registries</a> > <a ui-sref="registry({id: registry.Id})">{{ registry.Name }}</a> > Access management
<a ui-sref="registries">Registries</a> &gt; <a ui-sref="registry({id: registry.Id})">{{ registry.Name }}</a> &gt; Access management
</rd-header-content>
</rd-header>
+1 -1
View File
@@ -6,7 +6,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="secrets">Secrets</a> > <a ui-sref="secret({id: secret.Id})">{{ secret.Name }}</a>
<a ui-sref="secrets">Secrets</a> &gt; <a ui-sref="secret({id: secret.Id})">{{ secret.Name }}</a>
</rd-header-content>
</rd-header>
+1 -1
View File
@@ -63,6 +63,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
@@ -0,0 +1,57 @@
<div ng-if="service.ServicePreferences" id="service-placement-preferences">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Placement preferences">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="isUpdating || addPlacementPreference(service)" ng-disabled="isUpdating">
<i class="fa fa-plus-circle" aria-hidden="true"></i> placement preference
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="service.ServicePreferences.length === 0">
<p>There are no placement preferences for this service.</p>
</rd-widget-body>
<rd-widget-body ng-if="service.ServicePreferences.length > 0" classes="no-padding">
<table class="table" >
<thead>
<tr>
<th>Strategy</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="preference in service.ServicePreferences">
<td>
<div class="input-group input-group-sm">
<input type="text" class="form-control" ng-model="preference.strategy" placeholder="e.g. node.role" ng-change="updatePlacementPreference(service, preference)" ng-disabled="isUpdating">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<input type="text" class="form-control" ng-model="preference.value" placeholder="e.g. manager" ng-change="updatePlacementPreference(service, preference)" ng-disabled="isUpdating">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removePlacementPreference(service, $index)" ng-disabled="isUpdating">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['ServicePreferences'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['ServicePreferences'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>
+1 -1
View File
@@ -63,7 +63,7 @@
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar"
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['Ports'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+1 -1
View File
@@ -49,7 +49,7 @@
</thead>
<tbody>
<tr dir-paginate="task in (filteredTasks = ( tasks | orderBy:sortType:sortReverse | itemsPerPage: state.pagination_count))">
<td><a ui-sref="task({ id: task.Id })">{{ task.Id }}</a></td>
<td><a ui-sref="task({ id: task.Id })" class="monospaced">{{ task.Id }}</a></td>
<td><span class="label label-{{ task.Status.State|taskstatusbadge }}">{{ task.Status.State }}</span></td>
<td ng-if="service.Mode !== 'global'">{{ task.Slot }}</td>
<td>{{ task.NodeId | tasknodename: nodes }}</td>
@@ -47,18 +47,38 @@
</p>
</td>
</tr>
<tr ng-if="applicationState.endpoint.apiVersion >= 1.29">
<td>Order</td>
<td>
<div class="form-group">
<label class="radio-inline">
<input type="radio" name="updateconfig_order" ng-model="service.UpdateOrder" value="start-first" ng-change="updateServiceAttribute(service, 'UpdateOrder')" ng-disabled="isUpdating">
start-first
</label>
<label class="radio-inline">
<input type="radio" name="updateconfig_order" ng-model="service.UpdateOrder" value="stop-first" ng-change="updateServiceAttribute(service, 'UpdateOrder')" ng-disabled="isUpdating">
stop-first
</label>
</div>
</td>
<td>
<p class="small text-muted" style="margin-top: 10px;">
Operation order on failure.
</p>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])" ng-click="updateService(service)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service, ['UpdateFailureAction', 'UpdateDelay', 'UpdateParallelism', 'UpdateOrder'])">Reset changes</a></li>
<li><a ng-click="cancelChanges(service)">Reset all changes</a></li>
</ul>
</div>
+20 -4
View File
@@ -6,7 +6,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="services">Services</a> > <a ui-sref="service({id: service.Id})">{{ service.Name }}</a>
<a ui-sref="services">Services</a> &gt; <a ui-sref="service({id: service.Id})">{{ service.Name }}</a>
</rd-header-content>
</rd-header>
@@ -72,6 +72,13 @@
<input type="text" class="form-control" ng-model="service.Image" ng-change="updateServiceAttribute(service, 'Image')" ng-disabled="isUpdating" />
</td>
</tr>
<tr ng-if="applicationState.endpoint.apiVersion >= 1.30">
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a class="btn btn-outline-secondary" type="button" ui-sref="servicelogs({id: service.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
@@ -106,10 +113,11 @@
<li><a href ng-click="goToItem('service-network-specs')">Network &amp; published ports</a></li>
<li><a href ng-click="goToItem('service-resources')">Resource limits &amp; reservations</a></li>
<li><a href ng-click="goToItem('service-placement-constraints')">Placement constraints</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.30"><a href ng-click="goToItem('service-placement-preferences')">Placement preferences</a></li>
<li><a href ng-click="goToItem('service-restart-policy')">Restart policy</a></li>
<li><a href ng-click="goToItem('service-update-config')">Update configuration</a></li>
<li><a href ng-click="goToItem('service-labels')">Service labels</a></li>
<li><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li ng-if="applicationState.endpoint.apiVersion >= 1.25"><a href ng-click="goToItem('service-secrets')">Secrets</a></li>
<li><a href ng-click="goToItem('service-tasks')">Tasks</a></li>
</ul>
</rd-widget-body>
@@ -117,7 +125,14 @@
</div>
</div>
<div ng-include="'app/components/common/accessControlPanel/accessControlPanel.html'" ng-if="service && applicationState.application.authentication"></div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="service && applicationState.application.authentication"
resource-id="service.Id"
resource-control="service.ResourceControl"
resource-type="'service'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div class="row">
<hr>
@@ -145,10 +160,11 @@
<h3 id="service-specs">Service specification</h3>
<div id="service-resources" class="padding-top" ng-include="'app/components/service/includes/resources.html'"></div>
<div id="service-placement-constraints" class="padding-top" ng-include="'app/components/service/includes/constraints.html'"></div>
<div id="service-placement-preferences" ng-if="applicationState.endpoint.apiVersion >= 1.30" class="padding-top" ng-include="'app/components/service/includes/placementPreferences.html'"></div>
<div id="service-restart-policy" class="padding-top" ng-include="'app/components/service/includes/restart.html'"></div>
<div id="service-update-config" class="padding-top" ng-include="'app/components/service/includes/updateconfig.html'"></div>
<div id="service-labels" class="padding-top" ng-include="'app/components/service/includes/servicelabels.html'"></div>
<div id="service-secrets" class="padding-top" ng-include="'app/components/service/includes/secrets.html'"></div>
<div id="service-secrets" ng-if="applicationState.endpoint.apiVersion >= 1.25" class="padding-top" ng-include="'app/components/service/includes/secrets.html'"></div>
<div id="service-tasks" class="padding-top" ng-include="'app/components/service/includes/tasks.html'"></div>
</div>
</div>
+32 -109
View File
@@ -1,6 +1,6 @@
angular.module('service', [])
.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'Secret', 'SecretHelper', 'Service', 'ServiceHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService', 'ControllerDataPipeline',
function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, Secret, SecretHelper, Service, ServiceHelper, TaskService, NodeService, Notifications, Pagination, ModalService, ControllerDataPipeline) {
.controller('ServiceController', ['$q', '$scope', '$stateParams', '$state', '$location', '$timeout', '$anchorScroll', 'ServiceService', 'SecretService', 'SecretHelper', 'Service', 'ServiceHelper', 'LabelHelper', 'TaskService', 'NodeService', 'Notifications', 'Pagination', 'ModalService',
function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll, ServiceService, SecretService, SecretHelper, Service, ServiceHelper, LabelHelper, TaskService, NodeService, Notifications, Pagination, ModalService) {
$scope.state = {};
$scope.state.pagination_count = Pagination.getPaginationCount('service_tasks');
@@ -124,10 +124,24 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints);
}
};
$scope.updatePlacementConstraint = function updatePlacementConstraint(service, constraint) {
$scope.updatePlacementConstraint = function(service, constraint) {
updateServiceArray(service, 'ServiceConstraints', service.ServiceConstraints);
};
$scope.addPlacementPreference = function(service) {
service.ServicePreferences.push({ strategy: 'spread', value: '' });
updateServiceArray(service, 'ServicePreferences', service.ServicePreferences);
};
$scope.removePlacementPreference = function(service, index) {
var removedElement = service.ServicePreferences.splice(index, 1);
if (removedElement !== null) {
updateServiceArray(service, 'ServicePreferences', service.ServicePreferences);
}
};
$scope.updatePlacementPreference = function(service, constraint) {
updateServiceArray(service, 'ServicePreferences', service.ServicePreferences);
};
$scope.addPublishedPort = function addPublishedPort(service) {
if (!service.Ports) {
service.Ports = [];
@@ -174,9 +188,9 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
$('#loadingViewSpinner').show();
var config = ServiceHelper.serviceToConfig(service.Model);
config.Name = service.Name;
config.Labels = translateServiceLabelsToLabels(service.ServiceLabels);
config.TaskTemplate.ContainerSpec.Env = translateEnvironmentVariablesToEnv(service.EnvironmentVariables);
config.TaskTemplate.ContainerSpec.Labels = translateServiceLabelsToLabels(service.ServiceContainerLabels);
config.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceLabels);
config.TaskTemplate.ContainerSpec.Env = ServiceHelper.translateEnvironmentVariablesToEnv(service.EnvironmentVariables);
config.TaskTemplate.ContainerSpec.Labels = LabelHelper.fromKeyValueToLabelHash(service.ServiceContainerLabels);
config.TaskTemplate.ContainerSpec.Image = service.Image;
config.TaskTemplate.ContainerSpec.Secrets = service.ServiceSecrets ? service.ServiceSecrets.map(SecretHelper.secretConfig) : [];
@@ -188,6 +202,7 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
config.TaskTemplate.Placement = {};
}
config.TaskTemplate.Placement.Constraints = ServiceHelper.translateKeyValueToPlacementConstraints(service.ServiceConstraints);
config.TaskTemplate.Placement.Preferences = ServiceHelper.translateKeyValueToPlacementPreferences(service.ServicePreferences);
config.TaskTemplate.Resources = {
Limits: {
@@ -203,8 +218,10 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
config.UpdateConfig = {
Parallelism: service.UpdateParallelism,
Delay: service.UpdateDelay,
FailureAction: service.UpdateFailureAction
FailureAction: service.UpdateFailureAction,
Order: service.UpdateOrder
};
config.TaskTemplate.RestartPolicy = {
Condition: service.RestartCondition,
Delay: service.RestartDelay,
@@ -263,16 +280,17 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
function translateServiceArrays(service) {
service.ServiceSecrets = service.Secrets ? service.Secrets.map(SecretHelper.flattenSecret) : [];
service.EnvironmentVariables = translateEnvironmentVariables(service.Env);
service.ServiceLabels = translateLabelsToServiceLabels(service.Labels);
service.ServiceContainerLabels = translateLabelsToServiceLabels(service.ContainerLabels);
service.EnvironmentVariables = ServiceHelper.translateEnvironmentVariables(service.Env);
service.ServiceLabels = LabelHelper.fromLabelHashToKeyValue(service.Labels);
service.ServiceContainerLabels = LabelHelper.fromLabelHashToKeyValue(service.ContainerLabels);
service.ServiceMounts = angular.copy(service.Mounts);
service.ServiceConstraints = translateConstraintsToKeyValue(service.Constraints);
service.ServiceConstraints = ServiceHelper.translateConstraintsToKeyValue(service.Constraints);
service.ServicePreferences = ServiceHelper.translatePreferencesToKeyValue(service.Preferences);
}
function initView() {
$('#loadingViewSpinner').show();
var apiVersion = $scope.applicationState.endpoint.apiVersion;
ServiceService.service($stateParams.id)
.then(function success(data) {
var service = data;
@@ -283,52 +301,32 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
translateServiceArrays(service);
$scope.service = service;
ControllerDataPipeline.setAccessControlData('service', $stateParams.id, service.ResourceControl);
originalService = angular.copy(service);
return $q.all({
tasks: TaskService.serviceTasks(service.Name),
nodes: NodeService.nodes(),
secrets: Secret.query({}).$promise
secrets: apiVersion >= 1.25 ? SecretService.secrets() : []
});
})
.then(function success(data) {
$scope.tasks = data.tasks;
$scope.nodes = data.nodes;
$scope.secrets = data.secrets.map(function (secret) {
return new SecretViewModel(secret);
});
$scope.secrets = data.secrets;
$timeout(function() {
$anchorScroll();
});
})
.catch(function error(err) {
$scope.secrets = [];
Notifications.error('Failure', err, 'Unable to retrieve service details');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
function fetchSecrets() {
$('#loadSecretsSpinner').show();
Secret.query({}, function (d) {
$scope.secrets = d.map(function (secret) {
return new SecretViewModel(secret);
});
$('#loadSecretsSpinner').hide();
}, function(e) {
$('#loadSecretsSpinner').hide();
Notifications.error('Failure', e, 'Unable to retrieve secrets');
$scope.secrets = [];
});
}
$scope.updateServiceAttribute = function updateServiceAttribute(service, name) {
if (service[name] !== originalService[name] || !(name in originalService)) {
service.hasChanges = true;
@@ -341,80 +339,5 @@ function ($q, $scope, $stateParams, $state, $location, $timeout, $anchorScroll,
service.hasChanges = true;
}
function translateEnvironmentVariables(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
var idx = variable.indexOf('=');
var keyValue = [variable.slice(0,idx), variable.slice(idx+1)];
var originalValue = (keyValue.length > 1) ? keyValue[1] : '';
variables.push({ key: keyValue[0], value: originalValue, originalKey: keyValue[0], originalValue: originalValue, added: true});
});
return variables;
}
return [];
}
function translateEnvironmentVariablesToEnv(env) {
if (env) {
var variables = [];
env.forEach(function(variable) {
if (variable.key && variable.key !== '') {
variables.push(variable.key + '=' + variable.value);
}
});
return variables;
}
return [];
}
function translateLabelsToServiceLabels(Labels) {
var labels = [];
if (Labels) {
Object.keys(Labels).forEach(function(key) {
labels.push({ key: key, value: Labels[key], originalKey: key, originalValue: Labels[key], added: true});
});
}
return labels;
}
function translateServiceLabelsToLabels(labels) {
var Labels = {};
if (labels) {
labels.forEach(function(label) {
Labels[label.key] = label.value;
});
}
return Labels;
}
function translateConstraintsToKeyValue(constraints) {
function getOperator(constraint) {
var indexEquals = constraint.indexOf('==');
if (indexEquals >= 0) {
return [indexEquals, '=='];
}
return [constraint.indexOf('!='), '!='];
}
if (constraints) {
var keyValueConstraints = [];
constraints.forEach(function(constraint) {
var operatorIndices = getOperator(constraint);
var key = constraint.slice(0, operatorIndices[0]);
var operator = operatorIndices[1];
var value = constraint.slice(operatorIndices[0] + 2);
keyValueConstraints.push({
key: key,
value: value,
operator: operator,
originalKey: key,
originalValue: value
});
});
return keyValueConstraints;
}
return [];
}
initView();
}]);
@@ -0,0 +1,83 @@
angular.module('serviceLogs', [])
.controller('ServiceLogsController', ['$scope', '$stateParams', '$anchorScroll', 'ServiceLogs', 'Service',
function ($scope, $stateParams, $anchorScroll, ServiceLogs, Service) {
$scope.state = {};
$scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false;
$scope.stdout = '';
$scope.stderr = '';
$scope.tailLines = 2000;
function getLogs() {
$('#loadingViewSpinner').show();
getLogsStdout();
getLogsStderr();
$('#loadingViewSpinner').hide();
}
function getLogsStderr() {
ServiceLogs.get($stateParams.id, {
stdout: 0,
stderr: 1,
timestamps: $scope.state.displayTimestampsErr,
tail: $scope.tailLines
}, function (data, status, headers, config) {
// Replace carriage returns with newlines to clean up output
data = data.replace(/[\r]/g, '\n');
// Strip 8 byte header from each line of output
data = data.substring(8);
data = data.replace(/\n(.{8})/g, '\n');
$scope.stderr = data;
});
}
function getLogsStdout() {
ServiceLogs.get($stateParams.id, {
stdout: 1,
stderr: 0,
timestamps: $scope.state.displayTimestampsOut,
tail: $scope.tailLines
}, function (data, status, headers, config) {
// Replace carriage returns with newlines to clean up output
data = data.replace(/[\r]/g, '\n');
// Strip 8 byte header from each line of output
data = data.substring(8);
data = data.replace(/\n(.{8})/g, '\n');
$scope.stdout = data;
});
}
function getService() {
$('#loadingViewSpinner').show();
Service.get({id: $stateParams.id}, function (d) {
$scope.service = d;
$('#loadingViewSpinner').hide();
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve service info');
$('#loadingViewSpinner').hide();
});
}
function initView() {
getService();
getLogs();
var logIntervalId = window.setInterval(getLogs, 5000);
$scope.$on('$destroy', function () {
// clearing interval when view changes
clearInterval(logIntervalId);
});
$scope.toggleTimestampsOut = function () {
getLogsStdout();
};
$scope.toggleTimestampsErr = function () {
getLogsStderr();
};
}
initView();
}]);
@@ -0,0 +1,56 @@
<rd-header>
<rd-header-title title="Service logs">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="services">Services</a> > <a ui-sref="service({id: service.ID})">{{ service.Spec.Name }}</a> > Logs
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-body>
<div class="widget-icon grey pull-left">
<i class="fa fa-list-alt"></i>
</div>
<div class="title">{{ service.Spec.Name }}</div>
<div class="comment">Name</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-info-circle" title="Stdout logs"></rd-widget-header>
<rd-widget-taskbar>
<input type="checkbox" ng-model="state.displayTimestampsOut" id="displayAllTsOut" ng-change="toggleTimestampsOut()"/>
<label for="displayAllTsOut">Display timestamps</label>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="panel-body">
<pre id="stdoutLog" class="pre-scrollable pre-x-scrollable">{{stdout}}</pre>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-exclamation-triangle" title="Stderr logs"></rd-widget-header>
<rd-widget-taskbar>
<input type="checkbox" ng-model="state.displayTimestampsErr" id="displayAllTsErr" ng-change="toggleTimestampsErr()"/>
<label for="displayAllTsErr">Display timestamps</label>
</rd-widget-taskbar>
<rd-widget-body classes="no-padding">
<div class="panel-body">
<pre id="stderrLog" class="pre-scrollable pre-x-scrollable">{{stderr}}</pre>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
+1 -1
View File
@@ -128,6 +128,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
@@ -0,0 +1,254 @@
<rd-header>
<rd-header-title title="Authentication settings">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="settings">Settings</a> &gt; Authentication
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-users" title="Authentication"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Authentication method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="registry_quay" ng-model="settings.AuthenticationMethod" ng-value="1">
<label for="registry_quay">
<div class="boxselector_header">
<i class="fa fa-users" aria-hidden="true" style="margin-right: 2px;"></i>
Internal
</div>
<p>Internal authentication mechanism</p>
</label>
</div>
<div>
<input type="radio" id="registry_custom" ng-model="settings.AuthenticationMethod" ng-value="2">
<label for="registry_custom">
<div class="boxselector_header">
<i class="fa fa-users" aria-hidden="true" style="margin-right: 2px;"></i>
LDAP
</div>
<p>LDAP authentication</p>
</label>
</div>
</div>
</div>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group" ng-if="settings.AuthenticationMethod === 1">
<span class="col-sm-12 text-muted small">
When using internal authentication, Portainer will encrypt user passwords and store credentials locally.
</span>
</div>
<div class="form-group" ng-if="settings.AuthenticationMethod === 2">
<span class="col-sm-12 text-muted small">
When using LDAP authentication, Portainer will delegate user authentication to a LDAP server (exception for the <b>admin</b> user that always uses internal authentication).
<p style="margin-top:5px;">
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
<u>Users still need to be created in Portainer beforehand.</u>
</p>
</span>
</div>
<div ng-if="settings.AuthenticationMethod === 2">
<div class="col-sm-12 form-section-title">
LDAP configuration
</div>
<div class="form-group">
<label for="ldap_url" class="col-sm-3 col-lg-2 control-label text-left">
LDAP URL
<portainer-tooltip position="bottom" message="URL or IP address of the LDAP server."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="ldap_url" ng-model="LDAPSettings.URL" placeholder="e.g. 10.0.0.10:389 or myldap.domain.tld:389">
</div>
</div>
<div class="form-group">
<label for="ldap_username" class="col-sm-3 col-lg-2 control-label text-left">
Reader DN
<portainer-tooltip position="bottom" message="Account that will be used to search for users."></portainer-tooltip>
</label>
<div class="col-sm-9 col-lg-10">
<input type="text" class="form-control" id="ldap_username" ng-model="LDAPSettings.ReaderDN" placeholder="cn=readonly-account,dc=ldap,dc=domain,dc=tld">
</div>
</div>
<div class="form-group">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Password
</label>
<div class="col-sm-9 col-lg-10">
<input type="password" class="form-control" id="ldap_password" ng-model="LDAPSettings.Password" placeholder="password">
</div>
</div>
<div class="form-group" ng-if="!LDAPSettings.TLSConfig.TLS && !LDAPSettings.StartTLS">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Connectivity check
<i class="fa fa-check green-icon" style="margin-left: 5px;" ng-if="state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon" style="margin-left: 5px;" ng-if="state.failedConnectivityCheck"></i>
</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!LDAPSettings.URL || !LDAPSettings.ReaderDN || !LDAPSettings.Password" ng-click="LDAPConnectivityCheck()">Test connectivity</button>
<i id="connectivityCheckSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<div class="col-sm-12 form-section-title">
LDAP security
</div>
<!-- starttls -->
<div class="form-group" ng-if="!LDAPSettings.TLSConfig.TLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Use StartTLS
<portainer-tooltip position="bottom" message="Enable this option if want to use StartTLS to secure the connection to the server. Ignored if Use TLS is selected."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="LDAPSettings.StartTLS"><i></i>
</label>
</div>
</div>
<!-- !starttls -->
<!-- tls-checkbox -->
<div class="form-group" ng-if="!LDAPSettings.StartTLS">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Use TLS
<portainer-tooltip position="bottom" message="Enable this option if you need to specify TLS certificates to connect to the LDAP server."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="LDAPSettings.TLSConfig.TLS"><i></i>
</label>
</div>
</div>
<!-- !tls-checkbox -->
<!-- tls-skip-verify -->
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Skip verification of server certificate
<portainer-tooltip position="bottom" message="Skip the verification of the server TLS certificate. Not recommended on unsecured networks."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="LDAPSettings.TLSConfig.TLSSkipVerify"><i></i>
</label>
</div>
</div>
<!-- !tls-skip-verify -->
<!-- tls-certs -->
<div ng-if="LDAPSettings.TLSConfig.TLS || LDAPSettings.StartTLS">
<!-- ca-input -->
<div class="form-group" ng-if="!LDAPSettings.TLSConfig.TLSSkipVerify">
<label class="col-sm-2 control-label text-left">TLS CA certificate</label>
<div class="col-sm-10">
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.TLSCACert">Select file</button>
<span style="margin-left: 5px;">
{{ formValues.TLSCACert.name }}
<i class="fa fa-check green-icon" ng-if="formValues.TLSCACert && formValues.TLSCACert === LDAPSettings.TLSConfig.TLSCACert" aria-hidden="true"></i>
<i class="fa fa-times red-icon" ng-if="!formValues.TLSCACert" aria-hidden="true"></i>
<i class="fa fa-circle-o-notch fa-spin" ng-if="state.uploadInProgress"></i>
</span>
</div>
</div>
<!-- !ca-input -->
</div>
<!-- !tls-certs -->
<div class="form-group" ng-if="LDAPSettings.TLSConfig.TLS || LDAPSettings.StartTLS">
<label for="ldap_password" class="col-sm-3 col-lg-2 control-label text-left">
Connectivity check
<i class="fa fa-check green-icon" style="margin-left: 5px;" ng-if="state.successfulConnectivityCheck"></i>
<i class="fa fa-times red-icon" style="margin-left: 5px;" ng-if="state.failedConnectivityCheck"></i>
</label>
<div class="col-sm-9 col-lg-10">
<button type="button" class="btn btn-primary btn-sm" ng-click="LDAPConnectivityCheck()" ng-disabled="!LDAPSettings.URL || !LDAPSettings.ReaderDN || !LDAPSettings.Password || (!formValues.TLSCACert && !LDAPSettings.TLSConfig.TLSSkipVerify)">Test connectivity</button>
<i id="connectivityCheckSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
</div>
</div>
<div class="col-sm-12 form-section-title">
User search configurations
</div>
<!-- search-settings -->
<div ng-repeat="config in LDAPSettings.SearchSettings | limitTo: (1 - LDAPSettings.SearchSettings)" style="margin-top: 5px;">
<div class="form-group" ng-if="$index > 0">
<span class="col-sm-12 text-muted small">
Extra search configuration
</span>
</div>
<div class="form-group">
<label for="ldap_basedn_{{$index}}" class="col-sm-4 col-md-2 control-label text-left">
Base DN
<portainer-tooltip position="bottom" message="The distinguished name of the element from which the LDAP server will search for users."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-4">
<input type="text" class="form-control" id="ldap_basedn_{{$index}}" ng-model="config.BaseDN" placeholder="dc=ldap,dc=domain,dc=tld">
</div>
<label for="ldap_username_att_{{$index}}" class="col-sm-4 col-md-3 col-lg-2 margin-sm-top control-label text-left">
Username attribute
<portainer-tooltip position="bottom" message="LDAP attribute which denotes the username."></portainer-tooltip>
</label>
<div class="col-sm-8 col-md-3 col-lg-4 margin-sm-top">
<input type="text" class="form-control" id="ldap_username_att_{{$index}}" ng-model="config.UserNameAttribute" placeholder="uid">
</div>
</div>
<div class="form-group">
<label for="ldap_filter_{{$index}}" class="col-sm-4 col-md-2 control-label text-left">
Filter
<portainer-tooltip position="bottom" message="The LDAP search filter used to select user elements, optional."></portainer-tooltip>
</label>
<div class="col-sm-7 col-md-9">
<input type="text" class="form-control" id="ldap_filter_{{$index}}" ng-model="config.Filter" placeholder="(objectClass=account)">
</div>
<div class="col-sm-1" ng-if="$index > 0">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeSearchConfiguration($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="form-group">
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addSearchConfiguration()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add search configuration
</span>
</div>
</div>
<!-- !search-settings -->
</div>
<!-- actions -->
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-click="saveSettings()">Save</button>
<i id="updateSettingsSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<!-- <span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span> -->
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
@@ -0,0 +1,93 @@
angular.module('settingsAuthentication', [])
.controller('SettingsAuthenticationController', ['$q', '$scope', 'Notifications', 'SettingsService', 'FileUploadService',
function ($q, $scope, Notifications, SettingsService, FileUploadService) {
$scope.state = {
successfulConnectivityCheck: false,
failedConnectivityCheck: false,
uploadInProgress: false
};
$scope.formValues = {
TLSCACert: ''
};
$scope.addSearchConfiguration = function() {
$scope.LDAPSettings.SearchSettings.push({ BaseDN: '', UserNameAttribute: '', Filter: '' });
};
$scope.removeSearchConfiguration = function(index) {
$scope.LDAPSettings.SearchSettings.splice(index, 1);
};
$scope.LDAPConnectivityCheck = function() {
$('#connectivityCheckSpinner').show();
var settings = $scope.settings;
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
var uploadRequired = ($scope.LDAPSettings.TLSConfig.TLS || $scope.LDAPSettings.StartTLS) && !$scope.LDAPSettings.TLSConfig.TLSSkipVerify;
$scope.state.uploadInProgress = uploadRequired;
$q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null))
.then(function success(data) {
return SettingsService.checkLDAPConnectivity(settings);
})
.then(function success(data) {
$scope.state.failedConnectivityCheck = false;
$scope.state.successfulConnectivityCheck = true;
Notifications.success('Connection to LDAP successful');
})
.catch(function error(err) {
$scope.state.failedConnectivityCheck = true;
$scope.state.successfulConnectivityCheck = false;
Notifications.error('Failure', err, 'Connection to LDAP failed');
})
.finally(function final() {
$scope.state.uploadInProgress = false;
$('#connectivityCheckSpinner').hide();
});
};
$scope.saveSettings = function() {
$('#updateSettingsSpinner').show();
var settings = $scope.settings;
var TLSCAFile = $scope.formValues.TLSCACert !== settings.LDAPSettings.TLSConfig.TLSCACert ? $scope.formValues.TLSCACert : null;
var uploadRequired = ($scope.LDAPSettings.TLSConfig.TLS || $scope.LDAPSettings.StartTLS) && !$scope.LDAPSettings.TLSConfig.TLSSkipVerify;
$scope.state.uploadInProgress = uploadRequired;
$q.when(!uploadRequired || FileUploadService.uploadLDAPTLSFiles(TLSCAFile, null, null))
.then(function success(data) {
return SettingsService.update(settings);
})
.then(function success(data) {
Notifications.success('Authentication settings updated');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to update authentication settings');
})
.finally(function final() {
$scope.state.uploadInProgress = false;
$('#updateSettingsSpinner').hide();
});
};
function initView() {
$('#loadingViewSpinner').show();
SettingsService.settings()
.then(function success(data) {
var settings = data;
$scope.settings = settings;
$scope.LDAPSettings = settings.LDAPSettings;
$scope.formValues.TLSCACert = settings.LDAPSettings.TLSConfig.TLSCACert;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);
+8 -5
View File
@@ -25,7 +25,7 @@
<a ui-sref="templates_linuxserver" ui-sref-active="active">LinuxServer.io</a>
</div>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="services" ui-sref-active="active">Services <span class="menu-icon fa fa-list-alt"></span></a>
</li>
<li class="sidebar-list">
@@ -40,16 +40,16 @@
<li class="sidebar-list">
<a ui-sref="volumes" ui-sref-active="active">Volumes <span class="menu-icon fa fa-cubes"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.apiVersion >= 1.25 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="secrets" ui-sref-active="active">Secrets <span class="menu-icon fa fa-user-secret"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<a ui-sref="events" ui-sref-active="active">Events <span class="menu-icon fa fa-history"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM' || (applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER')">
<a ui-sref="swarm" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group"></span></a>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'">
<a ui-sref="docker" ui-sref-active="active">Docker <span class="menu-icon fa fa-th"></span></a>
</li>
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
@@ -58,7 +58,7 @@
<li class="sidebar-list" ng-if="applicationState.application.authentication && (isAdmin || isTeamLeader)">
<a ui-sref="users" ui-sref-active="active">User management <span class="menu-icon fa fa-users"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'users' || $state.current.name === 'user' || $state.current.name === 'teams' || $state.current.name === 'team')">
<a ui-sref="teams" ui-sref-active="active">Teams</span></a>
<a ui-sref="teams" ui-sref-active="active">Teams</a>
</div>
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
@@ -69,6 +69,9 @@
</li>
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
<a ui-sref="settings" ui-sref-active="active">Settings <span class="menu-icon fa fa-cogs"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'settings' || $state.current.name === 'settings_authentication') && applicationState.application.authentication && isAdmin">
<a ui-sref="settings_authentication" ui-sref-active="active">Authentication</a>
</div>
</li>
</ul>
<div class="sidebar-footer">
+1 -1
View File
@@ -49,7 +49,7 @@ function ($q, $scope, $state, Settings, EndpointService, StateManager, EndpointP
EndpointService.endpoints()
.then(function success(data) {
var endpoints = data;
$scope.endpoints = endpoints;
$scope.endpoints = _.sortBy(endpoints, ['Name']);
setActiveEndpoint(endpoints);
if (StateManager.getState().application.authentication) {
+2 -2
View File
@@ -1,7 +1,7 @@
<rd-header>
<rd-header-title title="Container stats"></rd-header-title>
<rd-header-content>
<a ui-sref="containers">Containers</a> > <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> > Stats
<a ui-sref="containers">Containers</a> &gt; <a ui-sref="container({id: container.Id})">{{ container.Name|trimcontainername }}</a> &gt; Stats
</rd-header-content>
</rd-header>
@@ -52,7 +52,7 @@
</rd-widget-body>
</rd-widget>
</div>
<div class="col-lg-6">
<div class="col-lg-6" ng-if="applicationState.endpoint.mode.provider !== 'VMWARE_VIC'">
<rd-widget>
<rd-widget-header icon="fa-tasks" title="Processes">
<div class="pull-right">
+4 -1
View File
@@ -213,5 +213,8 @@ function (Pagination, $scope, Notifications, $timeout, Container, ContainerTop,
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve container info');
});
$scope.getTop();
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider !== 'VMWARE_VIC') {
$scope.getTop();
}
}]);
+1 -1
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content ng-if="task && service">
<a ui-sref="services">Services</a> > <a ui-sref="service({id: service.Id })">{{ service.Name }}</a> > {{ task.Id }}
<a ui-sref="services">Services</a> &gt; <a ui-sref="service({id: service.Id })">{{ service.Name }}</a> &gt; {{ task.Id }}
</rd-header-content>
</rd-header>
+5 -5
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="teams">Teams</a> > <a ui-sref="team({id: team.Id})">{{ team.Name }}</a>
<a ui-sref="teams">Teams</a> &gt; <a ui-sref="team({id: team.Id})">{{ team.Name }}</a>
</rd-header-content>
</rd-header>
@@ -65,7 +65,7 @@
<thead>
<tr>
<th>
<a ui-sref="endpoint.access({id: endpoint.Id})" ng-click="orderUsers('Username')">
<a ng-click="orderUsers('Username')">
Name
<span ng-show="sortTypeUsers == 'Username' && !sortReverseUsers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeUsers == 'Username' && sortReverseUsers" class="glyphicon glyphicon-chevron-up"></span>
@@ -125,14 +125,14 @@
<thead>
<tr>
<th>
<a ui-sref="team({id: team.Id})" ng-click="orderGroupMembers('Username')">
<a ng-click="orderGroupMembers('Username')">
Name
<span ng-show="sortTypeGroupMembers == 'Username' && !sortReverseGroupMembers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeGroupMembers == 'Username' && sortReverseGroupMembers" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="team({id: team.Id})" ng-click="orderGroupMembers('TeamRole')">
<a ng-click="orderGroupMembers('TeamRole')">
Team Role
<span ng-show="sortTypeGroupMembers == 'TeamRole' && !sortReverseGroupMembers" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortTypeGroupMembers == 'TeamRole' && sortReverseGroupMembers" class="glyphicon glyphicon-chevron-up"></span>
@@ -144,7 +144,7 @@
<tr pagination-id="table2" dir-paginate="user in teamMembers | filter:state.filterGroupMembers | orderBy:sortTypeGroupMembers:sortReverseGroupMembers | itemsPerPage: state.pagination_count_groupMembers">
<td>
{{ user.Username }}
<span style="margin-left: 5px;" ng-if="isAdmin || user.TeamRole === 'Member'")>
<span style="margin-left: 5px;" ng-if="isAdmin || user.TeamRole === 'Member'">
<a class="btn-outline-secondary" ng-click="removeUser(user)"><i class="fa fa-minus-circle space-right" aria-hidden="true"></i>Remove</a>
</span>
</td>
+4 -4
View File
@@ -43,8 +43,8 @@
helper-elements="filter"
search-property="Username"
translation="{nothingSelected: 'Select one or more team leaders', search: 'Search...'}"
style="margin-left: 20px;"
</span>
style="margin-left: 20px;">
</span>
</div>
</div>
<!-- !team-leaders -->
@@ -95,7 +95,7 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="users" ng-click="order('Name')">
<a ng-click="order('Name')">
Name
<span ng-show="sortType == 'Name' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Name' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
@@ -125,6 +125,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
+4 -3
View File
@@ -68,7 +68,7 @@
</div>
<!-- !env -->
<!-- access-control -->
<div ng-include="'app/components/common/accessControlForm/accessControlForm.html'" ng-if="applicationState.application.authentication"></div>
<por-access-control-form form-data="formValues.AccessControlData" ng-if="applicationState.application.authentication"></por-access-control-form>
<!-- !access-control -->
<div class="form-group">
<div class="col-sm-12">
@@ -202,13 +202,14 @@
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.network" ng-click="createTemplate()">Create</button>
<i id="createContainerSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="small text-muted" style="margin-left: 10px" ng-if="globalNetworkCount === 0 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM'">
<span class="small text-muted" style="margin-left: 10px" ng-if="globalNetworkCount === 0 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM' && !state.formValidationError">
When using Swarm, we recommend deploying containers in a shared network. Looks like you don't have any shared network, head over the <a ui-sref="networks">networks view</a> to create one.
</span>
<span ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'" style="margin-left: 10px">
<span ng-if="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && !state.formValidationError" style="margin-left: 10px">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
<span class="small text-muted" style="margin-left: 5px;">App templates cannot be deployed as Swarm Mode services for the moment. You can still use them to quickly deploy containers on the Docker host.</span>
</span>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
+18 -22
View File
@@ -1,6 +1,6 @@
angular.module('templates', [])
.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'ControllerDataPipeline', 'FormValidator',
function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, ControllerDataPipeline, FormValidator) {
.controller('TemplatesController', ['$scope', '$q', '$state', '$stateParams', '$anchorScroll', '$filter', 'ContainerService', 'ContainerHelper', 'ImageService', 'NetworkService', 'TemplateService', 'TemplateHelper', 'VolumeService', 'Notifications', 'Pagination', 'ResourceControlService', 'Authentication', 'FormValidator',
function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerService, ContainerHelper, ImageService, NetworkService, TemplateService, TemplateHelper, VolumeService, Notifications, Pagination, ResourceControlService, Authentication, FormValidator) {
$scope.state = {
selectedTemplate: null,
showAdvancedOptions: false,
@@ -14,7 +14,8 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
$scope.formValues = {
network: '',
name: ''
name: '',
AccessControlData: new AccessControlFormData()
};
$scope.addVolume = function () {
@@ -49,7 +50,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
$('#createContainerSpinner').show();
var userDetails = Authentication.getUserDetails();
var accessControlData = ControllerDataPipeline.getAccessControlFormData();
var accessControlData = $scope.formValues.AccessControlData;
var isAdmin = userDetails.role === 1 ? true : false;
if (!validateForm(accessControlData, isAdmin)) {
@@ -69,7 +70,7 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
generatedVolumeIds.push(volumeId);
});
TemplateService.updateContainerConfigurationWithVolumes(templateConfiguration, template, data);
return ImageService.pullImage(template.Image, template.Registry);
return ImageService.pullImage(template.Image, { URL: template.Registry }, true);
})
.then(function success(data) {
return ContainerService.createAndStartContainer(templateConfiguration);
@@ -143,27 +144,20 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
return containerMapping;
}
function filterNetworksBasedOnProvider(networks) {
var endpointProvider = $scope.applicationState.endpoint.mode.provider;
if (endpointProvider === 'DOCKER_SWARM' || endpointProvider === 'DOCKER_SWARM_MODE') {
if (endpointProvider === 'DOCKER_SWARM') {
networks = NetworkService.filterGlobalNetworks(networks);
} else {
networks = NetworkService.filterSwarmModeAttachableNetworks(networks);
}
$scope.globalNetworkCount = networks.length;
NetworkService.addPredefinedLocalNetworks(networks);
}
return networks;
}
function initTemplates() {
var templatesKey = $stateParams.key;
var provider = $scope.applicationState.endpoint.mode.provider;
var apiVersion = $scope.applicationState.endpoint.apiVersion;
$q.all({
templates: TemplateService.getTemplates(templatesKey),
containers: ContainerService.getContainers(0),
networks: NetworkService.networks(),
volumes: VolumeService.getVolumes()
volumes: VolumeService.getVolumes(),
networks: NetworkService.networks(
provider === 'DOCKER_STANDALONE' || provider === 'DOCKER_SWARM_MODE',
false,
provider === 'DOCKER_SWARM_MODE' && apiVersion >= 1.25,
provider === 'DOCKER_SWARM')
})
.then(function success(data) {
$scope.templates = data.templates;
@@ -173,8 +167,10 @@ function ($scope, $q, $state, $stateParams, $anchorScroll, $filter, ContainerSer
});
$scope.availableCategories = _.sortBy(_.uniq(availableCategories));
$scope.runningContainers = data.containers;
$scope.availableNetworks = filterNetworksBasedOnProvider(data.networks);
$scope.availableVolumes = data.volumes.Volumes;
var networks = data.networks;
$scope.availableNetworks = networks;
$scope.globalNetworkCount = networks.length;
})
.catch(function error(err) {
$scope.templates = [];
+2 -25
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="users">Users</a> > <a ui-sref="user({id: user.Id})">{{ user.Username }}</a>
<a ui-sref="users">Users</a> &gt; <a ui-sref="user({id: user.Id})">{{ user.Username }}</a>
</rd-header-content>
</rd-header>
@@ -32,29 +32,6 @@
</label>
</td>
</tr>
<!-- <tr ng-if="!formValues.Administrator">
<td colspan="2">
<label for="teams" class="control-label text-left">
Teams
</label>
<span class="small text-muted" style="margin-left: 20px;" ng-if="teams.length === 0">
You have not yet created any team. Head over the <a ui-sref="teams">teams view</a> to manage user teams.</span>
</span>
<span isteven-multi-select
ng-if="teams.length > 0"
input-model="teams"
output-model="formValues.Teams"
button-label="Name"
item-label="Name"
tick-property="ticked"
helper-elements="filter"
search-property="Name"
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
style="margin-left: 20px;"
on-item-click="onTeamClick(data)"
</span>
</td>
</tr> -->
</tbody>
</table>
</rd-widget-body>
@@ -62,7 +39,7 @@
</div>
</div>
<div class="row">
<div class="row" ng-if="AuthenticationMethod === 1">
<div class="col-lg-12 col-md-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-lock" title="Change user password"></rd-widget-header>
+5 -3
View File
@@ -1,6 +1,6 @@
angular.module('user', [])
.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications',
function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications) {
.controller('UserController', ['$q', '$scope', '$state', '$stateParams', 'UserService', 'ModalService', 'Notifications', 'SettingsService',
function ($q, $scope, $state, $stateParams, UserService, ModalService, Notifications, SettingsService) {
$scope.state = {
updatePasswordError: ''
@@ -72,12 +72,14 @@ function ($q, $scope, $state, $stateParams, UserService, ModalService, Notificat
function initView() {
$('#loadingViewSpinner').show();
$q.all({
user: UserService.user($stateParams.id)
user: UserService.user($stateParams.id),
settings: SettingsService.publicSettings()
})
.then(function success(data) {
var user = data.user;
$scope.user = user;
$scope.formValues.Administrator = user.Role === 1 ? true : false;
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve user information');
@@ -1,5 +1,6 @@
<rd-header>
<rd-header-title title="User settings">
<i id="loadingViewSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px;"></i>
</rd-header-title>
<rd-header-content>User settings</rd-header-content>
</rd-header>
@@ -58,7 +59,11 @@
<!-- !confirm-password-input -->
<div class="form-group">
<div class="col-sm-12">
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="!formValues.currentPassword || formValues.newPassword.length < 8 || formValues.newPassword !== formValues.confirmPassword" ng-click="updatePassword()">Update password</button>
<button type="submit" class="btn btn-primary btn-sm" ng-disabled="AuthenticationMethod !== 1 || !formValues.currentPassword || formValues.newPassword.length < 8 || formValues.newPassword !== formValues.confirmPassword" ng-click="updatePassword()">Update password</button>
<span class="text-muted small" style="margin-left: 5px;" ng-if="AuthenticationMethod === 2">
<i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
You cannot change your password when using LDAP authentication.
</span>
</div>
</div>
</form>
@@ -1,6 +1,6 @@
angular.module('userSettings', [])
.controller('UserSettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications',
function ($scope, $state, $sanitize, Authentication, UserService, Notifications) {
.controller('UserSettingsController', ['$scope', '$state', '$sanitize', 'Authentication', 'UserService', 'Notifications', 'SettingsService',
function ($scope, $state, $sanitize, Authentication, UserService, Notifications, SettingsService) {
$scope.formValues = {
currentPassword: '',
newPassword: '',
@@ -26,4 +26,19 @@ function ($scope, $state, $sanitize, Authentication, UserService, Notifications)
}
});
};
function initView() {
SettingsService.publicSettings()
.then(function success(data) {
$scope.AuthenticationMethod = data.AuthenticationMethod;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve application settings');
})
.finally(function final() {
$('#loadingViewSpinner').hide();
});
}
initView();
}]);
+26 -12
View File
@@ -17,7 +17,10 @@
<form class="form-horizontal">
<!-- name-input -->
<div class="form-group">
<label for="username" class="col-sm-2 control-label text-left">Username</label>
<label for="username" class="col-sm-3 col-lg-2 control-label text-left">
Username
<portainer-tooltip ng-if="AuthenticationMethod === 2" position="bottom" message="Username must exactly match username defined in external LDAP source."></portainer-tooltip>
</label>
<div class="col-sm-8">
<div class="input-group">
<input type="text" class="form-control" id="username" ng-model="formValues.Username" ng-change="checkUsernameValidity()" placeholder="e.g. jdoe">
@@ -27,8 +30,8 @@
</div>
<!-- !name-input -->
<!-- new-password-input -->
<div class="form-group">
<label for="password" class="col-sm-2 control-label text-left">Password</label>
<div class="form-group" ng-if="AuthenticationMethod === 1">
<label for="password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
@@ -38,8 +41,8 @@
</div>
<!-- !new-password-input -->
<!-- confirm-password-input -->
<div class="form-group">
<label for="confirm_password" class="col-sm-2 control-label text-left">Confirm password</label>
<div class="form-group" ng-if="AuthenticationMethod === 1">
<label for="confirm_password" class="col-sm-3 col-lg-2 control-label text-left">Confirm password</label>
<div class="col-sm-8">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-lock" aria-hidden="true"></i></span>
@@ -69,7 +72,7 @@
Add to team(s)
</label>
<span class="small text-muted" style="margin-left: 20px;" ng-if="teams.length === 0">
You have not yet created any team. Head over the <a ui-sref="teams">teams view</a> to manage user teams.</span>
You have not yet created any team. Head over the <a ui-sref="teams">teams view</a> to manage user teams.
</span>
<span isteven-multi-select
ng-if="teams.length > 0"
@@ -81,8 +84,8 @@
helper-elements="filter"
search-property="Name"
translation="{nothingSelected: 'Select one or more teams', search: 'Search...'}"
style="margin-left: 20px;"
</span>
style="margin-left: 20px;">
</span>
</div>
</div>
<!-- !teams -->
@@ -95,7 +98,7 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!state.validUsername || formValues.Username === '' || formValues.Password === '' || formValues.Password !== formValues.ConfirmPassword" ng-click="addUser()"><i class="fa fa-user-plus" aria-hidden="true"></i> Add user</button>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!state.validUsername || formValues.Username === '' || (AuthenticationMethod === 1 && formValues.Password === '') || (AuthenticationMethod === 1 && formValues.Password !== formValues.ConfirmPassword)" ng-click="addUser()"><i class="fa fa-user-plus" aria-hidden="true"></i> Add user</button>
<i id="createUserSpinner" class="fa fa-cog fa-spin" style="margin-left: 5px; display: none;"></i>
<span class="text-danger" ng-if="state.userCreationError" style="margin: 5px;">
<i class="fa fa-exclamation-circle" aria-hidden="true"></i> {{ state.userCreationError }}
@@ -140,19 +143,26 @@
<input type="checkbox" ng-model="allSelected" ng-change="selectItems(allSelected)" />
</th>
<th>
<a ui-sref="users" ng-click="order('Username')">
<a ng-click="order('Username')">
Name
<span ng-show="sortType == 'Username' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'Username' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ui-sref="users" ng-click="order('RoleName')">
<a ng-click="order('RoleName')">
Role
<span ng-show="sortType == 'RoleName' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'RoleName' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th>
<a ng-click="order('AuthenticationMethod')">
Authentication
<span ng-show="sortType == 'AuthenticationMethod' && !sortReverse" class="glyphicon glyphicon-chevron-down"></span>
<span ng-show="sortType == 'AuthenticationMethod' && sortReverse" class="glyphicon glyphicon-chevron-up"></span>
</a>
</th>
<th ng-if="isAdmin"></th>
</tr>
</thead>
@@ -166,6 +176,10 @@
<i ng-if="user.isTeamLeader" class="fa fa-user-plus" aria-hidden="true" style="margin-right: 2px;"></i>
{{ user.RoleName }}
</td>
<td>
<span ng-if="AuthenticationMethod === 1 || user.Id === 1">Internal</span>
<span ng-if="AuthenticationMethod === 2 && user.Id !== 1">LDAP</span>
</td>
<td ng-if="isAdmin">
<a ui-sref="user({id: user.Id})"><i class="fa fa-pencil-square-o" aria-hidden="true"></i> Edit</a>
</td>
@@ -183,6 +197,6 @@
</div>
</div>
</rd-widget-body>
<rd-widget>
</rd-widget>
</div>
</div>
+5 -3
View File
@@ -1,6 +1,6 @@
angular.module('users', [])
.controller('UsersController', ['$q', '$scope', '$state', '$sanitize', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication',
function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication) {
.controller('UsersController', ['$q', '$scope', '$state', '$sanitize', 'UserService', 'TeamService', 'TeamMembershipService', 'ModalService', 'Notifications', 'Pagination', 'Authentication', 'SettingsService',
function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershipService, ModalService, Notifications, Pagination, Authentication, SettingsService) {
$scope.state = {
userCreationError: '',
selectedItemCount: 0,
@@ -140,13 +140,15 @@ function ($q, $scope, $state, $sanitize, UserService, TeamService, TeamMembershi
$q.all({
users: UserService.users(true),
teams: isAdmin ? TeamService.teams() : UserService.userLeadingTeams(userDetails.ID),
memberships: TeamMembershipService.memberships()
memberships: TeamMembershipService.memberships(),
settings: SettingsService.publicSettings()
})
.then(function success(data) {
var users = data.users;
assignTeamLeaders(users, data.memberships);
$scope.users = users;
$scope.teams = data.teams;
$scope.AuthenticationMethod = data.settings.AuthenticationMethod;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve users and teams');
+9 -2
View File
@@ -3,7 +3,7 @@
<i id="loadingViewSpinner" class="fa fa-cog fa-spin"></i>
</rd-header-title>
<rd-header-content>
<a ui-sref="volumes">Volumes</a> > <a ui-sref="volume({id: volume.Id})">{{ volume.Id }}</a>
<a ui-sref="volumes">Volumes</a> &gt; <a ui-sref="volume({id: volume.Id})">{{ volume.Id }}</a>
</rd-header-content>
</rd-header>
@@ -47,7 +47,14 @@
</div>
</div>
<div ng-include="'app/components/common/accessControlPanel/accessControlPanel.html'" ng-if="volume && applicationState.application.authentication"></div>
<!-- access-control-panel -->
<por-access-control-panel
ng-if="volume && applicationState.application.authentication"
resource-id="volume.Id"
resource-control="volume.ResourceControl"
resource-type="'volume'">
</por-access-control-panel>
<!-- !access-control-panel -->
<div class="row" ng-if="!(volume.Options | emptyobject)">
<div class="col-lg-12 col-md-12 col-xs-12">
+2 -3
View File
@@ -1,6 +1,6 @@
angular.module('volume', [])
.controller('VolumeController', ['$scope', '$state', '$stateParams', 'VolumeService', 'Notifications', 'ControllerDataPipeline',
function ($scope, $state, $stateParams, VolumeService, Notifications, ControllerDataPipeline) {
.controller('VolumeController', ['$scope', '$state', '$stateParams', 'VolumeService', 'Notifications',
function ($scope, $state, $stateParams, VolumeService, Notifications) {
$scope.removeVolume = function removeVolume() {
$('#loadingViewSpinner').show();
@@ -22,7 +22,6 @@ function ($scope, $state, $stateParams, VolumeService, Notifications, Controller
VolumeService.volume($stateParams.id)
.then(function success(data) {
var volume = data;
ControllerDataPipeline.setAccessControlData('volume', volume.Id, volume.ResourceControl);
$scope.volume = volume;
})
.catch(function error(err) {

Some files were not shown because too many files have changed in this diff Show More