Merge branch 'develop' into webpack

pull/2670/head
Chaim Lev-Ari 2018-11-26 16:32:07 +02:00
commit 3c66fa8084
61 changed files with 847 additions and 270 deletions

View File

@ -2,6 +2,7 @@ package main // import "github.com/portainer/portainer"
import (
"encoding/json"
"os"
"strings"
"time"
@ -88,7 +89,7 @@ func initJWTService(authenticationEnabled bool) portainer.JWTService {
}
func initDigitalSignatureService() portainer.DigitalSignatureService {
return &crypto.ECDSAService{}
return crypto.NewECDSAService(os.Getenv("AGENT_SECRET"))
}
func initCryptoService() portainer.CryptoService {
@ -141,9 +142,9 @@ func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter
}
snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter)
snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotJob, snapshotJobContext)
snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext)
err = jobScheduler.CreateSchedule(snapshotSchedule, snapshotJobRunner)
err = jobScheduler.ScheduleJob(snapshotJobRunner)
if err != nil {
return err
}
@ -179,9 +180,9 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul
}
endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints)
endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endpointSyncJob, endpointSyncJobContext)
endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endointSyncSchedule, endpointSyncJobContext)
err = jobScheduler.CreateSchedule(endointSyncSchedule, endpointSyncJobRunner)
err = jobScheduler.ScheduleJob(endpointSyncJobRunner)
if err != nil {
return err
}
@ -199,9 +200,9 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p
if schedule.JobType == portainer.ScriptExecutionJobType {
jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext)
err = jobScheduler.CreateSchedule(&schedule, jobRunner)
err = jobScheduler.ScheduleJob(jobRunner)
if err != nil {
return err
}

View File

@ -11,8 +11,8 @@ import (
// EndpointSyncJobRunner is used to run a EndpointSyncJob
type EndpointSyncJobRunner struct {
job *portainer.EndpointSyncJob
context *EndpointSyncJobContext
schedule *portainer.Schedule
context *EndpointSyncJobContext
}
// EndpointSyncJobContext represents the context of execution of a EndpointSyncJob
@ -30,10 +30,10 @@ func NewEndpointSyncJobContext(endpointService portainer.EndpointService, endpoi
}
// NewEndpointSyncJobRunner returns a new runner that can be scheduled
func NewEndpointSyncJobRunner(job *portainer.EndpointSyncJob, context *EndpointSyncJobContext) *EndpointSyncJobRunner {
func NewEndpointSyncJobRunner(schedule *portainer.Schedule, context *EndpointSyncJobContext) *EndpointSyncJobRunner {
return &EndpointSyncJobRunner{
job: job,
context: context,
schedule: schedule,
context: context,
}
}
@ -53,19 +53,9 @@ type fileEndpoint struct {
TLSKey string `json:"TLSKey,omitempty"`
}
// GetScheduleID returns the schedule identifier associated to the runner
func (runner *EndpointSyncJobRunner) GetScheduleID() portainer.ScheduleID {
return runner.job.ScheduleID
}
// SetScheduleID sets the schedule identifier associated to the runner
func (runner *EndpointSyncJobRunner) SetScheduleID(ID portainer.ScheduleID) {
runner.job.ScheduleID = ID
}
// GetJobType returns the job type associated to the runner
func (runner *EndpointSyncJobRunner) GetJobType() portainer.JobType {
return portainer.EndpointSyncJobType
// GetSchedule returns the schedule associated to the runner
func (runner *EndpointSyncJobRunner) GetSchedule() *portainer.Schedule {
return runner.schedule
}
// Run triggers the execution of the endpoint synchronization process.

View File

@ -2,14 +2,15 @@ package cron
import (
"log"
"time"
"github.com/portainer/portainer"
)
// ScriptExecutionJobRunner is used to run a ScriptExecutionJob
type ScriptExecutionJobRunner struct {
job *portainer.ScriptExecutionJob
context *ScriptExecutionJobContext
schedule *portainer.Schedule
context *ScriptExecutionJobContext
}
// ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob
@ -29,10 +30,10 @@ func NewScriptExecutionJobContext(jobService portainer.JobService, endpointServi
}
// NewScriptExecutionJobRunner returns a new runner that can be scheduled
func NewScriptExecutionJobRunner(job *portainer.ScriptExecutionJob, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner {
func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner {
return &ScriptExecutionJobRunner{
job: job,
context: context,
schedule: schedule,
context: context,
}
}
@ -40,37 +41,49 @@ func NewScriptExecutionJobRunner(job *portainer.ScriptExecutionJob, context *Scr
// It will iterate through all the endpoints specified in the context to
// execute the script associated to the job.
func (runner *ScriptExecutionJobRunner) Run() {
scriptFile, err := runner.context.fileService.GetFileContent(runner.job.ScriptPath)
scriptFile, err := runner.context.fileService.GetFileContent(runner.schedule.ScriptExecutionJob.ScriptPath)
if err != nil {
log.Printf("scheduled job error (script execution). Unable to retrieve script file (err=%s)\n", err)
return
}
for _, endpointID := range runner.job.Endpoints {
targets := make([]*portainer.Endpoint, 0)
for _, endpointID := range runner.schedule.ScriptExecutionJob.Endpoints {
endpoint, err := runner.context.endpointService.Endpoint(endpointID)
if err != nil {
log.Printf("scheduled job error (script execution). Unable to retrieve information about endpoint (id=%d) (err=%s)\n", endpointID, err)
return
}
err = runner.context.jobService.Execute(endpoint, "", runner.job.Image, scriptFile)
if err != nil {
log.Printf("scheduled job error (script execution). Unable to execute scrtip (endpoint=%s) (err=%s)\n", endpoint.Name, err)
targets = append(targets, endpoint)
}
runner.executeAndRetry(targets, scriptFile, 0)
}
func (runner *ScriptExecutionJobRunner) executeAndRetry(endpoints []*portainer.Endpoint, script []byte, retryCount int) {
retryTargets := make([]*portainer.Endpoint, 0)
for _, endpoint := range endpoints {
err := runner.context.jobService.ExecuteScript(endpoint, "", runner.schedule.ScriptExecutionJob.Image, script, runner.schedule)
if err == portainer.ErrUnableToPingEndpoint {
retryTargets = append(retryTargets, endpoint)
} else if err != nil {
log.Printf("scheduled job error (script execution). Unable to execute script (endpoint=%s) (err=%s)\n", endpoint.Name, err)
}
}
retryCount++
if retryCount >= runner.schedule.ScriptExecutionJob.RetryCount {
return
}
time.Sleep(time.Duration(runner.schedule.ScriptExecutionJob.RetryInterval) * time.Second)
runner.executeAndRetry(retryTargets, script, retryCount)
}
// GetScheduleID returns the schedule identifier associated to the runner
func (runner *ScriptExecutionJobRunner) GetScheduleID() portainer.ScheduleID {
return runner.job.ScheduleID
}
// SetScheduleID sets the schedule identifier associated to the runner
func (runner *ScriptExecutionJobRunner) SetScheduleID(ID portainer.ScheduleID) {
runner.job.ScheduleID = ID
}
// GetJobType returns the job type associated to the runner
func (runner *ScriptExecutionJobRunner) GetJobType() portainer.JobType {
return portainer.ScriptExecutionJobType
// GetSchedule returns the schedule associated to the runner
func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule {
return runner.schedule
}

View File

@ -8,8 +8,8 @@ import (
// SnapshotJobRunner is used to run a SnapshotJob
type SnapshotJobRunner struct {
job *portainer.SnapshotJob
context *SnapshotJobContext
schedule *portainer.Schedule
context *SnapshotJobContext
}
// SnapshotJobContext represents the context of execution of a SnapshotJob
@ -27,35 +27,27 @@ func NewSnapshotJobContext(endpointService portainer.EndpointService, snapshotte
}
// NewSnapshotJobRunner returns a new runner that can be scheduled
func NewSnapshotJobRunner(job *portainer.SnapshotJob, context *SnapshotJobContext) *SnapshotJobRunner {
func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner {
return &SnapshotJobRunner{
job: job,
context: context,
schedule: schedule,
context: context,
}
}
// GetScheduleID returns the schedule identifier associated to the runner
func (runner *SnapshotJobRunner) GetScheduleID() portainer.ScheduleID {
return runner.job.ScheduleID
// GetSchedule returns the schedule associated to the runner
func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule {
return runner.schedule
}
// SetScheduleID sets the schedule identifier associated to the runner
func (runner *SnapshotJobRunner) SetScheduleID(ID portainer.ScheduleID) {
runner.job.ScheduleID = ID
}
// GetJobType returns the job type associated to the runner
func (runner *SnapshotJobRunner) GetJobType() portainer.JobType {
return portainer.EndpointSyncJobType
}
// Run triggers the execution of the job.
// Run triggers the execution of the schedule.
// It will iterate through all the endpoints available in the database to
// create a snapshot of each one of them.
// As a snapshot can be a long process, to avoid any concurrency issue we
// retrieve the latest version of the endpoint right after a snapshot.
func (runner *SnapshotJobRunner) Run() {
endpoints, err := runner.context.endpointService.Endpoints()
if err != nil {
log.Printf("background job error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err)
log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err)
return
}
@ -64,20 +56,27 @@ func (runner *SnapshotJobRunner) Run() {
continue
}
snapshot, err := runner.context.snapshotter.CreateSnapshot(&endpoint)
endpoint.Status = portainer.EndpointStatusUp
if err != nil {
log.Printf("background job error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
endpoint.Status = portainer.EndpointStatusDown
snapshot, snapshotError := runner.context.snapshotter.CreateSnapshot(&endpoint)
latestEndpointReference, err := runner.context.endpointService.Endpoint(endpoint.ID)
if latestEndpointReference == nil {
log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
continue
}
latestEndpointReference.Status = portainer.EndpointStatusUp
if snapshotError != nil {
log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError)
latestEndpointReference.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
}
err = runner.context.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
err = runner.context.endpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
if err != nil {
log.Printf("background job error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
return
}
}

View File

@ -17,31 +17,25 @@ func NewJobScheduler() *JobScheduler {
}
}
// CreateSchedule schedules the execution of a job via a runner
func (scheduler *JobScheduler) CreateSchedule(schedule *portainer.Schedule, runner portainer.JobRunner) error {
runner.SetScheduleID(schedule.ID)
return scheduler.cron.AddJob(schedule.CronExpression, runner)
// ScheduleJob schedules the execution of a job via a runner
func (scheduler *JobScheduler) ScheduleJob(runner portainer.JobRunner) error {
return scheduler.cron.AddJob(runner.GetSchedule().CronExpression, runner)
}
// UpdateSchedule updates a specific scheduled job by re-creating a new cron
// UpdateSystemJobSchedule updates the first occurence of the specified
// scheduled job based on the specified job type.
// It does so by re-creating a new cron
// and adding all the existing jobs. It will then re-schedule the new job
// via the specified JobRunner parameter.
// with the update cron expression passed in parameter.
// NOTE: the cron library do not support updating schedules directly
// hence the work-around
func (scheduler *JobScheduler) UpdateSchedule(schedule *portainer.Schedule, runner portainer.JobRunner) error {
func (scheduler *JobScheduler) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error {
cronEntries := scheduler.cron.Entries()
newCron := cron.New()
for _, entry := range cronEntries {
if entry.Job.(portainer.JobRunner).GetScheduleID() == schedule.ID {
var jobRunner cron.Job = runner
if entry.Job.(portainer.JobRunner).GetJobType() == portainer.SnapshotJobType {
jobRunner = entry.Job
}
err := newCron.AddJob(schedule.CronExpression, jobRunner)
if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType {
err := newCron.AddJob(newCronExpression, entry.Job)
if err != nil {
return err
}
@ -56,17 +50,50 @@ func (scheduler *JobScheduler) UpdateSchedule(schedule *portainer.Schedule, runn
return nil
}
// RemoveSchedule remove a scheduled job by re-creating a new cron
// and adding all the existing jobs except for the one specified via scheduleID.
// NOTE: the cron library do not support removing schedules directly
// UpdateJobSchedule updates a specific scheduled job by re-creating a new cron
// and adding all the existing jobs. It will then re-schedule the new job
// via the specified JobRunner parameter.
// NOTE: the cron library do not support updating schedules directly
// hence the work-around
func (scheduler *JobScheduler) RemoveSchedule(scheduleID portainer.ScheduleID) {
func (scheduler *JobScheduler) UpdateJobSchedule(runner portainer.JobRunner) error {
cronEntries := scheduler.cron.Entries()
newCron := cron.New()
for _, entry := range cronEntries {
if entry.Job.(portainer.JobRunner).GetScheduleID() == scheduleID {
if entry.Job.(portainer.JobRunner).GetSchedule().ID == runner.GetSchedule().ID {
var jobRunner cron.Job = runner
if entry.Job.(portainer.JobRunner).GetSchedule().JobType == portainer.SnapshotJobType {
jobRunner = entry.Job
}
err := newCron.AddJob(runner.GetSchedule().CronExpression, jobRunner)
if err != nil {
return err
}
}
newCron.Schedule(entry.Schedule, entry.Job)
}
scheduler.cron.Stop()
scheduler.cron = newCron
scheduler.cron.Start()
return nil
}
// UnscheduleJob remove a scheduled job by re-creating a new cron
// and adding all the existing jobs except for the one specified via scheduleID.
// NOTE: the cron library do not support removing schedules directly
// hence the work-around
func (scheduler *JobScheduler) UnscheduleJob(scheduleID portainer.ScheduleID) {
cronEntries := scheduler.cron.Entries()
newCron := cron.New()
for _, entry := range cronEntries {
if entry.Job.(portainer.JobRunner).GetSchedule().ID == scheduleID {
continue
}

View File

@ -8,6 +8,8 @@ import (
"encoding/base64"
"encoding/hex"
"math/big"
"github.com/portainer/portainer"
)
const (
@ -26,6 +28,15 @@ type ECDSAService struct {
privateKey *ecdsa.PrivateKey
publicKey *ecdsa.PublicKey
encodedPubKey string
secret string
}
// NewECDSAService returns a pointer to a ECDSAService.
// An optional secret can be specified
func NewECDSAService(secret string) *ECDSAService {
return &ECDSAService{
secret: secret,
}
}
// EncodedPublicKey returns the encoded version of the public that can be used
@ -91,11 +102,17 @@ func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) {
return private, public, nil
}
// Sign creates a signature from a message.
// It automatically hash the message using MD5 and creates a signature from
// CreateSignature creates a digital signature.
// It automatically hash a specific message using MD5 and creates a signature from
// that hash.
// It then encodes the generated signature in base64.
func (service *ECDSAService) Sign(message string) (string, error) {
func (service *ECDSAService) CreateSignature() (string, error) {
message := portainer.PortainerAgentSignatureMessage
if service.secret != "" {
message = service.secret
}
hash := HashFromBytes([]byte(message))
r := big.NewInt(0)

View File

@ -67,7 +67,7 @@ func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.
return nil, err
}
signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := signatureService.CreateSignature()
if err != nil {
return nil, err
}

View File

@ -18,29 +18,35 @@ import (
// JobService represents a service that handles the execution of jobs
type JobService struct {
DockerClientFactory *ClientFactory
dockerClientFactory *ClientFactory
}
// NewJobService returns a pointer to a new job service
func NewJobService(dockerClientFactory *ClientFactory) *JobService {
return &JobService{
DockerClientFactory: dockerClientFactory,
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 {
// ExecuteScript will leverage a privileged container to execute a script against the specified endpoint/nodename.
// It will copy the script content specified as a parameter inside a container based on the specified image and execute it.
func (service *JobService) ExecuteScript(endpoint *portainer.Endpoint, nodeName, image string, script []byte, schedule *portainer.Schedule) error {
buffer, err := archive.TarFileInBuffer(script, "script.sh", 0700)
if err != nil {
return err
}
cli, err := service.DockerClientFactory.CreateClient(endpoint, nodeName)
cli, err := service.dockerClientFactory.CreateClient(endpoint, nodeName)
if err != nil {
return err
}
defer cli.Close()
_, err = cli.Ping(context.Background())
if err != nil {
return portainer.ErrUnableToPingEndpoint
}
err = pullImage(cli, image)
if err != nil {
return err
@ -59,6 +65,10 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image
Cmd: strslice.StrSlice([]string{"sh", "/tmp/script.sh"}),
}
if schedule != nil {
containerConfig.Labels["io.portainer.schedule.id"] = strconv.Itoa(int(schedule.ID))
}
hostConfig := &container.HostConfig{
Binds: []string{"/:/host", "/etc:/etc:ro", "/usr:/usr:ro", "/run:/run:ro", "/sbin:/sbin:ro", "/var:/var:ro"},
NetworkMode: "host",
@ -72,6 +82,13 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image
return err
}
if schedule != nil {
err = cli.ContainerRename(context.Background(), body.ID, schedule.Name+"_"+body.ID)
if err != nil {
return err
}
}
copyOptions := types.CopyToContainerOptions{}
err = cli.CopyToContainer(context.Background(), body.ID, "/tmp", bytes.NewReader(buffer), copyOptions)
if err != nil {
@ -79,12 +96,7 @@ func (service *JobService) Execute(endpoint *portainer.Endpoint, nodeName, image
}
startOptions := types.ContainerStartOptions{}
err = cli.ContainerStart(context.Background(), body.ID, startOptions)
if err != nil {
return err
}
return nil
return cli.ContainerStart(context.Background(), body.ID, startOptions)
}
func pullImage(cli *client.Client, image string) error {

View File

@ -88,6 +88,11 @@ const (
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
)
// Docker errors.
const (
ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint")
)
// Error represents an application error.
type Error string

View File

@ -140,7 +140,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string)
return err
}
signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := manager.signatureService.CreateSignature()
if err != nil {
return err
}

View File

@ -1,6 +1,7 @@
package endpointproxy
import (
"errors"
"strconv"
httperror "github.com/portainer/libhttp/error"
@ -23,6 +24,10 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
if endpoint.Status == portainer.EndpointStatusDown {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")}
}
err = handler.requestBouncer.EndpointAccess(r, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", portainer.ErrEndpointAccessDenied}

View File

@ -92,7 +92,7 @@ func (handler *Handler) executeJobFromFile(w http.ResponseWriter, r *http.Reques
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
err = handler.JobService.Execute(endpoint, nodeName, payload.Image, payload.File)
err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
}
@ -107,7 +107,7 @@ func (handler *Handler) executeJobFromFileContent(w http.ResponseWriter, r *http
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
err = handler.JobService.Execute(endpoint, nodeName, payload.Image, []byte(payload.FileContent))
err = handler.JobService.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
}

View File

@ -1,41 +1,51 @@
package endpoints
import (
"log"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// POST request on /api/endpoints/snapshot
// POST request on /api/endpoints/:id/snapshot
func (handler *Handler) endpointSnapshot(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoints, err := handler.EndpointService.Endpoints()
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
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}
}
snapshot, err := handler.Snapshotter.CreateSnapshot(&endpoint)
endpoint.Status = portainer.EndpointStatusUp
if err != nil {
log.Printf("http error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
endpoint.Status = portainer.EndpointStatusDown
}
if endpoint.Type == portainer.AzureEnvironment {
return &httperror.HandlerError{http.StatusBadRequest, "Snapshots not supported for Azure endpoints", err}
}
if snapshot != nil {
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
}
snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(endpoint)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID)
if latestEndpointReference == nil {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint with the specified identifier inside the database", err}
}
latestEndpointReference.Status = portainer.EndpointStatusUp
if snapshotError != nil {
latestEndpointReference.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
}
err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
return response.Empty(w)

View File

@ -0,0 +1,49 @@
package endpoints
import (
"log"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
// POST request on /api/endpoints/snapshot
func (handler *Handler) endpointSnapshots(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
snapshot, snapshotError := handler.Snapshotter.CreateSnapshot(&endpoint)
latestEndpointReference, err := handler.EndpointService.Endpoint(endpoint.ID)
if latestEndpointReference == nil {
log.Printf("background schedule error (endpoint snapshot). Endpoint not found inside the database anymore (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
continue
}
latestEndpointReference.Status = portainer.EndpointStatusUp
if snapshotError != nil {
log.Printf("background schedule error (endpoint snapshot). Unable to create snapshot (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, snapshotError)
latestEndpointReference.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot}
}
err = handler.EndpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
}
return response.Empty(w)
}

View File

@ -45,7 +45,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
h.Handle("/endpoints",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointCreate))).Methods(http.MethodPost)
h.Handle("/endpoints/snapshot",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshots))).Methods(http.MethodPost)
h.Handle("/endpoints",
bouncer.RestrictedAccess(httperror.LoggerHandler(h.endpointList))).Methods(http.MethodGet)
h.Handle("/endpoints/{id}",
@ -62,5 +62,7 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.endpointExtensionRemove))).Methods(http.MethodDelete)
h.Handle("/endpoints/{id}/job",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/snapshot",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
return h
}

View File

@ -18,7 +18,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
motd, err := client.Get(portainer.MessageOfTheDayURL, 0)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
response.JSON(w, &motdResponse{Message: ""})
return
}

View File

@ -37,5 +37,7 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleDelete))).Methods(http.MethodDelete)
h.Handle("/schedules/{id}/file",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleFile))).Methods(http.MethodGet)
h.Handle("/schedules/{id}/tasks",
bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleTasks))).Methods(http.MethodGet)
return h
}

View File

@ -14,32 +14,40 @@ import (
"github.com/portainer/portainer/cron"
)
type scheduleFromFilePayload struct {
type scheduleCreateFromFilePayload struct {
Name string
Image string
CronExpression string
Endpoints []portainer.EndpointID
File []byte
RetryCount int
RetryInterval int
}
type scheduleFromFileContentPayload struct {
type scheduleCreateFromFileContentPayload struct {
Name string
CronExpression string
Image string
Endpoints []portainer.EndpointID
FileContent string
RetryCount int
RetryInterval int
}
func (payload *scheduleFromFilePayload) Validate(r *http.Request) error {
func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error {
name, err := request.RetrieveMultiPartFormValue(r, "Name", false)
if err != nil {
return errors.New("Invalid name")
return errors.New("Invalid schedule name")
}
if !govalidator.Matches(name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
payload.Name = name
image, err := request.RetrieveMultiPartFormValue(r, "Image", false)
if err != nil {
return errors.New("Invalid image")
return errors.New("Invalid schedule image")
}
payload.Image = image
@ -62,14 +70,24 @@ func (payload *scheduleFromFilePayload) Validate(r *http.Request) error {
}
payload.File = file
retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true)
payload.RetryCount = retryCount
retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true)
payload.RetryInterval = retryInterval
return nil
}
func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error {
func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return portainer.Error("Invalid schedule name")
}
if !govalidator.Matches(payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
if govalidator.IsNull(payload.Image) {
return portainer.Error("Invalid schedule image")
}
@ -86,6 +104,10 @@ func (payload *scheduleFromFileContentPayload) Validate(r *http.Request) error {
return portainer.Error("Invalid script file content")
}
if payload.RetryCount != 0 && payload.RetryInterval == 0 {
return portainer.Error("RetryInterval must be set")
}
return nil
}
@ -107,71 +129,100 @@ func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *
}
func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload scheduleFromFileContentPayload
var payload scheduleCreateFromFileContentPayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
schedule, err := handler.createSchedule(payload.Name, payload.Image, payload.CronExpression, payload.Endpoints, []byte(payload.FileContent))
schedule := handler.createScheduleObjectFromFileContentPayload(&payload)
err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
}
return response.JSON(w, schedule)
}
func (handler *Handler) createScheduleFromFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
payload := &scheduleFromFilePayload{}
payload := &scheduleCreateFromFilePayload{}
err := payload.Validate(r)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
schedule, err := handler.createSchedule(payload.Name, payload.Image, payload.CronExpression, payload.Endpoints, payload.File)
schedule := handler.createScheduleObjectFromFilePayload(payload)
err = handler.addAndPersistSchedule(schedule, payload.File)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err}
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err}
}
return response.JSON(w, schedule)
}
func (handler *Handler) createSchedule(name, image, cronExpression string, endpoints []portainer.EndpointID, file []byte) (*portainer.Schedule, error) {
func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule {
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(scheduleIdentifier)), file)
if err != nil {
return nil, err
}
job := &portainer.ScriptExecutionJob{
Endpoints: endpoints,
Image: image,
ScriptPath: scriptPath,
ScheduleID: scheduleIdentifier,
Endpoints: payload.Endpoints,
Image: payload.Image,
// ScheduleID: scheduleIdentifier,
RetryCount: payload.RetryCount,
RetryInterval: payload.RetryInterval,
}
schedule := &portainer.Schedule{
ID: scheduleIdentifier,
Name: name,
CronExpression: cronExpression,
Name: payload.Name,
CronExpression: payload.CronExpression,
JobType: portainer.ScriptExecutionJobType,
ScriptExecutionJob: job,
Created: time.Now().Unix(),
}
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(job, jobContext)
err = handler.JobScheduler.CreateSchedule(schedule, jobRunner)
if err != nil {
return nil, err
}
err = handler.ScheduleService.CreateSchedule(schedule)
if err != nil {
return nil, err
}
return schedule, nil
return schedule
}
func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *scheduleCreateFromFileContentPayload) *portainer.Schedule {
scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier())
job := &portainer.ScriptExecutionJob{
Endpoints: payload.Endpoints,
Image: payload.Image,
// ScheduleID: scheduleIdentifier,
RetryCount: payload.RetryCount,
RetryInterval: payload.RetryInterval,
}
schedule := &portainer.Schedule{
ID: scheduleIdentifier,
Name: payload.Name,
CronExpression: payload.CronExpression,
JobType: portainer.ScriptExecutionJobType,
ScriptExecutionJob: job,
Created: time.Now().Unix(),
}
return schedule
}
func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error {
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file)
if err != nil {
return err
}
schedule.ScriptExecutionJob.ScriptPath = scriptPath
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext)
err = handler.JobScheduler.ScheduleJob(jobRunner)
if err != nil {
return err
}
return handler.ScheduleService.CreateSchedule(schedule)
}

View File

@ -34,6 +34,8 @@ func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the files associated to the schedule on the filesystem", err}
}
handler.JobScheduler.UnscheduleJob(schedule.ID)
err = handler.ScheduleService.DeleteSchedule(portainer.ScheduleID(scheduleID))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the schedule from the database", err}

View File

@ -0,0 +1,87 @@
package schedules
import (
"encoding/json"
"errors"
"net/http"
"strconv"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer"
)
type taskContainer struct {
ID string `json:"Id"`
EndpointID portainer.EndpointID `json:"EndpointId"`
Status string `json:"Status"`
Created float64 `json:"Created"`
Labels map[string]string `json:"Labels"`
}
// GET request on /api/schedules/:id/tasks
func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err}
}
schedule, err := handler.ScheduleService.Schedule(portainer.ScheduleID(scheduleID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a schedule with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
}
if schedule.JobType != portainer.ScriptExecutionJobType {
return &httperror.HandlerError{http.StatusBadRequest, "Unable to retrieve schedule tasks", errors.New("This type of schedule do not have any associated tasks")}
}
tasks := make([]taskContainer, 0)
for _, endpointID := range schedule.ScriptExecutionJob.Endpoints {
endpoint, err := handler.EndpointService.Endpoint(endpointID)
if err == portainer.ErrObjectNotFound {
continue
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
}
endpointTasks, err := extractTasksFromContainerSnasphot(endpoint, schedule.ID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find extract schedule tasks from endpoint snapshot", err}
}
tasks = append(tasks, endpointTasks...)
}
return response.JSON(w, tasks)
}
func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID portainer.ScheduleID) ([]taskContainer, error) {
endpointTasks := make([]taskContainer, 0)
if len(endpoint.Snapshots) == 0 {
return endpointTasks, nil
}
b, err := json.Marshal(endpoint.Snapshots[0].SnapshotRaw.Containers)
if err != nil {
return nil, err
}
var containers []taskContainer
err = json.Unmarshal(b, &containers)
if err != nil {
return nil, err
}
for _, container := range containers {
if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) {
container.EndpointID = endpoint.ID
endpointTasks = append(endpointTasks, container)
}
}
return endpointTasks, nil
}

View File

@ -1,9 +1,11 @@
package schedules
import (
"errors"
"net/http"
"strconv"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
@ -17,9 +19,14 @@ type scheduleUpdatePayload struct {
CronExpression *string
Endpoints []portainer.EndpointID
FileContent *string
RetryCount *int
RetryInterval *int
}
func (payload *scheduleUpdatePayload) Validate(r *http.Request) error {
if payload.Name != nil && !govalidator.Matches(*payload.Name, `^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`) {
return errors.New("Invalid schedule name format. Allowed characters are: [a-zA-Z0-9_.-]")
}
return nil
}
@ -54,8 +61,8 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *
if updateJobSchedule {
jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService)
jobRunner := cron.NewScriptExecutionJobRunner(schedule.ScriptExecutionJob, jobContext)
err := handler.JobScheduler.UpdateSchedule(schedule, jobRunner)
jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext)
err := handler.JobScheduler.UpdateJobSchedule(jobRunner)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err}
}
@ -91,5 +98,15 @@ func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload
updateJobSchedule = true
}
if payload.RetryCount != nil {
schedule.ScriptExecutionJob.RetryCount = *payload.RetryCount
updateJobSchedule = true
}
if payload.RetryInterval != nil {
schedule.ScriptExecutionJob.RetryInterval = *payload.RetryInterval
updateJobSchedule = true
}
return updateJobSchedule
}

View File

@ -9,6 +9,10 @@ import (
"github.com/portainer/portainer/http/security"
)
func hideFields(settings *portainer.Settings) {
settings.LDAPSettings.Password = ""
}
// Handler is the HTTP handler used to handle settings operations.
type Handler struct {
*mux.Router

View File

@ -14,5 +14,6 @@ func (handler *Handler) settingsInspect(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
}
hideFields(settings)
return response.JSON(w, settings)
}

View File

@ -108,7 +108,12 @@ func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, sna
snapshotSchedule := schedules[0]
snapshotSchedule.CronExpression = "@every " + snapshotInterval
err := handler.JobScheduler.UpdateSchedule(&snapshotSchedule, nil)
err := handler.JobScheduler.UpdateSystemJobSchedule(portainer.SnapshotJobType, snapshotSchedule.CronExpression)
if err != nil {
return err
}
err = handler.ScheduleService.UpdateSchedule(snapshotSchedule.ID, &snapshotSchedule)
if err != nil {
return err
}

View File

@ -111,12 +111,13 @@ func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Req
}
}
signature, err := handler.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := handler.SignatureService.CreateSignature()
if err != nil {
return err
}
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentPublicKeyHeader, handler.SignatureService.EncodedPublicKey())
out.Set(portainer.PortainerAgentSignatureHeader, signature)
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
}

View File

@ -64,7 +64,7 @@ func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Respon
request.URL.Path = path
if p.enableSignature {
signature, err := p.SignatureService.Sign(portainer.PortainerAgentSignatureMessage)
signature, err := p.SignatureService.CreateSignature()
if err != nil {
return nil, err
}

View File

@ -47,7 +47,7 @@ type (
// LDAPSettings represents the settings used to connect to a LDAP server
LDAPSettings struct {
ReaderDN string `json:"ReaderDN"`
Password string `json:"Password"`
Password string `json:"Password,omitempty"`
URL string `json:"URL"`
TLSConfig TLSConfiguration `json:"TLSConfig"`
StartTLS bool `json:"StartTLS"`
@ -228,21 +228,18 @@ type (
// ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container
ScriptExecutionJob struct {
ScheduleID ScheduleID `json:"ScheduleId"`
Endpoints []EndpointID
Image string
ScriptPath string
Endpoints []EndpointID
Image string
ScriptPath string
RetryCount int
RetryInterval int
}
// SnapshotJob represents a scheduled job that can create endpoint snapshots
SnapshotJob struct {
ScheduleID ScheduleID `json:"ScheduleId"`
}
SnapshotJob struct{}
// EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file
EndpointSyncJob struct {
ScheduleID ScheduleID `json:"ScheduleId"`
}
EndpointSyncJob struct{}
// Schedule represents a scheduled job.
// It only contains a pointer to one of the JobRunner implementations
@ -629,7 +626,7 @@ type (
GenerateKeyPair() ([]byte, []byte, error)
EncodedPublicKey() string
PEMHeaders() (string, string)
Sign(message string) (string, error)
CreateSignature() (string, error)
}
// JWTService represents a service for managing JWT tokens
@ -666,18 +663,17 @@ type (
// JobScheduler represents a service to run jobs on a periodic basis
JobScheduler interface {
CreateSchedule(schedule *Schedule, runner JobRunner) error
UpdateSchedule(schedule *Schedule, runner JobRunner) error
RemoveSchedule(ID ScheduleID)
ScheduleJob(runner JobRunner) error
UpdateJobSchedule(runner JobRunner) error
UpdateSystemJobSchedule(jobType JobType, newCronExpression string) error
UnscheduleJob(ID ScheduleID)
Start()
}
// JobRunner represents a service that can be used to run a job
JobRunner interface {
Run()
GetScheduleID() ScheduleID
SetScheduleID(ID ScheduleID)
GetJobType() JobType
GetSchedule() *Schedule
}
// Snapshotter represents a service used to create endpoint snapshots
@ -708,7 +704,7 @@ type (
// JobService represents a service to manage job execution on hosts
JobService interface {
Execute(endpoint *Endpoint, nodeName, image string, script []byte) error
ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error
}
)
@ -718,7 +714,7 @@ const (
// DBVersion is the version number of the Portainer database
DBVersion = 14
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
MessageOfTheDayURL = "https://raw.githubusercontent.com/portainer/motd/master/message.html"
MessageOfTheDayURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/motd.html"
// PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentTargetHeader represent the name of the header containing the target node name

View File

@ -71,7 +71,7 @@
<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>
{{ item | containername }}</a>
</td>
<td>
<span ng-if="['starting','healthy','unhealthy'].indexOf(item.Status) !== -1" class="label label-{{ item.Status|containerstatusbadge }} interactive"

View File

@ -6,7 +6,7 @@ angular.module('portainer.app')
interceptor.responseError = responseErrorInterceptor;
function responseErrorInterceptor(rejection) {
if (rejection.status === 502 || rejection.status === -1) {
if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) {
var endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined) {
var data = endpoint.Snapshots[0].SnapshotRaw.Containers;

View File

@ -6,7 +6,7 @@ angular.module('portainer.app')
interceptor.responseError = responseErrorInterceptor;
function responseErrorInterceptor(rejection) {
if (rejection.status === 502 || rejection.status === -1) {
if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) {
var endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined) {
var data = endpoint.Snapshots[0].SnapshotRaw.Images;

View File

@ -6,7 +6,7 @@ angular.module('portainer.app')
interceptor.responseError = responseErrorInterceptor;
function responseErrorInterceptor(rejection) {
if (rejection.status === 502 || rejection.status === -1) {
if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) {
var endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined) {
var data = endpoint.Snapshots[0].SnapshotRaw.Info;

View File

@ -6,7 +6,7 @@ angular.module('portainer.app')
interceptor.responseError = responseErrorInterceptor;
function responseErrorInterceptor(rejection) {
if (rejection.status === 502 || rejection.status === -1) {
if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) {
var endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined) {
var data = endpoint.Snapshots[0].SnapshotRaw.Networks;

View File

@ -6,7 +6,7 @@ angular.module('portainer.app')
interceptor.responseError = responseErrorInterceptor;
function responseErrorInterceptor(rejection) {
if (rejection.status === 502 || rejection.status === -1) {
if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) {
var endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined) {
var data = endpoint.Snapshots[0].SnapshotRaw.Version;

View File

@ -6,7 +6,7 @@ angular.module('portainer.app')
interceptor.responseError = responseErrorInterceptor;
function responseErrorInterceptor(rejection) {
if (rejection.status === 502 || rejection.status === -1) {
if (rejection.status === 502 || rejection.status === 503 || rejection.status === -1) {
var endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined) {
var data = endpoint.Snapshots[0].SnapshotRaw.Volumes;

View File

@ -1,7 +1,7 @@
import _ from 'lodash';
import { ResourceControlViewModel } from '../../portainer/models/resourceControl';
function createStatus(statusText) {
export function createStatus(statusText) {
var status = _.toLower(statusText);
if (status.indexOf('paused') > -1) {
@ -74,8 +74,12 @@ export function ContainerStatsViewModel(data) {
this.NumProcs = data.num_procs;
this.isWindows = true;
} else { // Linux
this.MemoryUsage = data.memory_stats.usage - data.memory_stats.stats.cache;
this.MemoryCache = data.memory_stats.stats.cache;
if (data.memory_stats.stats === undefined || data.memory_stats.usage === undefined) {
this.MemoryUsage = this.MemoryCache = 0;
} else {
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;

View File

@ -156,6 +156,7 @@ function ($q, $scope, $state, $transition$, $filter, Commit, ContainerHelper, Co
Commit.commitContainer({id: $transition$.params().id, tag: imageConfig.tag, repo: imageConfig.repo}, function () {
update();
Notifications.success('Container commited', $transition$.params().id);
$scope.config.Image = '';
}, function (e) {
update();
Notifications.error('Failure', e, 'Unable to commit container');

View File

@ -84,7 +84,7 @@
<i class="fa fa-th-list"></i>
</div>
<div class="title">{{ stackCount }}</div>
<div class="comment">Stacks</div>
<div class="comment">{{ stackCount === 1 ? 'Stack' : 'Stacks' }}</div>
</rd-widget-body>
</rd-widget>
</a>
@ -97,7 +97,7 @@
<i class="fa fa-list-alt"></i>
</div>
<div class="title">{{ serviceCount }}</div>
<div class="comment">Services</div>
<div class="comment">{{ serviceCount === 1 ? 'Service' : 'Services' }}</div>
</rd-widget-body>
</rd-widget>
</a>
@ -114,7 +114,7 @@
<div><i class="fa fa-heartbeat space-right red-icon"></i>{{ containers | stoppedcontainers }} stopped</div>
</div>
<div class="title">{{ containers.length }}</div>
<div class="comment">Containers</div>
<div class="comment">{{ containers.length === 1 ? 'Container' : 'Containers' }}</div>
</rd-widget-body>
</rd-widget>
</a>
@ -130,7 +130,7 @@
<div><i class="fa fa-chart-pie space-right"></i>{{ images | imagestotalsize | humansize }}</div>
</div>
<div class="title">{{ images.length }}</div>
<div class="comment">Images</div>
<div class="comment">{{ images.length === 1 ? 'Image' : 'Images' }}</div>
</rd-widget-body>
</rd-widget>
</a>
@ -143,7 +143,7 @@
<i class="fa fa-cubes"></i>
</div>
<div class="title">{{ volumeCount }}</div>
<div class="comment">Volumes</div>
<div class="comment">{{ volumeCount === 1 ? 'Volume' : 'Volumes' }}</div>
</rd-widget-body>
</rd-widget>
</a>
@ -156,7 +156,7 @@
<i class="fa fa-sitemap"></i>
</div>
<div class="title">{{ networkCount }}</div>
<div class="comment">Networks</div>
<div class="comment">{{ networkCount === 1 ? 'Network' : 'Networks' }}</div>
</rd-widget-body>
</rd-widget>
</a>

View File

@ -108,7 +108,11 @@ angular.module('portainer.docker')
config.ConfigFrom = {
Network: selectedNetworkConfig.Name
};
config.Scope = 'swarm';
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
config.Scope = 'swarm';
} else {
config.Scope = 'local';
}
}
function validateForm(accessControlData, isAdmin) {
@ -195,9 +199,6 @@ angular.module('portainer.docker')
PluginService.networkPlugins(apiVersion < 1.25)
.then(function success(data) {
if ($scope.applicationState.endpoint.mode.provider !== 'DOCKER_SWARM_MODE') {
data.splice(data.indexOf('macvlan'), 1);
}
$scope.availableNetworkDrivers = data;
})
.catch(function error(err) {

View File

@ -0,0 +1,97 @@
<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="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('Endpoint')">
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Endpoint' && $ctrl.state.reverseOrder"></i>
Endpoint
</a>
</th>
<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>
<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>
</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>
{{ item.Endpoint.Name }}
</td>
<td>
<a ng-click="$ctrl.goToContainerLogs(item.EndpointId, item.Id)">{{ item.Id | truncate: 32 }}</a>
</td>
<td>
<span 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 tasks 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,13 @@
angular.module('portainer.docker').component('scheduleTasksDatatable', {
templateUrl: './scheduleTasksDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
goToContainerLogs: '<'
}
});

View File

@ -38,13 +38,13 @@
<span class="blocklist-item-desc">
<span>
<span style="padding: 0 7px 0 0;">
<i class="fa fa-th-list space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].StackCount }} stacks
<i class="fa fa-th-list space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].StackCount }} {{ $ctrl.model.Snapshots[0].StackCount === 1 ? 'stack' : 'stacks' }}
</span>
<span style="padding: 0 7px 0 7px;" ng-if="$ctrl.model.Snapshots[0].Swarm">
<i class="fa fa-list-alt space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].ServiceCount }} services
<i class="fa fa-list-alt space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].ServiceCount }} {{ $ctrl.model.Snapshots[0].ServiceCount === 1 ? 'service' : 'services' }}
</span>
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-server space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} containers
<i class="fa fa-server space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount === 1 ? 'container' : 'containers' }}
<span ng-if="$ctrl.model.Snapshots[0].RunningContainerCount > 0 || $ctrl.model.Snapshots[0].StoppedContainerCount > 0">
-
<i class="fa fa-heartbeat green-icon" aria-hidden="true"></i> {{ $ctrl.model.Snapshots[0].RunningContainerCount }}
@ -52,10 +52,10 @@
</span>
</span>
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-cubes space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].VolumeCount }} volumes
<i class="fa fa-cubes space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].VolumeCount }} {{ $ctrl.model.Snapshots[0].VolumeCount === 1 ? 'volume' : 'volumes' }}
</span>
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-clone space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].ImageCount }} images
<i class="fa fa-clone space-right" aria-hidden="true"></i>{{ $ctrl.model.Snapshots[0].ImageCount }} {{ $ctrl.model.Snapshots[0].ImageCount === 1 ? 'image' : 'images' }}
</span>
</span>
</span>
@ -82,6 +82,9 @@
</span>
</span>
</span>
<span class="small text-muted">
{{ $ctrl.model.URL | stripprotocol }}
</span>
</div>
</span>

View File

@ -46,6 +46,7 @@ angular.module('portainer.app').controller('EndpointListController', [
return (
_.includes(endpoint.Name.toLowerCase(), lowerCaseKeyword) ||
_.includes(endpoint.GroupName.toLowerCase(), lowerCaseKeyword) ||
_.includes(endpoint.URL.toLowerCase(), lowerCaseKeyword) ||
_.some(endpoint.Tags, function(tag) {
return _.includes(tag.toLowerCase(), lowerCaseKeyword);
}) ||

View File

@ -21,7 +21,7 @@
class="searchInput"
ng-model="$ctrl.state.textFilter"
ng-change="$ctrl.onFilterChanged()"
placeholder="Search by name, group, tag, status..." auto-focus>
placeholder="Search by name, group, tag, status, URL..." auto-focus>
</div>
<div class="blocklist">

View File

@ -42,8 +42,8 @@
</div>
<!-- image-input -->
<div class="form-group">
<label for="schedule_image" class="col-sm-1 control-label text-left">Image</label>
<div class="col-sm-11">
<label for="schedule_image" class="col-sm-2 control-label text-left">Image</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="$ctrl.model.Job.Image" id="schedule_image" name="schedule_image" placeholder="e.g. ubuntu:latest" required>
</div>
</div>
@ -55,12 +55,24 @@
</div>
</div>
<!-- !image-input -->
<!-- retry-policy -->
<div class="form-group">
<span class="col-sm-12 text-muted small">
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
<code>/host</code> folder.
</span>
<label for="retrycount" class="col-sm-2 control-label text-left">
Retry count
<portainer-tooltip position="bottom" message="Number of retries when it's not possible to reach the endpoint."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-4">
<input type="number" class="form-control" ng-model="$ctrl.model.Job.RetryCount" id="retrycount" name="retrycount" placeholder="3">
</div>
<label for="retryinterval" class="col-sm-2 control-label text-left">
Retry interval
<portainer-tooltip position="bottom" message="Retry interval in seconds."></portainer-tooltip>
</label>
<div class="col-sm-10 col-md-4">
<input type="number" class="form-control" ng-model="$ctrl.model.Job.RetryInterval" id="retryinterval" name="retryinterval" placeholder="30">
</div>
</div>
<!-- !retry-policy -->
<!-- execution-method -->
<div ng-if="!$ctrl.model.Id">
<div class="col-sm-12 form-section-title">
@ -98,6 +110,12 @@
<div class="col-sm-12 form-section-title">
Web editor
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the
<code>/host</code> folder.
</span>
</div>
<div class="form-group">
<div class="col-sm-12">
<code-editor

View File

@ -4,5 +4,12 @@
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
This endpoint is currently offline (read-only). Data shown is based on the latest available snapshot.
</p>
<p class="text-muted">
<i class="fa fa-clock" aria-hidden="true" style="margin-right: 2px;"></i>
Last snapshot: {{ $ctrl.snapshotTime | getisodatefromtimestamp }}
</p>
<button type="button" class="btn btn-xs btn-primary" ng-click="$ctrl.triggerSnapshot()" ng-if="$ctrl.showRefreshButton">
<i class="fa fa-sync space-right" aria-hidden="true"></i>Refresh
</button>
</span>
</information-panel>
</information-panel>

View File

@ -1,4 +1,4 @@
angular.module('portainer.app').component('informationPanelOffline', {
templateUrl: './informationPanelOffline.html',
transclude: true
controller: 'InformationPanelOfflineController'
});

View File

@ -0,0 +1,34 @@
angular.module('portainer.app').controller('InformationPanelOfflineController', ['$state', 'EndpointProvider', 'EndpointService', 'Authentication', 'Notifications',
function StackDuplicationFormController($state, EndpointProvider, EndpointService, Authentication, Notifications) {
var ctrl = this;
this.$onInit = onInit;
this.triggerSnapshot = triggerSnapshot;
function triggerSnapshot() {
var endpointId = EndpointProvider.endpointID();
EndpointService.snapshotEndpoint(endpointId)
.then(function onSuccess() {
$state.reload();
})
.catch(function onError(err) {
Notifications.error('Failure', err, 'An error occured during endpoint snapshot');
});
}
function onInit() {
var endpointId = EndpointProvider.endpointID();
ctrl.showRefreshButton = Authentication.getUserDetails().role === 1;
EndpointService.endpoint(endpointId)
.then(function onSuccess(data) {
ctrl.snapshotTime = data.Snapshots[0].Time;
})
.catch(function onError(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint information');
});
}
}]);

View File

@ -32,7 +32,7 @@ angular.module('portainer.app')
function responseErrorInterceptor(rejection) {
var EndpointService = $injector.get('EndpointService');
var url = rejection.config.url;
if ((rejection.status === 502 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) {
if ((rejection.status === 502 || rejection.status === 503 || rejection.status === -1) && canBeOffline(url) && !EndpointProvider.offlineMode()) {
EndpointProvider.setOfflineMode(true);
EndpointService.updateEndpoint(EndpointProvider.endpointID(), {Status: EndpointProvider.endpointStatusFromOfflineMode(true)});
}

View File

@ -1,3 +1,5 @@
import { createStatus } from '../../docker/models/container';
export function ScheduleDefaultModel() {
this.Name = '';
this.CronExpression = '';
@ -29,6 +31,15 @@ function ScriptExecutionJobModel(data) {
this.Endpoints = data.Endpoints;
this.FileContent = '';
this.Method = 'editor';
this.RetryCount = data.RetryCount;
this.RetryInterval = data.RetryInterval;
}
export function ScriptExecutionTaskModel(data) {
this.Id = data.Id;
this.EndpointId = data.EndpointId;
this.Status = createStatus(data.Status);
this.Created = data.Created;
}
export function ScheduleCreateRequest(model) {
@ -37,6 +48,8 @@ export function ScheduleCreateRequest(model) {
this.Image = model.Job.Image;
this.Endpoints = model.Job.Endpoints;
this.FileContent = model.Job.FileContent;
this.RetryCount = model.Job.RetryCount;
this.RetryInterval = model.Job.RetryInterval;
this.File = model.Job.File;
}
@ -47,4 +60,6 @@ export function ScheduleUpdateRequest(model) {
this.Image = model.Job.Image;
this.Endpoints = model.Job.Endpoints;
this.FileContent = model.Job.FileContent;
this.RetryCount = model.Job.RetryCount;
this.RetryInterval = model.Job.RetryInterval;
}

View File

@ -7,7 +7,8 @@ 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' }},
snapshots: { method: 'POST', params: { action: 'snapshot' }},
snapshot: { method: 'POST', params: { id: '@id', action: 'snapshot' }},
executeJob: { method: 'POST', ignoreLoadingBar: true, params: { id: '@id', action: 'job' } }
});
}]);

View File

@ -8,6 +8,7 @@ function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) {
get: { method: 'GET', params: { id: '@id' } },
update: { method: 'PUT', params: { id: '@id' } },
remove: { method: 'DELETE', params: { id: '@id'} },
file: { method: 'GET', params: { id : '@id', action: 'file' } }
file: { method: 'GET', params: { id : '@id', action: 'file' } },
tasks: { method: 'GET', isArray: true, params: { id : '@id', action: 'tasks' } }
});
}]);

View File

@ -12,8 +12,12 @@ function EndpointServiceFactory($q, Endpoints, FileUploadService) {
return Endpoints.query({}).$promise;
};
service.snapshot = function() {
return Endpoints.snapshot({}, {}).$promise;
service.snapshotEndpoints = function() {
return Endpoints.snapshots({}, {}).$promise;
};
service.snapshotEndpoint = function(endpointID) {
return Endpoints.snapshot({ id: endpointID }, {}).$promise;
};
service.endpointsByGroup = function(groupId) {

View File

@ -1,4 +1,4 @@
import { ScheduleModel, ScheduleCreateRequest, ScheduleUpdateRequest } from '../../models/schedule';
import { ScheduleModel, ScheduleCreateRequest, ScheduleUpdateRequest, ScriptExecutionTaskModel } from '../../models/schedule';
angular.module('portainer.app')
.factory('ScheduleService', ['$q', 'Schedules', 'FileUploadService',
@ -38,6 +38,23 @@ function ScheduleService($q, Schedules, FileUploadService) {
return deferred.promise;
};
service.scriptExecutionTasks = function(scheduleId) {
var deferred = $q.defer();
Schedules.tasks({ id: scheduleId }).$promise
.then(function success(data) {
var tasks = data.map(function (item) {
return new ScriptExecutionTaskModel(item);
});
deferred.resolve(tasks);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve tasks associated to the schedule', err: err });
});
return deferred.promise;
};
service.createScheduleFromFileContent = function(model) {
var payload = new ScheduleCreateRequest(model);
return Schedules.create({ method: 'string' }, payload).$promise;

View File

@ -49,7 +49,9 @@ angular.module('portainer.app')
Name: payload.Name,
CronExpression: payload.CronExpression,
Image: payload.Image,
Endpoints: Upload.json(payload.Endpoints)
Endpoints: Upload.json(payload.Endpoints),
RetryCount: payload.RetryCount,
RetryInterval: payload.RetryInterval
}
});
};

View File

@ -8,7 +8,7 @@
</rd-header>
<information-panel
ng-if="motd && applicationState.UI.dismissedInfoHash !== motd.Hash"
ng-if="motd && motd.Message !== '' && applicationState.UI.dismissedInfoHash !== motd.Hash"
title-text="Important message"
dismiss-action="dismissImportantInformation(motd.Hash)">
<span class="text-muted">

View File

@ -101,7 +101,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G
}
function triggerSnapshot() {
EndpointService.snapshot()
EndpointService.snapshotEndpoints()
.then(function success() {
Notifications.success('Success', 'Endpoints updated');
$state.reload();

View File

@ -13,15 +13,53 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<schedule-form
ng-if="schedule"
model="schedule"
endpoints="endpoints"
groups="groups"
form-action="update"
form-action-label="Update schedule"
action-in-progress="state.actionInProgress"
></schedule-form>
<uib-tabset active="state.activeTab">
<uib-tab index="0">
<uib-tab-heading>
<i class="fa fa-wrench" aria-hidden="true"></i> Configuration
</uib-tab-heading>
<schedule-form
ng-if="schedule"
model="schedule"
endpoints="endpoints"
groups="groups"
form-action="update"
form-action-label="Update schedule"
action-in-progress="state.actionInProgress"
></schedule-form>
</uib-tab>
<uib-tab index="1">
<uib-tab-heading>
<i class="fa fa-tasks" aria-hidden="true"></i> Tasks
</uib-tab-heading>
<div class="col-sm-12 form-section-title">
Information
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small">
Tasks are retrieved across all endpoints via snapshots. Data available in this view might not be up-to-date.
</span>
</div>
<div class="col-sm-12 form-section-title" style="margin-bottom: 20px;">
Tasks
</div>
<schedule-tasks-datatable
ng-if="tasks"
title-text="Tasks" title-icon="fa-tasks"
dataset="tasks"
table-key="schedule-tasks"
order-by="Status" reverse-order="true"
go-to-container-logs="goToContainerLogs"
></schedule-tasks-datatable>
</uib-tab>
</uib-tabset>
</rd-widget-body>
</rd-widget>
</div>

View File

@ -1,12 +1,13 @@
angular.module('portainer.app')
.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService',
function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService) {
.controller('ScheduleController', ['$q', '$scope', '$transition$', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', 'EndpointProvider',
function ($q, $scope, $transition$, $state, Notifications, EndpointService, GroupService, ScheduleService, EndpointProvider) {
$scope.state = {
actionInProgress: false
};
$scope.update = update;
$scope.goToContainerLogs = goToContainerLogs;
function update() {
var model = $scope.schedule;
@ -25,25 +26,48 @@ function ($q, $scope, $transition$, $state, Notifications, EndpointService, Grou
});
}
function goToContainerLogs(endpointId, containerId) {
EndpointProvider.setEndpointID(endpointId);
$state.go('docker.containers.container.logs', { id: containerId });
}
function associateEndpointsToTasks(tasks, endpoints) {
for (var i = 0; i < tasks.length; i++) {
var task = tasks[i];
for (var j = 0; j < endpoints.length; j++) {
var endpoint = endpoints[j];
if (task.EndpointId === endpoint.Id) {
task.Endpoint = endpoint;
break;
}
}
}
}
function initView() {
var id = $transition$.params().id;
var schedule = null;
$q.all({
schedule: ScheduleService.schedule(id),
file: ScheduleService.getScriptFile(id),
tasks: ScheduleService.scriptExecutionTasks(id),
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
schedule = data.schedule;
var schedule = data.schedule;
schedule.Job.FileContent = data.file.ScheduleFileContent;
var endpoints = data.endpoints;
var tasks = data.tasks;
associateEndpointsToTasks(tasks, endpoints);
$scope.schedule = schedule;
$scope.tasks = data.tasks;
$scope.endpoints = data.endpoints;
$scope.groups = data.groups;
return ScheduleService.getScriptFile(schedule.Id);
})
.then(function success(data) {
schedule.Job.FileContent = data.ScheduleFileContent;
$scope.schedule = schedule;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint list');

View File

@ -168,13 +168,13 @@ a[ng-click]{
padding: 0.7rem;
margin-bottom: 0.7rem;
cursor: pointer;
border: 1px solid #333333;
border: 1px solid #cccccc;
border-radius: 2px;
box-shadow: 0 3px 10px -2px rgba(161, 170, 166, 0.5);
}
.blocklist-item--selected {
border: 2px solid #333333;
border: 2px solid #bbbbbb;
background-color: #ececec;
color: #2d3e63;
}

View File

@ -65,7 +65,7 @@
"splitargs": "github:deviantony/splitargs#~0.2.0",
"toastr": "^2.1.4",
"ui-select": "^0.19.8",
"xterm": "^3.1.0"
"xterm": "^3.8.0"
},
"devDependencies": {
"@babel/core": "^7.1.2",

View File

@ -9483,10 +9483,10 @@ xtend@~3.0.0:
resolved "https://registry.yarnpkg.com/xtend/-/xtend-3.0.0.tgz#5cce7407baf642cba7becda568111c493f59665a"
integrity sha1-XM50B7r2Qsunvs2laBEcST9ZZlo=
xterm@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.1.0.tgz#7f7e1c8cf4b80bd881a4e8891213b851423e90c9"
integrity sha512-xv4QQQAu8PjAPZE7m0h+i9X9z3vR82wPe0H3g4D9CbyDuQDNt1e+lxeTrkLwuMH3jcxqfHayu3Mu+iyQh62ECg==
xterm@^3.8.0:
version "3.8.0"
resolved "https://registry.yarnpkg.com/xterm/-/xterm-3.8.0.tgz#55d1de518bdc9c9793823f5e4e97d6898972938d"
integrity sha512-rS3HLryuMWbLsv98+jVVSUXCxmoyXPwqwJNC0ad0VSMdXgl65LefPztQVwfurkaF7kM7ZSgM8eJjnJ9kkdoR1w==
"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0:
version "4.0.0"