Merge branch 'release/1.16.3'

pull/1693/head 1.16.3
Anthony Lapenna 2018-03-03 09:19:59 +10:00
commit 8a9619c7e8
121 changed files with 1865 additions and 822 deletions

36
api/archive/tar.go Normal file
View File

@ -0,0 +1,36 @@
package archive
import (
"archive/tar"
"bytes"
)
// TarFileInBuffer will create a tar archive containing a single file named via fileName and using the content
// specified in fileContent. Returns the archive as a byte array.
func TarFileInBuffer(fileContent []byte, fileName string) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
header := &tar.Header{
Name: fileName,
Mode: 0600,
Size: int64(len(fileContent)),
}
err := tarWriter.WriteHeader(header)
if err != nil {
return nil, err
}
_, err = tarWriter.Write(fileContent)
if err != nil {
return nil, err
}
err = tarWriter.Close()
if err != nil {
return nil, err
}
return buffer.Bytes(), nil
}

View File

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

View File

@ -89,6 +89,13 @@ func (m *Migrator) Migrate() error {
}
}
if m.CurrentDBVersion < 8 {
err := m.updateEndpointsToVersion8()
if err != nil {
return err
}
}
err := m.VersionService.StoreDBVersion(portainer.DBVersion)
if err != nil {
return err

View File

@ -218,6 +218,7 @@ func main() {
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err = store.EndpointService.CreateEndpoint(endpoint)
if err != nil {

View File

@ -57,6 +57,12 @@ const (
ErrComposeFileNotFoundInRepository = Error("Unable to find a Compose file in the repository")
)
// Endpoint extensions error
const (
ErrEndpointExtensionNotSupported = Error("This extension is not supported")
ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint")
)
// Version errors.
const (
ErrDBVersionNotFound = Error("DB version not found")

View File

@ -28,13 +28,13 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []
for _, registry := range registries {
if registry.Authentication {
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
runCommandAndCaptureStdErr(command, registryArgs, nil)
runCommandAndCaptureStdErr(command, registryArgs, nil, "")
}
}
if dockerhub.Authentication {
dockerhubArgs := append(args, "login", "--username", dockerhub.Username, "--password", dockerhub.Password)
runCommandAndCaptureStdErr(command, dockerhubArgs, nil)
runCommandAndCaptureStdErr(command, dockerhubArgs, nil, "")
}
}
@ -42,7 +42,7 @@ func (manager *StackManager) Login(dockerhub *portainer.DockerHub, registries []
func (manager *StackManager) Logout(endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
// Deploy executes the docker stack deploy command.
@ -61,20 +61,21 @@ func (manager *StackManager) Deploy(stack *portainer.Stack, prune bool, endpoint
env = append(env, envvar.Name+"="+envvar.Value)
}
return runCommandAndCaptureStdErr(command, args, env)
return runCommandAndCaptureStdErr(command, args, env, stack.ProjectPath)
}
// Remove executes the docker stack rm command.
func (manager *StackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, endpoint)
args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil)
return runCommandAndCaptureStdErr(command, args, nil, "")
}
func runCommandAndCaptureStdErr(command string, args []string, env []string) error {
func runCommandAndCaptureStdErr(command string, args []string, env []string, workingDir string) error {
var stderr bytes.Buffer
cmd := exec.Command(command, args...)
cmd.Stderr = &stderr
cmd.Dir = workingDir
if env != nil {
cmd.Env = os.Environ()

View File

@ -35,24 +35,6 @@ func NewDockerHandler(bouncer *security.RequestBouncer) *DockerHandler {
return h
}
func (handler *DockerHandler) checkEndpointAccessControl(endpoint *portainer.Endpoint, userID portainer.UserID) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
memberships, _ := handler.TeamMembershipService.TeamMembershipsByUserID(userID)
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
for _, membership := range memberships {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
}
func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
@ -75,7 +57,14 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole && !handler.checkEndpointAccessControl(endpoint, tokenData.ID) {
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
@ -85,7 +74,7 @@ func (handler *DockerHandler) proxyRequestsToDockerAPI(w http.ResponseWriter, r
if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}

View File

@ -136,6 +136,7 @@ func (handler *EndpointHandler) handlePostEndpoints(w http.ResponseWriter, r *ht
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
@ -372,6 +373,7 @@ func (handler *EndpointHandler) handleDeleteEndpoint(w http.ResponseWriter, r *h
}
handler.ProxyManager.DeleteProxy(string(endpointID))
handler.ProxyManager.DeleteExtensionProxies(string(endpointID))
err = handler.EndpointService.DeleteEndpoint(portainer.EndpointID(endpointID))
if err != nil {

View File

@ -0,0 +1,99 @@
package handler
import (
"encoding/json"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// ExtensionHandler represents an HTTP API handler for managing Settings.
type ExtensionHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
ProxyManager *proxy.Manager
}
// NewExtensionHandler returns a new instance of ExtensionHandler.
func NewExtensionHandler(bouncer *security.RequestBouncer) *ExtensionHandler {
h := &ExtensionHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.Handle("/{endpointId}/extensions",
bouncer.AdministratorAccess(http.HandlerFunc(h.handlePostExtensions))).Methods(http.MethodPost)
return h
}
type (
postExtensionRequest struct {
Type int `valid:"required"`
URL string `valid:"required"`
}
)
func (handler *ExtensionHandler) handlePostExtensions(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["endpointId"])
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(id)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrEndpointNotFound {
httperror.WriteErrorResponse(w, err, http.StatusNotFound, handler.Logger)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
var req postExtensionRequest
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
}
extensionType := portainer.EndpointExtensionType(req.Type)
for _, extension := range endpoint.Extensions {
if extension.Type == extensionType {
httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionAlreadyAssociated, http.StatusConflict, handler.Logger)
return
}
}
extension := portainer.EndpointExtension{
Type: extensionType,
URL: req.URL,
}
endpoint.Extensions = append(endpoint.Extensions, extension)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
encodeJSON(w, extension, handler.Logger)
}

View File

@ -0,0 +1,97 @@
package extensions
import (
"strconv"
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
"log"
"net/http"
"os"
"github.com/gorilla/mux"
)
// StoridgeHandler represents an HTTP API handler for proxying requests to the Docker API.
type StoridgeHandler struct {
*mux.Router
Logger *log.Logger
EndpointService portainer.EndpointService
TeamMembershipService portainer.TeamMembershipService
ProxyManager *proxy.Manager
}
// NewStoridgeHandler returns a new instance of StoridgeHandler.
func NewStoridgeHandler(bouncer *security.RequestBouncer) *StoridgeHandler {
h := &StoridgeHandler{
Router: mux.NewRouter(),
Logger: log.New(os.Stderr, "", log.LstdFlags),
}
h.PathPrefix("/{id}/extensions/storidge").Handler(
bouncer.AuthenticatedAccess(http.HandlerFunc(h.proxyRequestsToStoridgeAPI)))
return h
}
func (handler *StoridgeHandler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
parsedID, err := strconv.Atoi(id)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusBadRequest, handler.Logger)
return
}
endpointID := portainer.EndpointID(parsedID)
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
tokenData, err := security.RetrieveTokenData(r)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
memberships, err := handler.TeamMembershipService.TeamMembershipsByUserID(tokenData.ID)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
if tokenData.Role != portainer.AdministratorRole && !security.AuthorizedEndpointAccess(endpoint, tokenData.ID, memberships) {
httperror.WriteErrorResponse(w, portainer.ErrEndpointAccessDenied, http.StatusForbidden, handler.Logger)
return
}
var storidgeExtension *portainer.EndpointExtension
for _, extension := range endpoint.Extensions {
if extension.Type == portainer.StoridgeEndpointExtension {
storidgeExtension = &extension
}
}
if storidgeExtension == nil {
httperror.WriteErrorResponse(w, portainer.ErrEndpointExtensionNotSupported, http.StatusInternalServerError, handler.Logger)
return
}
proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension)
var proxy http.Handler
proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey)
if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, handler.Logger)
return
}
}
http.StripPrefix("/"+id+"/extensions/storidge", proxy).ServeHTTP(w, r)
}

View File

@ -8,6 +8,7 @@ import (
"github.com/portainer/portainer"
httperror "github.com/portainer/portainer/http/error"
"github.com/portainer/portainer/http/handler/extensions"
)
// Handler is a collection of all the service handlers.
@ -19,6 +20,8 @@ type Handler struct {
EndpointHandler *EndpointHandler
RegistryHandler *RegistryHandler
DockerHubHandler *DockerHubHandler
ExtensionHandler *ExtensionHandler
StoridgeHandler *extensions.StoridgeHandler
ResourceHandler *ResourceHandler
StackHandler *StackHandler
StatusHandler *StatusHandler
@ -48,11 +51,16 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case strings.HasPrefix(r.URL.Path, "/api/dockerhub"):
http.StripPrefix("/api", h.DockerHubHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/endpoints"):
if strings.Contains(r.URL.Path, "/docker/") {
switch {
case strings.Contains(r.URL.Path, "/docker"):
http.StripPrefix("/api/endpoints", h.DockerHandler).ServeHTTP(w, r)
} else if strings.Contains(r.URL.Path, "/stacks") {
case strings.Contains(r.URL.Path, "/stacks"):
http.StripPrefix("/api/endpoints", h.StackHandler).ServeHTTP(w, r)
} else {
case strings.Contains(r.URL.Path, "/extensions/storidge"):
http.StripPrefix("/api/endpoints", h.StoridgeHandler).ServeHTTP(w, r)
case strings.Contains(r.URL.Path, "/extensions"):
http.StripPrefix("/api/endpoints", h.ExtensionHandler).ServeHTTP(w, r)
default:
http.StripPrefix("/api", h.EndpointHandler).ServeHTTP(w, r)
}
case strings.HasPrefix(r.URL.Path, "/api/registries"):

56
api/http/proxy/build.go Normal file
View File

@ -0,0 +1,56 @@
package proxy
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"github.com/portainer/portainer/archive"
)
type postDockerfileRequest struct {
Content string
}
// buildOperation inspects the "Content-Type" header to determine if it needs to alter the request.
// If the value of the header is empty, it means that a Dockerfile is posted via upload, the function
// will extract the file content from the request body, tar it, and rewrite the body.
// If the value of the header contains "application/json", it means that the content of a Dockerfile is posted
// in the request payload as JSON, the function will create a new file called Dockerfile inside a tar archive and
// rewrite the body of the request.
// In any other case, it will leave the request unaltered.
func buildOperation(request *http.Request) error {
contentTypeHeader := request.Header.Get("Content-Type")
if contentTypeHeader != "" && !strings.Contains(contentTypeHeader, "application/json") {
return nil
}
var dockerfileContent []byte
if contentTypeHeader == "" {
body, err := ioutil.ReadAll(request.Body)
if err != nil {
return err
}
dockerfileContent = body
} else {
var req postDockerfileRequest
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
return err
}
dockerfileContent = []byte(req.Content)
}
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile")
if err != nil {
return err
}
request.Body = ioutil.NopCloser(bytes.NewReader(buffer))
request.ContentLength = int64(len(buffer))
request.Header.Set("Content-Type", "application/x-tar")
return nil
}

View File

@ -17,14 +17,14 @@ type proxyFactory struct {
SettingsService portainer.SettingsService
}
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
func (factory *proxyFactory) newExtensionHTTPPRoxy(u *url.URL) http.Handler {
u.Scheme = "http"
return factory.createReverseProxy(u)
return newSingleHostReverseProxyWithHostHeader(u)
}
func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, endpoint *portainer.Endpoint) (http.Handler, error) {
u.Scheme = "https"
proxy := factory.createReverseProxy(u)
proxy := factory.createDockerReverseProxy(u)
config, err := crypto.CreateTLSConfiguration(&endpoint.TLSConfig)
if err != nil {
return nil, err
@ -34,7 +34,12 @@ func (factory *proxyFactory) newHTTPSProxy(u *url.URL, endpoint *portainer.Endpo
return proxy, nil
}
func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL) http.Handler {
u.Scheme = "http"
return factory.createDockerReverseProxy(u)
}
func (factory *proxyFactory) newDockerSocketProxy(path string) http.Handler {
proxy := &socketProxy{}
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
@ -46,13 +51,13 @@ func (factory *proxyFactory) newSocketProxy(path string) http.Handler {
return proxy
}
func (factory *proxyFactory) createReverseProxy(u *url.URL) *httputil.ReverseProxy {
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL) *httputil.ReverseProxy {
proxy := newSingleHostReverseProxyWithHostHeader(u)
transport := &proxyTransport{
ResourceControlService: factory.ResourceControlService,
TeamMembershipService: factory.TeamMembershipService,
SettingsService: factory.SettingsService,
dockerTransport: newHTTPTransport(),
dockerTransport: &http.Transport{},
}
proxy.Transport = transport
return proxy
@ -65,7 +70,3 @@ func newSocketTransport(socketPath string) *http.Transport {
},
}
}
func newHTTPTransport() *http.Transport {
return &http.Transport{}
}

View File

@ -3,6 +3,7 @@ package proxy
import (
"net/http"
"net/url"
"strings"
"github.com/orcaman/concurrent-map"
"github.com/portainer/portainer"
@ -10,14 +11,16 @@ import (
// Manager represents a service used to manage Docker proxies.
type Manager struct {
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
proxyFactory *proxyFactory
proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap
}
// NewManager initializes a new proxy Service
func NewManager(resourceControlService portainer.ResourceControlService, teamMembershipService portainer.TeamMembershipService, settingsService portainer.SettingsService) *Manager {
return &Manager{
proxies: cmap.New(),
proxies: cmap.New(),
extensionProxies: cmap.New(),
proxyFactory: &proxyFactory{
ResourceControlService: resourceControlService,
TeamMembershipService: teamMembershipService,
@ -38,16 +41,16 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
if endpointURL.Scheme == "tcp" {
if endpoint.TLSConfig.TLS {
proxy, err = manager.proxyFactory.newHTTPSProxy(endpointURL, endpoint)
proxy, err = manager.proxyFactory.newDockerHTTPSProxy(endpointURL, endpoint)
if err != nil {
return nil, err
}
} else {
proxy = manager.proxyFactory.newHTTPProxy(endpointURL)
proxy = manager.proxyFactory.newDockerHTTPProxy(endpointURL)
}
} else {
// Assume unix:// scheme
proxy = manager.proxyFactory.newSocketProxy(endpointURL.Path)
proxy = manager.proxyFactory.newDockerSocketProxy(endpointURL.Path)
}
manager.proxies.Set(string(endpoint.ID), proxy)
@ -67,3 +70,34 @@ func (manager *Manager) GetProxy(key string) http.Handler {
func (manager *Manager) DeleteProxy(key string) {
manager.proxies.Remove(key)
}
// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies.
func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
extensionURL, err := url.Parse(extensionAPIURL)
if err != nil {
return nil, err
}
proxy := manager.proxyFactory.newExtensionHTTPPRoxy(extensionURL)
manager.extensionProxies.Set(key, proxy)
return proxy, nil
}
// GetExtensionProxy returns the extension proxy associated to a key
func (manager *Manager) GetExtensionProxy(key string) http.Handler {
proxy, ok := manager.extensionProxies.Get(key)
if !ok {
return nil
}
return proxy.(http.Handler)
}
// DeleteExtensionProxies deletes all the extension proxies associated to a key
func (manager *Manager) DeleteExtensionProxies(key string) {
for _, k := range manager.extensionProxies.Keys() {
if strings.Contains(k, key+"_") {
manager.extensionProxies.Remove(k)
}
}
}

View File

@ -27,6 +27,7 @@ type (
labelBlackList []portainer.Pair
}
restrictedOperationRequest func(*http.Request, *http.Response, *operationExecutor) error
operationRequest func(*http.Request) error
)
func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error) {
@ -59,6 +60,8 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
return p.proxyNodeRequest(request)
case strings.HasPrefix(path, "/tasks"):
return p.proxyTaskRequest(request)
case strings.HasPrefix(path, "/build"):
return p.proxyBuildRequest(request)
default:
return p.executeDockerRequest(request)
}
@ -228,6 +231,10 @@ func (p *proxyTransport) proxyTaskRequest(request *http.Request) (*http.Response
}
}
func (p *proxyTransport) proxyBuildRequest(request *http.Request) (*http.Response, error) {
return p.interceptAndRewriteRequest(request, buildOperation)
}
// 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) {
@ -300,6 +307,15 @@ func (p *proxyTransport) rewriteOperation(request *http.Request, operation restr
return p.executeRequestAndRewriteResponse(request, operation, executor)
}
func (p *proxyTransport) interceptAndRewriteRequest(request *http.Request, operation operationRequest) (*http.Response, error) {
err := operation(request)
if err != nil {
return nil, err
}
return p.executeDockerRequest(request)
}
func (p *proxyTransport) executeRequestAndRewriteResponse(request *http.Request, operation restrictedOperationRequest, executor *operationExecutor) (*http.Response, error) {
response, err := p.executeDockerRequest(request)
if err != nil {

View File

@ -121,3 +121,22 @@ func AuthorizedUserManagement(userID portainer.UserID, context *RestrictedReques
}
return false
}
// AuthorizedEndpointAccess ensure that the user can access the specified endpoint.
// It will check if the user is part of the authorized users or part of a team that is
// listed in the authorized teams.
func AuthorizedEndpointAccess(endpoint *portainer.Endpoint, userID portainer.UserID, memberships []portainer.TeamMembership) bool {
for _, authorizedUserID := range endpoint.AuthorizedUsers {
if authorizedUserID == userID {
return true
}
}
for _, membership := range memberships {
for _, authorizedTeamID := range endpoint.AuthorizedTeams {
if membership.TeamID == authorizedTeamID {
return true
}
}
}
return false
}

View File

@ -12,6 +12,7 @@ type (
// RequestBouncer represents an entity that manages API request accesses
RequestBouncer struct {
jwtService portainer.JWTService
userService portainer.UserService
teamMembershipService portainer.TeamMembershipService
authDisabled bool
}
@ -27,9 +28,10 @@ type (
)
// NewRequestBouncer initializes a new RequestBouncer
func NewRequestBouncer(jwtService portainer.JWTService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
func NewRequestBouncer(jwtService portainer.JWTService, userService portainer.UserService, teamMembershipService portainer.TeamMembershipService, authDisabled bool) *RequestBouncer {
return &RequestBouncer{
jwtService: jwtService,
userService: userService,
teamMembershipService: teamMembershipService,
authDisabled: authDisabled,
}
@ -136,6 +138,15 @@ func (bouncer *RequestBouncer) mwCheckAuthentication(next http.Handler) http.Han
httperror.WriteErrorResponse(w, err, http.StatusUnauthorized, nil)
return
}
_, err = bouncer.userService.User(tokenData.ID)
if err != nil && err == portainer.ErrUserNotFound {
httperror.WriteErrorResponse(w, portainer.ErrUnauthorized, http.StatusUnauthorized, nil)
return
} else if err != nil {
httperror.WriteErrorResponse(w, err, http.StatusInternalServerError, nil)
return
}
} else {
tokenData = &portainer.TokenData{
Role: portainer.AdministratorRole,

View File

@ -3,6 +3,7 @@ package http
import (
"github.com/portainer/portainer"
"github.com/portainer/portainer/http/handler"
"github.com/portainer/portainer/http/handler/extensions"
"github.com/portainer/portainer/http/proxy"
"github.com/portainer/portainer/http/security"
@ -40,7 +41,7 @@ type Server struct {
// Start starts the HTTP server
func (server *Server) Start() error {
requestBouncer := security.NewRequestBouncer(server.JWTService, server.TeamMembershipService, server.AuthDisabled)
requestBouncer := security.NewRequestBouncer(server.JWTService, server.UserService, server.TeamMembershipService, server.AuthDisabled)
proxyManager := proxy.NewManager(server.ResourceControlService, server.TeamMembershipService, server.SettingsService)
var fileHandler = handler.NewFileHandler(filepath.Join(server.AssetsPath, "public"))
@ -96,6 +97,13 @@ func (server *Server) Start() error {
stackHandler.GitService = server.GitService
stackHandler.RegistryService = server.RegistryService
stackHandler.DockerHubService = server.DockerHubService
var extensionHandler = handler.NewExtensionHandler(requestBouncer)
extensionHandler.EndpointService = server.EndpointService
extensionHandler.ProxyManager = proxyManager
var storidgeHandler = extensions.NewStoridgeHandler(requestBouncer)
storidgeHandler.EndpointService = server.EndpointService
storidgeHandler.TeamMembershipService = server.TeamMembershipService
storidgeHandler.ProxyManager = proxyManager
server.Handler = &handler.Handler{
AuthHandler: authHandler,
@ -114,6 +122,8 @@ func (server *Server) Start() error {
WebSocketHandler: websocketHandler,
FileHandler: fileHandler,
UploadHandler: uploadHandler,
ExtensionHandler: extensionHandler,
StoridgeHandler: storidgeHandler,
}
if server.SSL {

View File

@ -171,13 +171,14 @@ type (
// Endpoint represents a Docker endpoint with all the info required
// to connect to it.
Endpoint struct {
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
ID EndpointID `json:"Id"`
Name string `json:"Name"`
URL string `json:"URL"`
PublicURL string `json:"PublicURL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
Extensions []EndpointExtension `json:"Extensions"`
// Deprecated fields
// Deprecated in DBVersion == 4
@ -187,6 +188,16 @@ type (
TLSKeyPath string `json:"TLSKey,omitempty"`
}
// EndpointExtension represents a extension associated to an endpoint.
EndpointExtension struct {
Type EndpointExtensionType `json:"Type"`
URL string `json:"URL"`
}
// EndpointExtensionType represents the type of an endpoint extension. Only
// one extension of each type can be associated to an endpoint.
EndpointExtensionType int
// ResourceControlID represents a resource control identifier.
ResourceControlID int
@ -389,9 +400,9 @@ type (
const (
// APIVersion is the version number of the Portainer API.
APIVersion = "1.16.2"
APIVersion = "1.16.3"
// DBVersion is the version number of the Portainer database.
DBVersion = 7
DBVersion = 8
// DefaultTemplatesURL represents the default URL for the templates definitions.
DefaultTemplatesURL = "https://raw.githubusercontent.com/portainer/templates/master/templates.json"
)
@ -452,3 +463,9 @@ const (
// ConfigResourceControl represents a resource control associated to a Docker config
ConfigResourceControl
)
const (
_ EndpointExtensionType = iota
// StoridgeEndpointExtension represents the Storidge extension
StoridgeEndpointExtension
)

View File

@ -56,7 +56,7 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.16.2"
version: "1.16.3"
title: "Portainer API"
contact:
email: "info@portainer.io"
@ -2143,7 +2143,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
example: "1.16.2"
example: "1.16.3"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"

View File

@ -13,6 +13,8 @@ angular.module('portainer', [
'angular-google-analytics',
'angular-json-tree',
'angular-loading-bar',
'angular-clipboard',
'luegg.directives',
'portainer.templates',
'portainer.app',
'portainer.docker',

View File

@ -179,6 +179,17 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var imageBuild = {
name: 'docker.images.build',
url: '/build',
views: {
'content@': {
templateUrl: 'app/docker/views/images/build/buildimage.html',
controller: 'BuildImageController'
}
}
};
var networks = {
name: 'docker.networks',
url: '/networks',
@ -378,6 +389,17 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var taskLogs = {
name: 'docker.tasks.task.logs',
url: '/logs',
views: {
'content@': {
templateUrl: 'app/docker/views/tasks/logs/tasklogs.html',
controller: 'TaskLogsController'
}
}
};
var templates = {
name: 'docker.templates',
url: '/templates',
@ -457,6 +479,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(events);
$stateRegistryProvider.register(images);
$stateRegistryProvider.register(image);
$stateRegistryProvider.register(imageBuild);
$stateRegistryProvider.register(networks);
$stateRegistryProvider.register(network);
$stateRegistryProvider.register(networkCreation);
@ -476,6 +499,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(swarmVisualizer);
$stateRegistryProvider.register(tasks);
$stateRegistryProvider.register(task);
$stateRegistryProvider.register(taskLogs);
$stateRegistryProvider.register(templates);
$stateRegistryProvider.register(templatesLinuxServer);
$stateRegistryProvider.register(volumes);

View File

@ -195,10 +195,10 @@
</td>
<td ng-if="$ctrl.settings.showQuickActionStats || $ctrl.settings.showQuickActionLogs || $ctrl.settings.showQuickActionConsole || $ctrl.settings.showQuickActionInspect">
<div class="btn-group btn-group-xs" role="group" aria-label="..." style="display:inline-flex;">
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id})"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionStats" style="margin: 0 2.5px;" ui-sref="docker.containers.container.stats({id: item.Id})" title="Stats"><i class="fa fa-area-chart space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionLogs" style="margin: 0 2.5px;" ui-sref="docker.containers.container.logs({id: item.Id})" title="Logs"><i class="fa fa-file-text-o space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionConsole" style="margin: 0 2.5px;" ui-sref="docker.containers.container.console({id: item.Id})" title="Console"><i class="fa fa-terminal space-right" aria-hidden="true"></i></a>
<a ng-if="$ctrl.settings.showQuickActionInspect" style="margin: 0 2.5px;" ui-sref="docker.containers.container.inspect({id: item.Id})" title="Inspect"><i class="fa fa-info-circle space-right" aria-hidden="true"></i></a>
</div>
</td>
<td>{{ item.StackName ? item.StackName : '-' }}</td>

View File

@ -25,6 +25,9 @@
<li><a ng-click="$ctrl.forceRemoveAction($ctrl.state.selectedItems, true)" ng-disabled="$ctrl.state.selectedItemCount === 0">Force Remove</a></li>
</ul>
</div>
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.images.build">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Build a new image
</button>
</div>
<div class="searchBar" ng-if="$ctrl.state.displayTextFilter">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>

View File

@ -114,7 +114,7 @@
</span>
</td>
<td>
<a ng-if="item.Ports && item.Ports.length > 0 && $ctrl.swarmManagerIp && p.PublishedPort" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.swarmManagerIp }}:{{ p.PublishedPort }}" target="_blank">
<a ng-if="item.Ports && item.Ports.length > 0 && p.PublishedPort" ng-repeat="p in item.Ports" class="image-tag" ng-href="http://{{ $ctrl.publicUrl }}:{{ p.PublishedPort }}" target="_blank">
<i class="fa fa-external-link" aria-hidden="true"></i> {{ p.PublishedPort }}:{{ p.TargetPort }}
</a>
<span ng-if="!item.Ports || item.Ports.length === 0 || !$ctrl.swarmManagerIp" >-</span>

View File

@ -12,7 +12,7 @@ angular.module('portainer.docker').component('servicesDatatable', {
showOwnershipColumn: '<',
removeAction: '<',
scaleAction: '<',
swarmManagerIp: '<',
publicUrl: '<',
forceUpdateAction: '<',
showForceUpdateButton: '<'
}

View File

@ -54,6 +54,7 @@
<i class="fa fa-sort-alpha-desc" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Updated' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th ng-if="$ctrl.showLogsButton">Actions</th>
</tr>
</thead>
<tbody>
@ -63,6 +64,11 @@
<td ng-if="$ctrl.showSlotColumn">{{ item.Slot ? item.Slot : '-' }}</td>
<td>{{ item.NodeId | tasknodename: $ctrl.nodes }}</td>
<td>{{ item.Updated | getisodate }}</td>
<td ng-if="$ctrl.showLogsButton">
<a ui-sref="docker.tasks.task.logs({id: item.Id})">
<i class="fa fa-file-text-o" aria-hidden="true"></i> View logs
</a>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="5" class="text-center text-muted">Loading...</td>

View File

@ -10,6 +10,7 @@ angular.module('portainer.docker').component('tasksDatatable', {
reverseOrder: '<',
nodes: '<',
showTextFilter: '<',
showSlotColumn: '<'
showSlotColumn: '<',
showLogsButton: '<'
}
});

View File

@ -0,0 +1,8 @@
angular.module('portainer.docker').component('logViewer', {
templateUrl: 'app/docker/components/log-viewer/logViewer.html',
controller: 'LogViewerController',
bindings: {
data: '=',
logCollectionChange: '<'
}
});

View File

@ -0,0 +1,62 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-header icon="fa-file-text-o" title="Log viewer settings"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Log collection
<portainer-tooltip position="bottom" message="Disabling this option allows you to pause the log collection process."></portainer-tooltip>
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="$ctrl.state.logCollection" ng-change="$ctrl.logCollectionChange($ctrl.state.logCollection)"><i></i>
</label>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<label for="tls" class="control-label text-left">
Auto-scrolling
</label>
<label class="switch" style="margin-left: 20px;">
<input type="checkbox" ng-model="$ctrl.state.autoScroll"><i></i>
</label>
</div>
</div>
<div class="form-group">
<label for="logs_search" class="col-sm-1 control-label text-left">
Search
</label>
<div class="col-sm-11">
<input class="form-control" type="text" name="logs_search" ng-model="$ctrl.state.search" ng-change="$ctrl.state.selectedLines.length = 0;" placeholder="Filter...">
</div>
</div>
<div class="form-group" ng-if="$ctrl.state.copySupported">
<label class="col-sm-1 control-label text-left">
Actions
</label>
<div class="col-sm-11">
<button class="btn btn-primary btn-sm" ng-click="$ctrl.copy()" ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0]) || !$ctrl.state.filteredLogs.length"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy</button>
<button class="btn btn-primary btn-sm" ng-click="$ctrl.copySelection()" ng-disabled="($ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0]) || !$ctrl.state.filteredLogs.length || !$ctrl.state.selectedLines.length"><i class="fa fa-copy space-right" aria-hidden="true"></i>Copy selected lines</button>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none;"></i>
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" style="height:54%;">
<div class="col-sm-12" style="height:100%;">
<pre class="log_viewer" scroll-glue="$ctrl.state.autoScroll">
<div ng-repeat="line in $ctrl.state.filteredLogs = ($ctrl.data | filter:$ctrl.state.search) track by $index" class="line" ng-if="line"><p class="inner_line" ng-click="active=!active; $ctrl.selectLine(line)" ng-class="{'line_selected': active}">{{ line }}</p></div>
<div ng-if="!$ctrl.state.filteredLogs.length" class="line"><p class="inner_line">No log line matching the '{{ $ctrl.state.search }}' filter</p></div>
<div ng-if="$ctrl.state.filteredLogs.length === 1 && !$ctrl.state.filteredLogs[0]" class="line"><p class="inner_line">No logs available</p></div>
</pre>
</div>
</div>

View File

@ -0,0 +1,35 @@
angular.module('portainer.docker')
.controller('LogViewerController', ['clipboard',
function (clipboard) {
var ctrl = this;
this.state = {
copySupported: clipboard.supported,
logCollection: true,
autoScroll: true,
search: '',
filteredLogs: [],
selectedLines: []
};
this.copy = function() {
clipboard.copyText(this.state.filteredLogs);
$('#refreshRateChange').show();
$('#refreshRateChange').fadeOut(1500);
};
this.copySelection = function() {
clipboard.copyText(this.state.selectedLines);
$('#refreshRateChange').show();
$('#refreshRateChange').fadeOut(1500);
};
this.selectLine = function(line) {
var idx = this.state.selectedLines.indexOf(line);
if (idx === -1) {
this.state.selectedLines.push(line);
} else {
this.state.selectedLines.splice(idx, 1);
}
};
}]);

View File

@ -4,6 +4,22 @@ function includeString(text, values) {
});
}
function strToHash(str) {
var hash = 0;
for (var i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return hash;
}
function hashToHexColor(hash) {
var color = '#';
for (var i = 0; i < 3;) {
color += ('00' + ((hash >> i++ * 8) & 0xFF).toString(16)).slice(-2);
}
return color;
}
angular.module('portainer.docker')
.filter('visualizerTask', function () {
'use strict';
@ -19,6 +35,14 @@ angular.module('portainer.docker')
return 'running';
};
})
.filter('visualizerTaskBorderColor', function () {
'use strict';
return function (str) {
var hash = strToHash(str);
var color = hashToHexColor(hash);
return color;
};
})
.filter('taskstatusbadge', function () {
'use strict';
return function (text) {
@ -42,11 +66,11 @@ angular.module('portainer.docker')
'use strict';
return function (text) {
var status = _.toLower(text);
if (includeString(status, ['paused', 'starting'])) {
if (includeString(status, ['paused', 'starting', 'unhealthy'])) {
return 'warning';
} else if (includeString(status, ['created'])) {
return 'info';
} else if (includeString(status, ['stopped', 'unhealthy', 'dead', 'exited'])) {
} else if (includeString(status, ['stopped', 'dead', 'exited'])) {
return 'danger';
}
return 'success';

View File

@ -9,16 +9,6 @@ angular.module('portainer.docker')
Labels: node.Spec.Labels,
Availability: node.Spec.Availability
};
},
getManagerIP: function(nodes) {
var managerIp;
for (var n in nodes) {
if (undefined === nodes[n].ManagerStatus || nodes[n].ManagerStatus.Reachability !== 'reachable') {
continue;
}
managerIp = nodes[n].ManagerStatus.Addr.split(':')[0];
}
return managerIp;
}
};
}]);

View File

@ -36,3 +36,35 @@ function ContainerViewModel(data) {
}
}
}
function ContainerStatsViewModel(data) {
this.Date = data.read;
this.MemoryUsage = data.memory_stats.usage;
this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage;
this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage;
this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage;
this.CurrentCPUSystemUsage = data.cpu_stats.system_cpu_usage;
if (data.cpu_stats.cpu_usage.percpu_usage) {
this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length;
}
this.Networks = _.values(data.networks);
}
function ContainerDetailsViewModel(data) {
this.Model = data;
this.Id = data.Id;
this.State = data.State;
this.Created = data.Created;
this.Name = data.Name;
this.NetworkSettings = data.NetworkSettings;
this.Args = data.Args;
this.Image = data.Image;
this.Config = data.Config;
this.HostConfig = data.HostConfig;
this.Mounts = data.Mounts;
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
}
}

View File

@ -1,18 +0,0 @@
function ContainerDetailsViewModel(data) {
this.Model = data;
this.Id = data.Id;
this.State = data.State;
this.Created = data.Created;
this.Name = data.Name;
this.NetworkSettings = data.NetworkSettings;
this.Args = data.Args;
this.Image = data.Image;
this.Config = data.Config;
this.HostConfig = data.HostConfig;
this.Mounts = data.Mounts;
if (data.Portainer) {
if (data.Portainer.ResourceControl) {
this.ResourceControl = new ResourceControlViewModel(data.Portainer.ResourceControl);
}
}
}

View File

@ -1,12 +0,0 @@
function ContainerStatsViewModel(data) {
this.Date = data.read;
this.MemoryUsage = data.memory_stats.usage;
this.PreviousCPUTotalUsage = data.precpu_stats.cpu_usage.total_usage;
this.PreviousCPUSystemUsage = data.precpu_stats.system_cpu_usage;
this.CurrentCPUTotalUsage = data.cpu_stats.cpu_usage.total_usage;
this.CurrentCPUSystemUsage = data.cpu_stats.system_cpu_usage;
if (data.cpu_stats.cpu_usage.percpu_usage) {
this.CPUCores = data.cpu_stats.cpu_usage.percpu_usage.length;
}
this.Networks = _.values(data.networks);
}

View File

@ -37,6 +37,33 @@ function createEventDetails(event) {
case 'attach':
details = 'Container ' + eventAttr.name + ' attached';
break;
case 'detach':
details = 'Container ' + eventAttr.name + ' detached';
break;
case 'copy':
details = 'Container ' + eventAttr.name + ' copied';
break;
case 'export':
details = 'Container ' + eventAttr.name + ' exported';
break;
case 'health_status':
details = 'Container ' + eventAttr.name + ' executed health status';
break;
case 'oom':
details = 'Container ' + eventAttr.name + ' goes in out of memory';
break;
case 'rename':
details = 'Container ' + eventAttr.name + ' renamed';
break;
case 'resize':
details = 'Container ' + eventAttr.name + ' resized';
break;
case 'top':
details = 'Showed running processes for container ' + eventAttr.name;
break;
case 'update':
details = 'Container ' + eventAttr.name + ' updated';
break;
default:
if (event.Action.indexOf('exec_create') === 0) {
details = 'Exec instance created';
@ -52,15 +79,27 @@ function createEventDetails(event) {
case 'delete':
details = 'Image deleted';
break;
case 'import':
details = 'Image ' + event.Actor.ID + ' imported';
break;
case 'load':
details = 'Image ' + event.Actor.ID + ' loaded';
break;
case 'tag':
details = 'New tag created for ' + eventAttr.name;
break;
case 'untag':
details = 'Image untagged';
break;
case 'save':
details = 'Image ' + event.Actor.ID + ' saved';
break;
case 'pull':
details = 'Image ' + event.Actor.ID + ' pulled';
break;
case 'push':
details = 'Image ' + event.Actor.ID + ' pushed';
break;
default:
details = 'Unsupported event';
}
@ -73,6 +112,9 @@ function createEventDetails(event) {
case 'destroy':
details = 'Network ' + eventAttr.name + ' deleted';
break;
case 'remove':
details = 'Network ' + eventAttr.name + ' removed';
break;
case 'connect':
details = 'Container connected to ' + eventAttr.name + ' network';
break;

View File

@ -8,3 +8,24 @@ function ImageViewModel(data) {
this.VirtualSize = data.VirtualSize;
this.ContainerCount = data.ContainerCount;
}
function ImageBuildModel(data) {
this.hasError = false;
var buildLogs = [];
for (var i = 0; i < data.length; i++) {
var line = data[i];
if (line.stream) {
line = line.stream.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
buildLogs.push(line);
}
if (line.errorDetail) {
buildLogs.push(line.errorDetail.message);
this.hasError = true;
}
}
this.buildLogs = buildLogs;
}

View File

@ -1,5 +1,6 @@
function StackTemplateViewModel(data) {
this.Type = data.type;
this.Name = data.name;
this.Title = data.title;
this.Description = data.description;
this.Note = data.note;

View File

@ -1,5 +1,6 @@
function TemplateViewModel(data) {
this.Type = data.type;
this.Name = data.name;
this.Title = data.title;
this.Description = data.description;
this.Note = data.note;
@ -45,5 +46,5 @@ function TemplateViewModel(data) {
};
});
}
this.Hosts = data.hosts ? data.hosts : [];
this.Hosts = data.hosts ? data.hosts : [];
}

18
app/docker/rest/build.js Normal file
View File

@ -0,0 +1,18 @@
angular.module('portainer.docker')
.factory('Build', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BuildFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/build', {
endpointId: EndpointProvider.endpointID
},
{
buildImage: {
method: 'POST', ignoreLoadingBar: true,
transformResponse: jsonObjectsToArrayHandler, isArray: true,
headers: { 'Content-Type': 'application/x-tar' }
},
buildImageOverride: {
method: 'POST', ignoreLoadingBar: true,
transformResponse: jsonObjectsToArrayHandler, isArray: true
}
});
}]);

10
app/docker/rest/commit.js Normal file
View File

@ -0,0 +1,10 @@
angular.module('portainer.docker')
.factory('Commit', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function CommitFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/commit', {
endpointId: EndpointProvider.endpointID
},
{
commitContainer: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}, ignoreLoadingBar: true}
});
}]);

View File

@ -13,6 +13,11 @@ angular.module('portainer.docker')
kill: {method: 'POST', params: {id: '@id', action: 'kill'}},
pause: {method: 'POST', params: {id: '@id', action: 'pause'}},
unpause: {method: 'POST', params: {id: '@id', action: 'unpause'}},
logs: {
method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true,
transformResponse: logsHandler, isArray: true
},
stats: {
method: 'GET', params: { id: '@id', stream: false, action: 'stats' },
timeout: 4500, ignoreLoadingBar: true

View File

@ -1,10 +0,0 @@
angular.module('portainer.docker')
.factory('ContainerCommit', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerCommitFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/commit', {
endpointId: EndpointProvider.endpointID
},
{
commit: {method: 'POST', params: {container: '@id', repo: '@repo', tag: '@tag'}, ignoreLoadingBar: true}
});
}]);

View File

@ -1,21 +0,0 @@
angular.module('portainer.docker')
.factory('ContainerLogs', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ContainerLogsFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return {
get: function (id, params, callback) {
$http({
method: 'GET',
url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/containers/' + id + '/logs',
params: {
'stdout': params.stdout || 0,
'stderr': params.stderr || 0,
'timestamps': params.timestamps || 0,
'tail': params.tail || 'all'
},
ignoreLoadingBar: true
}).success(callback).error(function (data, status, headers, config) {
console.log(data);
});
}
};
}]);

View File

@ -44,6 +44,18 @@ function genericHandler(data) {
return response;
}
// The Docker API returns the logs as a single string.
// This handler will return an array with each line being an entry.
// It will also strip the 8 first characters of each line and remove any ANSI code related character sequences.
function logsHandler(data) {
var logs = data;
logs = logs.substring(8);
logs = logs.replace(/\n(.{8})/g, '\n\r');
logs = logs.replace(
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
return logs.split('\n');
}
// Image delete API returns an array on success (Docker 1.9 -> Docker 1.12).
// On error, it returns either an error message as a string (Docker < 1.12) or a JSON object with the field message
// container the error (Docker = 1.12).

View File

@ -13,6 +13,11 @@ angular.module('portainer.docker')
ignoreLoadingBar: true
},
update: { method: 'POST', params: {id: '@id', action: 'update', version: '@version'} },
remove: { method: 'DELETE', params: {id: '@id'} }
remove: { method: 'DELETE', params: {id: '@id'} },
logs: {
method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true,
transformResponse: logsHandler, isArray: true
}
});
}]);

View File

@ -1,20 +0,0 @@
angular.module('portainer.docker')
.factory('ServiceLogs', ['$http', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function ServiceLogsFactory($http, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return {
get: function (id, params, callback) {
$http({
method: 'GET',
url: API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/services/' + id + '/logs',
params: {
'stdout': params.stdout || 0,
'stderr': params.stderr || 0,
'timestamps': params.timestamps || 0,
'tail': params.tail || 'all'
}
}).success(callback).error(function (data, status, headers, config) {
console.log(data);
});
}
};
}]);

View File

@ -7,7 +7,7 @@ angular.module('portainer.docker')
},
{
info: { method: 'GET', params: { action: 'info' }, ignoreLoadingBar: true },
version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true },
version: { method: 'GET', params: { action: 'version' }, ignoreLoadingBar: true, timeout: 4500 },
events: {
method: 'GET', params: { action: 'events', since: '@since', until: '@until' },
isArray: true, transformResponse: jsonObjectsToArrayHandler

View File

@ -1,11 +1,16 @@
angular.module('portainer.docker')
.factory('Task', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function TaskFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/tasks/:id', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/tasks/:id/:action', {
endpointId: EndpointProvider.endpointID
},
{
get: { method: 'GET', params: {id: '@id'} },
query: { method: 'GET', isArray: true, params: {filters: '@filters'} }
query: { method: 'GET', isArray: true, params: {filters: '@filters'} },
logs: {
method: 'GET', params: { id: '@id', action: 'logs' },
timeout: 4500, ignoreLoadingBar: true,
transformResponse: logsHandler, isArray: true
}
});
}]);

View File

@ -0,0 +1,65 @@
angular.module('portainer.docker')
.factory('BuildService', ['$q', 'Build', 'FileUploadService', function BuildServiceFactory($q, Build, FileUploadService) {
'use strict';
var service = {};
service.buildImageFromUpload = function(names, file, path) {
var deferred = $q.defer();
FileUploadService.buildImage(names, file, path)
.then(function success(response) {
var model = new ImageBuildModel(response.data);
deferred.resolve(model);
})
.catch(function error(err) {
deferred.reject(err);
});
return deferred.promise;
};
service.buildImageFromURL = function(names, url, path) {
var params = {
t: names,
remote: url,
dockerfile: path
};
var deferred = $q.defer();
Build.buildImage(params, {}).$promise
.then(function success(data) {
var model = new ImageBuildModel(data);
deferred.resolve(model);
})
.catch(function error(err) {
deferred.reject(err);
});
return deferred.promise;
};
service.buildImageFromDockerfileContent = function(names, content) {
var params = {
t: names
};
var payload = {
content: content
};
var deferred = $q.defer();
Build.buildImageOverride(params, payload).$promise
.then(function success(data) {
var model = new ImageBuildModel(data);
deferred.resolve(model);
})
.catch(function error(err) {
deferred.reject(err);
});
return deferred.promise;
};
return service;
}]);

View File

@ -131,6 +131,18 @@ angular.module('portainer.docker')
return deferred.promise;
};
service.logs = function(id, stdout, stderr, timestamps, tail) {
var parameters = {
id: id,
stdout: stdout || 0,
stderr: stderr || 0,
timestamps: timestamps || 0,
tail: tail || 'all'
};
return Container.logs(parameters).$promise;
};
service.containerStats = function(id) {
var deferred = $q.defer();

View File

@ -58,5 +58,17 @@ angular.module('portainer.docker')
return Service.update({ id: service.Id, version: service.Version }, config).$promise;
};
service.logs = function(id, stdout, stderr, timestamps, tail) {
var parameters = {
id: id,
stdout: stdout || 0,
stderr: stderr || 0,
timestamps: timestamps || 0,
tail: tail || 'all'
};
return Service.logs(parameters).$promise;
};
return service;
}]);

View File

@ -35,5 +35,17 @@ angular.module('portainer.docker')
return deferred.promise;
};
service.logs = function(id, stdout, stderr, timestamps, tail) {
var parameters = {
id: id,
stdout: stdout || 0,
stderr: stderr || 0,
timestamps: timestamps || 0,
tail: tail || 'all'
};
return Task.logs(parameters).$promise;
};
return service;
}]);

View File

@ -1,11 +1,12 @@
angular.module('portainer.docker')
.controller('CreateConfigController', ['$scope', '$state', '$document', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService', 'CodeMirrorService',
function ($scope, $state, $document, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService, CodeMirrorService) {
.controller('CreateConfigController', ['$scope', '$state', 'Notifications', 'ConfigService', 'Authentication', 'FormValidator', 'ResourceControlService',
function ($scope, $state, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) {
$scope.formValues = {
Name: '',
Labels: [],
AccessControlData: new AccessControlFormData()
AccessControlData: new AccessControlFormData(),
ConfigContent: ''
};
$scope.state = {
@ -31,9 +32,7 @@ function ($scope, $state, $document, Notifications, ConfigService, Authenticatio
}
function prepareConfigData(config) {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var configData = $scope.editor.getValue();
var configData = $scope.formValues.ConfigContent;
config.Data = btoa(unescape(encodeURIComponent(configData)));
}
@ -62,6 +61,11 @@ function ($scope, $state, $document, Notifications, ConfigService, Authenticatio
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1;
if ($scope.formValues.ConfigContent === '') {
$scope.state.formValidationError = 'Config content must not be empty';
return;
}
if (!validateForm(accessControlData, isAdmin)) {
return;
}
@ -83,14 +87,7 @@ function ($scope, $state, $document, Notifications, ConfigService, Authenticatio
});
};
function initView() {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('config-editor', false);
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, false);
}
});
}
initView();
$scope.editorUpdate = function(cm) {
$scope.formValues.ConfigContent = cm.getValue();
};
}]);

View File

@ -21,7 +21,12 @@
<!-- config-data -->
<div class="form-group">
<div class="col-sm-12">
<textarea id="config-editor" class="form-control"></textarea>
<code-editor
identifier="config-creation-editor"
placeholder="Define or paste the content of your config here"
yml="false"
on-change="editorUpdate"
></code-editor>
</div>
</div>
<!-- !config-data -->
@ -62,6 +67,7 @@
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!formValues.Name" ng-click="create()">Create config</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->

View File

@ -70,7 +70,12 @@
<form class="form-horizontal">
<div class="form-group">
<div class="col-sm-12">
<textarea id="config-editor" ng-model="config.Data" class="form-control"></textarea>
<code-editor
identifier="config-editor"
yml="false"
read-only="true"
value="config.Data"
></code-editor>
</div>
</div>
</form>

View File

@ -1,6 +1,6 @@
angular.module('portainer.docker')
.controller('ConfigController', ['$scope', '$transition$', '$state', '$document', 'ConfigService', 'Notifications', 'CodeMirrorService',
function ($scope, $transition$, $state, $document, ConfigService, Notifications, CodeMirrorService) {
.controller('ConfigController', ['$scope', '$transition$', '$state', 'ConfigService', 'Notifications',
function ($scope, $transition$, $state, ConfigService, Notifications) {
$scope.removeConfig = function removeConfig(configId) {
ConfigService.remove(configId)
@ -13,20 +13,10 @@ function ($scope, $transition$, $state, $document, ConfigService, Notifications,
});
};
function initEditor() {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('config-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, false, true);
}
});
}
function initView() {
ConfigService.config($transition$.params().id)
.then(function success(data) {
$scope.config = data;
initEditor();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve config details');

View File

@ -85,7 +85,7 @@
<td colspan="2">
<div class="btn-group" role="group" aria-label="...">
<a class="btn" type="button" ui-sref="docker.containers.container.stats({id: container.Id})"><i class="fa fa-area-chart space-right" aria-hidden="true"></i>Stats</a>
<a class="btn" type="button" ui-sref="docker.containers.container.logs({id: container.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Logs</a>
<a class="btn" type="button" ui-sref="docker.containers.container.logs({id: container.Id})"><i class="fa fa-file-text-o space-right" aria-hidden="true"></i>Logs</a>
<a class="btn" type="button" ui-sref="docker.containers.container.console({id: container.Id})"><i class="fa fa-terminal space-right" aria-hidden="true"></i>Console</a>
<a class="btn" type="button" ui-sref="docker.containers.container.inspect({id: container.Id})"><i class="fa fa-info-circle space-right" aria-hidden="true"></i>Inspect</a>
</div>

View File

@ -1,6 +1,6 @@
angular.module('portainer.docker')
.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'ContainerCommit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) {
.controller('ContainerController', ['$q', '$scope', '$state','$transition$', '$filter', 'Container', 'Commit', 'ContainerHelper', 'ContainerService', 'ImageHelper', 'Network', 'NetworkService', 'Notifications', 'ModalService', 'ResourceControlService', 'RegistryService', 'ImageService',
function ($q, $scope, $state, $transition$, $filter, Container, Commit, ContainerHelper, ContainerService, ImageHelper, Network, NetworkService, Notifications, ModalService, ResourceControlService, RegistryService, ImageService) {
$scope.activityTime = 0;
$scope.portBindings = [];
@ -80,7 +80,7 @@ function ($q, $scope, $state, $transition$, $filter, Container, ContainerCommit,
var image = $scope.config.Image;
var registry = $scope.config.Registry;
var imageConfig = ImageHelper.createImageConfigForCommit(image, registry.URL);
ContainerCommit.commit({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
Commit.commitContainer({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function (d) {
update();
Notifications.success('Container commited', $transition$.params().id);
}, function (e) {

View File

@ -1,70 +1,71 @@
angular.module('portainer.docker')
.controller('ContainerLogsController', ['$scope', '$transition$', '$anchorScroll', 'ContainerLogs', 'Container', 'Notifications',
function ($scope, $transition$, $anchorScroll, ContainerLogs, Container, Notifications) {
$scope.state = {};
$scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false;
$scope.stdout = '';
$scope.stderr = '';
$scope.tailLines = 2000;
.controller('ContainerLogsController', ['$scope', '$transition$', '$interval', 'ContainerService', 'Notifications',
function ($scope, $transition$, $interval, ContainerService, Notifications) {
$scope.state = {
refreshRate: 3,
lineCount: 2000
};
Container.get({id: $transition$.params().id}, function (d) {
$scope.container = d;
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve container info');
$scope.changeLogCollection = function(logCollectionStatus) {
if (!logCollectionStatus) {
stopRepeater();
} else {
setUpdateRepeater();
}
};
$scope.$on('$destroy', function() {
stopRepeater();
});
function getLogs() {
getLogsStdout();
getLogsStderr();
function stopRepeater() {
var repeater = $scope.repeater;
if (angular.isDefined(repeater)) {
$interval.cancel(repeater);
repeater = null;
}
}
function getLogsStderr() {
ContainerLogs.get($transition$.params().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 update(logs) {
$scope.logs = logs;
}
function setUpdateRepeater() {
var refreshRate = $scope.state.refreshRate;
$scope.repeater = $interval(function() {
ContainerService.logs($transition$.params().id, 1, 1, 0, $scope.state.lineCount)
.then(function success(data) {
$scope.logs = data;
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve container logs');
});
}, refreshRate * 1000);
}
function startLogPolling() {
ContainerService.logs($transition$.params().id, 1, 1, 0, $scope.state.lineCount)
.then(function success(data) {
$scope.logs = data;
setUpdateRepeater();
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve container logs');
});
}
function getLogsStdout() {
ContainerLogs.get($transition$.params().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 initView() {
ContainerService.container($transition$.params().id)
.then(function success(data) {
$scope.container = data;
startLogPolling();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve container information');
});
}
// initial call
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();
}]);

View File

@ -5,50 +5,6 @@
</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-server"></i>
</div>
<div class="title">{{ container.Name|trimcontainername }}</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>
<log-viewer
data="logs" ng-if="logs" log-collection-change="changeLogCollection"
></log-viewer>

View File

@ -0,0 +1,90 @@
angular.module('portainer.docker')
.controller('BuildImageController', ['$scope', '$state', 'BuildService', 'Notifications',
function ($scope, $state, BuildService, Notifications) {
$scope.state = {
BuildType: 'editor',
actionInProgress: false,
activeTab: 0
};
$scope.formValues = {
ImageNames: [{ Name: '' }],
UploadFile: null,
DockerFileContent: '',
URL: '',
Path: 'Dockerfile'
};
$scope.addImageName = function() {
$scope.formValues.ImageNames.push({ Name: '' });
};
$scope.removeImageName = function(index) {
$scope.formValues.ImageNames.splice(index, 1);
};
function buildImageBasedOnBuildType(method, names) {
var buildType = $scope.state.BuildType;
var dockerfilePath = $scope.formValues.Path;
if (buildType === 'upload') {
var file = $scope.formValues.UploadFile;
return BuildService.buildImageFromUpload(names, file, dockerfilePath);
} else if (buildType === 'url') {
var URL = $scope.formValues.URL;
return BuildService.buildImageFromURL(names, URL, dockerfilePath);
} else {
var dockerfileContent = $scope.formValues.DockerFileContent;
return BuildService.buildImageFromDockerfileContent(names, dockerfileContent);
}
}
$scope.buildImage = function() {
var buildType = $scope.state.BuildType;
if (buildType === 'editor' && $scope.formValues.DockerFileContent === '') {
$scope.state.formValidationError = 'Dockerfile content must not be empty';
return;
}
$scope.state.actionInProgress = true;
var imageNames = $scope.formValues.ImageNames.filter(function filterNull(x) {
return x.Name;
}).map(function getNames(x) {
return x.Name;
});
buildImageBasedOnBuildType(buildType, imageNames)
.then(function success(data) {
$scope.buildLogs = data.buildLogs;
$scope.state.activeTab = 1;
if (data.hasError) {
Notifications.error('An error occured during build', { msg: 'Please check build logs output' });
} else {
Notifications.success('Image successfully built');
}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to build image');
})
.finally(function final() {
$scope.state.actionInProgress = false;
});
};
$scope.validImageNames = function() {
for (var i = 0; i < $scope.formValues.ImageNames.length; i++) {
var item = $scope.formValues.ImageNames[i];
if (item.Name !== '') {
return true;
}
}
return false;
};
$scope.editorUpdate = function(cm) {
$scope.formValues.DockerFileContent = cm.getValue();
};
}]);

View File

@ -0,0 +1,231 @@
<rd-header>
<rd-header-title title="Build image"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.images">Images</a> &gt; Build image
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<uib-tabset active="state.activeTab">
<uib-tab index="0">
<uib-tab-heading>
<i class="fa fa-wrench space-right" aria-hidden="true"></i> Builder
</uib-tab-heading>
<form class="form-horizontal">
<div class="col-sm-12 form-section-title">
Naming
</div>
<!-- names -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can specify multiple names to your image.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<label class="control-label text-left">Names</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="addImageName()">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add additional name
</span>
</div>
</div>
<!-- !names -->
<div class="form-group" ng-if="formValues.ImageNames.length === 0">
<span class="col-sm-12 text-danger small">
<i class="fa fa-exclamation-triangle space-right" aria-hidden="true"></i>You must specify at least one name for the image.
</span>
</div>
<!-- name-input-list -->
<div ng-if="formValues.ImageNames.length > 0">
<div class="form-group">
<span class="col-sm-12 text-muted small">
A name must be specified in one of the following formats: <code>name:tag</code>, <code>repository/name:tag</code> or <code>registryfqdn:port/repository/name:tag</code> format. If you omit the tag the default <b>latest</b> value is assumed.
</span>
</div>
<div class="form-group" >
<div class="col-sm-12">
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="item in formValues.ImageNames track by $index" style="margin-top: 2px;">
<!-- name-input -->
<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="item.Name" placeholder="e.g. myImage:myTag" auto-focus>
<span class="input-group-addon"><i ng-class="{true: 'fa fa-check green-icon', false: 'fa fa-times red-icon'}[item.Name !== '']" aria-hidden="true"></i></span>
</div>
<!-- !name-input -->
<!-- actions -->
<div class="input-group col-sm-2 input-group-sm">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeImageName($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- !actions -->
</div>
</div>
</div>
</div>
</div>
<!-- !name-input-list -->
<!-- build-method -->
<div class="col-sm-12 form-section-title">
Build method
</div>
<div class="form-group"></div>
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.BuildType" value="editor" ng-click="toggleEditor()">
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="state.BuildType" value="upload" ng-click="saveEditorContent()">
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
Upload
</div>
<p>Upload a tarball or a Dockerfile from your computer</p>
</label>
</div>
<div>
<input type="radio" id="method_url" ng-model="state.BuildType" value="url" ng-click="saveEditorContent()">
<label for="method_url">
<div class="boxselector_header">
<i class="fa fa-globe" aria-hidden="true" style="margin-right: 2px;"></i>
URL
</div>
<p>Specify an URL to a file</p>
</label>
</div>
</div>
</div>
<!-- !build-method -->
<!-- web-editor -->
<div ng-show="state.BuildType === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can get more information about Dockerfile format in the <a href="https://docs.docker.com/engine/reference/builder/" target="_blank">official documentation</a>.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="image-build-editor"
placeholder="Define or paste the content of your Dockerfile here"
yml="false"
on-change="editorUpdate"
></code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-show="state.BuildType === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
You can upload a Dockerfile or a tar archive containing a Dockerfile from your computer. When using an tarball, the root folder will be used as the build context.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="formValues.UploadFile">Select file</button>
<span style="margin-left: 5px;">
{{ formValues.UploadFile.name }}
<i class="fa fa-times red-icon" ng-if="!formValues.UploadFile" aria-hidden="true"></i>
</span>
</div>
</div>
<div ng-if="formValues.UploadFile.type === 'application/gzip' || formValues.UploadFile.type === 'application/x-tar'">
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Dockerfile within the tarball.
</span>
</div>
<div class="form-group">
<label for="image_path" class="col-sm-2 control-label text-left">Dockerfile path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile">
</div>
</div>
</div>
</div>
<!-- !upload -->
<!-- url -->
<div ng-show="state.BuildType === 'url'">
<div class="col-sm-12 form-section-title">
URL
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Specify the URL to a Dockerfile, a tarball or a public Git repository (suffixed by <b>.git</b>). When using a tarball or a Git repository URL, the root folder will be used as the build context.
</span>
</div>
<div class="form-group">
<label for="image_url" class="col-sm-2 control-label text-left">URL</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.URL" id="image_url" placeholder="https://myhost.mydomain/myimage.tar.gz">
</div>
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Indicate the path to the Dockerfile within the tarball/repository (ignored when using a Dockerfile).
</span>
</div>
<div class="form-group">
<label for="image_path" class="col-sm-2 control-label text-left">Dockerfile path</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="formValues.Path" id="image_path" placeholder="Dockerfile">
</div>
</div>
</div>
<!-- !url -->
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm"
ng-disabled="state.actionInProgress
|| (state.BuildType === 'upload' && (!formValues.UploadFile || !formValues.Path))
|| (state.BuildType === 'url' && (!formValues.URL || !formValues.Path))
|| (formValues.ImageNames.length === 0 || !validImageNames())"
ng-click="buildImage()" button-spinner="state.actionInProgress">
<span ng-hide="state.actionInProgress">Build the image</span>
<span ng-show="state.actionInProgress">Image building in progress...</span>
</button>
<span class="text-danger" ng-if="state.formValidationError" style="margin-left: 5px;">{{ state.formValidationError }}</span>
</div>
</div>
<!-- !actions -->
</form>
</uib-tab>
<uib-tab index="1" disable="!buildLogs">
<uib-tab-heading>
<i class="fa fa-file-text space-right" aria-hidden="true"></i> Output
</uib-tab-heading>
<pre class="log_viewer">
<div ng-repeat="line in buildLogs track by $index" class="line"><p class="inner_line" ng-click="active=!active" ng-class="{'line_selected': active}">{{ line }}</p></div>
<div ng-if="!buildLogs.length" class="line"><p class="inner_line">No build output available.</p></div>
</pre>
</uib-tab>
</uib-tabset>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -6,5 +6,6 @@
nodes="nodes"
show-text-filter="true"
show-slot-column="service.Mode !== 'global'"
show-logs-button="applicationState.endpoint.apiVersion >= 1.30"
></tasks-datatable>
</div>

View File

@ -73,7 +73,7 @@
</tr>
<tr>
<td colspan="2">
<a ng-if="applicationState.endpoint.apiVersion >= 1.30" class="btn btn-primary btn-sm" type="button" ui-sref="docker.services.service.logs({id: service.Id})"><i class="fa fa-exclamation-circle space-right" aria-hidden="true"></i>Service logs</a>
<a ng-if="applicationState.endpoint.apiVersion >= 1.30" class="btn btn-primary btn-sm" type="button" ui-sref="docker.services.service.logs({id: service.Id})"><i class="fa fa-file-text-o space-right" aria-hidden="true"></i>Service logs</a>
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.updateInProgress || isUpdating" ng-click="forceUpdateService(service)" button-spinner="state.updateInProgress" ng-if="applicationState.endpoint.apiVersion >= 1.25">
<span ng-hide="state.updateInProgress"><i class="fa fa-refresh space-right" aria-hidden="true"></i>Update the service</span>
<span ng-show="state.updateInProgress">Update in progress...</span>

View File

@ -1,78 +1,69 @@
angular.module('portainer.docker')
.controller('ServiceLogsController', ['$scope', '$transition$', '$anchorScroll', 'ServiceLogs', 'Service',
function ($scope, $transition$, $anchorScroll, ServiceLogs, Service) {
$scope.state = {};
$scope.state.displayTimestampsOut = false;
$scope.state.displayTimestampsErr = false;
$scope.stdout = '';
$scope.stderr = '';
$scope.tailLines = 2000;
.controller('ServiceLogsController', ['$scope', '$transition$', '$interval', 'ServiceService', 'Notifications',
function ($scope, $transition$, $interval, ServiceService, Notifications) {
$scope.state = {
refreshRate: 3,
lineCount: 2000
};
function getLogs() {
getLogsStdout();
getLogsStderr();
$scope.changeLogCollection = function(logCollectionStatus) {
if (!logCollectionStatus) {
stopRepeater();
} else {
setUpdateRepeater();
}
};
$scope.$on('$destroy', function() {
stopRepeater();
});
function stopRepeater() {
var repeater = $scope.repeater;
if (angular.isDefined(repeater)) {
$interval.cancel(repeater);
repeater = null;
}
}
function getLogsStderr() {
ServiceLogs.get($transition$.params().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 setUpdateRepeater() {
var refreshRate = $scope.state.refreshRate;
$scope.repeater = $interval(function() {
ServiceService.logs($transition$.params().id, 1, 1, 0, $scope.state.lineCount)
.then(function success(data) {
$scope.logs = data;
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve service logs');
});
}, refreshRate * 1000);
}
function getLogsStdout() {
ServiceLogs.get($transition$.params().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() {
Service.get({id: $transition$.params().id}, function (d) {
$scope.service = d;
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve service info');
function startLogPolling() {
ServiceService.logs($transition$.params().id, 1, 1, 0, $scope.state.lineCount)
.then(function success(data) {
$scope.logs = data;
console.log(JSON.stringify(data, null, 4));
setUpdateRepeater();
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve service logs');
});
}
function initView() {
getService();
getLogs();
var logIntervalId = window.setInterval(getLogs, 5000);
$scope.$on('$destroy', function () {
// clearing interval when view changes
clearInterval(logIntervalId);
ServiceService.service($transition$.params().id)
.then(function success(data) {
$scope.service = data;
startLogPolling();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve service information');
});
$scope.toggleTimestampsOut = function () {
getLogsStdout();
};
$scope.toggleTimestampsErr = function () {
getLogsStderr();
};
}
initView();
}]);

View File

@ -1,54 +1,10 @@
<rd-header>
<rd-header-title title="Service logs"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.services">Services</a> > <a ui-sref="docker.services.service({id: service.ID})">{{ service.Spec.Name }}</a> > Logs
<a ui-sref="docker.services">Services</a> > <a ui-sref="docker.services.service({id: service.ID})">{{ service.Name }}</a> &gt; 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>
<log-viewer
data="logs" ng-if="logs" log-collection-change="changeLogCollection"
></log-viewer>

View File

@ -17,7 +17,7 @@
remove-action="removeAction"
scale-action="scaleAction"
force-update-action="forceUpdateAction"
swarm-manager-ip="swarmManagerIP"
public-url="state.publicURL"
show-force-update-button="applicationState.endpoint.apiVersion >= 1.25"
></services-datatable>
</div>

View File

@ -1,6 +1,10 @@
angular.module('portainer.docker')
.controller('ServicesController', ['$q', '$scope', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Task', 'Node', 'NodeHelper', 'ModalService',
function ($q, $scope, $state, Service, ServiceService, ServiceHelper, Notifications, Task, Node, NodeHelper, ModalService) {
.controller('ServicesController', ['$q', '$scope', '$state', 'Service', 'ServiceService', 'ServiceHelper', 'Notifications', 'Task', 'Node', 'ModalService', 'EndpointProvider',
function ($q, $scope, $state, Service, ServiceService, ServiceHelper, Notifications, Task, Node, ModalService, EndpointProvider) {
$scope.state = {
publicURL: EndpointProvider.endpointPublicURL()
};
$scope.scaleAction = function scaleService(service) {
var config = ServiceHelper.serviceToConfig(service.Model);
@ -88,7 +92,6 @@ function ($q, $scope, $state, Service, ServiceService, ServiceHelper, Notificati
nodes: Node.query({}).$promise
})
.then(function success(data) {
$scope.swarmManagerIP = NodeHelper.getManagerIP(data.nodes);
$scope.services = data.services.map(function (service) {
var runningTasks = data.tasks.filter(function (task) {
return task.ServiceID === service.ID && task.Status.State === 'running';

View File

@ -1,14 +1,10 @@
angular.module('portainer.docker')
.controller('CreateStackController', ['$scope', '$state', '$document', 'StackService', 'CodeMirrorService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper',
function ($scope, $state, $document, StackService, CodeMirrorService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) {
// Store the editor content when switching builder methods
var editorContent = '';
var editorEnabled = true;
.controller('CreateStackController', ['$scope', '$state', 'StackService', 'Authentication', 'Notifications', 'FormValidator', 'ResourceControlService', 'FormHelper',
function ($scope, $state, StackService, Authentication, Notifications, FormValidator, ResourceControlService, FormHelper) {
$scope.formValues = {
Name: '',
StackFileContent: '# Define or paste the content of your docker-compose file here',
StackFileContent: '',
StackFile: null,
RepositoryURL: '',
Env: [],
@ -42,15 +38,11 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
return true;
}
function createStack(name) {
var method = $scope.state.Method;
function createStack(name, method) {
var env = FormHelper.removeInvalidEnvVars($scope.formValues.Env);
if (method === 'editor') {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFileContent = $scope.editor.getValue();
var stackFileContent = $scope.formValues.StackFileContent;
return StackService.createStackFromFileContent(name, stackFileContent, env);
} else if (method === 'upload') {
var stackFile = $scope.formValues.StackFile;
@ -64,18 +56,24 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
$scope.deployStack = function () {
var name = $scope.formValues.Name;
var method = $scope.state.Method;
var accessControlData = $scope.formValues.AccessControlData;
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1;
var userId = userDetails.ID;
if (method === 'editor' && $scope.formValues.StackFileContent === '') {
$scope.state.formValidationError = 'Stack file content must not be empty';
return;
}
if (!validateForm(accessControlData, isAdmin)) {
return;
}
$scope.state.actionInProgress = true;
createStack(name)
createStack(name, method)
.then(function success(data) {
Notifications.success('Stack successfully deployed');
})
@ -96,33 +94,7 @@ function ($scope, $state, $document, StackService, CodeMirrorService, Authentica
});
};
function enableEditor(value) {
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
if (value) {
$scope.editor.setValue(value);
}
}
});
}
$scope.toggleEditor = function() {
if (!editorEnabled) {
enableEditor(editorContent);
editorEnabled = true;
}
$scope.editorUpdate = function(cm) {
$scope.formValues.StackFileContent = cm.getValue();
};
$scope.saveEditorContent = function() {
editorContent = $scope.editor.getValue();
editorEnabled = false;
};
function initView() {
enableEditor();
}
initView();
}]);

View File

@ -31,7 +31,7 @@
<div class="form-group" style="margin-bottom: 0">
<div class="boxselector_wrapper">
<div>
<input type="radio" id="method_editor" ng-model="state.Method" value="editor" ng-click="toggleEditor(state.Method)">
<input type="radio" id="method_editor" ng-model="state.Method" value="editor">
<label for="method_editor">
<div class="boxselector_header">
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
@ -41,7 +41,7 @@
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="state.Method" value="upload" ng-click="saveEditorContent()">
<input type="radio" id="method_upload" ng-model="state.Method" value="upload">
<label for="method_upload">
<div class="boxselector_header">
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
@ -51,7 +51,7 @@
</label>
</div>
<div>
<input type="radio" id="method_repository" ng-model="state.Method" value="repository" ng-click="saveEditorContent()">
<input type="radio" id="method_repository" ng-model="state.Method" value="repository">
<label for="method_repository">
<div class="boxselector_header">
<i class="fa fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
@ -64,7 +64,7 @@
</div>
<!-- !build-method -->
<!-- web-editor -->
<div ng-if="state.Method === 'editor'">
<div ng-show="state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
@ -75,13 +75,18 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<textarea id="web-editor" class="form-control" ng-model="formValues.StackFileContent" placeholder='version: "3"'></textarea>
<code-editor
identifier="stack-creation-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
on-change="editorUpdate"
></code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-if="state.Method === 'upload'">
<div ng-show="state.Method === 'upload'">
<div class="col-sm-12 form-section-title">
Upload
</div>
@ -102,7 +107,7 @@
</div>
<!-- !upload -->
<!-- repository -->
<div ng-if="state.Method === 'repository'">
<div ng-show="state.Method === 'repository'">
<div class="col-sm-12 form-section-title">
Git repository
</div>
@ -167,7 +172,7 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || (state.Method === 'editor' && !formValues.StackFileContent)
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress
|| (state.Method === 'upload' && !formValues.StackFile)
|| (state.Method === 'repository' && (!formValues.RepositoryURL || !formValues.RepositoryPath))
|| !formValues.Name" ng-click="deployStack()" button-spinner="state.actionInProgress">

View File

@ -40,6 +40,7 @@
nodes="nodes"
show-text-filter="true"
show-slot-column="true"
show-logs-button="applicationState.endpoint.apiVersion >= 1.30"
></tasks-datatable>
</div>
</div>
@ -57,7 +58,13 @@
</div>
<div class="form-group">
<div class="col-sm-12">
<textarea id="web-editor" class="form-control" ng-model="stackFileContent" placeholder='version: "3"'></textarea>
<code-editor
identifier="stack-editor"
placeholder="# Define or paste the content of your docker-compose file here"
yml="true"
on-change="editorUpdate"
value="stackFileContent"
></code-editor>
</div>
</div>
<div class="col-sm-12 form-section-title">

View File

@ -1,6 +1,6 @@
angular.module('portainer.docker')
.controller('StackController', ['$q', '$scope', '$state', '$transition$', '$document', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'CodeMirrorService', 'Notifications', 'FormHelper', 'EndpointProvider',
function ($q, $scope, $state, $transition$, $document, StackService, NodeService, ServiceService, TaskService, ServiceHelper, CodeMirrorService, Notifications, FormHelper, EndpointProvider) {
.controller('StackController', ['$q', '$scope', '$state', '$transition$', 'StackService', 'NodeService', 'ServiceService', 'TaskService', 'ServiceHelper', 'Notifications', 'FormHelper', 'EndpointProvider',
function ($q, $scope, $state, $transition$, StackService, NodeService, ServiceService, TaskService, ServiceHelper, Notifications, FormHelper, EndpointProvider) {
$scope.state = {
actionInProgress: false,
@ -12,9 +12,7 @@ function ($q, $scope, $state, $transition$, $document, StackService, NodeService
};
$scope.deployStack = function () {
// The codemirror editor does not work with ng-model so we need to retrieve
// the value directly from the editor.
var stackFile = $scope.editor.getValue();
var stackFile = $scope.stackFileContent;
var env = FormHelper.removeInvalidEnvVars($scope.stack.Env);
var prune = $scope.formValues.Prune;
@ -63,13 +61,6 @@ function ($q, $scope, $state, $transition$, $document, StackService, NodeService
.then(function success(data) {
$scope.stackFileContent = data.stackFile;
$document.ready(function() {
var webEditorElement = $document[0].getElementById('web-editor');
if (webEditorElement) {
$scope.editor = CodeMirrorService.applyCodeMirrorOnElement(webEditorElement, true, false);
}
});
$scope.nodes = data.nodes;
var services = data.services;
@ -89,5 +80,9 @@ function ($q, $scope, $state, $transition$, $document, StackService, NodeService
});
}
$scope.editorUpdate = function(cm) {
$scope.stackFileContent = cm.getValue();
};
initView();
}]);

View File

@ -52,7 +52,7 @@ function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskSer
}
function assignServiceName(services, tasks) {
function assignServiceInfo(services, tasks) {
for (var i = 0; i < services.length; i++) {
var service = services[i];
@ -84,7 +84,7 @@ function ($q, $scope, $document, $interval, NodeService, ServiceService, TaskSer
function prepareVisualizerData(nodes, services, tasks) {
var visualizerData = {};
assignServiceName(services, tasks);
assignServiceInfo(services, tasks);
assignTasksToNode(nodes, tasks);
visualizerData.nodes = nodes;

View File

@ -97,7 +97,7 @@
<div><span class="label label-{{ node.Status | nodestatusbadge }}">{{ node.Status }}</span></div>
</div>
<div class="tasks">
<div class="task task_{{ task.Status.State | visualizerTask }}" ng-repeat="task in node.Tasks | filter: (state.DisplayOnlyRunningTasks || '') && { Status: { State: 'running' } }">
<div class="task task_{{ task.Status.State | visualizerTask }}" style="border: 2px solid {{ task.ServiceId | visualizerTaskBorderColor }}" ng-repeat="task in node.Tasks | filter: (state.DisplayOnlyRunningTasks || '') && { Status: { State: 'running' } }">
<div class="service_name">{{ task.ServiceName }}</div>
<div>Image: {{ task.Spec.ContainerSpec.Image | hideshasum }}</div>
<div>Status: {{ task.Status.State }}</div>

View File

@ -44,6 +44,9 @@
<td>Container ID</td>
<td>{{ task.Status.ContainerStatus.ContainerID }}</td>
</tr>
<tr ng-if="applicationState.endpoint.apiVersion >= 1.30" >
<td colspan="2"><a class="btn btn-primary btn-sm" type="button" ui-sref="docker.tasks.task.logs({id: task.Id})"><i class="fa fa-file-text-o space-right" aria-hidden="true"></i>Task logs</a></td>
</tr>
</tbody>
</table>
</rd-widget-body>

View File

@ -1,16 +1,16 @@
angular.module('portainer.docker')
.controller('TaskController', ['$scope', '$transition$', 'TaskService', 'Service', 'Notifications',
function ($scope, $transition$, TaskService, Service, Notifications) {
.controller('TaskController', ['$scope', '$transition$', 'TaskService', 'ServiceService', 'Notifications',
function ($scope, $transition$, TaskService, ServiceService, Notifications) {
function initView() {
TaskService.task($transition$.params().id)
.then(function success(data) {
var task = data;
$scope.task = task;
return Service.get({ id: task.ServiceId }).$promise;
return ServiceService.service(task.ServiceId);
})
.then(function success(data) {
var service = new ServiceViewModel(data);
var service = data;
$scope.service = service;
})
.catch(function error(err) {

View File

@ -0,0 +1,73 @@
angular.module('portainer.docker')
.controller('TaskLogsController', ['$scope', '$transition$', '$interval', 'TaskService', 'ServiceService', 'Notifications',
function ($scope, $transition$, $interval, TaskService, ServiceService, Notifications) {
$scope.state = {
refreshRate: 3,
lineCount: 2000
};
$scope.changeLogCollection = function(logCollectionStatus) {
if (!logCollectionStatus) {
stopRepeater();
} else {
setUpdateRepeater();
}
};
$scope.$on('$destroy', function() {
stopRepeater();
});
function stopRepeater() {
var repeater = $scope.repeater;
if (angular.isDefined(repeater)) {
$interval.cancel(repeater);
repeater = null;
}
}
function setUpdateRepeater() {
var refreshRate = $scope.state.refreshRate;
$scope.repeater = $interval(function() {
TaskService.logs($transition$.params().id, 1, 1, 0, $scope.state.lineCount)
.then(function success(data) {
$scope.logs = data;
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve task logs');
});
}, refreshRate * 1000);
}
function startLogPolling() {
TaskService.logs($transition$.params().id, 1, 1, 0, $scope.state.lineCount)
.then(function success(data) {
$scope.logs = data;
setUpdateRepeater();
})
.catch(function error(err) {
stopRepeater();
Notifications.error('Failure', err, 'Unable to retrieve task logs');
});
}
function initView() {
TaskService.task($transition$.params().id)
.then(function success(data) {
var task = data;
$scope.task = task;
return ServiceService.service(task.ServiceId);
})
.then(function success(data) {
var service = data;
$scope.service = service;
startLogPolling();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve task details');
});
}
initView();
}]);

View File

@ -0,0 +1,10 @@
<rd-header>
<rd-header-title title="Task details"></rd-header-title>
<rd-header-content ng-if="task && service">
<a ui-sref="docker.services">Services</a> &gt; <a ui-sref="docker.services.service({id: service.Id })">{{ service.Name }}</a> &gt; <a ui-sref="docker.tasks.task({id: task.Id })">{{ task.Id }}</a> &gt; Logs
</rd-header-content>
</rd-header>
<log-viewer
data="logs" ng-if="logs" log-collection-change="changeLogCollection"
></log-viewer>

View File

@ -178,6 +178,12 @@ function ($scope, $q, $state, $transition$, $anchorScroll, $filter, ContainerSer
$scope.formValues.network = _.find($scope.availableNetworks, function(o) { return o.Name === 'bridge'; });
}
if (selectedTemplate.Name) {
$scope.formValues.name = selectedTemplate.Name;
} else {
$scope.formValues.name = '';
}
$anchorScroll('view-top');
}

View File

@ -1,6 +1,6 @@
angular.module('portainer.docker')
.controller('CreateVolumeController', ['$q', '$scope', '$state', 'VolumeService', 'PluginService', 'ResourceControlService', 'Authentication', 'Notifications', 'FormValidator', 'ExtensionManager',
function ($q, $scope, $state, VolumeService, PluginService, ResourceControlService, Authentication, Notifications, FormValidator, ExtensionManager) {
.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',
@ -88,11 +88,5 @@ function ($q, $scope, $state, VolumeService, PluginService, ResourceControlServi
}
}
ExtensionManager.init()
.then(function success(data) {
initView();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to initialize extensions');
});
initView();
}]);

View File

@ -1,52 +0,0 @@
angular.module('extension.storidge')
.factory('StoridgeCluster', ['$http', 'StoridgeManager', function StoridgeClusterFactory($http, StoridgeManager) {
'use strict';
var service = {};
service.queryEvents = function() {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/events',
skipAuthorization: true,
timeout: 4500,
ignoreLoadingBar: true
});
};
service.queryVersion = function() {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/version',
skipAuthorization: true
});
};
service.queryInfo = function() {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/info',
skipAuthorization: true,
timeout: 4500,
ignoreLoadingBar: true
});
};
service.reboot = function() {
return $http({
method: 'POST',
url: StoridgeManager.StoridgeAPIURL() + '/cluster/reboot',
skipAuthorization: true
});
};
service.shutdown = function() {
return $http({
method: 'POST',
url: StoridgeManager.StoridgeAPIURL() + '/cluster/shutdown',
skipAuthorization: true
});
};
return service;
}]);

View File

@ -1,24 +0,0 @@
angular.module('extension.storidge')
.factory('StoridgeNodes', ['$http', 'StoridgeManager', function StoridgeNodesFactory($http, StoridgeManager) {
'use strict';
var service = {};
service.query = function() {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/nodes',
skipAuthorization: true
});
};
service.inspect = function(id) {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/nodes/' + id,
skipAuthorization: true
});
};
return service;
}]);

View File

@ -1,52 +0,0 @@
angular.module('extension.storidge')
.factory('StoridgeProfiles', ['$http', 'StoridgeManager', function StoridgeProfilesFactory($http, StoridgeManager) {
'use strict';
var service = {};
service.create = function(payload) {
return $http({
method: 'POST',
url: StoridgeManager.StoridgeAPIURL() + '/profiles',
data: payload,
headers: { 'Content-type': 'application/json' },
skipAuthorization: true
});
};
service.update = function(id, payload) {
return $http({
method: 'PUT',
url: StoridgeManager.StoridgeAPIURL() + '/profiles/' + id,
data: payload,
headers: { 'Content-type': 'application/json' },
skipAuthorization: true
});
};
service.query = function() {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/profiles',
skipAuthorization: true
});
};
service.inspect = function(id) {
return $http({
method: 'GET',
url: StoridgeManager.StoridgeAPIURL() + '/profiles/' + id,
skipAuthorization: true
});
};
service.delete = function(id) {
return $http({
method: 'DELETE',
url: StoridgeManager.StoridgeAPIURL() + '/profiles/' + id,
skipAuthorization: true
});
};
return service;
}]);

View File

@ -0,0 +1,20 @@
angular.module('extension.storidge')
.factory('Storidge', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function StoridgeFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/storidge/:resource/:id/:action', {
endpointId: EndpointProvider.endpointID
},
{
rebootCluster: { method: 'POST', params: { resource: 'cluster', action: 'reboot' } },
shutdownCluster: { method: 'POST', params: { resource: 'cluster', action: 'shutdown' } },
queryEvents: { method: 'GET', params: { resource: 'events' }, timeout: 4500, ignoreLoadingBar: true, isArray: true },
getVersion: { method: 'GET', params: { resource: 'version' } },
getInfo: { method: 'GET', params: { resource: 'info' }, timeout: 4500, ignoreLoadingBar: true },
queryNodes: { method: 'GET', params: { resource: 'nodes' } },
queryProfiles: { method: 'GET', params: { resource: 'profiles' } },
getProfile: { method: 'GET', params: { resource: 'profiles' } },
createProfile: { method: 'POST', params: { resource: 'profiles' } },
updateProfile: { method: 'PUT', params: { resource: 'profiles', id: '@name' } },
deleteProfile: { method: 'DELETE', params: { resource: 'profiles' } }
});
}]);

View File

@ -1,22 +1,22 @@
angular.module('extension.storidge')
.factory('StoridgeClusterService', ['$q', 'StoridgeCluster', function StoridgeClusterServiceFactory($q, StoridgeCluster) {
.factory('StoridgeClusterService', ['$q', 'Storidge', function StoridgeClusterServiceFactory($q, Storidge) {
'use strict';
var service = {};
service.reboot = function() {
return StoridgeCluster.reboot();
return Storidge.rebootCluster().$promise;
};
service.shutdown = function() {
return StoridgeCluster.shutdown();
return Storidge.shutdownCluster().$promise;
};
service.info = function() {
var deferred = $q.defer();
StoridgeCluster.queryInfo()
.then(function success(response) {
var info = new StoridgeInfoModel(response.data);
Storidge.getInfo().$promise
.then(function success(data) {
var info = new StoridgeInfoModel(data);
deferred.resolve(info);
})
.catch(function error(err) {
@ -29,9 +29,9 @@ angular.module('extension.storidge')
service.version = function() {
var deferred = $q.defer();
StoridgeCluster.queryVersion()
.then(function success(response) {
var version = response.data.version;
Storidge.getVersion().$promise
.then(function success(data) {
var version = data.version;
deferred.resolve(version);
})
.catch(function error(err) {
@ -44,9 +44,9 @@ angular.module('extension.storidge')
service.events = function() {
var deferred = $q.defer();
StoridgeCluster.queryEvents()
.then(function success(response) {
var events = response.data.map(function(item) {
Storidge.queryEvents().$promise
.then(function success(data) {
var events = data.map(function(item) {
return new StoridgeEventModel(item);
});
deferred.resolve(events);

View File

@ -1,48 +0,0 @@
angular.module('extension.storidge')
.factory('StoridgeManager', ['$q', 'LocalStorage', 'SystemService', function StoridgeManagerFactory($q, LocalStorage, SystemService) {
'use strict';
var service = {
API: ''
};
service.init = function() {
var deferred = $q.defer();
var storedAPIURL = LocalStorage.getStoridgeAPIURL();
if (storedAPIURL) {
service.API = storedAPIURL;
deferred.resolve();
} else {
SystemService.info()
.then(function success(data) {
var endpointAddress = LocalStorage.getEndpointPublicURL();
var storidgeAPIURL = '';
if (endpointAddress) {
storidgeAPIURL = 'http://' + endpointAddress + ':8282';
} else {
var managerIP = data.Swarm.NodeAddr;
storidgeAPIURL = 'http://' + managerIP + ':8282';
}
service.API = storidgeAPIURL;
LocalStorage.storeStoridgeAPIURL(storidgeAPIURL);
deferred.resolve();
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve Storidge API URL', err: err });
});
}
return deferred.promise;
};
service.reset = function() {
LocalStorage.clearStoridgeAPIURL();
};
service.StoridgeAPIURL = function() {
return service.API;
};
return service;
}]);

View File

@ -1,14 +1,14 @@
angular.module('extension.storidge')
.factory('StoridgeNodeService', ['$q', 'StoridgeNodes', function StoridgeNodeServiceFactory($q, StoridgeNodes) {
.factory('StoridgeNodeService', ['$q', 'Storidge', function StoridgeNodeServiceFactory($q, Storidge) {
'use strict';
var service = {};
service.nodes = function() {
var deferred = $q.defer();
StoridgeNodes.query()
.then(function success(response) {
var nodeData = response.data.nodes;
Storidge.queryNodes().$promise
.then(function success(data) {
var nodeData = data.nodes;
var nodes = [];
for (var key in nodeData) {

View File

@ -1,28 +1,28 @@
angular.module('extension.storidge')
.factory('StoridgeProfileService', ['$q', 'StoridgeProfiles', function StoridgeProfileServiceFactory($q, StoridgeProfiles) {
.factory('StoridgeProfileService', ['$q', 'Storidge', function StoridgeProfileServiceFactory($q, Storidge) {
'use strict';
var service = {};
service.create = function(model) {
var payload = new StoridgeCreateProfileRequest(model);
return StoridgeProfiles.create(payload);
return Storidge.createProfile(payload).$promise;
};
service.update = function(model) {
var payload = new StoridgeCreateProfileRequest(model);
return StoridgeProfiles.update(model.Name, payload);
return Storidge.updateProfile(payload).$promise;
};
service.delete = function(profileName) {
return StoridgeProfiles.delete(profileName);
return Storidge.deleteProfile({ id: profileName }).$promise;
};
service.profile = function(profileName) {
var deferred = $q.defer();
StoridgeProfiles.inspect(profileName)
.then(function success(response) {
var profile = new StoridgeProfileModel(profileName, response.data);
Storidge.getProfile({ id: profileName }).$promise
.then(function success(data) {
var profile = new StoridgeProfileModel(profileName, data);
deferred.resolve(profile);
})
.catch(function error(err) {
@ -35,9 +35,9 @@ angular.module('extension.storidge')
service.profiles = function() {
var deferred = $q.defer();
StoridgeProfiles.query()
.then(function success(response) {
var profiles = response.data.profiles.map(function (item) {
Storidge.queryProfiles().$promise
.then(function success(data) {
var profiles = data.profiles.map(function (item) {
return new StoridgeProfileListModel(item);
});
deferred.resolve(profiles);

View File

@ -1,6 +1,6 @@
angular.module('extension.storidge')
.controller('StoridgeClusterController', ['$q', '$scope', '$state', 'Notifications', 'StoridgeClusterService', 'StoridgeNodeService', 'StoridgeManager', 'ModalService',
function ($q, $scope, $state, Notifications, StoridgeClusterService, StoridgeNodeService, StoridgeManager, ModalService) {
.controller('StoridgeClusterController', ['$q', '$scope', '$state', 'Notifications', 'StoridgeClusterService', 'StoridgeNodeService', 'ModalService',
function ($q, $scope, $state, Notifications, StoridgeClusterService, StoridgeNodeService, ModalService) {
$scope.state = {
shutdownInProgress: false,
@ -44,30 +44,20 @@ function ($q, $scope, $state, Notifications, StoridgeClusterService, StoridgeNod
function shutdownCluster() {
$scope.state.shutdownInProgress = true;
StoridgeClusterService.shutdown()
.then(function success(data) {
Notifications.success('Cluster successfully shutdown');
$state.go('docker.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to shutdown cluster');
})
.finally(function final() {
$scope.state.shutdownInProgress = false;
Notifications.success('Cluster successfully shutdown');
$state.go('docker.dashboard');
});
}
function rebootCluster() {
$scope.state.rebootInProgress = true;
StoridgeClusterService.reboot()
.then(function success(data) {
Notifications.success('Cluster successfully rebooted');
$state.reload();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to reboot cluster');
})
.finally(function final() {
$scope.state.rebootInProgress = false;
Notifications.success('Cluster successfully rebooted');
$state.reload();
});
}
@ -87,11 +77,5 @@ function ($q, $scope, $state, Notifications, StoridgeClusterService, StoridgeNod
});
}
StoridgeManager.init()
.then(function success() {
initView();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to communicate with Storidge API');
});
initView();
}]);

View File

@ -1,6 +1,6 @@
angular.module('extension.storidge')
.controller('StoridgeMonitorController', ['$q', '$scope', '$interval', '$document', 'Notifications', 'StoridgeClusterService', 'StoridgeChartService', 'StoridgeManager', 'ModalService',
function ($q, $scope, $interval, $document, Notifications, StoridgeClusterService, StoridgeChartService, StoridgeManager, ModalService) {
.controller('StoridgeMonitorController', ['$q', '$scope', '$interval', '$document', 'Notifications', 'StoridgeClusterService', 'StoridgeChartService', 'ModalService',
function ($q, $scope, $interval, $document, Notifications, StoridgeClusterService, StoridgeChartService, ModalService) {
$scope.$on('$destroy', function() {
stopRepeater();
@ -98,11 +98,5 @@ function ($q, $scope, $interval, $document, Notifications, StoridgeClusterServic
});
}
StoridgeManager.init()
.then(function success() {
initView();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to communicate with Storidge API');
});
initView();
}]);

View File

@ -1,6 +1,6 @@
angular.module('extension.storidge')
.controller('StoridgeCreateProfileController', ['$scope', '$state', '$transition$', 'Notifications', 'StoridgeProfileService', 'StoridgeManager',
function ($scope, $state, $transition$, Notifications, StoridgeProfileService, StoridgeManager) {
.controller('StoridgeCreateProfileController', ['$scope', '$state', '$transition$', 'Notifications', 'StoridgeProfileService',
function ($scope, $state, $transition$, Notifications, StoridgeProfileService) {
$scope.state = {
NoLimit: true,
@ -62,11 +62,5 @@ function ($scope, $state, $transition$, Notifications, StoridgeProfileService, S
$scope.model = profile;
}
StoridgeManager.init()
.then(function success() {
initView();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to communicate with Storidge API');
});
initView();
}]);

View File

@ -1,6 +1,6 @@
angular.module('extension.storidge')
.controller('StoridgeProfileController', ['$scope', '$state', '$transition$', 'Notifications', 'StoridgeProfileService', 'StoridgeManager', 'ModalService',
function ($scope, $state, $transition$, Notifications, StoridgeProfileService, StoridgeManager, ModalService) {
.controller('StoridgeProfileController', ['$scope', '$state', '$transition$', 'Notifications', 'StoridgeProfileService', 'ModalService',
function ($scope, $state, $transition$, Notifications, StoridgeProfileService, ModalService) {
$scope.state = {
NoLimit: false,
@ -88,11 +88,6 @@ function ($scope, $state, $transition$, Notifications, StoridgeProfileService, S
});
}
StoridgeManager.init()
.then(function success() {
initView();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to communicate with Storidge API');
});
initView();
}]);

View File

@ -1,6 +1,6 @@
angular.module('extension.storidge')
.controller('StoridgeProfilesController', ['$q', '$scope', '$state', 'Notifications', 'StoridgeProfileService', 'StoridgeManager',
function ($q, $scope, $state, Notifications, StoridgeProfileService, StoridgeManager) {
.controller('StoridgeProfilesController', ['$q', '$scope', '$state', 'Notifications', 'StoridgeProfileService',
function ($q, $scope, $state, Notifications, StoridgeProfileService) {
$scope.state = {
actionInProgress: false
@ -60,11 +60,5 @@ function ($q, $scope, $state, Notifications, StoridgeProfileService, StoridgeMan
});
}
StoridgeManager.init()
.then(function success() {
initView();
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to communicate with Storidge API');
});
initView();
}]);

View File

@ -0,0 +1,12 @@
angular.module('portainer.app').component('codeEditor', {
templateUrl: 'app/portainer/components/code-editor/codeEditor.html',
controller: 'CodeEditorController',
bindings: {
identifier: '@',
placeholder: '@',
yml: '<',
readOnly: '<',
onChange: '<',
value: '<'
}
});

View File

@ -0,0 +1 @@
<textarea id="{{ $ctrl.identifier }}" class="form-control" placeholder="{{ $ctrl.placeholder }}"></textarea>

View File

@ -0,0 +1,18 @@
angular.module('portainer.app')
.controller('CodeEditorController', ['$document', 'CodeMirrorService',
function ($document, CodeMirrorService) {
var ctrl = this;
this.$onInit = function() {
$document.ready(function() {
var editorElement = $document[0].getElementById(ctrl.identifier);
ctrl.editor = CodeMirrorService.applyCodeMirrorOnElement(editorElement, ctrl.yml, ctrl.readOnly);
if (ctrl.onChange) {
ctrl.editor.on('change', ctrl.onChange);
}
if (ctrl.value) {
ctrl.editor.setValue(ctrl.value);
}
});
};
}]);

View File

@ -0,0 +1,10 @@
angular.module('portainer.app')
.factory('Extensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function Extensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions', {
endpointId: EndpointProvider.endpointID
},
{
register: { method: 'POST', params: { endpointId: '@endpointId' } }
});
}]);

View File

@ -66,6 +66,7 @@ angular.module('portainer.app')
TLSSkipVerify: TLSSkipVerify,
TLSSkipClientVerify: TLSSkipClientVerify
};
var deferred = $q.defer();
Endpoints.create({}, endpoint).$promise
.then(function success(data) {
@ -85,6 +86,7 @@ angular.module('portainer.app')
deferred.notify({upload: false});
deferred.reject({msg: 'Unable to upload TLS certs', err: err});
});
return deferred.promise;
};

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