Merge remote-tracking branch 'origin/develop' into webpack

pull/2670/head
Chaim Lev-Ari 2018-10-28 08:07:29 +02:00
commit 71447e2cc0
72 changed files with 1740 additions and 320 deletions

View File

@ -6,7 +6,7 @@
[![Docker Pulls](https://img.shields.io/docker/pulls/portainer/portainer.svg)](https://hub.docker.com/r/portainer/portainer/)
[![Microbadger](https://images.microbadger.com/badges/image/portainer/portainer.svg)](http://microbadger.com/images/portainer/portainer "Image size")
[![Documentation Status](https://readthedocs.org/projects/portainer/badge/?version=stable)](http://portainer.readthedocs.io/en/stable/?badge=stable)
[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer)
[![Build Status](https://semaphoreci.com/api/v1/portainer/portainer-ci/branches/develop/badge.svg)](https://semaphoreci.com/portainer/portainer-ci)
[![Code Climate](https://codeclimate.com/github/portainer/portainer/badges/gpa.svg)](https://codeclimate.com/github/portainer/portainer)
[![Slack](https://portainer.io/slack/badge.svg)](https://portainer.io/slack/)
[![Gitter](https://badges.gitter.im/portainer/Lobby.svg)](https://gitter.im/portainer/Lobby?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)

View File

@ -7,13 +7,13 @@ import (
// 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) {
func TarFileInBuffer(fileContent []byte, fileName string, mode int64) ([]byte, error) {
var buffer bytes.Buffer
tarWriter := tar.NewWriter(&buffer)
header := &tar.Header{
Name: fileName,
Mode: 0600,
Mode: mode,
Size: int64(len(fileContent)),
}

View File

@ -383,6 +383,10 @@ func initEndpoint(flags *portainer.CLIFlags, endpointService portainer.EndpointS
return createUnsecuredEndpoint(*flags.EndpointURL, endpointService, snapshotter)
}
func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobService {
return docker.NewJobService(dockerClientFactory)
}
func main() {
flags := initCLI()
@ -408,6 +412,8 @@ func main() {
clientFactory := initClientFactory(digitalSignatureService)
jobService := initJobService(clientFactory)
snapshotter := initSnapshotter(clientFactory)
jobScheduler, err := initJobScheduler(store.EndpointService, snapshotter, flags)
@ -520,6 +526,7 @@ func main() {
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,
DockerClientFactory: clientFactory,
JobService: jobService,
}
log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr)

View File

@ -3,7 +3,6 @@ package docker
import (
"net/http"
"strings"
"time"
"github.com/docker/docker/client"
"github.com/portainer/portainer"
@ -27,12 +26,13 @@ func NewClientFactory(signatureService portainer.DigitalSignatureService) *Clien
}
// CreateClient is a generic function to create a Docker client based on
// a specific endpoint configuration
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*client.Client, error) {
// a specific endpoint configuration. The nodeName parameter can be used
// with an agent enabled endpoint to target a specific node in an agent cluster.
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeName string) (*client.Client, error) {
if endpoint.Type == portainer.AzureEnvironment {
return nil, unsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService)
return createAgentClient(endpoint, factory.signatureService, nodeName)
}
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
@ -61,7 +61,7 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
)
}
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) {
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
if err != nil {
return nil, err
@ -77,6 +77,10 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
portainer.PortainerAgentSignatureHeader: signature,
}
if nodeName != "" {
headers[portainer.PortainerAgentTargetHeader] = nodeName
}
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(portainer.SupportedDockerAPIVersion),
@ -97,7 +101,6 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
}
return &http.Client{
Timeout: time.Second * 10,
Transport: transport,
}, nil
}

103
api/docker/jobservice.go Normal file
View File

@ -0,0 +1,103 @@
package docker
import (
"bytes"
"context"
"io"
"io/ioutil"
"strconv"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/strslice"
"github.com/docker/docker/client"
"github.com/portainer/portainer"
"github.com/portainer/portainer/archive"
)
// JobService represnts a service that handles jobs on the host
type JobService struct {
DockerClientFactory *ClientFactory
}
// NewJobService returns a pointer to a new job service
func NewJobService(dockerClientFactory *ClientFactory) *JobService {
return &JobService{
DockerClientFactory: dockerClientFactory,
}
}
// Execute will execute a script on the endpoint host with the supplied image as a container
func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image string, script []byte) error {
buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700)
if err != nil {
return err
}
cli, err := service.DockerClientFactory.CreateClient(endpoint, nodeName)
if err != nil {
return err
}
defer cli.Close()
err = pullImage(cli, image)
if err != nil {
return err
}
containerConfig := &container.Config{
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Tty: true,
WorkingDir: "/tmp",
Image: image,
Labels: map[string]string{
"io.portainer.job.endpoint": strconv.Itoa(int(endpoint.ID)),
},
Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}),
}
hostConfig := &container.HostConfig{
Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"},
NetworkMode: "host",
Privileged: true,
}
networkConfig := &network.NetworkingConfig{}
body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "")
if err != nil {
return err
}
copyOptions := types.CopyToContainerOptions{}
err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions)
if err != nil {
return err
}
startOptions := types.ContainerStartOptions{}
err = cli.ContainerStart(context.Background(), body.ID, startOptions)
if err != nil {
return err
}
return nil
}
func pullImage(cli *client.Client, image string) error {
imageReadCloser, err := cli.ImagePull(context.Background(), image, types.ImagePullOptions{})
if err != nil {
return err
}
defer imageReadCloser.Close()
_, err = io.Copy(ioutil.Discard, imageReadCloser)
if err != nil {
return err
}
return nil
}

View File

@ -18,7 +18,7 @@ func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
// CreateSnapshot creates a snapshot of a specific endpoint
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) {
cli, err := snapshotter.clientFactory.CreateClient(endpoint)
cli, err := snapshotter.clientFactory.CreateClient(endpoint, "")
if err != nil {
return nil, err
}

View File

@ -0,0 +1,116 @@
package endpoints
import (
"errors"
"net/http"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type endpointJobFromFilePayload struct {
Image string
File []byte
}
type endpointJobFromFileContentPayload struct {
Image string
FileContent string
}
func (payload *endpointJobFromFilePayload) Validate(r *http.Request) error {
file, _, err := request.RetrieveMultiPartFormFile(r, "File")
if err != nil {
return portainer.Error("Invalid Script file. Ensure that the file is uploaded correctly")
}
payload.File = file
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
if err != nil {
return portainer.Error("Invalid image name")
}
payload.Image = image
return nil
}
func (payload *endpointJobFromFileContentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.FileContent) {
return portainer.Error("Invalid script file content")
}
if govalidator.IsNull(payload.Image) {
return portainer.Error("Invalid image name")
}
return nil
}
// POST request on /api/endpoints/:id/job?method&nodeName
func (handler *Handler) endpointJob(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
method, err := request.RetrieveQueryParameter(r, "method", false)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method", err}
}
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
endpoint, err := handler.EndpointService.Endpoint(portainer.EndpointID(endpointID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
err = handler.requestBouncer.EndpointAccess(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied}
}
switch method {
case "file":
return handler.executeJobFromFile(w, r, endpoint, nodeName)
case "string":
return handler.executeJobFromFileContent(w, r, endpoint, nodeName)
}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string or file", errors.New(request.ErrInvalidQueryParameter)}
}
func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError {
payload := &endpointJobFromFilePayload{}
err := payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
err = handler.JobService.Execute(endpoint, nodeName, payload.Image, payload.File)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
}
return response.Empty(w)
}
func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, nodeName string) *httperror.HandlerError {
var payload endpointJobFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
err = handler.JobService.Execute(endpoint, nodeName, payload.Image, []byte(payload.FileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
}
return response.Empty(w)
}

View File

@ -31,6 +31,7 @@ type Handler struct {
FileService portainer.FileService
ProxyManager *proxy.Manager
Snapshotter portainer.Snapshotter
JobService portainer.JobService
}
// NewHandler creates a handler to manage endpoint operations.
@ -59,6 +60,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionAdd))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/extensions/{extensionType}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/job",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost)
return h
}

View File

@ -49,7 +49,7 @@ func (handler *Handler) webhookExecute(w http.ResponseWriter, r *http.Request) *
}
func (handler *Handler) executeServiceWebhook(w http.ResponseWriter, endpoint *portainer.Endpoint, resourceID string) *httperror.HandlerError {
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint)
dockerClient, err := handler.DockerClientFactory.CreateClient(endpoint, "")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Error creating docker client", err}
}

View File

@ -43,7 +43,7 @@ func buildOperation(request *http.Request) error {
dockerfileContent = []byte(req.Content)
}
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile")
buffer, err := archive.TarFileInBuffer(dockerfileContent, "Dockerfile", 0600)
if err != nil {
return err
}

View File

@ -68,6 +68,7 @@ type Server struct {
SSLCert string
SSLKey string
DockerClientFactory *docker.ClientFactory
JobService portainer.JobService
}
// Start starts the HTTP server
@ -109,6 +110,7 @@ func (server *Server) Start() error {
endpointHandler.FileService = server.FileService
endpointHandler.ProxyManager = proxyManager
endpointHandler.Snapshotter = server.Snapshotter
endpointHandler.JobService = server.JobService
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService

View File

@ -635,6 +635,11 @@ type (
Up(stack *Stack, endpoint *Endpoint) error
Down(stack *Stack, endpoint *Endpoint) error
}
// JobService represents a service to manage job execution on hosts
JobService interface {
Execute(endpoint *Endpoint, nodeName, image string, script []byte) error
}
)
const (

View File

@ -153,6 +153,8 @@ paths:
operationId: "DockerHubInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -175,6 +177,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -211,6 +215,8 @@ paths:
operationId: "EndpointList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -233,6 +239,8 @@ paths:
- "multipart/form-data"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "Name"
in: "formData"
@ -265,7 +273,7 @@ paths:
- name: "TLSSkipVerify"
in: "formData"
type: "string"
description: "Skip server verification when using TLS" (example: false)
description: "Skip server verification when using TLS (example: false)"
- name: "TLSCACertFile"
in: "formData"
type: "file"
@ -324,6 +332,8 @@ paths:
operationId: "EndpointInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -365,6 +375,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -413,6 +425,8 @@ paths:
Remove an endpoint.
**Access policy**: administrator
operationId: "EndpointDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -460,6 +474,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -495,6 +511,78 @@ paths:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/endpoints/{id}/job:
post:
tags:
- "endpoints"
summary: "Execute a job on the endpoint host"
description: |
Execute a job (script) on the underlying host of the endpoint.
**Access policy**: administrator
operationId: "EndpointJob"
consumes:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
- name: "method"
in: "query"
description: "Job execution method. Possible values: file or string."
required: true
type: "string"
- name: "nodeName"
in: "query"
description: "Optional. Hostname of a node when targeting a Portainer agent cluster."
required: true
type: "string"
- in: "body"
name: "body"
description: "Job details. Required when method equals string."
required: true
schema:
$ref: "#/definitions/EndpointJobRequest"
- name: "Image"
in: "formData"
type: "string"
description: "Container image which will be used to execute the job. Required when method equals file."
- name: "file"
in: "formData"
type: "file"
description: "Job script file. Required when method equals file."
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/Endpoint"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
403:
description: "Unauthorized"
schema:
$ref: "#/definitions/GenericError"
404:
description: "Endpoint not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Endpoint not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/endpoint_groups:
get:
tags:
@ -508,6 +596,8 @@ paths:
operationId: "EndpointGroupList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -530,6 +620,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -564,6 +656,8 @@ paths:
operationId: "EndpointGroupInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -605,6 +699,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -655,6 +751,8 @@ paths:
Remove an endpoint group.
**Access policy**: administrator
operationId: "EndpointGroupDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -702,6 +800,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -750,6 +850,8 @@ paths:
operationId: "RegistryList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -772,6 +874,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -813,6 +917,8 @@ paths:
operationId: "RegistryInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -854,6 +960,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -904,6 +1012,8 @@ paths:
Remove a registry.
**Access policy**: administrator
operationId: "RegistryDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -944,6 +1054,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -992,6 +1104,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -1042,6 +1156,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1092,6 +1208,8 @@ paths:
Remove a resource control.
**Access policy**: restricted
operationId: "ResourceControlDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1137,6 +1255,8 @@ paths:
operationId: "SettingsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -1159,6 +1279,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -1193,6 +1315,8 @@ paths:
operationId: "PublicSettingsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -1216,6 +1340,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -1248,6 +1374,8 @@ paths:
operationId: "StatusInspect"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -1271,6 +1399,8 @@ paths:
operationId: "StackList"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "filters"
in: "query"
@ -1303,6 +1433,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "type"
in: "query"
@ -1382,6 +1514,8 @@ paths:
operationId: "StackInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1427,6 +1561,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1479,6 +1615,8 @@ paths:
Remove a stack.
**Access policy**: restricted
operationId: "StackDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1529,6 +1667,8 @@ paths:
operationId: "StackFileInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1574,6 +1714,8 @@ paths:
operationId: "StackMigrate"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1628,6 +1770,8 @@ paths:
operationId: "UserList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -1651,6 +1795,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -1699,6 +1845,8 @@ paths:
operationId: "UserInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1740,6 +1888,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1790,6 +1940,8 @@ paths:
Remove a user.
**Access policy**: administrator
operationId: "UserDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1828,6 +1980,8 @@ paths:
operationId: "UserMembershipsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1871,6 +2025,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -1918,6 +2074,8 @@ paths:
operationId: "UserAdminCheck"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
204:
@ -1947,6 +2105,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -1991,6 +2151,8 @@ paths:
- multipart/form-data
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "path"
name: "certificate"
@ -2032,6 +2194,8 @@ paths:
operationId: "TagList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -2054,6 +2218,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -2093,6 +2259,8 @@ paths:
Remove a tag.
**Access policy**: administrator
operationId: "TagDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2125,6 +2293,8 @@ paths:
operationId: "TeamList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -2147,6 +2317,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -2195,6 +2367,8 @@ paths:
operationId: "TeamInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2243,6 +2417,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2284,6 +2460,8 @@ paths:
Remove a team.
**Access policy**: administrator
operationId: "TeamDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2323,6 +2501,8 @@ paths:
operationId: "TeamMembershipsInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2364,6 +2544,8 @@ paths:
operationId: "TeamMembershipList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
@ -2393,6 +2575,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -2443,6 +2627,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2493,6 +2679,8 @@ paths:
Remove a team membership. Access is only available to administrators leaders of the associated team.
**Access policy**: restricted
operationId: "TeamMembershipDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2539,6 +2727,8 @@ paths:
operationId: "TemplateList"
produces:
- "application/json"
security:
- jwt: []
parameters:
responses:
200:
@ -2561,6 +2751,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
@ -2602,6 +2794,8 @@ paths:
operationId: "TemplateInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2650,6 +2844,8 @@ paths:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -2698,6 +2894,8 @@ paths:
Remove a template.
**Access policy**: administrator
operationId: "TemplateDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
@ -3783,9 +3981,9 @@ definitions:
TemplateCreateRequest:
type: "object"
required:
- "type"
- "title"
- "description"
- "type"
- "title"
- "description"
properties:
type:
type: "integer"
@ -4137,7 +4335,7 @@ definitions:
TemplateRepository:
type: "object"
required:
- "URL"
- "URL"
properties:
URL:
type: "string"
@ -4164,6 +4362,20 @@ definitions:
type: "string"
example: "new-stack"
description: "If provided will rename the migrated stack"
EndpointJobRequest:
type: "object"
required:
- "Image"
- "FileContent"
properties:
Image:
type: "string"
example: "ubuntu:latest"
description: "Container image which will be used to execute the job"
FileContent:
type: "string"
example: "ls -lah /host/tmp"
description: "Content of the job script"
StackCreateRequest:
type: "object"
required:

5
api/swagger_config.json Normal file
View File

@ -0,0 +1,5 @@
{
"packageName": "portainer",
"packageVersion": "1.20-dev",
"projectName": "portainer"
}

View File

@ -3,6 +3,7 @@ angular.module('portainer.agent').component('volumeBrowser', {
controller: 'VolumeBrowserController',
bindings: {
volumeId: '<',
nodeName: '<'
nodeName: '<',
isUploadEnabled: '<'
}
});

View File

@ -8,4 +8,7 @@
rename="$ctrl.rename(name, newName)"
download="$ctrl.download(name)"
delete="$ctrl.delete(name)"
is-upload-allowed="$ctrl.isUploadEnabled"
on-file-selected-for-upload="$ctrl.onFileSelectedForUpload"
></files-datatable>

View File

@ -87,6 +87,16 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService
});
}
this.onFileSelectedForUpload = function onFileSelectedForUpload(file) {
VolumeBrowserService.upload(ctrl.state.path, file, ctrl.volumeId)
.then(function onFileUpload() {
onFileUploaded();
})
.catch(function onFileUpload(err) {
Notifications.error('Failure', err, 'Unable to upload file');
});
};
function parentPath(path) {
if (path.lastIndexOf('/') === 0) {
return '/';
@ -115,4 +125,14 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService
});
};
function onFileUploaded() {
refreshList();
}
function refreshList() {
browse(ctrl.state.path);
}
}]);

View File

@ -1,8 +1,10 @@
angular.module('portainer.agent')
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Agent', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/agents', {
endpointId: EndpointProvider.endpointID,
version: StateManager.getAgentApiVersion
},
{
query: { method: 'GET', isArray: true }

View File

@ -2,10 +2,12 @@ import angular from 'angular';
import { browseGetResponse } from './response/browse';
angular.module('portainer.agent')
.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager',
function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:action', {
endpointId: EndpointProvider.endpointID
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', {
endpointId: EndpointProvider.endpointID,
version: StateManager.getAgentApiVersion
},
{
ls: {

View File

@ -1,11 +1,12 @@
angular.module('portainer.agent').factory('Host', [
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) {
'use strict';
return $resource(
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/host/:action',
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/host/:action',
{
endpointId: EndpointProvider.endpointID
endpointId: EndpointProvider.endpointID,
version: StateManager.getAgentApiVersion
},
{
info: { method: 'GET', params: { action: 'info' } }

33
app/agent/rest/ping.js Normal file
View File

@ -0,0 +1,33 @@
angular.module('portainer.agent').factory('AgentPing', [
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q',
function AgentPingFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) {
'use strict';
return $resource(
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/ping',
{
endpointId: EndpointProvider.endpointID
},
{
ping: {
method: 'GET',
interceptor: {
response: function versionInterceptor(response) {
var instance = response.resource;
var version =
response.headers('Portainer-Agent-Api-Version') || 1;
instance.version = Number(version);
return instance;
},
responseError: function versionResponseError(error) {
// 404 - agent is up - set version to 1
if (error.status === 404) {
return { version: 1 };
}
return $q.reject(error);
}
}
}
}
);
}
]);

View File

@ -0,0 +1,10 @@
angular.module('portainer.agent')
.factory('AgentVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/agents', {
endpointId: EndpointProvider.endpointID
},
{
query: { method: 'GET', isArray: true }
});
}]);

View File

@ -0,0 +1,25 @@
import angular from 'angular';
import { browseGetResponse } from '../response/browse';
angular.module('portainer.agent')
.factory('BrowseVersion1', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:volumeID/:action', {
endpointId: EndpointProvider.endpointID
},
{
ls: {
method: 'GET', isArray: true, params: { action: 'ls' }
},
get: {
method: 'GET', params: { action: 'get' },
transformResponse: browseGetResponse
},
delete: {
method: 'DELETE', params: { action: 'delete' }
},
rename: {
method: 'PUT', params: { action: 'rename' }
}
});
}]);

View File

@ -2,14 +2,19 @@ import angular from 'angular';
import { AgentViewModel } from '../models/agent';
angular.module('portainer.agent').factory('AgentService', [
'$q', 'Agent','HttpRequestHelper', 'Host',
function AgentServiceFactory($q, Agent, HttpRequestHelper, Host) {
'$q', 'Agent', 'AgentVersion1', 'HttpRequestHelper', 'Host', 'StateManager',
function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) {
'use strict';
var service = {};
service.agents = agents;
service.hostInfo = hostInfo;
function getAgentApiVersion() {
var state = StateManager.getState();
return state.endpoint.agentApiVersion;
}
function hostInfo(nodeName) {
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
return Host.info().$promise;
@ -18,7 +23,10 @@ angular.module('portainer.agent').factory('AgentService', [
function agents() {
var deferred = $q.defer();
Agent.query({})
var agentVersion = getAgentApiVersion();
var service = agentVersion > 1 ? Agent : AgentVersion1;
service.query({ version: agentVersion })
.$promise.then(function success(data) {
var agents = data.map(function(item) {
return new AgentViewModel(item);

View File

@ -1,6 +1,6 @@
angular.module('portainer.agent').factory('HostBrowserService', [
'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q',
function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) {
'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', 'StateManager',
function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) {
var service = {};
service.ls = ls;
@ -31,9 +31,17 @@ angular.module('portainer.agent').factory('HostBrowserService', [
function upload(path, file, onProgress) {
var deferred = $q.defer();
var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/browse/put';
var agentVersion = StateManager.getAgentApiVersion();
var url =
API_ENDPOINT_ENDPOINTS +
'/' +
EndpointProvider.endpointID() +
'/docker' +
(agentVersion > 1 ? '/v' + agentVersion : '') +
'/browse/put';
Upload.upload({
url: url,
url: url,
data: { file: file, Path: path }
}).then(deferred.resolve, deferred.reject, onProgress);
return deferred.promise;

View File

@ -0,0 +1,14 @@
angular.module('portainer.agent').service('AgentPingService', [
'AgentPing',
function AgentPingService(AgentPing) {
var service = {};
service.ping = ping;
function ping() {
return AgentPing.ping().$promise;
}
return service;
}
]);

View File

@ -1,27 +1,60 @@
angular.module('portainer.agent').factory('VolumeBrowserService', [
'$q', 'Browse',
function VolumeBrowserServiceFactory($q, Browse) {
'StateManager', 'Browse', 'BrowseVersion1', '$q', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'Upload',
function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) {
'use strict';
var service = {};
function getAgentApiVersion() {
var state = StateManager.getState();
return state.endpoint.agentApiVersion;
}
function getBrowseService() {
var agentVersion = getAgentApiVersion();
return agentVersion > 1 ? Browse : BrowseVersion1;
}
service.ls = function(volumeId, path) {
return Browse.ls({ volumeID: volumeId, path: path }).$promise;
return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise;
};
service.get = function(volumeId, path) {
return Browse.get({ volumeID: volumeId, path: path }).$promise;
return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise;
};
service.delete = function(volumeId, path) {
return Browse.delete({ volumeID: volumeId, path: path }).$promise;
return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise;
};
service.rename = function(volumeId, path, newPath) {
var payload = {
CurrentFilePath: path,
CurrentFilePath: path,
NewFilePath: newPath
};
return Browse.rename({ volumeID: volumeId }, payload).$promise;
return getBrowseService().rename({ volumeID: volumeId, version: getAgentApiVersion() }, payload).$promise;
};
service.upload = function upload(path, file, volumeId, onProgress) {
var deferred = $q.defer();
var agentVersion = StateManager.getAgentApiVersion();
if (agentVersion <2) {
deferred.reject('upload is not supported on this agent version');
return;
}
var url =
API_ENDPOINT_ENDPOINTS +
'/' +
EndpointProvider.endpointID() +
'/docker' +
'/v' + agentVersion +
'/browse/put?volumeID=' +
volumeId;
Upload.upload({
url: url,
data: { file: file, Path: path }
}).then(deferred.resolve, deferred.reject, onProgress);
return deferred.promise;
};
return service;

View File

@ -37,9 +37,13 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin
function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh();
authManager.redirectWhenUnauthenticated();
Authentication.init();
$rootScope.$on('tokenHasExpired', function() {
// The unauthenticated event is broadcasted by the jwtInterceptor when
// hitting a 401. We're using this instead of the usual combination of
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
// to have more controls on which URL should trigger the unauthenticated state.
$rootScope.$on('unauthenticated', function () {
$state.go('portainer.auth', {error: 'Your session has expired'});
});
}

View File

@ -19,9 +19,6 @@ angular.module('portainer')
jwtOptionsProvider.config({
tokenGetter: ['LocalStorage', function(LocalStorage) {
return LocalStorage.getJWT();
}],
unauthenticatedRedirector: ['$state', function($state) {
$state.go('portainer.auth', {error: 'Your session has expired'});
}]
});
$httpProvider.interceptors.push('jwtInterceptor');

View File

@ -151,6 +151,16 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var hostJob = {
name: 'docker.host.job',
url: '/job',
views: {
'content@': {
component: 'hostJobView'
}
}
};
var events = {
name: 'docker.events',
url: '/events',
@ -265,6 +275,16 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var nodeJob = {
name: 'docker.nodes.node.job',
url: '/job',
views: {
'content@': {
component: 'nodeJobView'
}
}
};
var secrets = {
name: 'docker.secrets',
url: '/secrets',
@ -436,7 +456,7 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
$stateRegistryProvider.register(configs);
$stateRegistryProvider.register(config);
@ -452,6 +472,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(dashboard);
$stateRegistryProvider.register(host);
$stateRegistryProvider.register(hostBrowser);
$stateRegistryProvider.register(hostJob);
$stateRegistryProvider.register(events);
$stateRegistryProvider.register(images);
$stateRegistryProvider.register(image);
@ -463,6 +484,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(nodes);
$stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeBrowser);
$stateRegistryProvider.register(nodeJob);
$stateRegistryProvider.register(secrets);
$stateRegistryProvider.register(secret);
$stateRegistryProvider.register(secretCreation);

View File

@ -0,0 +1,118 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ $ctrl.titleText }}
</div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-primary" ng-click="$ctrl.purgeAction()">
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Clear job history
</button>
</div>
<div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table table-hover table-filters nowrap-cells">
<thead>
<tr>
<th>
<a ng-click="$ctrl.changeOrderBy('Id')">
Id
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
<a ng-click="$ctrl.changeOrderBy('Status')">
State
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
</a>
<div>
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled"> Filter
<i class="fa fa-filter" aria-hidden="true"></i></span>
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled">Filter
<i class="fa fa-check" aria-hidden="true"></i></span>
</div>
<div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Filter by state
</div>
<div class="menuContent">
<div class="md-checkbox" ng-repeat="filter in $ctrl.filters.state.values track by $index">
<input id="filter_state_{{ $index }}" type="checkbox" ng-model="filter.display" ng-change="$ctrl.onStateFilterChange()" />
<label for="filter_state_{{ $index }}">{{ filter.label }}</label>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div>
</div>
</div>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Created')">
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Created' && $ctrl.state.reverseOrder"></i>
Created
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))">
<td>
<a ui-sref="docker.containers.container.logs({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Id }}">
{{ item.Id | truncate: 32}}</a>
</td>
<td>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive"
uib-tooltip="This container has a health check">{{ item.Status }}</span>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) === -1" class="label label-{{ item.Status|containerstatusbadge }}">
{{ item.Status }}</span>
</td>
<td>
{{item.Created | getisodatefromtimestamp}}
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="9" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="9" class="text-center text-muted">No jobs available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
angular.module('portainer.docker').component('jobsDatatable', {
templateUrl: './jobsDatatable.html',
controller: 'JobsDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<'
}
});

View File

@ -0,0 +1,143 @@
import angular from 'angular';
import _ from 'lodash';
angular.module('portainer.docker')
.controller('JobsDatatableController', ['$q', '$state', 'PaginationService', 'DatatableService', 'ContainerService', 'ModalService', 'Notifications',
function ($q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) {
var ctrl = this;
this.state = {
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
displayTextFilter: false
};
this.filters = {
state: {
open: false,
enabled: false,
values: []
}
};
this.changeOrderBy = function (orderField) {
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
this.state.orderBy = orderField;
DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder);
};
this.changePaginationLimit = function () {
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
};
this.applyFilters = function (value) {
var container = value;
var filters = ctrl.filters;
for (var i = 0; i < filters.state.values.length; i++) {
var filter = filters.state.values[i];
if (container.Status === filter.label && filter.display) {
return true;
}
}
return false;
};
this.onStateFilterChange = function () {
var filters = this.filters.state.values;
var filtered = false;
for (var i = 0; i < filters.length; i++) {
var filter = filters[i];
if (!filter.display) {
filtered = true;
}
}
this.filters.state.enabled = filtered;
DatatableService.setDataTableFilters(this.tableKey, this.filters);
};
this.prepareTableFromDataset = function () {
var availableStateFilters = [];
for (var i = 0; i < this.dataset.length; i++) {
var item = this.dataset[i];
availableStateFilters.push({
label: item.Status,
display: true
});
}
this.filters.state.values = _.uniqBy(availableStateFilters, 'label');
};
this.updateStoredFilters = function (storedFilters) {
var datasetFilters = this.filters.state.values;
for (var i = 0; i < datasetFilters.length; i++) {
var filter = datasetFilters[i];
var existingFilter = _.find(storedFilters, ['label', filter.label]);
if (existingFilter && !existingFilter.display) {
filter.display = existingFilter.display;
this.filters.state.enabled = true;
}
}
};
function confirmPurgeJobs() {
return showConfirmationModal();
function showConfirmationModal() {
var deferred = $q.defer();
ModalService.confirm({
title: 'Are you sure ?',
message: 'Clearing job history will remove all stopped jobs containers.',
buttons: {
confirm: {
label: 'Purge',
className: 'btn-danger'
}
},
callback: function onConfirm(confirmed) {
deferred.resolve(confirmed);
}
});
return deferred.promise;
}
}
this.purgeAction = function () {
confirmPurgeJobs().then(function success(confirmed) {
if (!confirmed) {
return $q.when();
}
ContainerService.prune({ label: ['io.portainer.job.endpoint'] }).then(function success() {
Notifications.success('Success', 'Job hisotry cleared');
$state.reload();
}).catch(function error(err) {
Notifications.error('Failure', err.message, 'Unable to clear job history');
});
});
};
this.$onInit = function () {
setDefaults(this);
this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.updateStoredFilters(storedFilters.state.values);
}
this.filters.state.open = false;
};
function setDefaults(ctrl) {
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
}
}
]);

View File

@ -11,14 +11,25 @@
<rd-header-content>Docker</rd-header-content>
</rd-header>
<host-details-panel
host="$ctrl.hostDetails"
is-agent="$ctrl.isAgent"
browse-url="{{$ctrl.browseUrl}}"></host-details-panel>
<host-details-panel
host="$ctrl.hostDetails"
is-browse-enabled="$ctrl.isAgent && $ctrl.agentApiVersion > 1"
browse-url="{{$ctrl.browseUrl}}"
is-job-enabled="$ctrl.isJobEnabled"
job-url="{{$ctrl.jobUrl}}"
></host-details-panel>
<engine-details-panel engine="$ctrl.engineDetails"></engine-details-panel>
<devices-panel ng-if="$ctrl.isAgent" devices="$ctrl.devices"></devices-panel>
<disks-panel ng-if="$ctrl.isAgent" disks="$ctrl.disks"></disks-panel>
<jobs-datatable
ng-if="$ctrl.isJobEnabled && $ctrl.jobs"
title-text="Jobs" title-icon="fa-tasks"
dataset="$ctrl.jobs"
table-key="jobs"
order-by="Created" reverse-order="true"
></jobs-datatable>
<ng-transclude></ng-transclude>
<devices-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1" devices="$ctrl.devices"></devices-panel>
<disks-panel ng-if="$ctrl.isAgent && $ctrl.agentApiVersion > 1" disks="$ctrl.disks"></disks-panel>
<ng-transclude></ng-transclude>

View File

@ -6,8 +6,12 @@ angular.module('portainer.docker').component('hostOverview', {
devices: '<',
disks: '<',
isAgent: '<',
agentApiVersion: '<',
refreshUrl: '@',
browseUrl: '@'
browseUrl: '@',
jobUrl: '@',
isJobEnabled: '<',
jobs: '<'
},
transclude: true
});

View File

@ -26,20 +26,19 @@
<td>Total memory</td>
<td>{{ $ctrl.host.totalMemory | humansize }}</td>
</tr>
<tr ng-if="$ctrl.isAgent">
<tr ng-if="$ctrl.isBrowseEnabled || $ctrl.isJobEnabled">
<td colspan="2">
<button
class="btn btn-primary btn-sm"
title="Browse"
ui-sref="{{$ctrl.browseUrl}}">
<button class="btn btn-primary btn-sm" title="Browse" ng-if="$ctrl.isBrowseEnabled" ui-sref="{{$ctrl.browseUrl}}">
Browse
</button>
<button class="btn btn-primary btn-sm" title="Execute job" ng-if="$ctrl.isJobEnabled" ui-sref="{{$ctrl.jobUrl}}">
Execute job
</button>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
</div>

View File

@ -1,9 +1,10 @@
angular.module('portainer.docker').component('hostDetailsPanel', {
templateUrl:
'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html',
templateUrl: './host-details-panel.html',
bindings: {
host: '<',
isAgent: '<',
browseUrl: '@'
isJobEnabled: '<',
isBrowseEnabled: '<',
browseUrl: '@',
jobUrl: '@'
}
});

View File

@ -67,7 +67,8 @@ export function ContainerViewModel(data) {
export function ContainerStatsViewModel(data) {
this.Date = data.read;
this.MemoryUsage = data.memory_stats.usage;
this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache;
this.MemoryCache = data.memory_stats.stats.cache;
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;

View File

@ -71,6 +71,9 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
},
update: {
method: 'POST', params: { id: '@id', action: 'update'}
},
prune: {
method: 'POST', params: { action: 'prune', filters: '@filters' }
}
});
}]);

View File

@ -189,5 +189,9 @@ function ContainerServiceFactory($q, Container, ResourceControlService, LogHelpe
return Container.inspect({ id: id }).$promise;
};
service.prune = function(filters) {
return Container.prune({ filters: filters }).$promise;
};
return service;
}]);

View File

@ -6,8 +6,8 @@ import { ContainerDetailsViewModel } from '../../../models/container';
angular.module('portainer.docker')
.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'HttpRequestHelper',
function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, HttpRequestHelper) {
.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper',
function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) {
$scope.create = create;
@ -26,7 +26,9 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
MemoryLimit: 0,
MemoryReservation: 0,
NodeName: null,
capabilities: []
capabilities: [],
LogDriverName: '',
LogDriverOpts: []
};
$scope.extraNetworks = {};
@ -117,6 +119,14 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
$scope.config.HostConfig.Devices.splice(index, 1);
};
$scope.addLogDriverOpt = function() {
$scope.formValues.LogDriverOpts.push({ name: '', value: ''});
};
$scope.removeLogDriverOpt = function(index) {
$scope.formValues.LogDriverOpts.splice(index, 1);
};
$scope.fromContainerMultipleNetworks = false;
function prepareImageConfig(config) {
@ -264,6 +274,23 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
}
}
function prepareLogDriver(config) {
var logOpts = {};
if ($scope.formValues.LogDriverName) {
config.HostConfig.LogConfig = { Type: $scope.formValues.LogDriverName };
if ($scope.formValues.LogDriverName !== 'none') {
$scope.formValues.LogDriverOpts.forEach(function (opt) {
if (opt.name) {
logOpts[opt.name] = opt.value;
}
});
if (Object.keys(logOpts).length !== 0 && logOpts.constructor === Object) {
config.HostConfig.LogConfig.Config = logOpts;
}
}
}
}
function prepareCapabilities(config) {
var allowed = $scope.formValues.capabilities.filter(function(item) {return item.allowed === true;});
var notAllowed = $scope.formValues.capabilities.filter(function(item) {return item.allowed === false;});
@ -285,6 +312,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
prepareLabels(config);
prepareDevices(config);
prepareResources(config);
prepareLogDriver(config);
prepareCapabilities(config);
return config;
}
@ -575,6 +603,11 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
Notifications.error('Failure', err, 'Unable to retrieve application settings');
});
PluginService.loggingPlugins(apiVersion < 1.25)
.then(function success(loggingDrivers) {
$scope.availableLoggingDrivers = loggingDrivers;
});
var userDetails = Authentication.getUserDetails();
$scope.isAdmin = userDetails.role === 1;
}

View File

@ -146,7 +146,7 @@
<rd-widget-header icon="fa-cog" title-text="Advanced container settings"></rd-widget-header>
<rd-widget-body>
<ul class="nav nav-pills nav-justified">
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command</a></li>
<li class="active interactive"><a data-target="#command" data-toggle="tab">Command & logging</a></li>
<li class="interactive"><a data-target="#volumes" data-toggle="tab">Volumes</a></li>
<li class="interactive"><a data-target="#network" data-toggle="tab">Network</a></li>
<li class="interactive"><a data-target="#env" data-toggle="tab">Env</a></li>
@ -162,7 +162,7 @@
<form class="form-horizontal" style="margin-top: 15px;">
<!-- command-input -->
<div class="form-group">
<label for="container_command" class="col-sm-2 col-lg-1 control-label text-left">Command</label>
<label for="container_command" class="col-sm-2 col-lg-1 control-label text-left">Command & logging</label>
<div class="col-sm-9">
<input type="text" class="form-control" ng-model="config.Cmd" id="container_command" placeholder="e.g. /usr/bin/nginx -t -c /mynginx.conf">
</div>
@ -221,6 +221,59 @@
</div>
</div>
<!-- !console -->
<div class="col-sm-12 form-section-title">
Logging
</div>
<!-- logging-driver -->
<div class="form-group">
<label for="log-driver" class="col-sm-2 col-lg-1 control-label text-left">Driver</label>
<div class="col-sm-4">
<select class="form-control" ng-model="formValues.LogDriverName" id="log-driver">
<option selected value="">Default logging driver</option>
<option ng-repeat="driver in availableLoggingDrivers" ng-value="driver">{{ driver }}</option>
<option value="none">none</option>
</select>
</div>
<div class="col-sm-5">
<p class="small text-muted">
Logging driver that will override the default docker daemon driver. Select Default logging driver if you don't want to override it. Supported logging drivers can be found <a href="https://docs.docker.com/engine/admin/logging/overview/#supported-logging-drivers" target="_blank">in the Docker documentation</a>.
</p>
</div>
</div>
<!-- !logging-driver -->
<!-- logging-opts -->
<div class="form-group">
<div class="col-sm-12" style="margin-top: 5px;">
<label class="control-label text-left">
Options
<portainer-tooltip position="top" message="Add button is disabled unless a driver other than none or default is selected. Options are specific to the selected driver, refer to the driver documentation."></portainer-tooltip>
</label>
<span class="label label-default interactive" style="margin-left: 10px;" ng-click="!formValues.LogDriverName || formValues.LogDriverName === 'none' || addLogDriverOpt(formValues.LogDriverName)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> add logging driver option
</span>
</div>
<!-- logging-opts-input-list -->
<div class="col-sm-12 form-inline" style="margin-top: 10px;">
<div ng-repeat="opt in formValues.LogDriverOpts" style="margin-top: 2px;">
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">option</span>
<input type="text" class="form-control" ng-model="opt.name" placeholder="e.g. FOO">
</div>
<div class="input-group col-sm-5 input-group-sm">
<span class="input-group-addon">value</span>
<input type="text" class="form-control" ng-model="opt.value" placeholder="e.g. bar">
</div>
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLogDriverOpt($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</div>
</div>
<!-- logging-opts-input-list -->
</div>
<!-- !logging-opts -->
</form>
</div>
<!-- !tab-command -->

View File

@ -34,9 +34,8 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char
function updateMemoryChart(stats, chart) {
var label = moment(stats.Date).format('HH:mm:ss');
var value = stats.MemoryUsage;
ChartService.UpdateMemoryChart(label, value, chart);
ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart);
}
function updateCPUChart(stats, chart) {

View File

@ -1,21 +1,17 @@
angular
.module('portainer.docker')
.controller('HostBrowserViewController', [
'SystemService', 'HttpRequestHelper',
function HostBrowserViewController(SystemService, HttpRequestHelper) {
var ctrl = this;
angular.module('portainer.docker').controller('HostBrowserViewController', [
'SystemService', 'Notifications',
function HostBrowserViewController(SystemService, Notifications) {
var ctrl = this;
ctrl.$onInit = $onInit;
ctrl.$onInit = $onInit;
function $onInit() {
loadInfo();
}
function loadInfo() {
SystemService.info().then(function onInfoLoaded(host) {
HttpRequestHelper.setPortainerAgentTargetHeader(host.Name);
ctrl.host = host;
});
}
function $onInit() {
SystemService.info()
.then(function onInfoLoaded(host) {
ctrl.host = host;
})
.catch(function onError(err) {
Notifications.error('Unable to retrieve host information', err);
});
}
]);
}
]);

View File

@ -0,0 +1,17 @@
angular.module('portainer.docker').controller('HostJobController', [
'SystemService', 'Notifications',
function HostJobController(SystemService, Notifications) {
var ctrl = this;
ctrl.$onInit = $onInit;
function $onInit() {
SystemService.info()
.then(function onInfoLoaded(host) {
ctrl.host = host;
})
.catch(function onError(err) {
Notifications.error('Unable to retrieve host information', err);
});
}
}
]);

View File

@ -0,0 +1,16 @@
<rd-header>
<rd-header-title title-text="Host job execution"></rd-header-title>
<rd-header-content>
Host &gt; <a ui-sref="docker.host">{{ $ctrl.host.Name }}</a> &gt; execute job
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<execute-job-form></execute-job-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,4 @@
angular.module('portainer.docker').component('hostJobView', {
templateUrl: './host-job.html',
controller: 'HostJobController'
});

View File

@ -1,44 +1,53 @@
import angular from 'angular';
angular.module('portainer.docker').controller('HostViewController', [
'$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService',
function HostViewController($q, SystemService, Notifications, StateManager, AgentService) {
'$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', 'ContainerService', 'Authentication',
function HostViewController($q, SystemService, Notifications, StateManager, AgentService, ContainerService, Authentication) {
var ctrl = this;
this.$onInit = initView;
ctrl.state = {
isAgent: false
isAgent: false,
isAdmin : false
};
this.engineDetails = {};
this.hostDetails = {};
this.devices = null;
this.disks = null;
function initView() {
var applicationState = StateManager.getState();
ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy;
ctrl.state.isAdmin = Authentication.getUserDetails().role === 1;
var agentApiVersion = applicationState.endpoint.agentApiVersion;
ctrl.state.agentApiVersion = agentApiVersion;
$q.all({
version: SystemService.version(),
info: SystemService.info()
info: SystemService.info(),
jobs: ctrl.state.isAdmin ? ContainerService.containers(true, { label: ['io.portainer.job.endpoint'] }) : []
})
.then(function success(data) {
ctrl.engineDetails = buildEngineDetails(data);
ctrl.hostDetails = buildHostDetails(data.info);
.then(function success(data) {
ctrl.engineDetails = buildEngineDetails(data);
ctrl.hostDetails = buildHostDetails(data.info);
ctrl.jobs = data.jobs;
if (ctrl.state.isAgent) {
return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) {
ctrl.devices = agentHostInfo.PCIDevices;
ctrl.disks = agentHostInfo.PhysicalDisks;
});
}
})
.catch(function error(err) {
Notifications.error(
'Failure',
err,
'Unable to retrieve engine details'
);
});
if (ctrl.state.isAgent && agentApiVersion > 1) {
return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) {
ctrl.devices = agentHostInfo.PCIDevices;
ctrl.disks = agentHostInfo.PhysicalDisks;
});
}
})
.catch(function error(err) {
Notifications.error(
'Failure',
err,
'Unable to retrieve engine details'
);
});
}
function buildEngineDetails(data) {

View File

@ -2,9 +2,12 @@
engine-details="$ctrl.engineDetails"
host-details="$ctrl.hostDetails"
is-agent="$ctrl.state.isAgent"
agent-api-version="$ctrl.state.agentApiVersion"
disks="$ctrl.disks"
devices="$ctrl.devices"
refresh-url="docker.host"
browse-url="docker.host.browser"
></host-overview>
is-job-enabled="$ctrl.state.isAdmin"
job-url="docker.host.job"
jobs="$ctrl.jobs"
></host-overview>

View File

@ -1,21 +1,21 @@
import angular from 'angular';
angular.module('portainer.docker').controller('NodeBrowserController', [
'NodeService', 'HttpRequestHelper', '$stateParams',
function NodeBrowserController(NodeService, HttpRequestHelper, $stateParams) {
'$stateParams', 'NodeService', 'HttpRequestHelper', 'Notifications',
function NodeBrowserController($stateParams, NodeService, HttpRequestHelper, Notifications) {
var ctrl = this;
ctrl.$onInit = $onInit;
function $onInit() {
ctrl.nodeId = $stateParams.id;
loadNode();
}
function loadNode() {
NodeService.node(ctrl.nodeId).then(function onNodeLoaded(node) {
NodeService.node(ctrl.nodeId)
.then(function onNodeLoaded(node) {
HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname);
ctrl.node = node;
})
.catch(function onError(err) {
Notifications.error('Unable to retrieve host information', err);
});
}
}

View File

@ -1,30 +1,45 @@
import angular from 'angular';
angular.module('portainer.docker').controller('NodeDetailsViewController', [
'$stateParams', 'NodeService', 'StateManager', 'AgentService',
function NodeDetailsViewController($stateParams, NodeService, StateManager, AgentService) {
'$q', '$stateParams', 'NodeService', 'StateManager', 'AgentService', 'ContainerService', 'Authentication',
function NodeDetailsViewController($q, $stateParams, NodeService, StateManager, AgentService, ContainerService, Authentication) {
var ctrl = this;
ctrl.$onInit = initView;
ctrl.state = {
isAgent: false
isAgent: false,
isAdmin: false
};
function initView() {
var applicationState = StateManager.getState();
ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy;
ctrl.state.isAdmin = Authentication.getUserDetails().role === 1;
var fetchJobs = ctrl.state.isAdmin && ctrl.state.isAgent;
var nodeId = $stateParams.id;
NodeService.node(nodeId).then(function(node) {
$q.all({
node: NodeService.node(nodeId),
jobs: fetchJobs ? ContainerService.containers(true, { label: ['io.portainer.job.endpoint'] }) : []
})
.then(function (data) {
var node = data.node;
ctrl.originalNode = node;
ctrl.hostDetails = buildHostDetails(node);
ctrl.engineDetails = buildEngineDetails(node);
ctrl.nodeDetails = buildNodeDetails(node);
ctrl.jobs = data.jobs;
if (ctrl.state.isAgent) {
AgentService.hostInfo(node.Hostname).then(function onHostInfoLoad(
agentHostInfo
) {
var agentApiVersion = applicationState.endpoint.agentApiVersion;
ctrl.state.agentApiVersion = agentApiVersion;
if (agentApiVersion < 2) {
return;
}
AgentService.hostInfo(node.Hostname)
.then(function onHostInfoLoad(agentHostInfo) {
ctrl.devices = agentHostInfo.PCIDevices;
ctrl.disks = agentHostInfo.PhysicalDisks;
});
@ -66,12 +81,12 @@ angular.module('portainer.docker').controller('NodeDetailsViewController', [
function transformPlugins(pluginsList, type) {
return pluginsList
.filter(function(plugin) {
return plugin.Type === type;
})
.map(function(plugin) {
return plugin.Name;
});
.filter(function(plugin) {
return plugin.Type === type;
})
.map(function(plugin) {
return plugin.Name;
});
}
}
]);

View File

@ -1,15 +1,18 @@
<host-overview
agent-api-version="$ctrl.state.agentApiVersion"
is-agent="$ctrl.state.isAgent"
host-details="$ctrl.hostDetails"
engine-details="$ctrl.engineDetails"
disks="$ctrl.disks"
devices="$ctrl.devices"
refresh-url="docker.nodes.node"
browse-url="docker.nodes.node.browse"
is-job-enabled="$ctrl.state.isAdmin && $ctrl.state.isAgent"
job-url="docker.nodes.node.job"
jobs="$ctrl.jobs"
>
<swarm-node-details-panel
details="$ctrl.nodeDetails"
original-node="$ctrl.originalNode"
></swarm-node-details-panel>
</host-overview>
</host-overview>

View File

@ -0,0 +1,20 @@
angular.module('portainer.docker').controller('NodeJobController', [
'$stateParams', 'NodeService', 'HttpRequestHelper', 'Notifications',
function NodeJobController($stateParams, NodeService, HttpRequestHelper, Notifications) {
var ctrl = this;
ctrl.$onInit = $onInit;
function $onInit() {
ctrl.nodeId = $stateParams.id;
NodeService.node(ctrl.nodeId)
.then(function onNodeLoaded(node) {
HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname);
ctrl.node = node;
})
.catch(function onError(err) {
Notifications.error('Unable to retrieve host information', err);
});
}
}
]);

View File

@ -0,0 +1,18 @@
<rd-header>
<rd-header-title title-text="Host job execution"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.swarm">Swarm</a> &gt; <a ui-sref="docker.nodes.node({ id: $ctrl.nodeId })">{{ $ctrl.node.Hostname }}</a> &gt; execute job
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<execute-job-form
node-name="$ctrl.node.Hostname"
></execute-job-form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,4 @@
angular.module('portainer.docker').component('nodeJobView', {
templateUrl: './node-job.html',
controller: 'NodeJobController'
});

View File

@ -10,7 +10,7 @@
<div ng-repeat="config in formValues.Configs" style="margin-top: 2px;">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">config</span>
<select class="form-control" ng-model="config.model" ng-options="config.Name for config in availableConfigs">
<select class="form-control" ng-model="config.model" ng-options="config.Name for config in availableConfigs | orderBy: 'Name'">
<option value="" selected="selected">Select a config</option>
</select>
</div>

View File

@ -15,7 +15,7 @@
<div ng-repeat="secret in formValues.Secrets track by $index" style="margin-top: 4px;">
<div class="input-group col-sm-4 input-group-sm">
<span class="input-group-addon">secret</span>
<select class="form-control" ng-model="secret.model" ng-options="secret.Name for secret in availableSecrets">
<select class="form-control" ng-model="secret.model" ng-options="secret.Name for secret in availableSecrets | orderBy: 'Name'">
<option value="" selected="selected">Select a secret</option>
</select>
</div>

View File

@ -5,7 +5,7 @@
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px;">
Add a config:
<select class="form-control" ng-options="config.Name for config in configs" ng-model="newConfig">
<select class="form-control" ng-options="config.Name for config in configs | orderBy: 'Name'" ng-model="newConfig">
<option selected disabled hidden value="">Select a config</option>
</select>
<a class="btn btn-default btn-sm" ng-click="addConfig(service, newConfig)">

View File

@ -5,7 +5,7 @@
<rd-widget-body classes="no-padding">
<div class="form-inline" style="padding: 10px;">
Add a secret:
<select class="form-control" ng-options="secret.Name for secret in secrets" ng-model="state.addSecret.secret">
<select class="form-control" ng-options="secret.Name for secret in secrets | orderBy: 'Name'" ng-model="state.addSecret.secret">
<option selected disabled hidden value="">Select a secret</option>
</select>
<div class="form-group" ng-if="applicationState.endpoint.apiVersion >= 1.30 && state.addSecret.override">

View File

@ -1,12 +1,13 @@
import angular from 'angular';
angular.module('portainer.docker')
.controller('BrowseVolumeController', ['$scope', '$transition$',
function ($scope, $transition$) {
.controller('BrowseVolumeController', ['$scope', '$transition$', 'StateManager',
function ($scope, $transition$, StateManager) {
function initView() {
$scope.volumeId = $transition$.params().id;
$scope.nodeName = $transition$.params().nodeName;
$scope.agentApiVersion = StateManager.getAgentApiVersion();
}
initView();

View File

@ -10,6 +10,8 @@
<volume-browser
volume-id="volumeId"
node-name="nodeName"
is-upload-enabled="agentApiVersion > 1"
></volume-browser>
</div>
</div>

View File

@ -0,0 +1,69 @@
angular.module('portainer.app')
.controller('JobFormController', ['$state', 'LocalStorage', 'EndpointService', 'EndpointProvider', 'Notifications',
function ($state, LocalStorage, EndpointService, EndpointProvider, Notifications) {
var ctrl = this;
ctrl.$onInit = onInit;
ctrl.editorUpdate = editorUpdate;
ctrl.executeJob = executeJob;
ctrl.state = {
Method: 'editor',
formValidationError: '',
actionInProgress: false
};
ctrl.formValues = {
Image: 'ubuntu:latest',
JobFileContent: '',
JobFile: null
};
function onInit() {
var storedImage = LocalStorage.getJobImage();
if (storedImage) {
ctrl.formValues.Image = storedImage;
}
}
function editorUpdate(cm) {
ctrl.formValues.JobFileContent = cm.getValue();
}
function createJob(image, method) {
var endpointId = EndpointProvider.endpointID();
var nodeName = ctrl.nodeName;
if (method === 'editor') {
var jobFileContent = ctrl.formValues.JobFileContent;
return EndpointService.executeJobFromFileContent(image, jobFileContent, endpointId, nodeName);
}
var jobFile = ctrl.formValues.JobFile;
return EndpointService.executeJobFromFileUpload(image, jobFile, endpointId, nodeName);
}
function executeJob() {
var method = ctrl.state.Method;
if (method === 'editor' && ctrl.formValues.JobFileContent === '') {
ctrl.state.formValidationError = 'Script file content must not be empty';
return;
}
var image = ctrl.formValues.Image;
LocalStorage.storeJobImage(image);
ctrl.state.actionInProgress = true;
createJob(image, method)
.then(function success() {
Notifications.success('Job successfully created');
$state.go('^');
})
.catch(function error(err) {
Notifications.error('Job execution failure', err);
})
.finally(function final() {
ctrl.state.actionInProgress = false;
});
}
}]);

View File

@ -0,0 +1,110 @@
<form class="form-horizontal" name="executeJobForm">
<!-- image-input -->
<div class="form-group">
<label for="job_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
<input type="text" class="form-control" ng-model="$ctrl.formValues.Image" id="job_image" name="job_image" placeholder="e.g. ubuntu:latest" required auto-focus>
</div>
</div>
<div class="form-group" ng-show="executeJobForm.job_image.$invalid">
<div class="col-sm-12 small text-warning">
<div ng-messages="executeJobForm.job_image.$error">
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
</div>
</div>
</div>
<!-- !image-input -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
This job will run inside a privileged container on the host. You can access the host filesystem under the
<code>/host</code> folder.
</span>
</div>
<!-- execution-method -->
<div class="col-sm-12 form-section-title">
Job creation
</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="$ctrl.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>
Web editor
</div>
<p>Use our Web editor</p>
</label>
</div>
<div>
<input type="radio" id="method_upload" ng-model="$ctrl.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>
Upload
</div>
<p>Upload from your computer</p>
</label>
</div>
</div>
</div>
<!-- !execution-method -->
<!-- web-editor -->
<div ng-show="$ctrl.state.Method === 'editor'">
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor
identifier="execute-job-editor"
placeholder="# Define or paste the content of your script file here"
on-change="$ctrl.editorUpdate">
</code-editor>
</div>
</div>
</div>
<!-- !web-editor -->
<!-- upload -->
<div ng-show="$ctrl.state.Method === '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 script file from your computer.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.JobFile">Select file</button>
<span style="margin-left: 5px;">
{{ $ctrl.formValues.JobFile.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.JobFile" aria-hidden="true"></i>
</span>
</div>
</div>
</div>
<!-- !upload -->
<!-- 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="$ctrl.state.actionInProgress || !executeJobForm.$valid
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.JobFile)"
ng-click="$ctrl.executeJob()"
button-spinner="$ctrl.state.actionInProgress">
<span ng-hide="$ctrl.state.actionInProgress">Execute</span>
<span ng-show="$ctrl.state.actionInProgress">Starting job...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
<!-- !actions -->
</form>

View File

@ -0,0 +1,7 @@
angular.module('portainer.app').component('executeJobForm', {
templateUrl: './execute-job-form.html',
controller: 'JobFormController',
bindings: {
nodeName: '<'
}
});

View File

@ -9,6 +9,7 @@ angular.module('portainer.app')
update: { method: 'PUT', params: { id: '@id' } },
updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } },
remove: { method: 'DELETE', params: { id: '@id'} },
snapshot: { method: 'POST', params: { id: 'snapshot' }}
snapshot: { method: 'POST', params: { id: 'snapshot' }},
executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } }
});
}]);

View File

@ -102,5 +102,18 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return deferred.promise;
};
service.executeJobFromFileUpload = function (image, jobFile, endpointId, nodeName) {
return FileUploadService.executeEndpointJob(image, jobFile, endpointId, nodeName);
};
service.executeJobFromFileContent = function (image, jobFileContent, endpointId, nodeName) {
var payload = {
Image: image,
FileContent: jobFileContent
};
return Endpoints.executeJob({ id: endpointId, method: 'string', nodeName: nodeName }, payload).$promise;
};
return service;
}]);

View File

@ -4,43 +4,59 @@ angular.module('portainer.app')
.factory('Authentication', ['$q', 'Auth', 'jwtHelper', 'LocalStorage', 'StateManager', 'EndpointProvider', function AuthenticationFactory($q, Auth, jwtHelper, LocalStorage, StateManager, EndpointProvider) {
'use strict';
var service = {};
var user = {};
return {
init: function() {
var jwt = LocalStorage.getJWT();
if (jwt) {
var tokenPayload = jwtHelper.decodeToken(jwt);
user.username = tokenPayload.username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
}
},
login: function(username, password) {
return $q(function (resolve, reject) {
Auth.login({username: username, password: password}).$promise
.then(function(data) {
LocalStorage.storeJWT(data.jwt);
var tokenPayload = jwtHelper.decodeToken(data.jwt);
user.username = username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
resolve();
}, function() {
reject();
});
});
},
logout: function() {
StateManager.clean();
EndpointProvider.clean();
LocalStorage.clean();
},
isAuthenticated: function() {
var jwt = LocalStorage.getJWT();
return jwt && !jwtHelper.isTokenExpired(jwt);
},
getUserDetails: function() {
return user;
service.init = init;
service.login = login;
service.logout = logout;
service.isAuthenticated = isAuthenticated;
service.getUserDetails = getUserDetails;
function init() {
var jwt = LocalStorage.getJWT();
if (jwt) {
var tokenPayload = jwtHelper.decodeToken(jwt);
user.username = tokenPayload.username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
}
};
}
function login(username, password) {
var deferred = $q.defer();
Auth.login({username: username, password: password}).$promise
.then(function success(data) {
LocalStorage.storeJWT(data.jwt);
var tokenPayload = jwtHelper.decodeToken(data.jwt);
user.username = username;
user.ID = tokenPayload.id;
user.role = tokenPayload.role;
deferred.resolve();
})
.catch(function error() {
deferred.reject();
});
return deferred.promise;
}
function logout() {
StateManager.clean();
EndpointProvider.clean();
LocalStorage.clean();
}
function isAuthenticated() {
var jwt = LocalStorage.getJWT();
return jwt && !jwtHelper.isTokenExpired(jwt);
}
function getUserDetails() {
return user;
}
return service;
}]);

View File

@ -3,16 +3,16 @@ import Chart from 'chart.js';
import filesize from 'filesize';
angular.module('portainer.app')
.factory('ChartService', [function ChartService() {
'use strict';
.factory('ChartService', [function ChartService() {
'use strict';
// Max. number of items to display on a chart
var CHART_LIMIT = 600;
// Max. number of items to display on a chart
var CHART_LIMIT = 600;
var service = {};
var service = {};
function defaultChartOptions(pos, tooltipCallback, scalesCallback) {
return {
function defaultChartOptions(pos, tooltipCallback, scalesCallback, isStacked) {
return {
animation: { duration: 0 },
responsiveAnimationDuration: 0,
responsive: true,
@ -21,7 +21,7 @@ angular.module('portainer.app')
intersect: false,
position: pos,
callbacks: {
label: function(tooltipItem, data) {
label: function (tooltipItem, data) {
var datasetLabel = data.datasets[tooltipItem.datasetIndex].label;
return tooltipCallback(datasetLabel, tooltipItem.yLabel);
}
@ -30,142 +30,188 @@ angular.module('portainer.app')
hover: { animationDuration: 0 },
scales: {
yAxes: [{
ticks: {
beginAtZero: true,
callback: scalesCallback
}
stacked: isStacked,
ticks: {
beginAtZero: true,
callback: scalesCallback
}
}]
}
};
}
function CreateChart (context, label, tooltipCallback, scalesCallback) {
return new Chart(context, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: label,
data: [],
fill: true,
backgroundColor: 'rgba(151,187,205,0.4)',
borderColor: 'rgba(151,187,205,0.6)',
pointBackgroundColor: 'rgba(151,187,205,1)',
pointBorderColor: 'rgba(151,187,205,1)',
pointRadius: 2,
borderWidth: 2
}
]
},
options: defaultChartOptions('nearest', tooltipCallback, scalesCallback)
});
}
service.CreateCPUChart = function(context) {
return CreateChart(context, 'CPU', percentageBasedTooltipLabel, percentageBasedAxisLabel);
};
service.CreateMemoryChart = function(context) {
return CreateChart(context, 'Memory', byteBasedTooltipLabel, byteBasedAxisLabel);
};
service.CreateNetworkChart = function(context) {
return new Chart(context, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'RX on eth0',
data: [],
fill: false,
backgroundColor: 'rgba(151,187,205,0.4)',
borderColor: 'rgba(151,187,205,0.6)',
pointBackgroundColor: 'rgba(151,187,205,1)',
pointBorderColor: 'rgba(151,187,205,1)',
pointRadius: 2,
borderWidth: 2
},
{
label: 'TX on eth0',
data: [],
fill: false,
backgroundColor: 'rgba(255,180,174,0.4)',
borderColor: 'rgba(255,180,174,0.6)',
pointBackgroundColor: 'rgba(255,180,174,1)',
pointBorderColor: 'rgba(255,180,174,1)',
pointRadius: 2,
borderWidth: 2
}
]
},
options: defaultChartOptions('average', byteBasedTooltipLabel, byteBasedAxisLabel)
});
};
function UpdateChart(label, value, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(value);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
}
chart.update(0);
}
service.UpdateMemoryChart = UpdateChart;
service.UpdateCPUChart = UpdateChart;
service.UpdateNetworkChart = function(label, rx, tx, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(rx);
chart.data.datasets[1].data.push(tx);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
chart.data.datasets[1].data.pop();
function CreateChart(context, label, tooltipCallback, scalesCallback) {
return new Chart(context, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: label,
data: [],
fill: true,
backgroundColor: 'rgba(151,187,205,0.4)',
borderColor: 'rgba(151,187,205,0.6)',
pointBackgroundColor: 'rgba(151,187,205,1)',
pointBorderColor: 'rgba(151,187,205,1)',
pointRadius: 2,
borderWidth: 2
}
]
},
options: defaultChartOptions('nearest', tooltipCallback, scalesCallback)
});
}
chart.update(0);
};
function byteBasedTooltipLabel(label, value) {
var processedValue = 0;
if (value > 5) {
processedValue = filesize(value, {base: 10, round: 1});
} else {
processedValue = value.toFixed(1) + 'B';
function CreateMemoryChart(context, tooltipCallback, scalesCallback) {
return new Chart(context, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'Memory',
data: [],
fill: true,
backgroundColor: 'rgba(151,187,205,0.4)',
borderColor: 'rgba(151,187,205,0.6)',
pointBackgroundColor: 'rgba(151,187,205,1)',
pointBorderColor: 'rgba(151,187,205,1)',
pointRadius: 2,
borderWidth: 2
},
{
label: 'Cache',
data: [],
fill: true,
backgroundColor: 'rgba(255,180,174,0.4)',
borderColor: 'rgba(255,180,174,0.6)',
pointBackgroundColor: 'rgba(255,180,174,1)',
pointBorderColor: 'rgba(255,180,174,1)',
pointRadius: 2,
borderWidth: 2
}
]
},
options: defaultChartOptions('nearest', tooltipCallback, scalesCallback, true)
});
}
return label + ': ' + processedValue;
}
function byteBasedAxisLabel(value) {
if (value > 5) {
return filesize(value, {base: 10, round: 1});
service.CreateCPUChart = function (context) {
return CreateChart(context, 'CPU', percentageBasedTooltipLabel, percentageBasedAxisLabel);
};
service.CreateMemoryChart = function (context) {
return CreateMemoryChart(context, byteBasedTooltipLabel, byteBasedAxisLabel);
};
service.CreateNetworkChart = function (context) {
return new Chart(context, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'RX on eth0',
data: [],
fill: false,
backgroundColor: 'rgba(151,187,205,0.4)',
borderColor: 'rgba(151,187,205,0.6)',
pointBackgroundColor: 'rgba(151,187,205,1)',
pointBorderColor: 'rgba(151,187,205,1)',
pointRadius: 2,
borderWidth: 2
},
{
label: 'TX on eth0',
data: [],
fill: false,
backgroundColor: 'rgba(255,180,174,0.4)',
borderColor: 'rgba(255,180,174,0.6)',
pointBackgroundColor: 'rgba(255,180,174,1)',
pointBorderColor: 'rgba(255,180,174,1)',
pointRadius: 2,
borderWidth: 2
}
]
},
options: defaultChartOptions('average', byteBasedTooltipLabel, byteBasedAxisLabel)
});
};
function UpdateChart(label, value, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(value);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
}
chart.update(0);
}
return value.toFixed(1) + 'B';
}
function percentageBasedAxisLabel(value) {
if (value > 1) {
return Math.round(value) + '%';
service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(memoryValue);
chart.data.datasets[1].data.push(cacheValue);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
}
chart.update(0);
};
service.UpdateCPUChart = UpdateChart;
service.UpdateNetworkChart = function (label, rx, tx, chart) {
chart.data.labels.push(label);
chart.data.datasets[0].data.push(rx);
chart.data.datasets[1].data.push(tx);
if (chart.data.datasets[0].data.length > CHART_LIMIT) {
chart.data.labels.pop();
chart.data.datasets[0].data.pop();
chart.data.datasets[1].data.pop();
}
chart.update(0);
};
function byteBasedTooltipLabel(label, value) {
var processedValue = 0;
if (value > 5) {
processedValue = filesize(value, { base: 10, round: 1 });
} else {
processedValue = value.toFixed(1) + 'B';
}
return label + ': ' + processedValue;
}
return value.toFixed(1) + '%';
}
function percentageBasedTooltipLabel(label, value) {
var processedValue = 0;
if (value > 1) {
processedValue = Math.round(value);
} else {
processedValue = value.toFixed(1);
function byteBasedAxisLabel(value) {
if (value > 5) {
return filesize(value, { base: 10, round: 1 });
}
return value.toFixed(1) + 'B';
}
return label + ': ' + processedValue + '%';
}
return service;
}]);
function percentageBasedAxisLabel(value) {
if (value > 1) {
return Math.round(value) + '%';
}
return value.toFixed(1) + '%';
}
function percentageBasedTooltipLabel(label, value) {
var processedValue = 0;
if (value > 1) {
processedValue = Math.round(value);
} else {
processedValue = value.toFixed(1);
}
return label + ': ' + processedValue + '%';
}
return service;
}]);

View File

@ -67,6 +67,17 @@ angular.module('portainer.app')
});
};
service.executeEndpointJob = function (imageName, file, endpointId, nodeName) {
return Upload.upload({
url: 'api/endpoints/' + endpointId + '/job?method=file&nodeName=' + nodeName,
data: {
File: file,
Image: imageName
},
ignoreLoadingBar: true
});
};
service.createEndpoint = function(name, type, URL, PublicURL, groupID, tags, TLS, TLSSkipVerify, TLSSkipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) {
return Upload.upload({
url: 'api/endpoints',

View File

@ -91,6 +91,12 @@ angular.module('portainer.app')
getColumnVisibilitySettings: function(key) {
return localStorageService.get('col_visibility_' + key);
},
storeJobImage: function(data) {
localStorageService.set('job_image', data);
},
getJobImage: function() {
return localStorageService.get('job_image');
},
clean: function() {
localStorageService.clearAll();
}

View File

@ -16,7 +16,9 @@ angular.module('portainer.app')
service.error = function(title, e, fallbackText) {
var msg = fallbackText;
if (e.data && e.data.message) {
if (e.data && e.data.details) {
msg = e.data.details;
} else if (e.data && e.data.message) {
msg = e.data.message;
} else if (e.message) {
msg = e.message;

View File

@ -2,8 +2,8 @@ import angular from 'angular';
import moment from 'moment';
angular.module('portainer.app')
.factory('StateManager', ['$q', 'SystemService', 'InfoHelper', 'LocalStorage', 'SettingsService', 'StatusService', 'APPLICATION_CACHE_VALIDITY',
function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY) {
.factory('StateManager', ['$q', 'SystemService', 'InfoHelper', 'LocalStorage', 'SettingsService', 'StatusService', 'APPLICATION_CACHE_VALIDITY', 'AgentPingService',
function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, SettingsService, StatusService, APPLICATION_CACHE_VALIDITY, AgentPingService) {
'use strict';
var manager = {};
@ -160,6 +160,14 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
state.endpoint.name = name;
state.endpoint.apiVersion = endpointAPIVersion;
state.endpoint.extensions = assignExtensions(extensions);
if (endpointMode.agentProxy) {
return AgentPingService.ping().then(function onPingSuccess(data) {
state.endpoint.agentApiVersion = data.version;
});
}
}).then(function () {
LocalStorage.storeEndpointState(state.endpoint);
deferred.resolve();
})
@ -173,5 +181,9 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
return deferred.promise;
};
manager.getAgentApiVersion = function getAgentApiVersion() {
return state.endpoint.agentApiVersion;
};
return manager;
}]);