diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ddc75e8c..e26559a7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,14 +77,14 @@ The subject contains succinct description of the change: ## Contribution process -Our contribution process is described below. Some of the steps can be visualized inside Github via specific `contrib/` labels, such as `contrib/func-review-in-progress` or `contrib/tech-review-approved`. +Our contribution process is described below. Some of the steps can be visualized inside Github via specific `status/` labels, such as `status/1-functional-review` or `status/2-technical-review`. ### Bug report -![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/43569306-5571b3a0-9637-11e8-8559-786cfc82a14f.png) +![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/45727219-50190a00-bbf5-11e8-9fe8-3a563bb8d5d7.png) ### Feature request -The feature request process is similar to the bug report process but has an extra functional validation before the technical validation. +The feature request process is similar to the bug report process but has an extra functional validation before the technical validation as well as a documentation validation before the testing phase. -![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/43569315-5d30a308-9637-11e8-8292-3c62b5612925.png) +![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/45727229-5ad39f00-bbf5-11e8-9550-16ba66c50615.png) diff --git a/README.md b/README.md index 7b2e9e096..cee536856 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/api/archive/tar.go b/api/archive/tar.go index 4040a9ec7..3beccec8a 100644 --- a/api/archive/tar.go +++ b/api/archive/tar.go @@ -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)), } diff --git a/api/archive/zip.go b/api/archive/zip.go new file mode 100644 index 000000000..1c46c9f10 --- /dev/null +++ b/api/archive/zip.go @@ -0,0 +1,48 @@ +package archive + +import ( + "archive/zip" + "bytes" + "io" + "io/ioutil" + "os" + "path/filepath" +) + +// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk +func UnzipArchive(archiveData []byte, dest string) error { + zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData))) + if err != nil { + return err + } + + for _, zipFile := range zipReader.File { + + f, err := zipFile.Open() + if err != nil { + return err + } + defer f.Close() + + data, err := ioutil.ReadAll(f) + if err != nil { + return err + } + + fpath := filepath.Join(dest, zipFile.Name) + + outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode()) + if err != nil { + return err + } + + _, err = io.Copy(outFile, bytes.NewReader(data)) + if err != nil { + return err + } + + outFile.Close() + } + + return nil +} diff --git a/api/bolt/datastore.go b/api/bolt/datastore.go index 192bc0271..0e94ad157 100644 --- a/api/bolt/datastore.go +++ b/api/bolt/datastore.go @@ -10,9 +10,11 @@ import ( "github.com/portainer/portainer/bolt/dockerhub" "github.com/portainer/portainer/bolt/endpoint" "github.com/portainer/portainer/bolt/endpointgroup" + "github.com/portainer/portainer/bolt/extension" "github.com/portainer/portainer/bolt/migrator" "github.com/portainer/portainer/bolt/registry" "github.com/portainer/portainer/bolt/resourcecontrol" + "github.com/portainer/portainer/bolt/schedule" "github.com/portainer/portainer/bolt/settings" "github.com/portainer/portainer/bolt/stack" "github.com/portainer/portainer/bolt/tag" @@ -38,6 +40,7 @@ type Store struct { DockerHubService *dockerhub.Service EndpointGroupService *endpointgroup.Service EndpointService *endpoint.Service + ExtensionService *extension.Service RegistryService *registry.Service ResourceControlService *resourcecontrol.Service SettingsService *settings.Service @@ -49,6 +52,7 @@ type Store struct { UserService *user.Service VersionService *version.Service WebhookService *webhook.Service + ScheduleService *schedule.Service } // NewStore initializes a new Store and the associated services @@ -138,6 +142,7 @@ func (store *Store) MigrateData() error { ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, StackService: store.StackService, + TemplateService: store.TemplateService, UserService: store.UserService, VersionService: store.VersionService, FileService: store.fileService, @@ -174,6 +179,12 @@ func (store *Store) initServices() error { } store.EndpointService = endpointService + extensionService, err := extension.NewService(store.db) + if err != nil { + return err + } + store.ExtensionService = extensionService + registryService, err := registry.NewService(store.db) if err != nil { return err @@ -240,5 +251,11 @@ func (store *Store) initServices() error { } store.WebhookService = webhookService + scheduleService, err := schedule.NewService(store.db) + if err != nil { + return err + } + store.ScheduleService = scheduleService + return nil } diff --git a/api/bolt/extension/extension.go b/api/bolt/extension/extension.go new file mode 100644 index 000000000..e60963f97 --- /dev/null +++ b/api/bolt/extension/extension.go @@ -0,0 +1,86 @@ +package extension + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "extension" +) + +// Service represents a service for managing endpoint data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Extension returns a extension by ID +func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extension, error) { + var extension portainer.Extension + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &extension) + if err != nil { + return nil, err + } + + return &extension, nil +} + +// Extensions return an array containing all the extensions. +func (service *Service) Extensions() ([]portainer.Extension, error) { + var extensions = make([]portainer.Extension, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var extension portainer.Extension + err := internal.UnmarshalObject(v, &extension) + if err != nil { + return err + } + extensions = append(extensions, extension) + } + + return nil + }) + + return extensions, err +} + +// Persist persists a extension inside the database. +func (service *Service) Persist(extension *portainer.Extension) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + data, err := internal.MarshalObject(extension) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(extension.ID)), data) + }) +} + +// DeleteExtension deletes a Extension. +func (service *Service) DeleteExtension(ID portainer.ExtensionID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} diff --git a/api/bolt/migrator/migrate_dbversion14.go b/api/bolt/migrator/migrate_dbversion14.go new file mode 100644 index 000000000..ebaa4495e --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion14.go @@ -0,0 +1,35 @@ +package migrator + +import ( + "strings" + + "github.com/portainer/portainer" +) + +func (m *Migrator) updateSettingsToDBVersion15() error { + legacySettings, err := m.settingsService.Settings() + if err != nil { + return err + } + + legacySettings.EnableHostManagementFeatures = false + return m.settingsService.UpdateSettings(legacySettings) +} + +func (m *Migrator) updateTemplatesToVersion15() error { + legacyTemplates, err := m.templateService.Templates() + if err != nil { + return err + } + + for _, template := range legacyTemplates { + template.Logo = strings.Replace(template.Logo, "https://portainer.io/images", portainer.AssetsServerURL, -1) + + err = m.templateService.UpdateTemplate(template.ID, &template) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 4d05820aa..1f7d25b21 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -8,6 +8,7 @@ import ( "github.com/portainer/portainer/bolt/resourcecontrol" "github.com/portainer/portainer/bolt/settings" "github.com/portainer/portainer/bolt/stack" + "github.com/portainer/portainer/bolt/template" "github.com/portainer/portainer/bolt/user" "github.com/portainer/portainer/bolt/version" ) @@ -22,6 +23,7 @@ type ( resourceControlService *resourcecontrol.Service settingsService *settings.Service stackService *stack.Service + templateService *template.Service userService *user.Service versionService *version.Service fileService portainer.FileService @@ -36,6 +38,7 @@ type ( ResourceControlService *resourcecontrol.Service SettingsService *settings.Service StackService *stack.Service + TemplateService *template.Service UserService *user.Service VersionService *version.Service FileService portainer.FileService @@ -51,6 +54,7 @@ func NewMigrator(parameters *Parameters) *Migrator { endpointService: parameters.EndpointService, resourceControlService: parameters.ResourceControlService, settingsService: parameters.SettingsService, + templateService: parameters.TemplateService, stackService: parameters.StackService, userService: parameters.UserService, versionService: parameters.VersionService, @@ -186,5 +190,18 @@ func (m *Migrator) Migrate() error { } } + // Portainer 1.20.0 + if m.currentDBVersion < 15 { + err := m.updateSettingsToDBVersion15() + if err != nil { + return err + } + + err = m.updateTemplatesToVersion15() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/bolt/schedule/schedule.go b/api/bolt/schedule/schedule.go new file mode 100644 index 000000000..db824768f --- /dev/null +++ b/api/bolt/schedule/schedule.go @@ -0,0 +1,129 @@ +package schedule + +import ( + "github.com/portainer/portainer" + "github.com/portainer/portainer/bolt/internal" + + "github.com/boltdb/bolt" +) + +const ( + // BucketName represents the name of the bucket where this service stores data. + BucketName = "schedules" +) + +// Service represents a service for managing schedule data. +type Service struct { + db *bolt.DB +} + +// NewService creates a new instance of a service. +func NewService(db *bolt.DB) (*Service, error) { + err := internal.CreateBucket(db, BucketName) + if err != nil { + return nil, err + } + + return &Service{ + db: db, + }, nil +} + +// Schedule returns a schedule by ID. +func (service *Service) Schedule(ID portainer.ScheduleID) (*portainer.Schedule, error) { + var schedule portainer.Schedule + identifier := internal.Itob(int(ID)) + + err := internal.GetObject(service.db, BucketName, identifier, &schedule) + if err != nil { + return nil, err + } + + return &schedule, nil +} + +// UpdateSchedule updates a schedule. +func (service *Service) UpdateSchedule(ID portainer.ScheduleID, schedule *portainer.Schedule) error { + identifier := internal.Itob(int(ID)) + return internal.UpdateObject(service.db, BucketName, identifier, schedule) +} + +// DeleteSchedule deletes a schedule. +func (service *Service) DeleteSchedule(ID portainer.ScheduleID) error { + identifier := internal.Itob(int(ID)) + return internal.DeleteObject(service.db, BucketName, identifier) +} + +// Schedules return a array containing all the schedules. +func (service *Service) Schedules() ([]portainer.Schedule, error) { + var schedules = make([]portainer.Schedule, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var schedule portainer.Schedule + err := internal.UnmarshalObject(v, &schedule) + if err != nil { + return err + } + schedules = append(schedules, schedule) + } + + return nil + }) + + return schedules, err +} + +// SchedulesByJobType return a array containing all the schedules +// with the specified JobType. +func (service *Service) SchedulesByJobType(jobType portainer.JobType) ([]portainer.Schedule, error) { + var schedules = make([]portainer.Schedule, 0) + + err := service.db.View(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + cursor := bucket.Cursor() + for k, v := cursor.First(); k != nil; k, v = cursor.Next() { + var schedule portainer.Schedule + err := internal.UnmarshalObject(v, &schedule) + if err != nil { + return err + } + if schedule.JobType == jobType { + schedules = append(schedules, schedule) + } + } + + return nil + }) + + return schedules, err +} + +// CreateSchedule assign an ID to a new schedule and saves it. +func (service *Service) CreateSchedule(schedule *portainer.Schedule) error { + return service.db.Update(func(tx *bolt.Tx) error { + bucket := tx.Bucket([]byte(BucketName)) + + // We manually manage sequences for schedules + err := bucket.SetSequence(uint64(schedule.ID)) + if err != nil { + return err + } + + data, err := internal.MarshalObject(schedule) + if err != nil { + return err + } + + return bucket.Put(internal.Itob(int(schedule.ID)), data) + }) +} + +// GetNextIdentifier returns the next identifier for a schedule. +func (service *Service) GetNextIdentifier() int { + return internal.GetNextIdentifier(service.db, BucketName) +} diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index cecf986b7..e0a9dd2f9 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -2,7 +2,9 @@ package main // import "github.com/portainer/portainer" import ( "encoding/json" + "os" "strings" + "time" "github.com/portainer/portainer" "github.com/portainer/portainer/bolt" @@ -87,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 { @@ -110,25 +112,110 @@ func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter return docker.NewSnapshotter(clientFactory) } -func initJobScheduler(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter, flags *portainer.CLIFlags) (portainer.JobScheduler, error) { - jobScheduler := cron.NewJobScheduler(endpointService, snapshotter) +func initJobScheduler() portainer.JobScheduler { + return cron.NewJobScheduler() +} - if *flags.ExternalEndpoints != "" { - log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.") - err := jobScheduler.ScheduleEndpointSyncJob(*flags.ExternalEndpoints, *flags.SyncInterval) - if err != nil { - return nil, err +func loadSnapshotSystemSchedule(jobScheduler portainer.JobScheduler, snapshotter portainer.Snapshotter, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, settingsService portainer.SettingsService) error { + settings, err := settingsService.Settings() + if err != nil { + return err + } + + schedules, err := scheduleService.SchedulesByJobType(portainer.SnapshotJobType) + if err != nil { + return err + } + + var snapshotSchedule *portainer.Schedule + if len(schedules) == 0 { + snapshotJob := &portainer.SnapshotJob{} + snapshotSchedule = &portainer.Schedule{ + ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()), + Name: "system_snapshot", + CronExpression: "@every " + settings.SnapshotInterval, + Recurring: true, + JobType: portainer.SnapshotJobType, + SnapshotJob: snapshotJob, + Created: time.Now().Unix(), + } + } else { + snapshotSchedule = &schedules[0] + } + + snapshotJobContext := cron.NewSnapshotJobContext(endpointService, snapshotter) + snapshotJobRunner := cron.NewSnapshotJobRunner(snapshotSchedule, snapshotJobContext) + + err = jobScheduler.ScheduleJob(snapshotJobRunner) + if err != nil { + return err + } + + if len(schedules) == 0 { + return scheduleService.CreateSchedule(snapshotSchedule) + } + return nil +} + +func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, flags *portainer.CLIFlags) error { + if *flags.ExternalEndpoints == "" { + return nil + } + + log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.") + + schedules, err := scheduleService.SchedulesByJobType(portainer.EndpointSyncJobType) + if err != nil { + return err + } + + if len(schedules) != 0 { + return nil + } + + endpointSyncJob := &portainer.EndpointSyncJob{} + + endointSyncSchedule := &portainer.Schedule{ + ID: portainer.ScheduleID(scheduleService.GetNextIdentifier()), + Name: "system_endpointsync", + CronExpression: "@every " + *flags.SyncInterval, + Recurring: true, + JobType: portainer.EndpointSyncJobType, + EndpointSyncJob: endpointSyncJob, + Created: time.Now().Unix(), + } + + endpointSyncJobContext := cron.NewEndpointSyncJobContext(endpointService, *flags.ExternalEndpoints) + endpointSyncJobRunner := cron.NewEndpointSyncJobRunner(endointSyncSchedule, endpointSyncJobContext) + + err = jobScheduler.ScheduleJob(endpointSyncJobRunner) + if err != nil { + return err + } + + return scheduleService.CreateSchedule(endointSyncSchedule) +} + +func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService) error { + schedules, err := scheduleService.Schedules() + if err != nil { + return err + } + + for _, schedule := range schedules { + + if schedule.JobType == portainer.ScriptExecutionJobType { + jobContext := cron.NewScriptExecutionJobContext(jobService, endpointService, fileService) + jobRunner := cron.NewScriptExecutionJobRunner(&schedule, jobContext) + + err = jobScheduler.ScheduleJob(jobRunner) + if err != nil { + return err + } } } - if *flags.Snapshot { - err := jobScheduler.ScheduleSnapshotJob(*flags.SnapshotInterval) - if err != nil { - return nil, err - } - } - - return jobScheduler, nil + return nil } func initStatus(endpointManagement, snapshot bool, flags *portainer.CLIFlags) *portainer.Status { @@ -175,6 +262,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL }, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, + EnableHostManagementFeatures: false, SnapshotInterval: *flags.SnapshotInterval, } @@ -383,6 +471,43 @@ 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 initExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) (portainer.ExtensionManager, error) { + extensionManager := exec.NewExtensionManager(fileService, extensionService) + + extensions, err := extensionService.Extensions() + if err != nil { + return nil, err + } + + for _, extension := range extensions { + err := extensionManager.EnableExtension(&extension, extension.License.LicenseKey) + if err != nil { + return nil, err + } + } + + return extensionManager, nil +} + +func terminateIfNoAdminCreated(userService portainer.UserService) { + timer1 := time.NewTimer(5 * time.Minute) + <-timer1.C + + users, err := userService.UsersByRole(portainer.AdministratorRole) + if err != nil { + log.Fatal(err) + } + + if len(users) == 0 { + log.Fatal("No administrator account was created after 5 min. Shutting down the Portainer instance for security reasons.") + return + } +} + func main() { flags := initCLI() @@ -406,16 +531,16 @@ func main() { log.Fatal(err) } - clientFactory := initClientFactory(digitalSignatureService) - - snapshotter := initSnapshotter(clientFactory) - - jobScheduler, err := initJobScheduler(store.EndpointService, snapshotter, flags) + extensionManager, err := initExtensionManager(fileService, store.ExtensionService) if err != nil { log.Fatal(err) } - jobScheduler.Start() + clientFactory := initClientFactory(digitalSignatureService) + + jobService := initJobService(clientFactory) + + snapshotter := initSnapshotter(clientFactory) endpointManagement := true if *flags.ExternalEndpoints != "" { @@ -439,6 +564,27 @@ func main() { log.Fatal(err) } + jobScheduler := initJobScheduler() + + err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService) + if err != nil { + log.Fatal(err) + } + + err = loadEndpointSyncSystemSchedule(jobScheduler, store.ScheduleService, store.EndpointService, flags) + if err != nil { + log.Fatal(err) + } + + if *flags.Snapshot { + err = loadSnapshotSystemSchedule(jobScheduler, snapshotter, store.ScheduleService, store.EndpointService, store.SettingsService) + if err != nil { + log.Fatal(err) + } + } + + jobScheduler.Start() + err = initDockerHub(store.DockerHubService) if err != nil { log.Fatal(err) @@ -487,6 +633,10 @@ func main() { } } + if !*flags.NoAuth { + go terminateIfNoAdminCreated(store.UserService) + } + var server portainer.Server = &http.Server{ Status: applicationStatus, BindAddress: *flags.Addr, @@ -498,16 +648,19 @@ func main() { TeamMembershipService: store.TeamMembershipService, EndpointService: store.EndpointService, EndpointGroupService: store.EndpointGroupService, + ExtensionService: store.ExtensionService, ResourceControlService: store.ResourceControlService, SettingsService: store.SettingsService, RegistryService: store.RegistryService, DockerHubService: store.DockerHubService, StackService: store.StackService, + ScheduleService: store.ScheduleService, TagService: store.TagService, TemplateService: store.TemplateService, WebhookService: store.WebhookService, SwarmStackManager: swarmStackManager, ComposeStackManager: composeStackManager, + ExtensionManager: extensionManager, CryptoService: cryptoService, JWTService: jwtService, FileService: fileService, @@ -520,6 +673,7 @@ func main() { SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, DockerClientFactory: clientFactory, + JobService: jobService, } log.Printf("Starting Portainer %s on %s", portainer.APIVersion, *flags.Addr) diff --git a/api/cron/job_endpoint_snapshot.go b/api/cron/job_endpoint_snapshot.go deleted file mode 100644 index ff7cc333b..000000000 --- a/api/cron/job_endpoint_snapshot.go +++ /dev/null @@ -1,60 +0,0 @@ -package cron - -import ( - "log" - - "github.com/portainer/portainer" -) - -type ( - endpointSnapshotJob struct { - endpointService portainer.EndpointService - snapshotter portainer.Snapshotter - } -) - -func newEndpointSnapshotJob(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) endpointSnapshotJob { - return endpointSnapshotJob{ - endpointService: endpointService, - snapshotter: snapshotter, - } -} - -func (job endpointSnapshotJob) Snapshot() error { - - endpoints, err := job.endpointService.Endpoints() - if err != nil { - return err - } - - for _, endpoint := range endpoints { - if endpoint.Type == portainer.AzureEnvironment { - continue - } - - snapshot, err := job.snapshotter.CreateSnapshot(&endpoint) - endpoint.Status = portainer.EndpointStatusUp - if err != nil { - log.Printf("cron error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) - endpoint.Status = portainer.EndpointStatusDown - } - - if snapshot != nil { - endpoint.Snapshots = []portainer.Snapshot{*snapshot} - } - - err = job.endpointService.UpdateEndpoint(endpoint.ID, &endpoint) - if err != nil { - return err - } - } - - return nil -} - -func (job endpointSnapshotJob) Run() { - err := job.Snapshot() - if err != nil { - log.Printf("cron error: snapshot job error (err=%s)\n", err) - } -} diff --git a/api/cron/job_endpoint_sync.go b/api/cron/job_endpoint_sync.go index 9fbf595f3..ef13a4970 100644 --- a/api/cron/job_endpoint_sync.go +++ b/api/cron/job_endpoint_sync.go @@ -9,44 +9,93 @@ import ( "github.com/portainer/portainer" ) -type ( - endpointSyncJob struct { - endpointService portainer.EndpointService - endpointFilePath string - } +// EndpointSyncJobRunner is used to run a EndpointSyncJob +type EndpointSyncJobRunner struct { + schedule *portainer.Schedule + context *EndpointSyncJobContext +} - synchronization struct { - endpointsToCreate []*portainer.Endpoint - endpointsToUpdate []*portainer.Endpoint - endpointsToDelete []*portainer.Endpoint - } +// EndpointSyncJobContext represents the context of execution of a EndpointSyncJob +type EndpointSyncJobContext struct { + endpointService portainer.EndpointService + endpointFilePath string +} - fileEndpoint struct { - Name string `json:"Name"` - URL string `json:"URL"` - TLS bool `json:"TLS,omitempty"` - TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"` - TLSCACert string `json:"TLSCACert,omitempty"` - TLSCert string `json:"TLSCert,omitempty"` - TLSKey string `json:"TLSKey,omitempty"` - } -) - -const ( - // ErrEmptyEndpointArray is an error raised when the external endpoint source array is empty. - ErrEmptyEndpointArray = portainer.Error("External endpoint source is empty") -) - -func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob { - return endpointSyncJob{ +// NewEndpointSyncJobContext returns a new context that can be used to execute a EndpointSyncJob +func NewEndpointSyncJobContext(endpointService portainer.EndpointService, endpointFilePath string) *EndpointSyncJobContext { + return &EndpointSyncJobContext{ endpointService: endpointService, endpointFilePath: endpointFilePath, } } +// NewEndpointSyncJobRunner returns a new runner that can be scheduled +func NewEndpointSyncJobRunner(schedule *portainer.Schedule, context *EndpointSyncJobContext) *EndpointSyncJobRunner { + return &EndpointSyncJobRunner{ + schedule: schedule, + context: context, + } +} + +type synchronization struct { + endpointsToCreate []*portainer.Endpoint + endpointsToUpdate []*portainer.Endpoint + endpointsToDelete []*portainer.Endpoint +} + +type fileEndpoint struct { + Name string `json:"Name"` + URL string `json:"URL"` + TLS bool `json:"TLS,omitempty"` + TLSSkipVerify bool `json:"TLSSkipVerify,omitempty"` + TLSCACert string `json:"TLSCACert,omitempty"` + TLSCert string `json:"TLSCert,omitempty"` + TLSKey string `json:"TLSKey,omitempty"` +} + +// 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. +func (runner *EndpointSyncJobRunner) Run() { + data, err := ioutil.ReadFile(runner.context.endpointFilePath) + if endpointSyncError(err) { + return + } + + var fileEndpoints []fileEndpoint + err = json.Unmarshal(data, &fileEndpoints) + if endpointSyncError(err) { + return + } + + if len(fileEndpoints) == 0 { + log.Println("background job error (endpoint synchronization). External endpoint source is empty") + return + } + + storedEndpoints, err := runner.context.endpointService.Endpoints() + if endpointSyncError(err) { + return + } + + convertedFileEndpoints := convertFileEndpoints(fileEndpoints) + + sync := prepareSyncData(storedEndpoints, convertedFileEndpoints) + if sync.requireSync() { + err = runner.context.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete) + if endpointSyncError(err) { + return + } + log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete)) + } +} + func endpointSyncError(err error) bool { if err != nil { - log.Printf("cron error: synchronization job error (err=%s)\n", err) + log.Printf("background job error (endpoint synchronization). Unable to synchronize endpoints (err=%s)\n", err) return true } return false @@ -126,8 +175,7 @@ func (sync synchronization) requireSync() bool { return false } -// TMP: endpointSyncJob method to access logger, should be generic -func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization { +func prepareSyncData(storedEndpoints, fileEndpoints []portainer.Endpoint) *synchronization { endpointsToCreate := make([]*portainer.Endpoint, 0) endpointsToUpdate := make([]*portainer.Endpoint, 0) endpointsToDelete := make([]*portainer.Endpoint, 0) @@ -164,43 +212,3 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port endpointsToDelete: endpointsToDelete, } } - -func (job endpointSyncJob) Sync() error { - data, err := ioutil.ReadFile(job.endpointFilePath) - if endpointSyncError(err) { - return err - } - - var fileEndpoints []fileEndpoint - err = json.Unmarshal(data, &fileEndpoints) - if endpointSyncError(err) { - return err - } - - if len(fileEndpoints) == 0 { - return ErrEmptyEndpointArray - } - - storedEndpoints, err := job.endpointService.Endpoints() - if endpointSyncError(err) { - return err - } - - convertedFileEndpoints := convertFileEndpoints(fileEndpoints) - - sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints) - if sync.requireSync() { - err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete) - if endpointSyncError(err) { - return err - } - log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete)) - } - return nil -} - -func (job endpointSyncJob) Run() { - log.Println("cron: synchronization job started") - err := job.Sync() - endpointSyncError(err) -} diff --git a/api/cron/job_script_execution.go b/api/cron/job_script_execution.go new file mode 100644 index 000000000..2143a83f5 --- /dev/null +++ b/api/cron/job_script_execution.go @@ -0,0 +1,96 @@ +package cron + +import ( + "log" + "time" + + "github.com/portainer/portainer" +) + +// ScriptExecutionJobRunner is used to run a ScriptExecutionJob +type ScriptExecutionJobRunner struct { + schedule *portainer.Schedule + context *ScriptExecutionJobContext + executedOnce bool +} + +// ScriptExecutionJobContext represents the context of execution of a ScriptExecutionJob +type ScriptExecutionJobContext struct { + jobService portainer.JobService + endpointService portainer.EndpointService + fileService portainer.FileService +} + +// NewScriptExecutionJobContext returns a new context that can be used to execute a ScriptExecutionJob +func NewScriptExecutionJobContext(jobService portainer.JobService, endpointService portainer.EndpointService, fileService portainer.FileService) *ScriptExecutionJobContext { + return &ScriptExecutionJobContext{ + jobService: jobService, + endpointService: endpointService, + fileService: fileService, + } +} + +// NewScriptExecutionJobRunner returns a new runner that can be scheduled +func NewScriptExecutionJobRunner(schedule *portainer.Schedule, context *ScriptExecutionJobContext) *ScriptExecutionJobRunner { + return &ScriptExecutionJobRunner{ + schedule: schedule, + context: context, + executedOnce: false, + } +} + +// Run triggers the execution of the job. +// It will iterate through all the endpoints specified in the context to +// execute the script associated to the job. +func (runner *ScriptExecutionJobRunner) Run() { + if !runner.schedule.Recurring && runner.executedOnce { + return + } + runner.executedOnce = true + + 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 + } + + 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 + } + + 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) +} + +// GetSchedule returns the schedule associated to the runner +func (runner *ScriptExecutionJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule +} diff --git a/api/cron/job_snapshot.go b/api/cron/job_snapshot.go new file mode 100644 index 000000000..513a324b9 --- /dev/null +++ b/api/cron/job_snapshot.go @@ -0,0 +1,85 @@ +package cron + +import ( + "log" + + "github.com/portainer/portainer" +) + +// SnapshotJobRunner is used to run a SnapshotJob +type SnapshotJobRunner struct { + schedule *portainer.Schedule + context *SnapshotJobContext +} + +// SnapshotJobContext represents the context of execution of a SnapshotJob +type SnapshotJobContext struct { + endpointService portainer.EndpointService + snapshotter portainer.Snapshotter +} + +// NewSnapshotJobContext returns a new context that can be used to execute a SnapshotJob +func NewSnapshotJobContext(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) *SnapshotJobContext { + return &SnapshotJobContext{ + endpointService: endpointService, + snapshotter: snapshotter, + } +} + +// NewSnapshotJobRunner returns a new runner that can be scheduled +func NewSnapshotJobRunner(schedule *portainer.Schedule, context *SnapshotJobContext) *SnapshotJobRunner { + return &SnapshotJobRunner{ + schedule: schedule, + context: context, + } +} + +// GetSchedule returns the schedule associated to the runner +func (runner *SnapshotJobRunner) GetSchedule() *portainer.Schedule { + return runner.schedule +} + +// 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() { + go func() { + endpoints, err := runner.context.endpointService.Endpoints() + if err != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to retrieve endpoint list (err=%s)\n", err) + return + } + + for _, endpoint := range endpoints { + if endpoint.Type == portainer.AzureEnvironment { + continue + } + + 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 { + latestEndpointReference.Snapshots = []portainer.Snapshot{*snapshot} + } + + err = runner.context.endpointService.UpdateEndpoint(latestEndpointReference.ID, latestEndpointReference) + if err != nil { + log.Printf("background schedule error (endpoint snapshot). Unable to update endpoint (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err) + return + } + } + }() +} diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go index 9f65b6d5a..144e5ed2f 100644 --- a/api/cron/scheduler.go +++ b/api/cron/scheduler.go @@ -1,80 +1,109 @@ package cron import ( - "log" - "github.com/portainer/portainer" "github.com/robfig/cron" ) -// JobScheduler represents a service for managing crons. +// JobScheduler represents a service for managing crons type JobScheduler struct { - cron *cron.Cron - endpointService portainer.EndpointService - snapshotter portainer.Snapshotter - - endpointFilePath string - endpointSyncInterval string + cron *cron.Cron } -// NewJobScheduler initializes a new service. -func NewJobScheduler(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) *JobScheduler { +// NewJobScheduler initializes a new service +func NewJobScheduler() *JobScheduler { return &JobScheduler{ - cron: cron.New(), - endpointService: endpointService, - snapshotter: snapshotter, + cron: cron.New(), } } -// ScheduleEndpointSyncJob schedules a cron job to synchronize the endpoints from a file -func (scheduler *JobScheduler) ScheduleEndpointSyncJob(endpointFilePath string, interval string) error { - - scheduler.endpointFilePath = endpointFilePath - scheduler.endpointSyncInterval = interval - - job := newEndpointSyncJob(endpointFilePath, scheduler.endpointService) - - err := job.Sync() - if err != nil { - return err - } - - return scheduler.cron.AddJob("@every "+interval, job) +// 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) } -// ScheduleSnapshotJob schedules a cron job to create endpoint snapshots -func (scheduler *JobScheduler) ScheduleSnapshotJob(interval string) error { - job := newEndpointSnapshotJob(scheduler.endpointService, scheduler.snapshotter) +// 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 +// 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) UpdateSystemJobSchedule(jobType portainer.JobType, newCronExpression string) error { + cronEntries := scheduler.cron.Entries() + newCron := cron.New() - err := job.Snapshot() - if err != nil { - return err - } - - return scheduler.cron.AddJob("@every "+interval, job) -} - -// UpdateSnapshotJob will update the schedules to match the new snapshot interval -func (scheduler *JobScheduler) UpdateSnapshotJob(interval string) { - // TODO: the cron library do not support removing/updating schedules. - // As a work-around we need to re-create the cron and reschedule the jobs. - // We should update the library. - jobs := scheduler.cron.Entries() - scheduler.cron.Stop() - - scheduler.cron = cron.New() - - for _, job := range jobs { - switch job.Job.(type) { - case endpointSnapshotJob: - scheduler.ScheduleSnapshotJob(interval) - case endpointSyncJob: - scheduler.ScheduleEndpointSyncJob(scheduler.endpointFilePath, scheduler.endpointSyncInterval) - default: - log.Println("Unsupported job") + for _, entry := range cronEntries { + if entry.Job.(portainer.JobRunner).GetSchedule().JobType == jobType { + err := newCron.AddJob(newCronExpression, entry.Job) + if err != nil { + return err + } + continue } + + newCron.Schedule(entry.Schedule, entry.Job) } + scheduler.cron.Stop() + scheduler.cron = newCron + scheduler.cron.Start() + return nil +} + +// 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) UpdateJobSchedule(runner portainer.JobRunner) error { + cronEntries := scheduler.cron.Entries() + newCron := cron.New() + + for _, entry := range cronEntries { + + 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 + } + continue + } + + 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 + } + + newCron.Schedule(entry.Schedule, entry.Job) + } + + scheduler.cron.Stop() + scheduler.cron = newCron scheduler.cron.Start() } diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 003547531..35cc0b283 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -26,6 +26,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 +100,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. +// If a secret is associated to the service, it will be used instead of the specified +// message. // It then encodes the generated signature in base64. -func (service *ECDSAService) Sign(message string) (string, error) { +func (service *ECDSAService) CreateSignature(message string) (string, error) { + if service.secret != "" { + message = service.secret + } + hash := HashFromBytes([]byte(message)) r := big.NewInt(0) diff --git a/api/docker/client.go b/api/docker/client.go index af9f08c46..421f92c04 100644 --- a/api/docker/client.go +++ b/api/docker/client.go @@ -27,12 +27,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,13 +62,13 @@ 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 } - signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err } @@ -77,6 +78,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 +102,7 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) { } return &http.Client{ - Timeout: time.Second * 10, Transport: transport, + Timeout: 30 * time.Second, }, nil } diff --git a/api/docker/job.go b/api/docker/job.go new file mode 100644 index 000000000..7765f5f2e --- /dev/null +++ b/api/docker/job.go @@ -0,0 +1,115 @@ +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 represents a service that handles the execution of jobs +type JobService struct { + dockerClientFactory *ClientFactory +} + +// NewJobService returns a pointer to a new job service +func NewJobService(dockerClientFactory *ClientFactory) *JobService { + return &JobService{ + dockerClientFactory: dockerClientFactory, + } +} + +// 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) + 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 + } + + 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"}), + } + + 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", + Privileged: true, + } + + networkConfig := &network.NetworkingConfig{} + + body, err := cli.ContainerCreate(context.Background(), containerConfig, hostConfig, networkConfig, "") + if err != nil { + 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 { + return err + } + + startOptions := types.ContainerStartOptions{} + return cli.ContainerStart(context.Background(), body.ID, startOptions) +} + +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 +} diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go index d8c481fb7..342465b1f 100644 --- a/api/docker/snapshot.go +++ b/api/docker/snapshot.go @@ -52,6 +52,16 @@ func snapshot(cli *client.Client) (*portainer.Snapshot, error) { return nil, err } + err = snapshotNetworks(snapshot, cli) + if err != nil { + return nil, err + } + + err = snapshotVersion(snapshot, cli) + if err != nil { + return nil, err + } + snapshot.Time = time.Now().Unix() return snapshot, nil } @@ -66,6 +76,7 @@ func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error { snapshot.DockerVersion = info.ServerVersion snapshot.TotalCPU = info.NCPU snapshot.TotalMemory = info.MemTotal + snapshot.SnapshotRaw.Info = info return nil } @@ -132,6 +143,7 @@ func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error snapshot.RunningContainerCount = runningContainers snapshot.StoppedContainerCount = stoppedContainers snapshot.StackCount += len(stacks) + snapshot.SnapshotRaw.Containers = containers return nil } @@ -142,6 +154,7 @@ func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error { } snapshot.ImageCount = len(images) + snapshot.SnapshotRaw.Images = images return nil } @@ -152,5 +165,24 @@ func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error { } snapshot.VolumeCount = len(volumes.Volumes) + snapshot.SnapshotRaw.Volumes = volumes + return nil +} + +func snapshotNetworks(snapshot *portainer.Snapshot, cli *client.Client) error { + networks, err := cli.NetworkList(context.Background(), types.NetworkListOptions{}) + if err != nil { + return err + } + snapshot.SnapshotRaw.Networks = networks + return nil +} + +func snapshotVersion(snapshot *portainer.Snapshot, cli *client.Client) error { + version, err := cli.ServerVersion(context.Background()) + if err != nil { + return err + } + snapshot.SnapshotRaw.Version = version return nil } diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go index 34cb35def..ee095f6d5 100644 --- a/api/docker/snapshotter.go +++ b/api/docker/snapshotter.go @@ -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 } diff --git a/api/errors.go b/api/errors.go index e348aaf48..ef11d5522 100644 --- a/api/errors.go +++ b/api/errors.go @@ -88,6 +88,21 @@ const ( ErrUndefinedTLSFileType = Error("Undefined TLS file type") ) +// Extension errors. +const ( + ErrExtensionAlreadyEnabled = Error("This extension is already enabled") +) + +// Docker errors. +const ( + ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint") +) + +// Schedule errors. +const ( + ErrHostManagementFeaturesDisabled = Error("Host management features are disabled") +) + // Error represents an application error. type Error string diff --git a/api/exec/extension.go b/api/exec/extension.go new file mode 100644 index 000000000..20cf2eca8 --- /dev/null +++ b/api/exec/extension.go @@ -0,0 +1,209 @@ +package exec + +import ( + "bytes" + "encoding/json" + "errors" + "os/exec" + "path" + "runtime" + "strconv" + "strings" + + "github.com/orcaman/concurrent-map" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/" + +var extensionBinaryMap = map[portainer.ExtensionID]string{ + portainer.RegistryManagementExtension: "extension-registry-management", +} + +// ExtensionManager represents a service used to +// manage extension processes. +type ExtensionManager struct { + processes cmap.ConcurrentMap + fileService portainer.FileService + extensionService portainer.ExtensionService +} + +// NewExtensionManager returns a pointer to an ExtensionManager +func NewExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) *ExtensionManager { + return &ExtensionManager{ + processes: cmap.New(), + fileService: fileService, + extensionService: extensionService, + } +} + +func processKey(ID portainer.ExtensionID) string { + return strconv.Itoa(int(ID)) +} + +func buildExtensionURL(extension *portainer.Extension) string { + extensionURL := extensionDownloadBaseURL + extensionURL += extensionBinaryMap[extension.ID] + extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH + extensionURL += "-" + extension.Version + extensionURL += ".zip" + return extensionURL +} + +func buildExtensionPath(binaryPath string, extension *portainer.Extension) string { + + extensionFilename := extensionBinaryMap[extension.ID] + extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH + extensionFilename += "-" + extension.Version + + if runtime.GOOS == "windows" { + extensionFilename += ".exe" + } + + extensionPath := path.Join( + binaryPath, + extensionFilename) + + return extensionPath +} + +// FetchExtensionDefinitions will fetch the list of available +// extension definitions from the official Portainer assets server +func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) { + extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30) + if err != nil { + return nil, err + } + + var extensions []portainer.Extension + err = json.Unmarshal(extensionData, &extensions) + if err != nil { + return nil, err + } + + return extensions, nil +} + +// EnableExtension will check for the existence of the extension binary on the filesystem +// first. If it does not exist, it will download it from the official Portainer assets server. +// After installing the binary on the filesystem, it will execute the binary in license check +// mode to validate the extension license. If the license is valid, it will then start +// the extension process and register it in the processes map. +func (manager *ExtensionManager) EnableExtension(extension *portainer.Extension, licenseKey string) error { + extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) + extensionBinaryExists, err := manager.fileService.FileExists(extensionBinaryPath) + if err != nil { + return err + } + + if !extensionBinaryExists { + err := manager.downloadExtension(extension) + if err != nil { + return err + } + } + + licenseDetails, err := validateLicense(extensionBinaryPath, licenseKey) + if err != nil { + return err + } + + extension.License = portainer.LicenseInformation{ + LicenseKey: licenseKey, + Company: licenseDetails[0], + Expiration: licenseDetails[1], + } + extension.Version = licenseDetails[2] + + return manager.startExtensionProcess(extension, extensionBinaryPath) +} + +// DisableExtension will retrieve the process associated to the extension +// from the processes map and kill the process. It will then remove the process +// from the processes map and remove the binary associated to the extension +// from the filesystem +func (manager *ExtensionManager) DisableExtension(extension *portainer.Extension) error { + process, ok := manager.processes.Get(processKey(extension.ID)) + if !ok { + return nil + } + + err := process.(*exec.Cmd).Process.Kill() + if err != nil { + return err + } + + manager.processes.Remove(processKey(extension.ID)) + + extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) + return manager.fileService.RemoveDirectory(extensionBinaryPath) +} + +// UpdateExtension will download the new extension binary from the official Portainer assets +// server, disable the previous extension via DisableExtension, trigger a license check +// and then start the extension process and add it to the processes map +func (manager *ExtensionManager) UpdateExtension(extension *portainer.Extension, version string) error { + oldVersion := extension.Version + + extension.Version = version + err := manager.downloadExtension(extension) + if err != nil { + return err + } + + extension.Version = oldVersion + err = manager.DisableExtension(extension) + if err != nil { + return err + } + + extension.Version = version + extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension) + + licenseDetails, err := validateLicense(extensionBinaryPath, extension.License.LicenseKey) + if err != nil { + return err + } + + extension.Version = licenseDetails[2] + + return manager.startExtensionProcess(extension, extensionBinaryPath) +} + +func (manager *ExtensionManager) downloadExtension(extension *portainer.Extension) error { + extensionURL := buildExtensionURL(extension) + + data, err := client.Get(extensionURL, 30) + if err != nil { + return err + } + + return manager.fileService.ExtractExtensionArchive(data) +} + +func validateLicense(binaryPath, licenseKey string) ([]string, error) { + licenseCheckProcess := exec.Command(binaryPath, "-license", licenseKey, "-check") + cmdOutput := &bytes.Buffer{} + licenseCheckProcess.Stdout = cmdOutput + + err := licenseCheckProcess.Run() + if err != nil { + return nil, errors.New("Invalid extension license key") + } + + output := string(cmdOutput.Bytes()) + + return strings.Split(output, "|"), nil +} + +func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Extension, binaryPath string) error { + extensionProcess := exec.Command(binaryPath, "-license", extension.License.LicenseKey) + err := extensionProcess.Start() + if err != nil { + return err + } + + manager.processes.Set(processKey(extension.ID), extensionProcess) + return nil +} diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index aa32bfe54..da781b030 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -140,7 +140,7 @@ func (manager *SwarmStackManager) updateDockerCLIConfiguration(dataPath string) return err } - signature, err := manager.signatureService.Sign(portainer.PortainerAgentSignatureMessage) + signature, err := manager.signatureService.CreateSignature(portainer.PortainerAgentSignatureMessage) if err != nil { return err } diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go index 1f5ca322e..dcb397eac 100644 --- a/api/filesystem/filesystem.go +++ b/api/filesystem/filesystem.go @@ -7,6 +7,7 @@ import ( "io/ioutil" "github.com/portainer/portainer" + "github.com/portainer/portainer/archive" "io" "os" @@ -32,6 +33,13 @@ const ( PrivateKeyFile = "portainer.key" // PublicKeyFile represents the name on disk of the file containing the public key. PublicKeyFile = "portainer.pub" + // BinaryStorePath represents the subfolder where binaries are stored in the file store folder. + BinaryStorePath = "bin" + // ScheduleStorePath represents the subfolder where schedule files are stored. + ScheduleStorePath = "schedules" + // ExtensionRegistryManagementStorePath represents the subfolder where files related to the + // registry management extension are stored. + ExtensionRegistryManagementStorePath = "extensions" ) // Service represents a service for managing files and directories. @@ -63,9 +71,30 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) { return nil, err } + err = service.createDirectoryInStore(BinaryStorePath) + if err != nil { + return nil, err + } + return service, nil } +// GetBinaryFolder returns the full path to the binary store on the filesystem +func (service *Service) GetBinaryFolder() string { + return path.Join(service.fileStorePath, BinaryStorePath) +} + +// ExtractExtensionArchive extracts the content of an extension archive +// specified as raw data into the binary store on the filesystem +func (service *Service) ExtractExtensionArchive(data []byte) error { + err := archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath)) + if err != nil { + return err + } + + return nil +} + // RemoveDirectory removes a directory on the filesystem. func (service *Service) RemoveDirectory(directoryPath string) error { return os.RemoveAll(directoryPath) @@ -97,6 +126,27 @@ func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string return path.Join(service.fileStorePath, stackStorePath), nil } +// StoreRegistryManagementFileFromBytes creates a subfolder in the +// ExtensionRegistryManagementStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) { + extensionStorePath := path.Join(ExtensionRegistryManagementStorePath, folder) + err := service.createDirectoryInStore(extensionStorePath) + if err != nil { + return "", err + } + + file := path.Join(extensionStorePath, fileName) + r := bytes.NewReader(data) + + err = service.createFileInStore(file, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, file), nil +} + // StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes. // It returns the path to the newly created file. func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) { @@ -318,3 +368,32 @@ func (service *Service) getContentFromPEMFile(filePath string) ([]byte, error) { block, _ := pem.Decode(fileContent) return block.Bytes, nil } + +// GetScheduleFolder returns the absolute path on the filesystem for a schedule based +// on its identifier. +func (service *Service) GetScheduleFolder(identifier string) string { + return path.Join(service.fileStorePath, ScheduleStorePath, identifier) +} + +// StoreScheduledJobFileFromBytes creates a subfolder in the ScheduleStorePath and stores a new file from bytes. +// It returns the path to the folder where the file is stored. +func (service *Service) StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) { + scheduleStorePath := path.Join(ScheduleStorePath, identifier) + err := service.createDirectoryInStore(scheduleStorePath) + if err != nil { + return "", err + } + + filePath := path.Join(scheduleStorePath, createScheduledJobFileName(identifier)) + r := bytes.NewReader(data) + err = service.createFileInStore(filePath, r) + if err != nil { + return "", err + } + + return path.Join(service.fileStorePath, filePath), nil +} + +func createScheduledJobFileName(identifier string) string { + return "job_" + identifier + ".sh" +} diff --git a/api/http/client/client.go b/api/http/client/client.go index 541ec8257..8892b6472 100644 --- a/api/http/client/client.go +++ b/api/http/client/client.go @@ -15,6 +15,7 @@ import ( const ( errInvalidResponseStatus = portainer.Error("Invalid response status (expecting 200)") + defaultHTTPTimeout = 5 ) // HTTPClient represents a client to send HTTP requests. @@ -26,7 +27,7 @@ type HTTPClient struct { func NewHTTPClient() *HTTPClient { return &HTTPClient{ &http.Client{ - Timeout: time.Second * 5, + Timeout: time.Second * time.Duration(defaultHTTPTimeout), }, } } @@ -67,10 +68,16 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain } // Get executes a simple HTTP GET to the specified URL and returns -// the content of the response body. -func Get(url string) ([]byte, error) { +// the content of the response body. Timeout can be specified via the timeout parameter, +// will default to defaultHTTPTimeout if set to 0. +func Get(url string, timeout int) ([]byte, error) { + + if timeout == 0 { + timeout = defaultHTTPTimeout + } + client := &http.Client{ - Timeout: time.Second * 3, + Timeout: time.Second * time.Duration(timeout), } response, err := client.Get(url) diff --git a/api/http/handler/endpointproxy/proxy_docker.go b/api/http/handler/endpointproxy/proxy_docker.go index f03ca8e67..9ca932a97 100644 --- a/api/http/handler/endpointproxy/proxy_docker.go +++ b/api/http/handler/endpointproxy/proxy_docker.go @@ -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} diff --git a/api/http/handler/endpointproxy/proxy_storidge.go b/api/http/handler/endpointproxy/proxy_storidge.go index 86375f091..697d74ff5 100644 --- a/api/http/handler/endpointproxy/proxy_storidge.go +++ b/api/http/handler/endpointproxy/proxy_storidge.go @@ -1,5 +1,7 @@ package endpointproxy +// TODO: legacy extension management + import ( "strconv" @@ -42,9 +44,9 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension) var proxy http.Handler - proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey) + proxy = handler.ProxyManager.GetLegacyExtensionProxy(proxyExtensionKey) if proxy == nil { - proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL) + proxy, err = handler.ProxyManager.CreateLegacyExtensionProxy(proxyExtensionKey, storidgeExtension.URL) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err} } diff --git a/api/http/handler/endpoints/endpoint_delete.go b/api/http/handler/endpoints/endpoint_delete.go index 865c2055b..3b34db01e 100644 --- a/api/http/handler/endpoints/endpoint_delete.go +++ b/api/http/handler/endpoints/endpoint_delete.go @@ -42,7 +42,6 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) * } handler.ProxyManager.DeleteProxy(string(endpointID)) - handler.ProxyManager.DeleteExtensionProxies(string(endpointID)) return response.Empty(w) } diff --git a/api/http/handler/endpoints/endpoint_extension_add.go b/api/http/handler/endpoints/endpoint_extension_add.go index 9a9eebbda..7c2f69e3a 100644 --- a/api/http/handler/endpoints/endpoint_extension_add.go +++ b/api/http/handler/endpoints/endpoint_extension_add.go @@ -1,5 +1,7 @@ package endpoints +// TODO: legacy extension management + import ( "net/http" diff --git a/api/http/handler/endpoints/endpoint_extension_remove.go b/api/http/handler/endpoints/endpoint_extension_remove.go index 8b265dc6c..3f68955cc 100644 --- a/api/http/handler/endpoints/endpoint_extension_remove.go +++ b/api/http/handler/endpoints/endpoint_extension_remove.go @@ -1,5 +1,7 @@ package endpoints +// TODO: legacy extension management + import ( "net/http" diff --git a/api/http/handler/endpoints/endpoint_job.go b/api/http/handler/endpoints/endpoint_job.go new file mode 100644 index 000000000..565418f42 --- /dev/null +++ b/api/http/handler/endpoints/endpoint_job.go @@ -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.ExecuteScript(endpoint, nodeName, payload.Image, payload.File, nil) + 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.ExecuteScript(endpoint, nodeName, payload.Image, []byte(payload.FileContent), nil) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Failed executing job", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/endpoints/endpoint_snapshot.go b/api/http/handler/endpoints/endpoint_snapshot.go index 720f4c072..559cf127c 100644 --- a/api/http/handler/endpoints/endpoint_snapshot.go +++ b/api/http/handler/endpoints/endpoint_snapshot.go @@ -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) diff --git a/api/http/handler/endpoints/endpoint_snapshots.go b/api/http/handler/endpoints/endpoint_snapshots.go new file mode 100644 index 000000000..f59b7a40d --- /dev/null +++ b/api/http/handler/endpoints/endpoint_snapshots.go @@ -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) +} diff --git a/api/http/handler/endpoints/endpoint_update.go b/api/http/handler/endpoints/endpoint_update.go index 5244cdc98..405f6ce38 100644 --- a/api/http/handler/endpoints/endpoint_update.go +++ b/api/http/handler/endpoints/endpoint_update.go @@ -12,16 +12,17 @@ import ( ) type endpointUpdatePayload struct { - Name string - URL string - PublicURL string - GroupID int - TLS bool - TLSSkipVerify bool - TLSSkipClientVerify bool - AzureApplicationID string - AzureTenantID string - AzureAuthenticationKey string + Name *string + URL *string + PublicURL *string + GroupID *int + TLS *bool + TLSSkipVerify *bool + TLSSkipClientVerify *bool + Status *int + AzureApplicationID *string + AzureTenantID *string + AzureAuthenticationKey *string Tags []string } @@ -53,36 +54,49 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err} } - if payload.Name != "" { - endpoint.Name = payload.Name + if payload.Name != nil { + endpoint.Name = *payload.Name } - if payload.URL != "" { - endpoint.URL = payload.URL + if payload.URL != nil { + endpoint.URL = *payload.URL } - if payload.PublicURL != "" { - endpoint.PublicURL = payload.PublicURL + if payload.PublicURL != nil { + endpoint.PublicURL = *payload.PublicURL } - if payload.GroupID != 0 { - endpoint.GroupID = portainer.EndpointGroupID(payload.GroupID) + if payload.GroupID != nil { + endpoint.GroupID = portainer.EndpointGroupID(*payload.GroupID) } if payload.Tags != nil { endpoint.Tags = payload.Tags } + if payload.Status != nil { + switch *payload.Status { + case 1: + endpoint.Status = portainer.EndpointStatusUp + break + case 2: + endpoint.Status = portainer.EndpointStatusDown + break + default: + break + } + } + if endpoint.Type == portainer.AzureEnvironment { credentials := endpoint.AzureCredentials - if payload.AzureApplicationID != "" { - credentials.ApplicationID = payload.AzureApplicationID + if payload.AzureApplicationID != nil { + credentials.ApplicationID = *payload.AzureApplicationID } - if payload.AzureTenantID != "" { - credentials.TenantID = payload.AzureTenantID + if payload.AzureTenantID != nil { + credentials.TenantID = *payload.AzureTenantID } - if payload.AzureAuthenticationKey != "" { - credentials.AuthenticationKey = payload.AzureAuthenticationKey + if payload.AzureAuthenticationKey != nil { + credentials.AuthenticationKey = *payload.AzureAuthenticationKey } httpClient := client.NewHTTPClient() @@ -93,44 +107,55 @@ func (handler *Handler) endpointUpdate(w http.ResponseWriter, r *http.Request) * endpoint.AzureCredentials = credentials } - folder := strconv.Itoa(endpointID) - if payload.TLS { - endpoint.TLSConfig.TLS = true - endpoint.TLSConfig.TLSSkipVerify = payload.TLSSkipVerify - if !payload.TLSSkipVerify { - caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) - endpoint.TLSConfig.TLSCACertPath = caCertPath - } else { - endpoint.TLSConfig.TLSCACertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) - } + if payload.TLS != nil { + folder := strconv.Itoa(endpointID) + + if *payload.TLS { + endpoint.TLSConfig.TLS = true + if payload.TLSSkipVerify != nil { + endpoint.TLSConfig.TLSSkipVerify = *payload.TLSSkipVerify + + if !*payload.TLSSkipVerify { + caCertPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCA) + endpoint.TLSConfig.TLSCACertPath = caCertPath + } else { + endpoint.TLSConfig.TLSCACertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCA) + } + } + + if payload.TLSSkipClientVerify != nil { + if !*payload.TLSSkipClientVerify { + certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSCertPath = certPath + keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) + endpoint.TLSConfig.TLSKeyPath = keyPath + } else { + endpoint.TLSConfig.TLSCertPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) + endpoint.TLSConfig.TLSKeyPath = "" + handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) + } + } - if !payload.TLSSkipClientVerify { - certPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileCert) - endpoint.TLSConfig.TLSCertPath = certPath - keyPath, _ := handler.FileService.GetPathForTLSFile(folder, portainer.TLSFileKey) - endpoint.TLSConfig.TLSKeyPath = keyPath } else { + endpoint.TLSConfig.TLS = false + endpoint.TLSConfig.TLSSkipVerify = false + endpoint.TLSConfig.TLSCACertPath = "" endpoint.TLSConfig.TLSCertPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileCert) endpoint.TLSConfig.TLSKeyPath = "" - handler.FileService.DeleteTLSFile(folder, portainer.TLSFileKey) - } - } else { - endpoint.TLSConfig.TLS = false - endpoint.TLSConfig.TLSSkipVerify = false - endpoint.TLSConfig.TLSCACertPath = "" - endpoint.TLSConfig.TLSCertPath = "" - endpoint.TLSConfig.TLSKeyPath = "" - err = handler.FileService.DeleteTLSFiles(folder) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + err = handler.FileService.DeleteTLSFiles(folder) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove TLS files from disk", err} + } } } - _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + if payload.URL != nil || payload.TLS != nil || endpoint.Type == portainer.AzureEnvironment { + _, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register HTTP proxy for the endpoint", err} + } } err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint) diff --git a/api/http/handler/endpoints/handler.go b/api/http/handler/endpoints/handler.go index 779cd9390..d8a94d360 100644 --- a/api/http/handler/endpoints/handler.go +++ b/api/http/handler/endpoints/handler.go @@ -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. @@ -44,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}", @@ -59,6 +60,9 @@ 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) + h.Handle("/endpoints/{id}/snapshot", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) return h } diff --git a/api/http/handler/extensions/extension_create.go b/api/http/handler/extensions/extension_create.go new file mode 100644 index 000000000..b0ce72406 --- /dev/null +++ b/api/http/handler/extensions/extension_create.go @@ -0,0 +1,79 @@ +package extensions + +import ( + "net/http" + "strconv" + + "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 extensionCreatePayload struct { + License string +} + +func (payload *extensionCreatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.License) { + return portainer.Error("Invalid license") + } + + return nil +} + +func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload extensionCreatePayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extensionIdentifier, err := strconv.Atoi(string(payload.License[0])) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extensions, err := handler.ExtensionService.Extensions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err} + } + + for _, existingExtension := range extensions { + if existingExtension.ID == extensionID { + return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled} + } + } + + extension := &portainer.Extension{ + ID: extensionID, + } + + extensionDefinitions, err := handler.ExtensionManager.FetchExtensionDefinitions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} + } + + for _, def := range extensionDefinitions { + if def.ID == extension.ID { + extension.Version = def.Version + break + } + } + + err = handler.ExtensionManager.EnableExtension(extension, payload.License) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to enable extension", err} + } + + extension.Enabled = true + + err = handler.ExtensionService.Persist(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/extension_delete.go b/api/http/handler/extensions/extension_delete.go new file mode 100644 index 000000000..be9d72bb3 --- /dev/null +++ b/api/http/handler/extensions/extension_delete.go @@ -0,0 +1,38 @@ +package extensions + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// DELETE request on /api/extensions/:id +func (handler *Handler) extensionDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extension, err := handler.ExtensionService.Extension(extensionID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + err = handler.ExtensionManager.DisableExtension(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete extension", err} + } + + err = handler.ExtensionService.DeleteExtension(extensionID) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the extension from the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/extension_inspect.go b/api/http/handler/extensions/extension_inspect.go new file mode 100644 index 000000000..712e4403e --- /dev/null +++ b/api/http/handler/extensions/extension_inspect.go @@ -0,0 +1,63 @@ +package extensions + +import ( + "encoding/json" + "net/http" + + "github.com/coreos/go-semver/semver" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/client" +) + +// GET request on /api/extensions/:id +func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err} + } + + var extensions []portainer.Extension + err = json.Unmarshal(extensionData, &extensions) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err} + } + + var extension portainer.Extension + for _, p := range extensions { + if p.ID == extensionID { + extension = p + if extension.DescriptionURL != "" { + description, _ := client.Get(extension.DescriptionURL, 10) + extension.Description = string(description) + } + break + } + } + + storedExtension, err := handler.ExtensionService.Extension(extensionID) + if err == portainer.ErrObjectNotFound { + return response.JSON(w, extension) + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + extension.Enabled = storedExtension.Enabled + + extensionVer := semver.New(extension.Version) + pVer := semver.New(storedExtension.Version) + + if pVer.LessThan(*extensionVer) { + extension.UpdateAvailable = true + } + + return response.JSON(w, extension) +} diff --git a/api/http/handler/extensions/extension_list.go b/api/http/handler/extensions/extension_list.go new file mode 100644 index 000000000..392822528 --- /dev/null +++ b/api/http/handler/extensions/extension_list.go @@ -0,0 +1,55 @@ +package extensions + +import ( + "net/http" + + "github.com/coreos/go-semver/semver" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// GET request on /api/extensions?store= +func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + storeDetails, _ := request.RetrieveBooleanQueryParameter(r, "store", true) + + extensions, err := handler.ExtensionService.Extensions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err} + } + + if storeDetails { + definitions, err := handler.ExtensionManager.FetchExtensionDefinitions() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions", err} + } + + for idx := range definitions { + associateExtensionData(&definitions[idx], extensions) + } + + extensions = definitions + } + + return response.JSON(w, extensions) +} + +func associateExtensionData(definition *portainer.Extension, extensions []portainer.Extension) { + for _, extension := range extensions { + if extension.ID == definition.ID { + + definition.Enabled = extension.Enabled + definition.License.Company = extension.License.Company + definition.License.Expiration = extension.License.Expiration + + definitionVersion := semver.New(definition.Version) + extensionVersion := semver.New(extension.Version) + if extensionVersion.LessThan(*definitionVersion) { + definition.UpdateAvailable = true + } + + break + } + } +} diff --git a/api/http/handler/extensions/extension_update.go b/api/http/handler/extensions/extension_update.go new file mode 100644 index 000000000..4e80ea1bc --- /dev/null +++ b/api/http/handler/extensions/extension_update.go @@ -0,0 +1,56 @@ +package extensions + +import ( + "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 extensionUpdatePayload struct { + Version string +} + +func (payload *extensionUpdatePayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Version) { + return portainer.Error("Invalid extension version") + } + + return nil +} + +func (handler *Handler) extensionUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err} + } + extensionID := portainer.ExtensionID(extensionIdentifier) + + var payload extensionUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + extension, err := handler.ExtensionService.Extension(extensionID) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + err = handler.ExtensionManager.UpdateExtension(extension, payload.Version) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update extension", err} + } + + err = handler.ExtensionService.Persist(extension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/extensions/handler.go b/api/http/handler/extensions/handler.go new file mode 100644 index 000000000..b7ebbd06b --- /dev/null +++ b/api/http/handler/extensions/handler.go @@ -0,0 +1,37 @@ +package extensions + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle extension operations. +type Handler struct { + *mux.Router + ExtensionService portainer.ExtensionService + ExtensionManager portainer.ExtensionManager +} + +// NewHandler creates a handler to manage extension operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/extensions", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet) + h.Handle("/extensions", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost) + h.Handle("/extensions/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet) + h.Handle("/extensions/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete) + h.Handle("/extensions/{id}/update", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost) + + return h +} diff --git a/api/http/handler/file/handler.go b/api/http/handler/file/handler.go index 464062be1..06a3c4737 100644 --- a/api/http/handler/file/handler.go +++ b/api/http/handler/file/handler.go @@ -34,7 +34,6 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") } - w.Header().Add("X-Frame-Options", "DENY") w.Header().Add("X-XSS-Protection", "1; mode=block") w.Header().Add("X-Content-Type-Options", "nosniff") handler.Handler.ServeHTTP(w, r) diff --git a/api/http/handler/handler.go b/api/http/handler/handler.go index 40ae9f57d..9cb3059df 100644 --- a/api/http/handler/handler.go +++ b/api/http/handler/handler.go @@ -9,10 +9,12 @@ import ( "github.com/portainer/portainer/http/handler/endpointgroups" "github.com/portainer/portainer/http/handler/endpointproxy" "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/extensions" "github.com/portainer/portainer/http/handler/file" "github.com/portainer/portainer/http/handler/motd" "github.com/portainer/portainer/http/handler/registries" "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/schedules" "github.com/portainer/portainer/http/handler/settings" "github.com/portainer/portainer/http/handler/stacks" "github.com/portainer/portainer/http/handler/status" @@ -36,6 +38,7 @@ type Handler struct { EndpointProxyHandler *endpointproxy.Handler FileHandler *file.Handler MOTDHandler *motd.Handler + ExtensionHandler *extensions.Handler RegistryHandler *registries.Handler ResourceControlHandler *resourcecontrols.Handler SettingsHandler *settings.Handler @@ -49,6 +52,7 @@ type Handler struct { UserHandler *users.Handler WebSocketHandler *websocket.Handler WebhookHandler *webhooks.Handler + SchedulesHanlder *schedules.Handler } // ServeHTTP delegates a request to the appropriate subhandler. @@ -73,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } case strings.HasPrefix(r.URL.Path, "/api/motd"): http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/extensions"): + http.StripPrefix("/api", h.ExtensionHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/registries"): http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/resource_controls"): @@ -99,6 +105,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.StripPrefix("/api", h.WebSocketHandler).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/api/webhooks"): http.StripPrefix("/api", h.WebhookHandler).ServeHTTP(w, r) + case strings.HasPrefix(r.URL.Path, "/api/schedules"): + http.StripPrefix("/api", h.SchedulesHanlder).ServeHTTP(w, r) case strings.HasPrefix(r.URL.Path, "/"): h.FileHandler.ServeHTTP(w, r) } diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index fbe8b5acd..53246fec1 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -16,9 +16,9 @@ type motdResponse struct { func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { - motd, err := client.Get(portainer.MessageOfTheDayURL) + motd, err := client.Get(portainer.MessageOfTheDayURL, 0) if err != nil { - w.WriteHeader(http.StatusInternalServerError) + response.JSON(w, &motdResponse{Message: ""}) return } diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 33a161932..a8f3ae24c 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -1,23 +1,27 @@ package registries import ( - httperror "github.com/portainer/libhttp/error" - "github.com/portainer/portainer" - "github.com/portainer/portainer/http/security" - "net/http" "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/proxy" + "github.com/portainer/portainer/http/security" ) func hideFields(registry *portainer.Registry) { registry.Password = "" + registry.ManagementConfiguration = nil } // Handler is the HTTP handler used to handle registry operations. type Handler struct { *mux.Router - RegistryService portainer.RegistryService + RegistryService portainer.RegistryService + ExtensionService portainer.ExtensionService + FileService portainer.FileService + ProxyManager *proxy.Manager } // NewHandler creates a handler to manage registry operations. @@ -36,8 +40,12 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut) h.Handle("/registries/{id}/access", bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut) + h.Handle("/registries/{id}/configure", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost) h.Handle("/registries/{id}", bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) + h.PathPrefix("/registries/{id}/v2").Handler( + bouncer.AdministratorAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) return h } diff --git a/api/http/handler/registries/proxy.go b/api/http/handler/registries/proxy.go new file mode 100644 index 000000000..b83bcc549 --- /dev/null +++ b/api/http/handler/registries/proxy.go @@ -0,0 +1,78 @@ +package registries + +import ( + "encoding/json" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer" +) + +// request on /api/registries/:id/v2 +func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register registry proxy", err} + } + } + + managementConfiguration := registry.ManagementConfiguration + if managementConfiguration == nil { + managementConfiguration = createDefaultManagementConfiguration(registry) + } + + encodedConfiguration, err := json.Marshal(managementConfiguration) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err} + } + + id := strconv.Itoa(int(registryID)) + r.Header.Set("X-RegistryManagement-Key", id) + r.Header.Set("X-RegistryManagement-URI", registry.URL) + r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration)) + r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey) + + http.StripPrefix("/registries/"+id, proxy).ServeHTTP(w, r) + return nil +} + +func createDefaultManagementConfiguration(registry *portainer.Registry) *portainer.RegistryManagementConfiguration { + config := &portainer.RegistryManagementConfiguration{ + Type: registry.Type, + TLSConfig: portainer.TLSConfiguration{ + TLS: false, + }, + } + + if registry.Authentication { + config.Authentication = true + config.Username = registry.Username + config.Password = registry.Password + } + + return config +} diff --git a/api/http/handler/registries/registry_configure.go b/api/http/handler/registries/registry_configure.go new file mode 100644 index 000000000..0f5569141 --- /dev/null +++ b/api/http/handler/registries/registry_configure.go @@ -0,0 +1,137 @@ +package registries + +import ( + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type registryConfigurePayload struct { + Authentication bool + Username string + Password string + TLS bool + TLSSkipVerify bool + TLSCertFile []byte + TLSKeyFile []byte + TLSCACertFile []byte +} + +func (payload *registryConfigurePayload) Validate(r *http.Request) error { + useAuthentication, _ := request.RetrieveBooleanMultiPartFormValue(r, "Authentication", true) + payload.Authentication = useAuthentication + + if useAuthentication { + username, err := request.RetrieveMultiPartFormValue(r, "Username", false) + if err != nil { + return portainer.Error("Invalid username") + } + payload.Username = username + + password, _ := request.RetrieveMultiPartFormValue(r, "Password", true) + payload.Password = password + } + + useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true) + payload.TLS = useTLS + + skipTLSVerify, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true) + payload.TLSSkipVerify = skipTLSVerify + + if useTLS && !skipTLSVerify { + cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile") + if err != nil { + return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCertFile = cert + + key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile") + if err != nil { + return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly") + } + payload.TLSKeyFile = key + + ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile") + if err != nil { + return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly") + } + payload.TLSCACertFile = ca + } + + return nil +} + +// POST request on /api/registries/:id/configure +func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + payload := ®istryConfigurePayload{} + err = payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + registry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{ + Type: registry.Type, + } + + if payload.Authentication { + registry.ManagementConfiguration.Authentication = true + registry.ManagementConfiguration.Username = payload.Username + if payload.Username == registry.Username && payload.Password == "" { + registry.ManagementConfiguration.Password = registry.Password + } else { + registry.ManagementConfiguration.Password = payload.Password + } + } + + if payload.TLS { + registry.ManagementConfiguration.TLSConfig = portainer.TLSConfiguration{ + TLS: true, + TLSSkipVerify: payload.TLSSkipVerify, + } + + if !payload.TLSSkipVerify { + folder := strconv.Itoa(int(registry.ID)) + + certPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "cert.pem", payload.TLSCertFile) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err} + } + registry.ManagementConfiguration.TLSConfig.TLSCertPath = certPath + + keyPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "key.pem", payload.TLSKeyFile) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err} + } + registry.ManagementConfiguration.TLSConfig.TLSKeyPath = keyPath + + cacertPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "ca.pem", payload.TLSCACertFile) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err} + } + registry.ManagementConfiguration.TLSConfig.TLSCACertPath = cacertPath + } + } + + err = handler.RegistryService.UpdateRegistry(registry.ID, registry) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err} + } + + return response.Empty(w) +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index ad4acb58a..082f61352 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -12,6 +12,7 @@ import ( type registryCreatePayload struct { Name string + Type int URL string Authentication bool Username string @@ -28,6 +29,9 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error { if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") } + if payload.Type != 1 && payload.Type != 2 && payload.Type != 3 { + return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)") + } return nil } @@ -49,6 +53,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * } registry := &portainer.Registry{ + Type: portainer.RegistryType(payload.Type), Name: payload.Name, URL: payload.URL, Authentication: payload.Authentication, diff --git a/api/http/handler/schedules/handler.go b/api/http/handler/schedules/handler.go new file mode 100644 index 000000000..c2c091209 --- /dev/null +++ b/api/http/handler/schedules/handler.go @@ -0,0 +1,44 @@ +package schedules + +import ( + "net/http" + + "github.com/gorilla/mux" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/portainer" + "github.com/portainer/portainer/http/security" +) + +// Handler is the HTTP handler used to handle schedule operations. +type Handler struct { + *mux.Router + ScheduleService portainer.ScheduleService + EndpointService portainer.EndpointService + SettingsService portainer.SettingsService + FileService portainer.FileService + JobService portainer.JobService + JobScheduler portainer.JobScheduler +} + +// NewHandler creates a handler to manage schedule operations. +func NewHandler(bouncer *security.RequestBouncer) *Handler { + h := &Handler{ + Router: mux.NewRouter(), + } + + h.Handle("/schedules", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleList))).Methods(http.MethodGet) + h.Handle("/schedules", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleCreate))).Methods(http.MethodPost) + h.Handle("/schedules/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleInspect))).Methods(http.MethodGet) + h.Handle("/schedules/{id}", + bouncer.AdministratorAccess(httperror.LoggerHandler(h.scheduleUpdate))).Methods(http.MethodPut) + h.Handle("/schedules/{id}", + 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 +} diff --git a/api/http/handler/schedules/schedule_create.go b/api/http/handler/schedules/schedule_create.go new file mode 100644 index 000000000..5c0ecbdbc --- /dev/null +++ b/api/http/handler/schedules/schedule_create.go @@ -0,0 +1,238 @@ +package schedules + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" + "github.com/portainer/portainer/cron" +) + +type scheduleCreateFromFilePayload struct { + Name string + Image string + CronExpression string + Recurring bool + Endpoints []portainer.EndpointID + File []byte + RetryCount int + RetryInterval int +} + +type scheduleCreateFromFileContentPayload struct { + Name string + CronExpression string + Recurring bool + Image string + Endpoints []portainer.EndpointID + FileContent string + RetryCount int + RetryInterval int +} + +func (payload *scheduleCreateFromFilePayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + 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 schedule image") + } + payload.Image = image + + cronExpression, err := request.RetrieveMultiPartFormValue(r, "CronExpression", false) + if err != nil { + return errors.New("Invalid cron expression") + } + payload.CronExpression = cronExpression + + var endpoints []portainer.EndpointID + err = request.RetrieveMultiPartFormJSONValue(r, "Endpoints", &endpoints, false) + if err != nil { + return errors.New("Invalid endpoints") + } + payload.Endpoints = endpoints + + 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 + + retryCount, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryCount", true) + payload.RetryCount = retryCount + + retryInterval, _ := request.RetrieveNumericMultiPartFormValue(r, "RetryInterval", true) + payload.RetryInterval = retryInterval + + return nil +} + +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") + } + + if govalidator.IsNull(payload.CronExpression) { + return portainer.Error("Invalid cron expression") + } + + if payload.Endpoints == nil || len(payload.Endpoints) == 0 { + return portainer.Error("Invalid endpoints payload") + } + + if govalidator.IsNull(payload.FileContent) { + return portainer.Error("Invalid script file content") + } + + if payload.RetryCount != 0 && payload.RetryInterval == 0 { + return portainer.Error("RetryInterval must be set") + } + + return nil +} + +// POST /api/schedules?method=file/string +func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + method, err := request.RetrieveQueryParameter(r, "method", false) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", err} + } + + switch method { + case "string": + return handler.createScheduleFromFileContent(w, r) + case "file": + return handler.createScheduleFromFile(w, r) + default: + return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: method. Valid values are: file or string", errors.New(request.ErrInvalidQueryParameter)} + } +} + +func (handler *Handler) createScheduleFromFileContent(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + var payload scheduleCreateFromFileContentPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + schedule := handler.createScheduleObjectFromFileContentPayload(&payload) + + err = handler.addAndPersistSchedule(schedule, []byte(payload.FileContent)) + if err != nil { + 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 := &scheduleCreateFromFilePayload{} + err := payload.Validate(r) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} + } + + schedule := handler.createScheduleObjectFromFilePayload(payload) + + err = handler.addAndPersistSchedule(schedule, payload.File) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to schedule script job", err} + } + + return response.JSON(w, schedule) +} + +func (handler *Handler) createScheduleObjectFromFilePayload(payload *scheduleCreateFromFilePayload) *portainer.Schedule { + scheduleIdentifier := portainer.ScheduleID(handler.ScheduleService.GetNextIdentifier()) + + job := &portainer.ScriptExecutionJob{ + Endpoints: payload.Endpoints, + Image: payload.Image, + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, + } + + schedule := &portainer.Schedule{ + ID: scheduleIdentifier, + Name: payload.Name, + CronExpression: payload.CronExpression, + Recurring: payload.Recurring, + JobType: portainer.ScriptExecutionJobType, + ScriptExecutionJob: job, + Created: time.Now().Unix(), + } + + 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, + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval, + } + + schedule := &portainer.Schedule{ + ID: scheduleIdentifier, + Name: payload.Name, + CronExpression: payload.CronExpression, + Recurring: payload.Recurring, + 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) +} diff --git a/api/http/handler/schedules/schedule_delete.go b/api/http/handler/schedules/schedule_delete.go new file mode 100644 index 000000000..67a02010a --- /dev/null +++ b/api/http/handler/schedules/schedule_delete.go @@ -0,0 +1,53 @@ +package schedules + +import ( + "errors" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +func (handler *Handler) scheduleDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + 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.SnapshotJobType || schedule.JobType == portainer.EndpointSyncJobType { + return &httperror.HandlerError{http.StatusBadRequest, "Cannot remove system schedules", errors.New("Cannot remove system schedule")} + } + + scheduleFolder := handler.FileService.GetScheduleFolder(strconv.Itoa(scheduleID)) + err = handler.FileService.RemoveDirectory(scheduleFolder) + if err != nil { + 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} + } + + return response.Empty(w) +} diff --git a/api/http/handler/schedules/schedule_file.go b/api/http/handler/schedules/schedule_file.go new file mode 100644 index 000000000..f24e10867 --- /dev/null +++ b/api/http/handler/schedules/schedule_file.go @@ -0,0 +1,49 @@ +package schedules + +import ( + "errors" + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +type scheduleFileResponse struct { + ScheduleFileContent string `json:"ScheduleFileContent"` +} + +// GET request on /api/schedules/:id/file +func (handler *Handler) scheduleFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + 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 script file", errors.New("This type of schedule do not have any associated script file")} + } + + scheduleFileContent, err := handler.FileService.GetFileContent(schedule.ScriptExecutionJob.ScriptPath) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedule script file from disk", err} + } + + return response.JSON(w, &scheduleFileResponse{ScheduleFileContent: string(scheduleFileContent)}) +} diff --git a/api/http/handler/schedules/schedule_inspect.go b/api/http/handler/schedules/schedule_inspect.go new file mode 100644 index 000000000..bcd74b4b9 --- /dev/null +++ b/api/http/handler/schedules/schedule_inspect.go @@ -0,0 +1,35 @@ +package schedules + +import ( + "net/http" + + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" +) + +func (handler *Handler) scheduleInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + 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} + } + + return response.JSON(w, schedule) +} diff --git a/api/http/handler/schedules/schedule_list.go b/api/http/handler/schedules/schedule_list.go new file mode 100644 index 000000000..f67eee452 --- /dev/null +++ b/api/http/handler/schedules/schedule_list.go @@ -0,0 +1,27 @@ +package schedules + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/response" + "github.com/portainer/portainer" +) + +// GET request on /api/schedules +func (handler *Handler) scheduleList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + schedules, err := handler.ScheduleService.Schedules() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve schedules from the database", err} + } + + return response.JSON(w, schedules) +} diff --git a/api/http/handler/schedules/schedule_tasks.go b/api/http/handler/schedules/schedule_tasks.go new file mode 100644 index 000000000..0cbf24372 --- /dev/null +++ b/api/http/handler/schedules/schedule_tasks.go @@ -0,0 +1,95 @@ +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 { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + 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 +} diff --git a/api/http/handler/schedules/schedule_update.go b/api/http/handler/schedules/schedule_update.go new file mode 100644 index 000000000..0edfd0dde --- /dev/null +++ b/api/http/handler/schedules/schedule_update.go @@ -0,0 +1,126 @@ +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" + "github.com/portainer/portainer" + "github.com/portainer/portainer/cron" +) + +type scheduleUpdatePayload struct { + Name *string + Image *string + CronExpression *string + Recurring *bool + 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 +} + +func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to retrieve settings", err} + } + if !settings.EnableHostManagementFeatures { + return &httperror.HandlerError{http.StatusServiceUnavailable, "Host management features are disabled", portainer.ErrHostManagementFeaturesDisabled} + } + + scheduleID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid schedule identifier route variable", err} + } + + var payload scheduleUpdatePayload + err = request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", 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} + } + + updateJobSchedule := updateSchedule(schedule, &payload) + + if payload.FileContent != nil { + _, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent)) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist script file changes on the filesystem", err} + } + updateJobSchedule = true + } + + if updateJobSchedule { + jobContext := cron.NewScriptExecutionJobContext(handler.JobService, handler.EndpointService, handler.FileService) + jobRunner := cron.NewScriptExecutionJobRunner(schedule, jobContext) + err := handler.JobScheduler.UpdateJobSchedule(jobRunner) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update job scheduler", err} + } + } + + err = handler.ScheduleService.UpdateSchedule(portainer.ScheduleID(scheduleID), schedule) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist schedule changes inside the database", err} + } + + return response.JSON(w, schedule) +} + +func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool { + updateJobSchedule := false + + if payload.Name != nil { + schedule.Name = *payload.Name + } + + if payload.Endpoints != nil { + schedule.ScriptExecutionJob.Endpoints = payload.Endpoints + updateJobSchedule = true + } + + if payload.CronExpression != nil { + schedule.CronExpression = *payload.CronExpression + updateJobSchedule = true + } + + if payload.Recurring != nil { + schedule.Recurring = *payload.Recurring + updateJobSchedule = true + } + + if payload.Image != nil { + schedule.ScriptExecutionJob.Image = *payload.Image + 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 +} diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 9440aedd5..0acbd2ca6 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -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 @@ -16,6 +20,7 @@ type Handler struct { LDAPService portainer.LDAPService FileService portainer.FileService JobScheduler portainer.JobScheduler + ScheduleService portainer.ScheduleService } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_inspect.go b/api/http/handler/settings/settings_inspect.go index c922e1f47..a28b1ef09 100644 --- a/api/http/handler/settings/settings_inspect.go +++ b/api/http/handler/settings/settings_inspect.go @@ -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) } diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index 549cf999e..9744b319a 100644 --- a/api/http/handler/settings/settings_public.go +++ b/api/http/handler/settings/settings_public.go @@ -13,6 +13,7 @@ type publicSettingsResponse struct { AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` ExternalTemplates bool `json:"ExternalTemplates"` } @@ -28,6 +29,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AuthenticationMethod: settings.AuthenticationMethod, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + EnableHostManagementFeatures: settings.EnableHostManagementFeatures, ExternalTemplates: false, } diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 18513931f..5b4d33ae3 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -18,6 +18,7 @@ type settingsUpdatePayload struct { LDAPSettings *portainer.LDAPSettings AllowBindMountsForRegularUsers *bool AllowPrivilegedModeForRegularUsers *bool + EnableHostManagementFeatures *bool SnapshotInterval *string TemplatesURL *string } @@ -76,9 +77,15 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers } + if payload.EnableHostManagementFeatures != nil { + settings.EnableHostManagementFeatures = *payload.EnableHostManagementFeatures + } + if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { - settings.SnapshotInterval = *payload.SnapshotInterval - handler.JobScheduler.UpdateSnapshotJob(settings.SnapshotInterval) + err := handler.updateSnapshotInterval(settings, *payload.SnapshotInterval) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update snapshot interval", err} + } } tlsError := handler.updateTLS(settings) @@ -94,6 +101,32 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return response.JSON(w, settings) } +func (handler *Handler) updateSnapshotInterval(settings *portainer.Settings, snapshotInterval string) error { + settings.SnapshotInterval = snapshotInterval + + schedules, err := handler.ScheduleService.SchedulesByJobType(portainer.SnapshotJobType) + if err != nil { + return err + } + + if len(schedules) != 0 { + snapshotSchedule := schedules[0] + snapshotSchedule.CronExpression = "@every " + snapshotInterval + + 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 + } + } + + return nil +} + func (handler *Handler) updateTLS(settings *portainer.Settings) *httperror.HandlerError { if (settings.LDAPSettings.TLSConfig.TLS || settings.LDAPSettings.StartTLS) && !settings.LDAPSettings.TLSConfig.TLSSkipVerify { caCertPath, _ := handler.FileService.GetPathForTLSFile(filesystem.LDAPStorePath, portainer.TLSFileCA) diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 8a0ec0c69..704394766 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -14,6 +14,7 @@ import ( type stackMigratePayload struct { EndpointID int SwarmID string + Name string } func (payload *stackMigratePayload) Validate(r *http.Request) error { @@ -89,11 +90,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht stack.SwarmID = payload.SwarmID } + oldName := stack.Name + if payload.Name != "" { + stack.Name = payload.Name + } + migrationError := handler.migrateStack(r, stack, targetEndpoint) if migrationError != nil { return migrationError } + stack.Name = oldName err = handler.deleteStack(stack, endpoint) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} diff --git a/api/http/handler/templates/template_list.go b/api/http/handler/templates/template_list.go index 24ca93bbd..7e802528f 100644 --- a/api/http/handler/templates/template_list.go +++ b/api/http/handler/templates/template_list.go @@ -26,7 +26,7 @@ func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *ht } } else { var templateData []byte - templateData, err = client.Get(settings.TemplatesURL) + templateData, err = client.Get(settings.TemplatesURL, 0) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err} } diff --git a/api/http/handler/webhooks/webhook_execute.go b/api/http/handler/webhooks/webhook_execute.go index f43045899..9126942e7 100644 --- a/api/http/handler/webhooks/webhook_execute.go +++ b/api/http/handler/webhooks/webhook_execute.go @@ -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} } diff --git a/api/http/handler/websocket/websocket_exec.go b/api/http/handler/websocket/websocket_exec.go index d797e020a..ba4a9d7e6 100644 --- a/api/http/handler/websocket/websocket_exec.go +++ b/api/http/handler/websocket/websocket_exec.go @@ -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(portainer.PortainerAgentSignatureMessage) 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) } diff --git a/api/http/proxy/build.go b/api/http/proxy/build.go index 0deab93b9..aaa486f07 100644 --- a/api/http/proxy/build.go +++ b/api/http/proxy/build.go @@ -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 } diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 37e46c2dd..7521ee337 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -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(portainer.PortainerAgentSignatureMessage) if err != nil { return nil, err } diff --git a/api/http/proxy/factory.go b/api/http/proxy/factory.go index 73593f836..90b5ad444 100644 --- a/api/http/proxy/factory.go +++ b/api/http/proxy/factory.go @@ -25,7 +25,7 @@ type proxyFactory struct { func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { u.Scheme = "http" - return newSingleHostReverseProxyWithHostHeader(u) + return httputil.NewSingleHostReverseProxy(u) } func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index eb60a5dc0..b1e2aa6f4 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -3,18 +3,25 @@ package proxy import ( "net/http" "net/url" - "strings" + "strconv" "github.com/orcaman/concurrent-map" "github.com/portainer/portainer" ) +// TODO: contain code related to legacy extension management + +var extensionPorts = map[portainer.ExtensionID]string{ + portainer.RegistryManagementExtension: "7001", +} + type ( // Manager represents a service used to manage Docker proxies. Manager struct { - proxyFactory *proxyFactory - proxies cmap.ConcurrentMap - extensionProxies cmap.ConcurrentMap + proxyFactory *proxyFactory + proxies cmap.ConcurrentMap + extensionProxies cmap.ConcurrentMap + legacyExtensionProxies cmap.ConcurrentMap } // ManagerParams represents the required parameters to create a new Manager instance. @@ -31,8 +38,9 @@ type ( // NewManager initializes a new proxy Service func NewManager(parameters *ManagerParams) *Manager { return &Manager{ - proxies: cmap.New(), - extensionProxies: cmap.New(), + proxies: cmap.New(), + extensionProxies: cmap.New(), + legacyExtensionProxies: cmap.New(), proxyFactory: &proxyFactory{ ResourceControlService: parameters.ResourceControlService, TeamMembershipService: parameters.TeamMembershipService, @@ -44,6 +52,83 @@ func NewManager(parameters *ManagerParams) *Manager { } } +// GetProxy returns the proxy associated to a key +func (manager *Manager) GetProxy(key string) http.Handler { + proxy, ok := manager.proxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. +// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. +func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { + proxy, err := manager.createProxy(endpoint) + if err != nil { + return nil, err + } + + manager.proxies.Set(string(endpoint.ID), proxy) + return proxy, nil +} + +// DeleteProxy deletes the proxy associated to a key +func (manager *Manager) DeleteProxy(key string) { + manager.proxies.Remove(key) +} + +// GetExtensionProxy returns an extension proxy associated to an extension identifier +func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) http.Handler { + proxy, ok := manager.extensionProxies.Get(strconv.Itoa(int(extensionID))) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and +// registers it in the extension map associated to the specified extension identifier +func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { + address := "http://localhost:" + extensionPorts[extensionID] + + extensionURL, err := url.Parse(address) + if err != nil { + return nil, err + } + + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) + manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy) + + return proxy, nil +} + +// DeleteExtensionProxy deletes the extension proxy associated to an extension identifier +func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) { + manager.extensionProxies.Remove(strconv.Itoa(int(extensionID))) +} + +// GetLegacyExtensionProxy returns a legacy extension proxy associated to a key +func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler { + proxy, ok := manager.legacyExtensionProxies.Get(key) + if !ok { + return nil + } + return proxy.(http.Handler) +} + +// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies. +func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { + extensionURL, err := url.Parse(extensionAPIURL) + if err != nil { + return nil, err + } + + proxy := manager.proxyFactory.newHTTPProxy(extensionURL) + manager.extensionProxies.Set(key, proxy) + return proxy, nil +} + func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) { if endpointURL.Scheme == "tcp" { if tlsConfig.TLS || tlsConfig.TLSSkipVerify { @@ -69,59 +154,3 @@ func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig) } } - -// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies. -// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy. -func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) { - proxy, err := manager.createProxy(endpoint) - if err != nil { - return nil, err - } - - manager.proxies.Set(string(endpoint.ID), proxy) - return proxy, nil -} - -// GetProxy returns the proxy associated to a key -func (manager *Manager) GetProxy(key string) http.Handler { - proxy, ok := manager.proxies.Get(key) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// DeleteProxy deletes the proxy associated to a key -func (manager *Manager) DeleteProxy(key string) { - manager.proxies.Remove(key) -} - -// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies. -func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) { - extensionURL, err := url.Parse(extensionAPIURL) - if err != nil { - return nil, err - } - - proxy := manager.proxyFactory.newHTTPProxy(extensionURL) - manager.extensionProxies.Set(key, proxy) - return proxy, nil -} - -// GetExtensionProxy returns the extension proxy associated to a key -func (manager *Manager) GetExtensionProxy(key string) http.Handler { - proxy, ok := manager.extensionProxies.Get(key) - if !ok { - return nil - } - return proxy.(http.Handler) -} - -// DeleteExtensionProxies deletes all the extension proxies associated to a key -func (manager *Manager) DeleteExtensionProxies(key string) { - for _, k := range manager.extensionProxies.Keys() { - if strings.Contains(k, key+"_") { - manager.extensionProxies.Remove(k) - } - } -} diff --git a/api/http/security/bouncer.go b/api/http/security/bouncer.go index 0b25bd389..de0c75523 100644 --- a/api/http/security/bouncer.go +++ b/api/http/security/bouncer.go @@ -114,7 +114,6 @@ func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portain // mwSecureHeaders provides secure headers middleware for handlers. func mwSecureHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("X-Frame-Options", "DENY") w.Header().Add("X-XSS-Protection", "1; mode=block") w.Header().Add("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) diff --git a/api/http/server.go b/api/http/server.go index 2258e86d3..1220d94d1 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -11,10 +11,12 @@ import ( "github.com/portainer/portainer/http/handler/endpointgroups" "github.com/portainer/portainer/http/handler/endpointproxy" "github.com/portainer/portainer/http/handler/endpoints" + "github.com/portainer/portainer/http/handler/extensions" "github.com/portainer/portainer/http/handler/file" "github.com/portainer/portainer/http/handler/motd" "github.com/portainer/portainer/http/handler/registries" "github.com/portainer/portainer/http/handler/resourcecontrols" + "github.com/portainer/portainer/http/handler/schedules" "github.com/portainer/portainer/http/handler/settings" "github.com/portainer/portainer/http/handler/stacks" "github.com/portainer/portainer/http/handler/status" @@ -40,6 +42,7 @@ type Server struct { AuthDisabled bool EndpointManagement bool Status *portainer.Status + ExtensionManager portainer.ExtensionManager ComposeStackManager portainer.ComposeStackManager CryptoService portainer.CryptoService SignatureService portainer.DigitalSignatureService @@ -52,8 +55,10 @@ type Server struct { GitService portainer.GitService JWTService portainer.JWTService LDAPService portainer.LDAPService + ExtensionService portainer.ExtensionService RegistryService portainer.RegistryService ResourceControlService portainer.ResourceControlService + ScheduleService portainer.ScheduleService SettingsService portainer.SettingsService StackService portainer.StackService SwarmStackManager portainer.SwarmStackManager @@ -68,6 +73,7 @@ type Server struct { SSLCert string SSLKey string DockerClientFactory *docker.ClientFactory + JobService portainer.JobService } // Start starts the HTTP server @@ -80,6 +86,7 @@ func (server *Server) Start() error { AuthDisabled: server.AuthDisabled, } requestBouncer := security.NewRequestBouncer(requestBouncerParameters) + proxyManagerParameters := &proxy.ManagerParams{ ResourceControlService: server.ResourceControlService, TeamMembershipService: server.TeamMembershipService, @@ -89,6 +96,7 @@ func (server *Server) Start() error { SignatureService: server.SignatureService, } proxyManager := proxy.NewManager(proxyManagerParameters) + rateLimiter := security.NewRateLimiter(10, 1*time.Second, 1*time.Hour) var authHandler = auth.NewHandler(requestBouncer, rateLimiter, server.AuthDisabled) @@ -109,6 +117,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 @@ -122,17 +131,33 @@ func (server *Server) Start() error { var motdHandler = motd.NewHandler(requestBouncer) + var extensionHandler = extensions.NewHandler(requestBouncer) + extensionHandler.ExtensionService = server.ExtensionService + extensionHandler.ExtensionManager = server.ExtensionManager + var registryHandler = registries.NewHandler(requestBouncer) registryHandler.RegistryService = server.RegistryService + registryHandler.ExtensionService = server.ExtensionService + registryHandler.FileService = server.FileService + registryHandler.ProxyManager = proxyManager var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer) resourceControlHandler.ResourceControlService = server.ResourceControlService + var schedulesHandler = schedules.NewHandler(requestBouncer) + schedulesHandler.ScheduleService = server.ScheduleService + schedulesHandler.EndpointService = server.EndpointService + schedulesHandler.FileService = server.FileService + schedulesHandler.JobService = server.JobService + schedulesHandler.JobScheduler = server.JobScheduler + schedulesHandler.SettingsService = server.SettingsService + var settingsHandler = settings.NewHandler(requestBouncer) settingsHandler.SettingsService = server.SettingsService settingsHandler.LDAPService = server.LDAPService settingsHandler.FileService = server.FileService settingsHandler.JobScheduler = server.JobScheduler + settingsHandler.ScheduleService = server.ScheduleService var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.FileService = server.FileService @@ -188,6 +213,7 @@ func (server *Server) Start() error { EndpointProxyHandler: endpointProxyHandler, FileHandler: fileHandler, MOTDHandler: motdHandler, + ExtensionHandler: extensionHandler, RegistryHandler: registryHandler, ResourceControlHandler: resourceControlHandler, SettingsHandler: settingsHandler, @@ -201,6 +227,7 @@ func (server *Server) Start() error { UserHandler: userHandler, WebSocketHandler: websocketHandler, WebhookHandler: webhookHandler, + SchedulesHanlder: schedulesHandler, } if server.SSL { diff --git a/api/ldap/ldap.go b/api/ldap/ldap.go index 528a92e7f..05c9d55d7 100644 --- a/api/ldap/ldap.go +++ b/api/ldap/ldap.go @@ -22,11 +22,13 @@ type Service struct{} func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) { var userDN string found := false + usernameEscaped := ldap.EscapeFilter(username) + for _, searchSettings := range settings { searchRequest := ldap.NewSearchRequest( searchSettings.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username), + fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped), []string{"dn"}, nil, ) @@ -134,12 +136,13 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings) // Get a list of group names for specified user from LDAP/AD func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string { groups := make([]string, 0) + userDNEscaped := ldap.EscapeFilter(userDN) for _, searchSettings := range settings { searchRequest := ldap.NewSearchRequest( searchSettings.GroupBaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, - fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDN), + fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDNEscaped), []string{"cn"}, nil, ) diff --git a/api/portainer.go b/api/portainer.go index 6367b14e0..bf4fbcc89 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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"` @@ -89,6 +89,7 @@ type ( AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` SnapshotInterval string `json:"SnapshotInterval"` TemplatesURL string `json:"TemplatesURL"` + EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` // Deprecated fields DisplayDonationHeader bool @@ -164,17 +165,32 @@ type ( // RegistryID represents a registry identifier RegistryID int + // RegistryType represents a type of registry + RegistryType int + // Registry represents a Docker registry with all the info required // to connect to it Registry struct { - ID RegistryID `json:"Id"` - Name string `json:"Name"` - URL string `json:"URL"` - Authentication bool `json:"Authentication"` - Username string `json:"Username"` - Password string `json:"Password,omitempty"` - AuthorizedUsers []UserID `json:"AuthorizedUsers"` - AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + ID RegistryID `json:"Id"` + Type RegistryType `json:"Type"` + Name string `json:"Name"` + URL string `json:"URL"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password,omitempty"` + AuthorizedUsers []UserID `json:"AuthorizedUsers"` + AuthorizedTeams []TeamID `json:"AuthorizedTeams"` + ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` + } + + // RegistryManagementConfiguration represents a configuration that can be used to query + // the registry API via the registry management extension. + RegistryManagementConfiguration struct { + Type RegistryType `json:"Type"` + Authentication bool `json:"Authentication"` + Username string `json:"Username"` + Password string `json:"Password"` + TLSConfig TLSConfiguration `json:"TLSConfig"` } // DockerHub represents all the required information to connect and use the @@ -220,7 +236,44 @@ type ( TLSKeyPath string `json:"TLSKey,omitempty"` } - // WebhookID represents an webhook identifier. + // ScheduleID represents a schedule identifier. + ScheduleID int + + // JobType represents a job type + JobType int + + // ScriptExecutionJob represents a scheduled job that can execute a script via a privileged container + ScriptExecutionJob struct { + Endpoints []EndpointID + Image string + ScriptPath string + RetryCount int + RetryInterval int + } + + // SnapshotJob represents a scheduled job that can create endpoint snapshots + SnapshotJob struct{} + + // EndpointSyncJob represents a scheduled job that synchronize endpoints based on an external file + EndpointSyncJob struct{} + + // Schedule represents a scheduled job. + // It only contains a pointer to one of the JobRunner implementations + // based on the JobType. + // NOTE: The Recurring option is only used by ScriptExecutionJob at the moment + Schedule struct { + ID ScheduleID `json:"Id"` + Name string + CronExpression string + Recurring bool + Created int64 + JobType JobType + ScriptExecutionJob *ScriptExecutionJob + SnapshotJob *SnapshotJob + EndpointSyncJob *EndpointSyncJob + } + + // WebhookID represents a webhook identifier. WebhookID int // WebhookType represents the type of resource a webhook is related to @@ -245,17 +298,28 @@ type ( // Snapshot represents a snapshot of a specific endpoint at a specific time Snapshot struct { - Time int64 `json:"Time"` - DockerVersion string `json:"DockerVersion"` - Swarm bool `json:"Swarm"` - TotalCPU int `json:"TotalCPU"` - TotalMemory int64 `json:"TotalMemory"` - RunningContainerCount int `json:"RunningContainerCount"` - StoppedContainerCount int `json:"StoppedContainerCount"` - VolumeCount int `json:"VolumeCount"` - ImageCount int `json:"ImageCount"` - ServiceCount int `json:"ServiceCount"` - StackCount int `json:"StackCount"` + Time int64 `json:"Time"` + DockerVersion string `json:"DockerVersion"` + Swarm bool `json:"Swarm"` + TotalCPU int `json:"TotalCPU"` + TotalMemory int64 `json:"TotalMemory"` + RunningContainerCount int `json:"RunningContainerCount"` + StoppedContainerCount int `json:"StoppedContainerCount"` + VolumeCount int `json:"VolumeCount"` + ImageCount int `json:"ImageCount"` + ServiceCount int `json:"ServiceCount"` + StackCount int `json:"StackCount"` + SnapshotRaw SnapshotRaw `json:"SnapshotRaw"` + } + + // SnapshotRaw represents all the information related to a snapshot as returned by the Docker API + SnapshotRaw struct { + Containers interface{} `json:"Containers"` + Volumes interface{} `json:"Volumes"` + Networks interface{} `json:"Networks"` + Images interface{} `json:"Images"` + Info interface{} `json:"Info"` + Version interface{} `json:"Version"` } // EndpointGroupID represents an endpoint group identifier @@ -274,7 +338,8 @@ type ( Labels []Pair `json:"Labels"` } - // EndpointExtension represents a extension associated to an endpoint + // EndpointExtension represents a deprecated form of Portainer extension + // TODO: legacy extension management EndpointExtension struct { Type EndpointExtensionType `json:"Type"` URL string `json:"URL"` @@ -410,6 +475,36 @@ type ( // It can be either a TLS CA file, a TLS certificate file or a TLS key file TLSFileType int + // ExtensionID represents a extension identifier + ExtensionID int + + // Extension represents a Portainer extension + Extension struct { + ID ExtensionID `json:"Id"` + Enabled bool `json:"Enabled"` + Name string `json:"Name,omitempty"` + ShortDescription string `json:"ShortDescription,omitempty"` + Description string `json:"Description,omitempty"` + DescriptionURL string `json:"DescriptionURL,omitempty"` + Price string `json:"Price,omitempty"` + PriceDescription string `json:"PriceDescription,omitempty"` + Deal bool `json:"Deal,omitempty"` + Available bool `json:"Available,omitempty"` + License LicenseInformation `json:"License,omitempty"` + Version string `json:"Version"` + UpdateAvailable bool `json:"UpdateAvailable"` + ShopURL string `json:"ShopURL,omitempty"` + Images []string `json:"Images,omitempty"` + Logo string `json:"Logo,omitempty"` + } + + // LicenseInformation represents information about an extension license + LicenseInformation struct { + LicenseKey string `json:"LicenseKey,omitempty"` + Company string `json:"Company,omitempty"` + Expiration string `json:"Expiration,omitempty"` + } + // CLIService represents a service for managing CLI CLIService interface { ParseFlags(version string) (*CLIFlags, error) @@ -541,6 +636,17 @@ type ( DeleteResourceControl(ID ResourceControlID) error } + // ScheduleService represents a service for managing schedule data + ScheduleService interface { + Schedule(ID ScheduleID) (*Schedule, error) + Schedules() ([]Schedule, error) + SchedulesByJobType(jobType JobType) ([]Schedule, error) + CreateSchedule(schedule *Schedule) error + UpdateSchedule(ID ScheduleID, schedule *Schedule) error + DeleteSchedule(ID ScheduleID) error + GetNextIdentifier() int + } + // TagService represents a service for managing tag data TagService interface { Tags() ([]Tag, error) @@ -557,6 +663,14 @@ type ( DeleteTemplate(ID TemplateID) error } + // ExtensionService represents a service for managing extension data + ExtensionService interface { + Extension(ID ExtensionID) (*Extension, error) + Extensions() ([]Extension, error) + Persist(extension *Extension) error + DeleteExtension(ID ExtensionID) error + } + // CryptoService represents a service for encrypting/hashing data CryptoService interface { Hash(data string) (string, error) @@ -569,7 +683,7 @@ type ( GenerateKeyPair() ([]byte, []byte, error) EncodedPublicKey() string PEMHeaders() (string, string) - Sign(message string) (string, error) + CreateSignature(message string) (string, error) } // JWTService represents a service for managing JWT tokens @@ -589,11 +703,16 @@ type ( DeleteTLSFiles(folder string) error GetStackProjectPath(stackIdentifier string) string StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error) + StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) KeyPairFilesExist() (bool, error) StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error LoadKeyPair() ([]byte, []byte, error) WriteJSONToFile(path string, content interface{}) error FileExists(path string) (bool, error) + StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error) + GetScheduleFolder(identifier string) string + ExtractExtensionArchive(data []byte) error + GetBinaryFolder() string } // GitService represents a service for managing Git @@ -604,12 +723,19 @@ type ( // JobScheduler represents a service to run jobs on a periodic basis JobScheduler interface { - ScheduleEndpointSyncJob(endpointFilePath, interval string) error - ScheduleSnapshotJob(interval string) error - UpdateSnapshotJob(interval string) + 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() + GetSchedule() *Schedule + } + // Snapshotter represents a service used to create endpoint snapshots Snapshotter interface { CreateSnapshot(endpoint *Endpoint) (*Snapshot, error) @@ -635,15 +761,32 @@ 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 { + ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error + } + + // ExtensionManager represents a service used to manage extensions + ExtensionManager interface { + FetchExtensionDefinitions() ([]Extension, error) + EnableExtension(extension *Extension, licenseKey string) error + DisableExtension(extension *Extension) error + UpdateExtension(extension *Extension, version string) error + } ) const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.19.2" + APIVersion = "1.20.0" // DBVersion is the version number of the Portainer database - DBVersion = 14 + DBVersion = 15 + // AssetsServerURL represents the URL of the Portainer asset server + AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved - MessageOfTheDayURL = "https://raw.githubusercontent.com/portainer/motd/master/message.html" + MessageOfTheDayURL = AssetsServerURL + "/motd.html" + // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved + ExtensionDefinitionsURL = AssetsServerURL + "/extensions.json" // 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 @@ -763,3 +906,31 @@ const ( // ServiceWebhook is a webhook for restarting a docker service ServiceWebhook ) + +const ( + _ ExtensionID = iota + // RegistryManagementExtension represents the registry management extension + RegistryManagementExtension +) + +const ( + _ JobType = iota + // ScriptExecutionJobType is a non-system job used to execute a script against a list of + // endpoints via privileged containers + ScriptExecutionJobType + // SnapshotJobType is a system job used to create endpoint snapshots + SnapshotJobType + // EndpointSyncJobType is a system job used to synchronize endpoints from + // an external definition store + EndpointSyncJobType +) + +const ( + _ RegistryType = iota + // QuayRegistry represents a Quay.io registry + QuayRegistry + // AzureRegistry represents an ACR registry + AzureRegistry + // CustomRegistry represents a custom registry + CustomRegistry +) diff --git a/api/swagger.yaml b/api/swagger.yaml index 890c5fc2a..d8a3ad48a 100644 --- a/api/swagger.yaml +++ b/api/swagger.yaml @@ -54,7 +54,7 @@ info: **NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8). - version: "1.19.2" + version: "1.20.0" title: "Portainer API" contact: email: "info@portainer.io" @@ -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,11 @@ 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: "TLSSkipClientVerify" + in: "formData" + type: "string" + description: "Skip client verification when using TLS (example: false)" - name: "TLSCACertFile" in: "formData" type: "file" @@ -324,6 +336,8 @@ paths: operationId: "EndpointInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -365,6 +379,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -413,6 +429,8 @@ paths: Remove an endpoint. **Access policy**: administrator operationId: "EndpointDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -460,6 +478,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -495,6 +515,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 +600,8 @@ paths: operationId: "EndpointGroupList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -530,6 +624,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -564,6 +660,8 @@ paths: operationId: "EndpointGroupInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -605,6 +703,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -655,6 +755,8 @@ paths: Remove an endpoint group. **Access policy**: administrator operationId: "EndpointGroupDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -702,6 +804,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -750,6 +854,8 @@ paths: operationId: "RegistryList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -772,6 +878,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -813,6 +921,8 @@ paths: operationId: "RegistryInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -854,6 +964,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -904,6 +1016,8 @@ paths: Remove a registry. **Access policy**: administrator operationId: "RegistryDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -944,6 +1058,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -992,6 +1108,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1042,6 +1160,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1092,6 +1212,8 @@ paths: Remove a resource control. **Access policy**: restricted operationId: "ResourceControlDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1137,6 +1259,8 @@ paths: operationId: "SettingsInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1159,6 +1283,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1193,6 +1319,8 @@ paths: operationId: "PublicSettingsInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1216,6 +1344,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1248,6 +1378,8 @@ paths: operationId: "StatusInspect" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1271,6 +1403,8 @@ paths: operationId: "StackList" produces: - "application/json" + security: + - jwt: [] parameters: - name: "filters" in: "query" @@ -1303,6 +1437,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "type" in: "query" @@ -1382,6 +1518,8 @@ paths: operationId: "StackInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1427,6 +1565,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1479,6 +1619,8 @@ paths: Remove a stack. **Access policy**: restricted operationId: "StackDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1529,6 +1671,8 @@ paths: operationId: "StackFileInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1574,6 +1718,8 @@ paths: operationId: "StackMigrate" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1628,6 +1774,8 @@ paths: operationId: "UserList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -1651,6 +1799,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1699,6 +1849,8 @@ paths: operationId: "UserInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1740,6 +1892,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1790,6 +1944,8 @@ paths: Remove a user. **Access policy**: administrator operationId: "UserDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1828,6 +1984,8 @@ paths: operationId: "UserMembershipsInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1871,6 +2029,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -1918,6 +2078,8 @@ paths: operationId: "UserAdminCheck" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 204: @@ -1947,6 +2109,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -1991,6 +2155,8 @@ paths: - multipart/form-data produces: - "application/json" + security: + - jwt: [] parameters: - in: "path" name: "certificate" @@ -2032,6 +2198,8 @@ paths: operationId: "TagList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2054,6 +2222,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2093,6 +2263,8 @@ paths: Remove a tag. **Access policy**: administrator operationId: "TagDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2125,6 +2297,8 @@ paths: operationId: "TeamList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2147,6 +2321,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2195,6 +2371,8 @@ paths: operationId: "TeamInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2243,6 +2421,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2284,6 +2464,8 @@ paths: Remove a team. **Access policy**: administrator operationId: "TeamDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2323,6 +2505,8 @@ paths: operationId: "TeamMembershipsInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2364,6 +2548,8 @@ paths: operationId: "TeamMembershipList" produces: - "application/json" + security: + - jwt: [] parameters: [] responses: 200: @@ -2393,6 +2579,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2443,6 +2631,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2493,6 +2683,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 +2731,8 @@ paths: operationId: "TemplateList" produces: - "application/json" + security: + - jwt: [] parameters: responses: 200: @@ -2561,6 +2755,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - in: "body" name: "body" @@ -2602,6 +2798,8 @@ paths: operationId: "TemplateInspect" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2650,6 +2848,8 @@ paths: - "application/json" produces: - "application/json" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2698,6 +2898,8 @@ paths: Remove a template. **Access policy**: administrator operationId: "TemplateDelete" + security: + - jwt: [] parameters: - name: "id" in: "path" @@ -2816,7 +3018,7 @@ definitions: description: "Is analytics enabled" Version: type: "string" - example: "1.19.2" + example: "1.20.0" description: "Portainer API version" PublicSettingsInspectResponse: type: "object" @@ -3783,9 +3985,9 @@ definitions: TemplateCreateRequest: type: "object" required: - - "type" - - "title" - - "description" + - "type" + - "title" + - "description" properties: type: type: "integer" @@ -4137,7 +4339,7 @@ definitions: TemplateRepository: type: "object" required: - - "URL" + - "URL" properties: URL: type: "string" @@ -4160,6 +4362,24 @@ definitions: type: "string" example: "jpofkc0i9uo9wtx1zesuk649w" description: "Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated" + Name: + 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: @@ -4269,7 +4489,7 @@ definitions: Prune: type: "boolean" example: false - description: "Prune services that are no longer referenced" + description: "Prune services that are no longer referenced (only available for Swarm stacks)" StackFileInspectResponse: type: "object" properties: diff --git a/api/swagger_config.json b/api/swagger_config.json new file mode 100644 index 000000000..beee114ea --- /dev/null +++ b/api/swagger_config.json @@ -0,0 +1,5 @@ +{ + "packageName": "portainer", + "packageVersion": "1.20.0", + "projectName": "portainer" +} diff --git a/app/__module.js b/app/__module.js index 00be9300d..0ffc3c7c6 100644 --- a/app/__module.js +++ b/app/__module.js @@ -22,5 +22,8 @@ angular.module('portainer', [ 'portainer.agent', 'portainer.azure', 'portainer.docker', + 'portainer.extensions', 'extension.storidge', - 'rzModule']); + 'rzModule', + 'moment-picker' + ]); diff --git a/app/agent/components/file-uploader/file-uploader-controller.js b/app/agent/components/file-uploader/file-uploader-controller.js new file mode 100644 index 000000000..e6516c67c --- /dev/null +++ b/app/agent/components/file-uploader/file-uploader-controller.js @@ -0,0 +1,23 @@ +angular.module('portainer.agent').controller('FileUploaderController', [ + '$q', + function FileUploaderController($q) { + var ctrl = this; + + ctrl.state = { + uploadInProgress: false + }; + + ctrl.onFileSelected = onFileSelected; + + function onFileSelected(file) { + if (!file) { + return; + } + + ctrl.state.uploadInProgress = true; + $q.when(ctrl.uploadFile(file)).finally(function toggleProgress() { + ctrl.state.uploadInProgress = false; + }); + } + } +]); diff --git a/app/agent/components/file-uploader/file-uploader.html b/app/agent/components/file-uploader/file-uploader.html new file mode 100644 index 000000000..e092ce5d6 --- /dev/null +++ b/app/agent/components/file-uploader/file-uploader.html @@ -0,0 +1,6 @@ + diff --git a/app/agent/components/file-uploader/file-uploader.js b/app/agent/components/file-uploader/file-uploader.js new file mode 100644 index 000000000..58ba7b04b --- /dev/null +++ b/app/agent/components/file-uploader/file-uploader.js @@ -0,0 +1,7 @@ +angular.module('portainer.agent').component('fileUploader', { + templateUrl: 'app/agent/components/file-uploader/file-uploader.html', + controller: 'FileUploaderController', + bindings: { + uploadFile: ' + + + + -
-
- {{ $ctrl.titleText }} -
-
@@ -41,23 +41,29 @@ - + @@ -65,13 +71,14 @@ {{ item.ModTime | getisodatefromtimestamp }} @@ -87,4 +94,4 @@ - + \ No newline at end of file diff --git a/app/agent/components/files-datatable/files-datatable.js b/app/agent/components/files-datatable/files-datatable.js new file mode 100644 index 000000000..4e90e1d00 --- /dev/null +++ b/app/agent/components/files-datatable/files-datatable.js @@ -0,0 +1,22 @@ +angular.module('portainer.agent').component('filesDatatable', { + templateUrl: 'app/agent/components/files-datatable/files-datatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + + isRoot: '<', + goToParent: '&', + browse: '&', + rename: '&', + download: '&', + delete: '&', + + isUploadAllowed: '<', + onFileSelectedForUpload: '<' + } +}); diff --git a/app/agent/components/host-browser/host-browser-controller.js b/app/agent/components/host-browser/host-browser-controller.js new file mode 100644 index 000000000..a50cb6b6e --- /dev/null +++ b/app/agent/components/host-browser/host-browser-controller.js @@ -0,0 +1,147 @@ +angular.module('portainer.agent').controller('HostBrowserController', [ + 'HostBrowserService', 'Notifications', 'FileSaver', 'ModalService', + function HostBrowserController(HostBrowserService, Notifications, FileSaver, ModalService) { + var ctrl = this; + var ROOT_PATH = '/host'; + ctrl.state = { + path: ROOT_PATH + }; + + ctrl.goToParent = goToParent; + ctrl.browse = browse; + ctrl.renameFile = renameFile; + ctrl.downloadFile = downloadFile; + ctrl.deleteFile = confirmDeleteFile; + ctrl.isRoot = isRoot; + ctrl.onFileSelectedForUpload = onFileSelectedForUpload; + ctrl.$onInit = $onInit; + ctrl.getRelativePath = getRelativePath; + + function getRelativePath(path) { + path = path || ctrl.state.path; + var rootPathRegex = new RegExp('^' + ROOT_PATH + '\/?'); + var relativePath = path.replace(rootPathRegex, '/'); + return relativePath; + } + + function goToParent() { + getFilesForPath(parentPath(this.state.path)); + } + + function isRoot() { + return ctrl.state.path === ROOT_PATH; + } + + function browse(folder) { + getFilesForPath(buildPath(ctrl.state.path, folder)); + } + + function getFilesForPath(path) { + HostBrowserService.ls(path) + .then(function onFilesLoaded(files) { + ctrl.state.path = path; + ctrl.files = files; + }) + .catch(function onLoadingFailed(err) { + Notifications.error('Failure', err, 'Unable to browse'); + }); + } + + function renameFile(name, newName) { + var filePath = buildPath(ctrl.state.path, name); + var newFilePath = buildPath(ctrl.state.path, newName); + + HostBrowserService.rename(filePath, newFilePath) + .then(function onRenameSuccess() { + Notifications.success('File successfully renamed', getRelativePath(newFilePath)); + return HostBrowserService.ls(ctrl.state.path); + }) + .then(function onFilesLoaded(files) { + ctrl.files = files; + }) + .catch(function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to rename file'); + }); + } + + function downloadFile(file) { + var filePath = buildPath(ctrl.state.path, file); + HostBrowserService.get(filePath) + .then(function onFileReceived(data) { + var downloadData = new Blob([data.file], { + type: 'text/plain;charset=utf-8' + }); + FileSaver.saveAs(downloadData, file); + }) + .catch(function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to download file'); + }); + } + + function confirmDeleteFile(name) { + var filePath = buildPath(ctrl.state.path, name); + + ModalService.confirmDeletion( + 'Are you sure that you want to delete ' + getRelativePath(filePath) + ' ?', + function onConfirm(confirmed) { + if (!confirmed) { + return; + } + return deleteFile(filePath); + } + ); + } + + function deleteFile(path) { + HostBrowserService.delete(path) + .then(function onDeleteSuccess() { + Notifications.success('File successfully deleted', getRelativePath(path)); + return HostBrowserService.ls(ctrl.state.path); + }) + .then(function onFilesLoaded(data) { + ctrl.files = data; + }) + .catch(function notifyOnError(err) { + Notifications.error('Failure', err, 'Unable to delete file'); + }); + } + + function $onInit() { + getFilesForPath(ROOT_PATH); + } + + function parentPath(path) { + if (path === ROOT_PATH) { + return ROOT_PATH; + } + + var split = _.split(path, '/'); + return _.join(_.slice(split, 0, split.length - 1), '/'); + } + + function buildPath(parent, file) { + if (parent.lastIndexOf('/') === parent.length - 1) { + return parent + file; + } + return parent + '/' + file; + } + + function onFileSelectedForUpload(file) { + HostBrowserService.upload(ctrl.state.path, file) + .then(function onFileUpload() { + onFileUploaded(); + }) + .catch(function onFileUpload(err) { + Notifications.error('Failure', err, 'Unable to upload file'); + }); + } + + function onFileUploaded() { + refreshList(); + } + + function refreshList() { + getFilesForPath(ctrl.state.path); + } + } +]); diff --git a/app/agent/components/host-browser/host-browser.html b/app/agent/components/host-browser/host-browser.html new file mode 100644 index 000000000..cf7b59306 --- /dev/null +++ b/app/agent/components/host-browser/host-browser.html @@ -0,0 +1,16 @@ + + + diff --git a/app/agent/components/host-browser/host-browser.js b/app/agent/components/host-browser/host-browser.js new file mode 100644 index 000000000..a136730ee --- /dev/null +++ b/app/agent/components/host-browser/host-browser.js @@ -0,0 +1,5 @@ +angular.module('portainer.agent').component('hostBrowser', { + controller: 'HostBrowserController', + templateUrl: 'app/agent/components/host-browser/host-browser.html', + bindings: {} +}); diff --git a/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js b/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js deleted file mode 100644 index e3139974d..000000000 --- a/app/agent/components/volume-browser/volume-browser-datatable/volume-browser-datatable.js +++ /dev/null @@ -1,15 +0,0 @@ -angular.module('portainer.agent').component('volumeBrowserDatatable', { - templateUrl: 'app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html', - controller: 'GenericDatatableController', - bindings: { - titleText: '@', - titleIcon: '@', - dataset: '<', - tableKey: '@', - orderBy: '@', - reverseOrder: '<' - }, - require: { - volumeBrowser: '^^volumeBrowser' - } -}); diff --git a/app/agent/components/volume-browser/volume-browser.js b/app/agent/components/volume-browser/volume-browser.js index 964803da0..78e963604 100644 --- a/app/agent/components/volume-browser/volume-browser.js +++ b/app/agent/components/volume-browser/volume-browser.js @@ -3,6 +3,7 @@ angular.module('portainer.agent').component('volumeBrowser', { controller: 'VolumeBrowserController', bindings: { volumeId: '<', - nodeName: '<' + nodeName: '<', + isUploadEnabled: '<' } }); diff --git a/app/agent/components/volume-browser/volumeBrowser.html b/app/agent/components/volume-browser/volumeBrowser.html index 643d8c88b..97c8a4da6 100644 --- a/app/agent/components/volume-browser/volumeBrowser.html +++ b/app/agent/components/volume-browser/volumeBrowser.html @@ -1,5 +1,14 @@ - + is-root="$ctrl.state.path === '/'" + go-to-parent="$ctrl.up()" + browse="$ctrl.browse(name)" + 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" +> diff --git a/app/agent/components/volume-browser/volumeBrowserController.js b/app/agent/components/volume-browser/volumeBrowserController.js index 2fa4426b9..8db735a3b 100644 --- a/app/agent/components/volume-browser/volumeBrowserController.js +++ b/app/agent/components/volume-browser/volumeBrowserController.js @@ -84,6 +84,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 '/'; @@ -112,4 +122,14 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService }); }; + function onFileUploaded() { + refreshList(); + } + + function refreshList() { + browse(ctrl.state.path); + } + + + }]); diff --git a/app/agent/rest/agent.js b/app/agent/rest/agent.js index a7800717c..522296c3a 100644 --- a/app/agent/rest/agent.js +++ b/app/agent/rest/agent.js @@ -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 } diff --git a/app/agent/rest/browse.js b/app/agent/rest/browse.js index b4e8d53e0..e78cd3206 100644 --- a/app/agent/rest/browse.js +++ b/app/agent/rest/browse.js @@ -1,22 +1,24 @@ 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/:id/:action', { - endpointId: EndpointProvider.endpointID + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/browse/:action', { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion }, { ls: { - method: 'GET', isArray: true, params: { id: '@id', action: 'ls' } + method: 'GET', isArray: true, params: { action: 'ls' } }, get: { - method: 'GET', params: { id: '@id', action: 'get' }, + method: 'GET', params: { action: 'get' }, transformResponse: browseGetResponse }, delete: { - method: 'DELETE', params: { id: '@id', action: 'delete' } + method: 'DELETE', params: { action: 'delete' } }, rename: { - method: 'PUT', params: { id: '@id', action: 'rename' } + method: 'PUT', params: { action: 'rename' } } }); }]); diff --git a/app/agent/rest/host.js b/app/agent/rest/host.js new file mode 100644 index 000000000..f184d2544 --- /dev/null +++ b/app/agent/rest/host.js @@ -0,0 +1,16 @@ +angular.module('portainer.agent').factory('Host', [ + '$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'StateManager', + function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, StateManager) { + 'use strict'; + return $resource( + API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/v:version/host/:action', + { + endpointId: EndpointProvider.endpointID, + version: StateManager.getAgentApiVersion + }, + { + info: { method: 'GET', params: { action: 'info' } } + } + ); + } +]); diff --git a/app/agent/rest/ping.js b/app/agent/rest/ping.js new file mode 100644 index 000000000..7eeb93f2e --- /dev/null +++ b/app/agent/rest/ping.js @@ -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); + } + } + } + } + ); + } +]); diff --git a/app/agent/rest/v1/agent.js b/app/agent/rest/v1/agent.js new file mode 100644 index 000000000..a78755b35 --- /dev/null +++ b/app/agent/rest/v1/agent.js @@ -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 } + }); +}]); diff --git a/app/agent/rest/v1/browse.js b/app/agent/rest/v1/browse.js new file mode 100644 index 000000000..d576433fa --- /dev/null +++ b/app/agent/rest/v1/browse.js @@ -0,0 +1,22 @@ +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' } + } + }); +}]); diff --git a/app/agent/services/agentService.js b/app/agent/services/agentService.js index 5f011d01c..17a2c3166 100644 --- a/app/agent/services/agentService.js +++ b/app/agent/services/agentService.js @@ -1,24 +1,42 @@ -angular.module('portainer.agent') -.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) { - 'use strict'; - var service = {}; +angular.module('portainer.agent').factory('AgentService', [ + '$q', 'Agent', 'AgentVersion1', 'HttpRequestHelper', 'Host', 'StateManager', + function AgentServiceFactory($q, Agent, AgentVersion1, HttpRequestHelper, Host, StateManager) { + 'use strict'; + var service = {}; - service.agents = function() { - var deferred = $q.defer(); + service.agents = agents; + service.hostInfo = hostInfo; - Agent.query({}).$promise - .then(function success(data) { - var agents = data.map(function (item) { - return new AgentViewModel(item); - }); - deferred.resolve(agents); - }) - .catch(function error(err) { - deferred.reject({ msg: 'Unable to retrieve agents', err: err }); - }); + function getAgentApiVersion() { + var state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } - return deferred.promise; - }; + function hostInfo(nodeName) { + HttpRequestHelper.setPortainerAgentTargetHeader(nodeName); + return Host.info().$promise; + } - return service; -}]); + function agents() { + var deferred = $q.defer(); + + 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); + }); + deferred.resolve(agents); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve agents', err: err }); + }); + + return deferred.promise; + } + + return service; + } +]); diff --git a/app/agent/services/hostBrowserService.js b/app/agent/services/hostBrowserService.js new file mode 100644 index 000000000..6f292c36a --- /dev/null +++ b/app/agent/services/hostBrowserService.js @@ -0,0 +1,52 @@ +angular.module('portainer.agent').factory('HostBrowserService', [ + 'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q', 'StateManager', + function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q, StateManager) { + var service = {}; + + service.ls = ls; + service.get = get; + service.delete = deletePath; + service.rename = rename; + service.upload = upload; + + function ls(path) { + return Browse.ls({ path: path }).$promise; + } + + function get(path) { + return Browse.get({ path: path }).$promise; + } + + function deletePath(path) { + return Browse.delete({ path: path }).$promise; + } + + function rename(path, newPath) { + var payload = { + CurrentFilePath: path, + NewFilePath: newPath + }; + return Browse.rename({}, payload).$promise; + } + + function upload(path, file, onProgress) { + var deferred = $q.defer(); + var agentVersion = StateManager.getAgentApiVersion(); + var url = + API_ENDPOINT_ENDPOINTS + + '/' + + EndpointProvider.endpointID() + + '/docker' + + (agentVersion > 1 ? '/v' + agentVersion : '') + + '/browse/put'; + + Upload.upload({ + url: url, + data: { file: file, Path: path } + }).then(deferred.resolve, deferred.reject, onProgress); + return deferred.promise; + } + + return service; + } +]); diff --git a/app/agent/services/pingService.js b/app/agent/services/pingService.js new file mode 100644 index 000000000..765d47a5f --- /dev/null +++ b/app/agent/services/pingService.js @@ -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; + } +]); diff --git a/app/agent/services/volumeBrowserService.js b/app/agent/services/volumeBrowserService.js index 22b020494..a4ffdbf9d 100644 --- a/app/agent/services/volumeBrowserService.js +++ b/app/agent/services/volumeBrowserService.js @@ -1,27 +1,62 @@ -angular.module('portainer.agent') -.factory('VolumeBrowserService', ['$q', 'Browse', function VolumeBrowserServiceFactory($q, Browse) { - 'use strict'; - var service = {}; +angular.module('portainer.agent').factory('VolumeBrowserService', [ + 'StateManager', 'Browse', 'BrowseVersion1', '$q', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'Upload', + function VolumeBrowserServiceFactory(StateManager, Browse, BrowseVersion1, $q, API_ENDPOINT_ENDPOINTS, EndpointProvider, Upload) { + 'use strict'; + var service = {}; - service.ls = function(volumeId, path) { - return Browse.ls({ 'id': volumeId, 'path': path }).$promise; - }; + function getAgentApiVersion() { + var state = StateManager.getState(); + return state.endpoint.agentApiVersion; + } - service.get = function(volumeId, path) { - return Browse.get({ 'id': volumeId, 'path': path }).$promise; - }; + function getBrowseService() { + var agentVersion = getAgentApiVersion(); + return agentVersion > 1 ? Browse : BrowseVersion1; + } - service.delete = function(volumeId, path) { - return Browse.delete({ 'id': volumeId, 'path': path }).$promise; - }; - - service.rename = function(volumeId, path, newPath) { - var payload = { - CurrentFilePath: path, - NewFilePath: newPath + service.ls = function(volumeId, path) { + return getBrowseService().ls({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; }; - return Browse.rename({ 'id': volumeId }, payload).$promise; - }; - return service; -}]); + service.get = function(volumeId, path) { + return getBrowseService().get({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; + }; + + service.delete = function(volumeId, path) { + return getBrowseService().delete({ volumeID: volumeId, path: path, version: getAgentApiVersion() }).$promise; + }; + + service.rename = function(volumeId, path, newPath) { + var payload = { + CurrentFilePath: path, + NewFilePath: newPath + }; + 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; + } +]); diff --git a/app/app.js b/app/app.js index a32633fe2..021f4d58a 100644 --- a/app/app.js +++ b/app/app.js @@ -37,10 +37,16 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin function initAuthentication(authManager, Authentication, $rootScope, $state) { authManager.checkAuthOnRefresh(); - authManager.redirectWhenUnauthenticated(); Authentication.init(); - $rootScope.$on('tokenHasExpired', function() { - $state.go('portainer.auth', {error: 'Your session has expired'}); + + // 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 (event, data) { + if (!_.includes(data.config.url, '/v2/')) { + $state.go('portainer.auth', {error: 'Your session has expired'}); + } }); } diff --git a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html index 8ba81dece..9f85333b7 100644 --- a/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html +++ b/app/azure/components/datatables/containergroups-datatable/containerGroupsDatatable.html @@ -17,7 +17,7 @@
- Go to parent + Go + to parent
- + - + - {{ item.Name }} + {{ item.Name }} - {{ item.Name }} + {{ + item.Name }} {{ item.Size | humansize }} - + Download Rename - + Delete
diff --git a/app/config.js b/app/config.js index 8e3366856..cd21e1f08 100644 --- a/app/config.js +++ b/app/config.js @@ -14,12 +14,10 @@ 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'); + $httpProvider.interceptors.push('EndpointStatusInterceptor'); $httpProvider.defaults.headers.post['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.put['Content-Type'] = 'application/json'; $httpProvider.defaults.headers.patch['Content-Type'] = 'application/json'; @@ -51,6 +49,7 @@ angular.module('portainer') cfpLoadingBarProvider.includeSpinner = false; cfpLoadingBarProvider.parentSelector = '#loadingbar-placeholder'; + cfpLoadingBarProvider.latencyThreshold = 600; $urlRouterProvider.otherwise('/auth'); }]); diff --git a/app/constants.js b/app/constants.js index b20d44649..464089158 100644 --- a/app/constants.js +++ b/app/constants.js @@ -4,8 +4,10 @@ angular.module('portainer') .constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints') .constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups') .constant('API_ENDPOINT_MOTD', 'api/motd') +.constant('API_ENDPOINT_EXTENSIONS', 'api/extensions') .constant('API_ENDPOINT_REGISTRIES', 'api/registries') .constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls') +.constant('API_ENDPOINT_SCHEDULES', 'api/schedules') .constant('API_ENDPOINT_SETTINGS', 'api/settings') .constant('API_ENDPOINT_STACKS', 'api/stacks') .constant('API_ENDPOINT_STATUS', 'api/status') diff --git a/app/docker/__module.js b/app/docker/__module.js index ffd451fbd..9bd99a7cd 100644 --- a/app/docker/__module.js +++ b/app/docker/__module.js @@ -129,13 +129,32 @@ angular.module('portainer.docker', ['portainer.app']) } }; - var engine = { - name: 'docker.engine', - url: '/engine', + var host = { + name: 'docker.host', + url: '/host', views: { 'content@': { - templateUrl: 'app/docker/views/engine/engine.html', - controller: 'EngineController' + component: 'hostView' + } + } + }; + + var hostBrowser = { + name: 'docker.host.browser', + url: '/browser', + views: { + 'content@': { + component: 'hostBrowserView' + } + } + }; + + var hostJob = { + name: 'docker.host.job', + url: '/job', + views: { + 'content@': { + component: 'hostJobView' } } }; @@ -239,8 +258,27 @@ angular.module('portainer.docker', ['portainer.app']) url: '/:id', views: { 'content@': { - templateUrl: 'app/docker/views/nodes/edit/node.html', - controller: 'NodeController' + component: 'nodeDetailsView' + } + } + }; + + var nodeBrowser = { + name: 'docker.nodes.node.browse', + url: '/browse', + views: { + 'content@': { + component: 'nodeBrowserView' + } + } + }; + + var nodeJob = { + name: 'docker.nodes.node.job', + url: '/job', + views: { + 'content@': { + component: 'nodeJobView' } } }; @@ -416,6 +454,8 @@ angular.module('portainer.docker', ['portainer.app']) } }; + + $stateRegistryProvider.register(configs); $stateRegistryProvider.register(config); $stateRegistryProvider.register(configCreation); @@ -428,7 +468,9 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(containerStats); $stateRegistryProvider.register(docker); $stateRegistryProvider.register(dashboard); - $stateRegistryProvider.register(engine); + $stateRegistryProvider.register(host); + $stateRegistryProvider.register(hostBrowser); + $stateRegistryProvider.register(hostJob); $stateRegistryProvider.register(events); $stateRegistryProvider.register(images); $stateRegistryProvider.register(image); @@ -439,6 +481,8 @@ angular.module('portainer.docker', ['portainer.app']) $stateRegistryProvider.register(networkCreation); $stateRegistryProvider.register(nodes); $stateRegistryProvider.register(node); + $stateRegistryProvider.register(nodeBrowser); + $stateRegistryProvider.register(nodeJob); $stateRegistryProvider.register(secrets); $stateRegistryProvider.register(secret); $stateRegistryProvider.register(secretCreation); diff --git a/app/docker/components/container-quick-actions/containerQuickActions.html b/app/docker/components/container-quick-actions/containerQuickActions.html new file mode 100644 index 000000000..0befd7e8d --- /dev/null +++ b/app/docker/components/container-quick-actions/containerQuickActions.html @@ -0,0 +1,44 @@ + \ No newline at end of file diff --git a/app/docker/components/container-quick-actions/containerQuickActions.js b/app/docker/components/container-quick-actions/containerQuickActions.js new file mode 100644 index 000000000..3fb616d7c --- /dev/null +++ b/app/docker/components/container-quick-actions/containerQuickActions.js @@ -0,0 +1,10 @@ +angular.module('portainer.docker').component('containerQuickActions', { + templateUrl: 'app/docker/components/container-quick-actions/containerQuickActions.html', + bindings: { + containerId: '<', + nodeName: '<', + status: '<', + state: '<', + taskId: '<' + } +}); diff --git a/app/docker/components/datatables/configs-datatable/configsDatatable.html b/app/docker/components/datatables/configs-datatable/configsDatatable.html index 9b2f4364f..5e8f075a6 100644 --- a/app/docker/components/datatables/configs-datatable/configsDatatable.html +++ b/app/docker/components/datatables/configs-datatable/configsDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html index 9da49860c..a79fdc698 100644 --- a/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html +++ b/app/docker/components/datatables/container-networks-datatable/containerNetworksDatatable.html @@ -13,7 +13,7 @@
diff --git a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js index 88980f8f3..efacb89e7 100644 --- a/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js +++ b/app/docker/components/datatables/containers-datatable/actions/containersDatatableActionsController.js @@ -72,6 +72,7 @@ function ($state, ContainerService, ModalService, Notifications, HttpRequestHelp Notifications.success(successMessage, container.Names[0]); }) .catch(function error(err) { + errorMessage = errorMessage + ':' + container.Names[0]; Notifications.error('Failure', err, errorMessage); }) .finally(function final() { diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.html b/app/docker/components/datatables/containers-datatable/containersDatatable.html index 19cd2a0c0..e9179cd33 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.html +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.html @@ -99,7 +99,7 @@
-
- + - + diff --git a/app/docker/components/datatables/containers-datatable/containersDatatable.js b/app/docker/components/datatables/containers-datatable/containersDatatable.js index 18a693511..478f17367 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatable.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('containersDatatable', { reverseOrder: '<', showOwnershipColumn: '<', showHostColumn: '<', - showAddAction: '<' + showAddAction: '<', + offlineMode: '<' } }); diff --git a/app/docker/components/datatables/containers-datatable/containersDatatableController.js b/app/docker/components/datatables/containers-datatable/containersDatatableController.js index 7151e5d64..68a3d925b 100644 --- a/app/docker/components/datatables/containers-datatable/containersDatatableController.js +++ b/app/docker/components/datatables/containers-datatable/containersDatatableController.js @@ -78,6 +78,10 @@ function (PaginationService, DatatableService, EndpointProvider) { } }; + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + this.onColumnVisibilityChange = function() { DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility); }; @@ -204,7 +208,6 @@ function (PaginationService, DatatableService, EndpointProvider) { this.$onInit = function() { setDefaults(this); this.prepareTableFromDataset(); - var storedOrder = DatatableService.getDataTableOrder(this.tableKey); if (storedOrder !== null) { this.state.reverseOrder = storedOrder.reverse; @@ -228,6 +231,11 @@ function (PaginationService, DatatableService, EndpointProvider) { this.columnVisibility = storedColumnVisibility; } this.columnVisibility.state.open = false; + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } }; function setDefaults(ctrl) { diff --git a/app/docker/components/datatables/events-datatable/eventsDatatable.html b/app/docker/components/datatables/events-datatable/eventsDatatable.html index 77bb95c71..f70cbb9e7 100644 --- a/app/docker/components/datatables/events-datatable/eventsDatatable.html +++ b/app/docker/components/datatables/events-datatable/eventsDatatable.html @@ -8,7 +8,7 @@
- + @@ -210,26 +210,27 @@
- + - {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} + {{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }} {{ item.Status }} {{ item.Status }} -
- - - - -
+
+ + {{ item.StackName ? item.StackName : '-' }}{{ item.Image | trimshasum }} + {{ item.Image | trimshasum }} + {{ item.Image | trimshasum }} + {{item.Created | getisodatefromtimestamp}}
diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html new file mode 100644 index 000000000..2e0ce6ffe --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html @@ -0,0 +1,118 @@ +
+
+
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + +
+ + Id + + + + + + State + + + +
+ Filter + + Filter + +
+ +
+ + + + Created + +
+ + {{ item | containername }} + + {{ item.Status }} + + {{ item.Status }} + + {{item.Created | getisodatefromtimestamp}} +
Loading...
No jobs available.
+
+ +
+
+ + + diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js new file mode 100644 index 000000000..8c599cae3 --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatable.js @@ -0,0 +1,12 @@ +angular.module('portainer.docker').component('jobsDatatable', { + templateUrl: 'app/docker/components/datatables/host-jobs-datatable/jobsDatatable.html', + controller: 'JobsDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<' + } +}); \ No newline at end of file diff --git a/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js new file mode 100644 index 000000000..02016fcde --- /dev/null +++ b/app/docker/components/datatables/host-jobs-datatable/jobsDatatableController.js @@ -0,0 +1,149 @@ +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.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + + 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]; + 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 history 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; + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } + }; + + function setDefaults(ctrl) { + ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } + } + ]); diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.html b/app/docker/components/datatables/images-datatable/imagesDatatable.html index 87cba5507..ebe7aa4b1 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.html +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }} -
+
- + @@ -111,11 +111,12 @@
- + - {{ item.Id | truncate:40 }} + {{ item.Id | truncate:40 }} + {{ item.Id | truncate:40 }} Unused diff --git a/app/docker/components/datatables/images-datatable/imagesDatatable.js b/app/docker/components/datatables/images-datatable/imagesDatatable.js index 615f4375e..57ed625c7 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatable.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatable.js @@ -12,6 +12,7 @@ angular.module('portainer.docker').component('imagesDatatable', { removeAction: '<', downloadAction: '<', forceRemoveAction: '<', - exportInProgress: '<' + exportInProgress: '<', + offlineMode: '<' } }); diff --git a/app/docker/components/datatables/images-datatable/imagesDatatableController.js b/app/docker/components/datatables/images-datatable/imagesDatatableController.js index f5254a47b..c197ccc64 100644 --- a/app/docker/components/datatables/images-datatable/imagesDatatableController.js +++ b/app/docker/components/datatables/images-datatable/imagesDatatableController.js @@ -22,6 +22,10 @@ function (PaginationService, DatatableService) { } }; + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + this.changeOrderBy = function(orderField) { this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; this.state.orderBy = orderField; @@ -86,6 +90,11 @@ function (PaginationService, DatatableService) { this.filters = storedFilters; } this.filters.usage.open = false; + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } }; function setDefaults(ctrl) { diff --git a/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html b/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html index 5b8b3660e..f4a65f6c2 100644 --- a/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html +++ b/app/docker/components/datatables/macvlan-nodes-datatable/macvlanNodesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.html b/app/docker/components/datatables/networks-datatable/networksDatatable.html index afb808ce8..29b82b6a7 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.html +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }} -
+
diff --git a/app/docker/components/datatables/networks-datatable/networksDatatable.js b/app/docker/components/datatables/networks-datatable/networksDatatable.js index 2cec0f70c..a6383af97 100644 --- a/app/docker/components/datatables/networks-datatable/networksDatatable.js +++ b/app/docker/components/datatables/networks-datatable/networksDatatable.js @@ -10,6 +10,7 @@ angular.module('portainer.docker').component('networksDatatable', { reverseOrder: '<', showOwnershipColumn: '<', showHostColumn: '<', - removeAction: '<' + removeAction: '<', + offlineMode: '<' } }); diff --git a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html index 81c40551e..ea87ad303 100644 --- a/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html +++ b/app/docker/components/datatables/node-tasks-datatable/nodeTasksDatatable.html @@ -8,7 +8,7 @@
- + @@ -109,11 +109,12 @@
- + - {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} + {{ item.Name | truncate:40 }} {{ item.StackName ? item.StackName : '-' }} {{ item.Scope }}
diff --git a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html index 20f575f41..dac5c644b 100644 --- a/app/docker/components/datatables/nodes-datatable/nodesDatatable.html +++ b/app/docker/components/datatables/nodes-datatable/nodesDatatable.html @@ -8,7 +8,7 @@
@@ -68,8 +68,8 @@ diff --git a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html index ed5249557..bb5df46d6 100644 --- a/app/docker/components/datatables/secrets-datatable/secretsDatatable.html +++ b/app/docker/components/datatables/secrets-datatable/secretsDatatable.html @@ -17,7 +17,7 @@
- {{ item.Hostname }} - {{ item.Hostname }} + {{ item.Name || item.Hostname }} + {{ item.Name || item.Hostname }} {{ item.Role }} {{ item.CPUs / 1000000000 }}
diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html index cfba0524f..40f255ae0 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatable.html @@ -64,11 +64,8 @@ {{ item.Id }}Doz diff --git a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js index b0a8da0b2..46c12d67c 100644 --- a/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js +++ b/app/docker/components/datatables/service-tasks-datatable/serviceTasksDatatableController.js @@ -4,7 +4,11 @@ function (DatatableService) { var ctrl = this; this.state = { - orderBy: this.orderBy + orderBy: this.orderBy, + showQuickActionStats: true, + showQuickActionLogs: true, + showQuickActionConsole: true, + showQuickActionInspect: true }; this.filters = { diff --git a/app/docker/components/datatables/services-datatable/servicesDatatable.html b/app/docker/components/datatables/services-datatable/servicesDatatable.html index ba3d870ad..c982365a3 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatable.html +++ b/app/docker/components/datatables/services-datatable/servicesDatatable.html @@ -14,7 +14,7 @@ >
-
- - - -
+ +
{{ item.Slot ? item.Slot : '-' }} {{ item.NodeId | tasknodename: $ctrl.nodes }}
diff --git a/app/docker/components/datatables/services-datatable/servicesDatatableController.js b/app/docker/components/datatables/services-datatable/servicesDatatableController.js index 38c39947b..b265863b4 100644 --- a/app/docker/components/datatables/services-datatable/servicesDatatableController.js +++ b/app/docker/components/datatables/services-datatable/servicesDatatableController.js @@ -16,6 +16,10 @@ function (PaginationService, DatatableService, EndpointProvider) { publicURL: EndpointProvider.endpointPublicURL() }; + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + this.changeOrderBy = function(orderField) { this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; this.state.orderBy = orderField; @@ -109,6 +113,11 @@ function (PaginationService, DatatableService, EndpointProvider) { if (storedExpandedItems !== null) { this.expandItems(storedExpandedItems); } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } }; function setDefaults(ctrl) { diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html index db3d50ebb..391852180 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.html +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.html @@ -8,19 +8,12 @@
- + + - + - + - diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js index 43e8237ac..f24e14446 100644 --- a/app/docker/components/datatables/tasks-datatable/tasksDatatable.js +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatable.js @@ -1,6 +1,6 @@ angular.module('portainer.docker').component('tasksDatatable', { templateUrl: 'app/docker/components/datatables/tasks-datatable/tasksDatatable.html', - controller: 'GenericDatatableController', + controller: 'TasksDatatableController', bindings: { titleText: '@', titleIcon: '@', diff --git a/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js b/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js new file mode 100644 index 000000000..368fa76d2 --- /dev/null +++ b/app/docker/components/datatables/tasks-datatable/tasksDatatableController.js @@ -0,0 +1,70 @@ +angular.module('portainer.docker') +.controller('TasksDatatableController', ['PaginationService', 'DatatableService', +function (PaginationService, DatatableService) { + this.state = { + showQuickActionStats: true, + showQuickActionLogs: true, + showQuickActionConsole: true, + showQuickActionInspect: true, + selectAll: false, + orderBy: this.orderBy, + paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), + displayTextFilter: false, + selectedItemCount: 0, + selectedItems: [] + }; + + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + + 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.selectItem = function(item) { + if (item.Checked) { + this.state.selectedItemCount++; + this.state.selectedItems.push(item); + } else { + this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1); + this.state.selectedItemCount--; + } + }; + + this.selectAll = function() { + for (var i = 0; i < this.state.filteredDataSet.length; i++) { + var item = this.state.filteredDataSet[i]; + if (item.Checked !== this.state.selectAll) { + item.Checked = this.state.selectAll; + this.selectItem(item); + } + } + }; + + this.changePaginationLimit = function() { + PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + }; + + this.$onInit = function() { + setDefaults(this); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } + }; + + function setDefaults(ctrl) { + ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } +}]); diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html index 60632f296..ca92dad87 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }} -
+
- - Id - - - - Status @@ -28,6 +21,14 @@ + + Id + + + + Actions Slot @@ -49,24 +50,22 @@ Actions
{{ item.Status.State }} {{ item.ServiceName }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }} {{ item.ServiceName }}{{ item.Slot ? '.' + item.Slot : '' }}{{ '.' + item.Id }} {{ item.Status.State }} + + + {{ item.Slot ? item.Slot : '-' }} {{ item.NodeId | tasknodename: $ctrl.nodes }} {{ item.Updated | getisodate }} - View logs - View logs - Console -
Loading...
- + @@ -98,12 +98,13 @@
- + - {{ item.Id | truncate:40 }} - + {{ item.Id | truncate:40 }} + {{ item.Id | truncate:40 }} + browse Unused diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js index 6b946438f..4c878677a 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatable.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatable.js @@ -11,6 +11,7 @@ angular.module('portainer.docker').component('volumesDatatable', { showOwnershipColumn: '<', showHostColumn: '<', removeAction: '<', - showBrowseAction: '<' + showBrowseAction: '<', + offlineMode: '<' } }); diff --git a/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js b/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js index 38b3ebbff..e7a88dd96 100644 --- a/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js +++ b/app/docker/components/datatables/volumes-datatable/volumesDatatableController.js @@ -22,6 +22,10 @@ function (PaginationService, DatatableService) { } }; + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + this.changeOrderBy = function(orderField) { this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; this.state.orderBy = orderField; @@ -86,6 +90,11 @@ function (PaginationService, DatatableService) { this.filters = storedFilters; } this.filters.usage.open = false; + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } }; function setDefaults(ctrl) { diff --git a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js index 325a3497d..2a33aabbc 100644 --- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js +++ b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js @@ -1,9 +1,10 @@ angular.module('portainer.docker').component('dockerSidebarContent', { templateUrl: 'app/docker/components/dockerSidebarContent/dockerSidebarContent.html', bindings: { - 'endpointApiVersion': '<', - 'swarmManagement': '<', - 'standaloneManagement': '<', - 'adminAccess': '<' + endpointApiVersion: '<', + swarmManagement: '<', + standaloneManagement: '<', + adminAccess: '<', + offlineMode: '<' } }); diff --git a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html index 870c959ed..12151542a 100644 --- a/app/docker/components/dockerSidebarContent/dockerSidebarContent.html +++ b/app/docker/components/dockerSidebarContent/dockerSidebarContent.html @@ -1,7 +1,7 @@ - - diff --git a/app/docker/components/host-overview/host-overview.html b/app/docker/components/host-overview/host-overview.html new file mode 100644 index 000000000..968c09708 --- /dev/null +++ b/app/docker/components/host-overview/host-overview.html @@ -0,0 +1,34 @@ + + + + + + + Docker + + + + + + + + + + + + + + diff --git a/app/docker/components/host-overview/host-overview.js b/app/docker/components/host-overview/host-overview.js new file mode 100644 index 000000000..4eae6a787 --- /dev/null +++ b/app/docker/components/host-overview/host-overview.js @@ -0,0 +1,19 @@ +angular.module('portainer.docker').component('hostOverview', { + templateUrl: 'app/docker/components/host-overview/host-overview.html', + bindings: { + hostDetails: '<', + engineDetails: '<', + devices: '<', + disks: '<', + isAgent: '<', + offlineMode: '<', + agentApiVersion: '<', + refreshUrl: '@', + browseUrl: '@', + jobUrl: '@', + isJobEnabled: '<', + hostFeaturesEnabled: '<', + jobs: '<' + }, + transclude: true +}); diff --git a/app/docker/components/host-view-panels/devices-panel/devices-panel.html b/app/docker/components/host-view-panels/devices-panel/devices-panel.html new file mode 100644 index 000000000..5279f4ac1 --- /dev/null +++ b/app/docker/components/host-view-panels/devices-panel/devices-panel.html @@ -0,0 +1,31 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
NameVendor
{{device.Name}}{{device.Vendor}}
Loading...
+ No device available. +
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/devices-panel/devices-panel.js b/app/docker/components/host-view-panels/devices-panel/devices-panel.js new file mode 100644 index 000000000..1bd32e1f6 --- /dev/null +++ b/app/docker/components/host-view-panels/devices-panel/devices-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('devicesPanel', { + templateUrl: + 'app/docker/components/host-view-panels/devices-panel/devices-panel.html', + bindings: { + devices: '<' + } +}); diff --git a/app/docker/components/host-view-panels/disks-panel/disks-panel.html b/app/docker/components/host-view-panels/disks-panel/disks-panel.html new file mode 100644 index 000000000..632f07151 --- /dev/null +++ b/app/docker/components/host-view-panels/disks-panel/disks-panel.html @@ -0,0 +1,31 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
VendorSize
{{disk.Vendor}}{{disk.Size | humansize}}
Loading...
+ No disks available. +
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/disks-panel/disks-panel.js b/app/docker/components/host-view-panels/disks-panel/disks-panel.js new file mode 100644 index 000000000..ae96224ee --- /dev/null +++ b/app/docker/components/host-view-panels/disks-panel/disks-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('disksPanel', { + templateUrl: + 'app/docker/components/host-view-panels/disks-panel/disks-panel.html', + bindings: { + disks: '<' + } +}); diff --git a/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html new file mode 100644 index 000000000..4eebf6bcd --- /dev/null +++ b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html @@ -0,0 +1,37 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Version{{ $ctrl.engine.releaseVersion }} (API: {{ $ctrl.engine.apiVersion }})
Root directory{{ $ctrl.engine.rootDirectory }}
Storage Driver{{ $ctrl.engine.storageDriver }}
Logging Driver{{ $ctrl.engine.loggingDriver }}
Volume Plugins{{ $ctrl.engine.volumePlugins | arraytostr: ', ' }}
Network Plugins{{ $ctrl.engine.networkPlugins | arraytostr: ', ' }}
+
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js new file mode 100644 index 000000000..666dd5254 --- /dev/null +++ b/app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.docker').component('engineDetailsPanel', { + templateUrl: + 'app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html', + bindings: { + engine: '<' + } +}); diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html new file mode 100644 index 000000000..9b5b006d7 --- /dev/null +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.html @@ -0,0 +1,44 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hostname{{ $ctrl.host.name }}
OS Information{{ $ctrl.host.os.type }} {{$ctrl.host.os.arch}} + {{$ctrl.host.os.name}}
Kernel Version{{ $ctrl.host.kernelVersion }}
Total CPU{{ $ctrl.host.totalCPU }}
Total memory{{ $ctrl.host.totalMemory | humansize }}
+ + +
+
+
+
+
diff --git a/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js new file mode 100644 index 000000000..8f1b55270 --- /dev/null +++ b/app/docker/components/host-view-panels/host-details-panel/host-details-panel.js @@ -0,0 +1,10 @@ +angular.module('portainer.docker').component('hostDetailsPanel', { + templateUrl: 'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html', + bindings: { + host: '<', + isJobEnabled: '<', + isBrowseEnabled: '<', + browseUrl: '@', + jobUrl: '@' + } +}); diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js b/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js new file mode 100644 index 000000000..52df40cd6 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select-controller.js @@ -0,0 +1,11 @@ +angular + .module('portainer.docker') + .controller('NodeAvailabilitySelectController', [ + function NodeAvailabilitySelectController() { + this.onChange = onChange; + + function onChange() { + this.onSave({ availability: this.availability }); + } + } + ]); diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html new file mode 100644 index 000000000..94e086127 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.html @@ -0,0 +1,8 @@ +
+ +
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js new file mode 100644 index 000000000..b09730f37 --- /dev/null +++ b/app/docker/components/host-view-panels/node-availability-select/node-availability-select.js @@ -0,0 +1,10 @@ +angular.module('portainer.docker').component('nodeAvailabilitySelect', { + templateUrl: + 'app/docker/components/host-view-panels/node-availability-select/node-availability-select.html', + controller: 'NodeAvailabilitySelectController', + bindings: { + availability: '<', + originalValue: '<', + onSave: '&' + } +}); diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js b/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js new file mode 100644 index 000000000..a9ad8ab58 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table-controller.js @@ -0,0 +1,23 @@ +angular.module('portainer.docker').controller('NodeLabelsTableController', [ + function NodeLabelsTableController() { + var ctrl = this; + ctrl.removeLabel = removeLabel; + ctrl.updateLabel = updateLabel; + + function removeLabel(index) { + var label = ctrl.labels.splice(index, 1); + if (label !== null) { + ctrl.onChangedLabels({ labels: ctrl.labels }); + } + } + + function updateLabel(label) { + if ( + label.value !== label.originalValue || + label.key !== label.originalKey + ) { + ctrl.onChangedLabels({ labels: ctrl.labels }); + } + } + } +]); diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html new file mode 100644 index 000000000..86eee9356 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.html @@ -0,0 +1,35 @@ +
+ There are no labels for this node. +
+ + + + + + + + + + + + + + +
LabelValue
+
+ Name + +
+
+
+ Value + + + + +
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js new file mode 100644 index 000000000..5d6d6c320 --- /dev/null +++ b/app/docker/components/host-view-panels/node-labels-table/node-labels-table.js @@ -0,0 +1,9 @@ +angular.module('portainer.docker').component('nodeLabelsTable', { + templateUrl: + 'app/docker/components/host-view-panels/node-labels-table/node-labels-table.html', + controller: 'NodeLabelsTableController', + bindings: { + labels: '<', + onChangedLabels: '&' + } +}); diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js new file mode 100644 index 000000000..65e376d99 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel-controller.js @@ -0,0 +1,96 @@ +angular + .module('portainer.docker') + .controller('SwarmNodeDetailsPanelController', [ + 'NodeService', 'LabelHelper', 'Notifications', '$state', + function SwarmNodeDetailsPanelController(NodeService, LabelHelper, Notifications, $state) { + var ctrl = this; + ctrl.state = { + managerAddress: '', + hasChanges: false + }; + ctrl.$onChanges = $onChanges; + ctrl.addLabel = addLabel; + ctrl.updateNodeLabels = updateNodeLabels; + ctrl.updateNodeAvailability = updateNodeAvailability; + ctrl.saveChanges = saveChanges; + ctrl.cancelChanges = cancelChanges; + + var managerRole = 'manager'; + + function $onChanges() { + if (!ctrl.details) { + return; + } + if (ctrl.details.role === managerRole) { + ctrl.state.managerAddress = '(' + ctrl.details.managerAddress + ')'; + } + } + + function addLabel() { + ctrl.details.nodeLabels.push({ + key: '', + value: '', + originalValue: '', + originalKey: '' + }); + } + + function updateNodeLabels(labels) { + ctrl.details.nodeLabels = labels; + ctrl.state.hasChanges = true; + } + + function updateNodeAvailability(availability) { + ctrl.details.availability = availability; + ctrl.state.hasChanges = true; + } + + function saveChanges() { + var originalNode = ctrl.originalNode; + var config = { + Name: originalNode.Name, + Availability: ctrl.details.availability, + Role: originalNode.Role, + Labels: LabelHelper.fromKeyValueToLabelHash(ctrl.details.nodeLabels), + Id: originalNode.Id, + Version: originalNode.Version + }; + + NodeService.updateNode(config) + .then(onUpdateSuccess) + .catch(notifyOnError); + + function onUpdateSuccess() { + Notifications.success('Node successfully updated', 'Node updated'); + $state.go( + 'docker.nodes.node', + { id: originalNode.Id }, + { reload: true } + ); + } + + function notifyOnError(error) { + Notifications.error('Failure', error, 'Failed to update node'); + } + } + + function cancelChanges() { + cancelLabelChanges(); + ctrl.details.availability = ctrl.originalNode.Availability; + ctrl.state.hasChanges = false; + } + + function cancelLabelChanges() { + ctrl.details.nodeLabels = ctrl.details.nodeLabels + .filter(function(label) { + return label.originalValue || label.originalKey; + }) + .map(function(label) { + return Object.assign(label, { + value: label.originalValue, + key: label.originalKey + }); + }); + } + } + ]); diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html new file mode 100644 index 000000000..291025e13 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html @@ -0,0 +1,75 @@ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Node name{{ $ctrl.details.name }}
Role{{ $ctrl.details.role }} {{ $ctrl.state.managerAddress }}
Availability + + +
Status{{ + $ctrl.details.status }}
Engine Labels{{ $ctrl.details.engineLabels | arraytostr:', ' }}
+ + Node Labels +
+ +
+ +
+ +
+
+
+
\ No newline at end of file diff --git a/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js new file mode 100644 index 000000000..7eea3e708 --- /dev/null +++ b/app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.js @@ -0,0 +1,9 @@ +angular.module('portainer.docker').component('swarmNodeDetailsPanel', { + templateUrl: + 'app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html', + controller: 'SwarmNodeDetailsPanelController', + bindings: { + details: '<', + originalNode: '<' + } +}); diff --git a/app/docker/components/log-viewer/log-viewer.js b/app/docker/components/log-viewer/log-viewer.js index 070567b9e..b0864aea4 100644 --- a/app/docker/components/log-viewer/log-viewer.js +++ b/app/docker/components/log-viewer/log-viewer.js @@ -5,6 +5,7 @@ angular.module('portainer.docker').component('logViewer', { data: '=', displayTimestamps: '=', logCollectionChange: '<', + sinceTimestamp: '=', lineCount: '=' } }); diff --git a/app/docker/components/log-viewer/logViewer.html b/app/docker/components/log-viewer/logViewer.html index 9e1f95c02..44c1c5d7e 100644 --- a/app/docker/components/log-viewer/logViewer.html +++ b/app/docker/components/log-viewer/logViewer.html @@ -35,6 +35,17 @@ +
+ +
+ +
+
+ +
+ Logging +
+ +
+ +
+ +
+
+

+ 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 in the Docker documentation. +

+
+
+ + +
+
+ + + add logging driver option + +
+ +
+
+
+ option + +
+
+ value + +
+ +
+
+ +
+ + + @@ -309,7 +362,7 @@
@@ -503,11 +556,11 @@
- -
- +
diff --git a/app/docker/views/containers/edit/containerController.js b/app/docker/views/containers/edit/containerController.js index f687b116c..63902e77e 100644 --- a/app/docker/views/containers/edit/containerController.js +++ b/app/docker/views/containers/edit/containerController.js @@ -154,6 +154,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'); diff --git a/app/docker/views/containers/logs/containerLogsController.js b/app/docker/views/containers/logs/containerLogsController.js index 1d23adeb8..633739f42 100644 --- a/app/docker/views/containers/logs/containerLogsController.js +++ b/app/docker/views/containers/logs/containerLogsController.js @@ -3,7 +3,8 @@ angular.module('portainer.docker') function ($scope, $transition$, $interval, ContainerService, Notifications, HttpRequestHelper) { $scope.state = { refreshRate: 3, - lineCount: 2000, + lineCount: 100, + sinceTimestamp: '', displayTimestamps: false }; @@ -30,7 +31,7 @@ function ($scope, $transition$, $interval, ContainerService, Notifications, Http function setUpdateRepeater(skipHeaders) { var refreshRate = $scope.state.refreshRate; $scope.repeater = $interval(function() { - ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount, skipHeaders) + ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount, skipHeaders) .then(function success(data) { $scope.logs = data; }) @@ -42,7 +43,7 @@ function ($scope, $transition$, $interval, ContainerService, Notifications, Http } function startLogPolling(skipHeaders) { - ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount, skipHeaders) + ContainerService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount, skipHeaders) .then(function success(data) { $scope.logs = data; setUpdateRepeater(skipHeaders); diff --git a/app/docker/views/containers/logs/containerlogs.html b/app/docker/views/containers/logs/containerlogs.html index 804731ec5..133e354c2 100644 --- a/app/docker/views/containers/logs/containerlogs.html +++ b/app/docker/views/containers/logs/containerlogs.html @@ -6,5 +6,5 @@ diff --git a/app/docker/views/containers/stats/containerStatsController.js b/app/docker/views/containers/stats/containerStatsController.js index ed161c8d1..5f62b09d4 100644 --- a/app/docker/views/containers/stats/containerStatsController.js +++ b/app/docker/views/containers/stats/containerStatsController.js @@ -23,22 +23,21 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char if (stats.Networks.length > 0) { var rx = stats.Networks[0].rx_bytes; var tx = stats.Networks[0].tx_bytes; - var label = moment(stats.Date).format('HH:mm:ss'); + var label = moment(stats.read).format('HH:mm:ss'); ChartService.UpdateNetworkChart(label, rx, tx, chart); } } function updateMemoryChart(stats, chart) { - var label = moment(stats.Date).format('HH:mm:ss'); - var value = stats.MemoryUsage; + var label = moment(stats.read).format('HH:mm:ss'); - ChartService.UpdateMemoryChart(label, value, chart); + ChartService.UpdateMemoryChart(label, stats.MemoryUsage, stats.MemoryCache, chart); } function updateCPUChart(stats, chart) { - var label = moment(stats.Date).format('HH:mm:ss'); - var value = calculateCPUPercentUnix(stats); + var label = moment(stats.read).format('HH:mm:ss'); + var value = stats.isWindows ? calculateCPUPercentWindows(stats) : calculateCPUPercentUnix(stats); ChartService.UpdateCPUChart(label, value, chart); } @@ -55,6 +54,17 @@ function ($q, $scope, $transition$, $document, $interval, ContainerService, Char return cpuPercent; } + function calculateCPUPercentWindows(stats) { + var possIntervals = stats.NumProcs * parseFloat( + moment(stats.read, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf() - moment(stats.preread, 'YYYY-MM-DDTHH:mm:ss.SSSSSSSSSZ').valueOf()); + var windowsCpuUsage = 0.0; + if(possIntervals > 0) { + windowsCpuUsage = parseFloat(stats.CurrentCPUTotalUsage - stats.PreviousCPUTotalUsage) / parseFloat(possIntervals * 100); + } + return windowsCpuUsage; + } + + $scope.changeUpdateRepeater = function() { var networkChart = $scope.networkChart; var cpuChart = $scope.cpuChart; diff --git a/app/docker/views/containers/stats/containerstats.html b/app/docker/views/containers/stats/containerstats.html index 79adc3029..13fe72145 100644 --- a/app/docker/views/containers/stats/containerstats.html +++ b/app/docker/views/containers/stats/containerstats.html @@ -26,6 +26,8 @@
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Version{{ version.Version }}
API version{{ version.ApiVersion }}
Go version{{ version.GoVersion }}
OS type{{ version.Os }}
OS{{ info.OperatingSystem }}
Architecture{{ version.Arch }}
Kernel version{{ version.KernelVersion }}
- - -
- - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Total CPU{{ info.NCPU }}
Total memory{{ info.MemTotal|humansize }}
Docker root directory{{ info.DockerRootDir }}
Storage driver{{ info.Driver }}
Logging driver{{ info.LoggingDriver }}
Cgroup driver{{ info.CgroupDriver }}
Execution driver{{ info.ExecutionDriver }}
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - -
Volume{{ info.Plugins.Volume|arraytostr: ', '}}
Network{{ info.Plugins.Network|arraytostr: ', '}}
Authorization{{ info.Plugins.Authorization|arraytostr: ', '}}
-
-
-
-
diff --git a/app/docker/views/engine/engineController.js b/app/docker/views/engine/engineController.js deleted file mode 100644 index f106273a5..000000000 --- a/app/docker/views/engine/engineController.js +++ /dev/null @@ -1,22 +0,0 @@ -angular.module('portainer.docker') -.controller('EngineController', ['$q', '$scope', 'SystemService', 'Notifications', -function ($q, $scope, SystemService, Notifications) { - - function initView() { - $q.all({ - version: SystemService.version(), - info: SystemService.info() - }) - .then(function success(data) { - $scope.version = data.version; - $scope.info = data.info; - }) - .catch(function error(err) { - $scope.info = {}; - $scope.version = {}; - Notifications.error('Failure', err, 'Unable to retrieve engine details'); - }); - } - - initView(); -}]); diff --git a/app/docker/views/host/host-browser-view/host-browser-view-controller.js b/app/docker/views/host/host-browser-view/host-browser-view-controller.js new file mode 100644 index 000000000..3f45fcc48 --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view-controller.js @@ -0,0 +1,17 @@ +angular.module('portainer.docker').controller('HostBrowserViewController', [ + 'SystemService', 'Notifications', + function HostBrowserViewController(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); + }); + } + } +]); diff --git a/app/docker/views/host/host-browser-view/host-browser-view.html b/app/docker/views/host/host-browser-view/host-browser-view.html new file mode 100644 index 000000000..2d87e4b2e --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view.html @@ -0,0 +1,14 @@ + + + + Host > {{ $ctrl.host.Name }} > browse + + + +
+
+ +
+
diff --git a/app/docker/views/host/host-browser-view/host-browser-view.js b/app/docker/views/host/host-browser-view/host-browser-view.js new file mode 100644 index 000000000..7ce93994a --- /dev/null +++ b/app/docker/views/host/host-browser-view/host-browser-view.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('hostBrowserView', { + templateUrl: 'app/docker/views/host/host-browser-view/host-browser-view.html', + controller: 'HostBrowserViewController' +}); diff --git a/app/docker/views/host/host-job/host-job-controller.js b/app/docker/views/host/host-job/host-job-controller.js new file mode 100644 index 000000000..811509f7b --- /dev/null +++ b/app/docker/views/host/host-job/host-job-controller.js @@ -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); + }); + } + } +]); diff --git a/app/docker/views/host/host-job/host-job.html b/app/docker/views/host/host-job/host-job.html new file mode 100644 index 000000000..adfbd970b --- /dev/null +++ b/app/docker/views/host/host-job/host-job.html @@ -0,0 +1,16 @@ + + + + Host > {{ $ctrl.host.Name }} > execute job + + + +
+
+ + + + + +
+
diff --git a/app/docker/views/host/host-job/host-job.js b/app/docker/views/host/host-job/host-job.js new file mode 100644 index 000000000..c23070c46 --- /dev/null +++ b/app/docker/views/host/host-job/host-job.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('hostJobView', { + templateUrl: 'app/docker/views/host/host-job/host-job.html', + controller: 'HostJobController' +}); diff --git a/app/docker/views/host/host-view-controller.js b/app/docker/views/host/host-view-controller.js new file mode 100644 index 000000000..c68dfed3d --- /dev/null +++ b/app/docker/views/host/host-view-controller.js @@ -0,0 +1,82 @@ +angular.module('portainer.docker').controller('HostViewController', [ + '$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService', 'ContainerService', 'Authentication', 'EndpointProvider', + function HostViewController($q, SystemService, Notifications, StateManager, AgentService, ContainerService, Authentication, EndpointProvider) { + var ctrl = this; + + this.$onInit = initView; + + ctrl.state = { + isAgent: false, + isAdmin : false, + offlineMode: 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; + ctrl.state.enableHostManagementFeatures = applicationState.application.enableHostManagementFeatures; + + $q.all({ + version: SystemService.version(), + 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); + ctrl.state.offlineMode = EndpointProvider.offlineMode(); + ctrl.jobs = data.jobs; + + 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) { + var versionDetails = data.version; + var info = data.info; + return { + releaseVersion: versionDetails.Version, + apiVersion: versionDetails.ApiVersion, + rootDirectory: info.DockerRootDir, + storageDriver: info.Driver, + loggingDriver: info.LoggingDriver, + volumePlugins: info.Plugins.Volume, + networkPlugins: info.Plugins.Network + }; + } + + function buildHostDetails(info) { + return { + os: { + arch: info.Architecture, + type: info.OSType, + name: info.OperatingSystem + }, + name: info.Name, + kernelVersion: info.KernelVersion, + totalCPU: info.NCPU, + totalMemory: info.MemTotal + }; + } + } +]); diff --git a/app/docker/views/host/host-view.html b/app/docker/views/host/host-view.html new file mode 100644 index 000000000..8a62e64bc --- /dev/null +++ b/app/docker/views/host/host-view.html @@ -0,0 +1,15 @@ + diff --git a/app/docker/views/host/host-view.js b/app/docker/views/host/host-view.js new file mode 100644 index 000000000..e321dccb4 --- /dev/null +++ b/app/docker/views/host/host-view.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('hostView', { + templateUrl: 'app/docker/views/host/host-view.html', + controller: 'HostViewController' +}); diff --git a/app/docker/views/images/images.html b/app/docker/views/images/images.html index 3c8df020a..8637e4e25 100644 --- a/app/docker/views/images/images.html +++ b/app/docker/views/images/images.html @@ -7,7 +7,7 @@ Images -
+
@@ -51,7 +51,7 @@
- +
diff --git a/app/docker/views/images/imagesController.js b/app/docker/views/images/imagesController.js index f495f4185..0504ceaea 100644 --- a/app/docker/views/images/imagesController.js +++ b/app/docker/views/images/imagesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob', -function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob) { +.controller('ImagesController', ['$scope', '$state', 'ImageService', 'Notifications', 'ModalService', 'HttpRequestHelper', 'FileSaver', 'Blob', 'EndpointProvider', +function ($scope, $state, ImageService, Notifications, ModalService, HttpRequestHelper, FileSaver, Blob, EndpointProvider) { $scope.state = { actionInProgress: false, exportInProgress: false @@ -113,10 +113,13 @@ function ($scope, $state, ImageService, Notifications, ModalService, HttpRequest }); }; + $scope.offlineMode = false; + function initView() { ImageService.images(true) .then(function success(data) { $scope.images = data; + $scope.offlineMode = EndpointProvider.offlineMode(); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve images'); diff --git a/app/docker/views/networks/create/createNetworkController.js b/app/docker/views/networks/create/createNetworkController.js index 9f5dd6493..a2893f353 100644 --- a/app/docker/views/networks/create/createNetworkController.js +++ b/app/docker/views/networks/create/createNetworkController.js @@ -105,7 +105,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) { @@ -192,9 +196,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) { diff --git a/app/docker/views/networks/networks.html b/app/docker/views/networks/networks.html index ff0f6edd6..c547d54ba 100644 --- a/app/docker/views/networks/networks.html +++ b/app/docker/views/networks/networks.html @@ -6,7 +6,7 @@ Networks - +
diff --git a/app/docker/views/networks/networksController.js b/app/docker/views/networks/networksController.js index c730dc337..77c7647d9 100644 --- a/app/docker/views/networks/networksController.js +++ b/app/docker/views/networks/networksController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', -function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) { +.controller('NetworksController', ['$scope', '$state', 'NetworkService', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', +function ($scope, $state, NetworkService, Notifications, HttpRequestHelper, EndpointProvider) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -24,10 +24,13 @@ function ($scope, $state, NetworkService, Notifications, HttpRequestHelper) { }); }; + $scope.offlineMode = false; + function initView() { NetworkService.networks(true, true, true) .then(function success(data) { $scope.networks = data; + $scope.offlineMode = EndpointProvider.offlineMode(); }) .catch(function error(err) { $scope.networks = []; diff --git a/app/docker/views/nodes/edit/node.html b/app/docker/views/nodes/edit/node.html deleted file mode 100644 index b7d733206..000000000 --- a/app/docker/views/nodes/edit/node.html +++ /dev/null @@ -1,243 +0,0 @@ - - - - - - - - Swarm nodes > {{ node.Hostname }} - - - -
-
-
- Loading... -
- - - - -

It looks like the node you wish to inspect does not exist.

-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Name - -
Host name{{ node.Hostname }}
Role{{ node.Role }}
Availability -
- -
-
Status{{ node.Status }}
-
- -

- View the Docker Swarm mode Node documentation here. -

- -
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - -
Leader - Yes - No -
Reachability{{ node.Reachability }}
Manager address{{ node.ManagerAddr }}
-
-
-
-
- -
-
- - - - - - - - - - - - - - - - - - - - - - -
CPU{{ node.CPUs / 1000000000 }}
Memory{{ node.Memory|humansize: 2 }}
Platform{{ node.PlatformOS }} {{ node.PlatformArchitecture }}
Docker Engine version{{ node.EngineVersion }}
-
-
-
-
- -
-
- - - -

There are no engine labels for this node.

-
- - - - - - - - - - - - - - -
LabelValue
{{ engineLabel.key }}{{ engineLabel.value }}
-
-
-
-
- -
-
- - - - - -

There are no labels for this node.

-
- - - - - - - - - - - - - - -
LabelValue
-
- name - -
-
-
- value - - - - -
-
-
- - - -
-
-
- -
-
- -
-
diff --git a/app/docker/views/nodes/edit/nodeController.js b/app/docker/views/nodes/edit/nodeController.js deleted file mode 100644 index 30f210b2a..000000000 --- a/app/docker/views/nodes/edit/nodeController.js +++ /dev/null @@ -1,96 +0,0 @@ -angular.module('portainer.docker') -.controller('NodeController', ['$scope', '$state', '$transition$', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Notifications', -function ($scope, $state, $transition$, LabelHelper, Node, NodeHelper, Task, Notifications) { - - $scope.loading = true; - $scope.tasks = []; - - var originalNode = {}; - var editedKeys = []; - - $scope.updateNodeAttribute = function updateNodeAttribute(node, key) { - editedKeys.push(key); - }; - $scope.addLabel = function addLabel(node) { - node.Labels.push({ key: '', value: '', originalValue: '', originalKey: '' }); - $scope.updateNodeAttribute(node, 'Labels'); - }; - $scope.removeLabel = function removeLabel(node, index) { - var removedElement = node.Labels.splice(index, 1); - if (removedElement !== null) { - $scope.updateNodeAttribute(node, 'Labels'); - } - }; - $scope.updateLabel = function updateLabel(node, label) { - if (label.value !== label.originalValue || label.key !== label.originalKey) { - $scope.updateNodeAttribute(node, 'Labels'); - } - }; - - $scope.hasChanges = function(node, elements) { - if (!elements) { - elements = Object.keys(originalNode); - } - var hasChanges = false; - elements.forEach(function(key) { - hasChanges = hasChanges || ((editedKeys.indexOf(key) >= 0) && node[key] !== originalNode[key]); - }); - return hasChanges; - }; - - $scope.cancelChanges = function(node) { - editedKeys.forEach(function(key) { - node[key] = originalNode[key]; - }); - editedKeys = []; - }; - - $scope.updateNode = function updateNode(node) { - var config = NodeHelper.nodeToConfig(node.Model); - config.Name = node.Name; - config.Availability = node.Availability; - config.Role = node.Role; - config.Labels = LabelHelper.fromKeyValueToLabelHash(node.Labels); - - Node.update({ id: node.Id, version: node.Version }, config, function () { - Notifications.success('Node successfully updated', 'Node updated'); - $state.go('docker.nodes.node', {id: node.Id}, {reload: true}); - }, function (e) { - Notifications.error('Failure', e, 'Failed to update node'); - }); - }; - - function loadNodeAndTasks() { - $scope.loading = true; - if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') { - Node.get({ id: $transition$.params().id}, function(d) { - if (d.message) { - Notifications.error('Failure', e, 'Unable to inspect the node'); - } else { - var node = new NodeViewModel(d); - originalNode = angular.copy(node); - $scope.node = node; - getTasks(d); - } - $scope.loading = false; - }); - } else { - $scope.loading = false; - } - } - - function getTasks(node) { - if (node) { - Task.query({filters: {node: [node.ID]}}, function (tasks) { - $scope.tasks = tasks.map(function (task) { - return new TaskViewModel(task); - }); - }, function (e) { - Notifications.error('Failure', e, 'Unable to retrieve tasks associated to the node'); - }); - } - } - - loadNodeAndTasks(); - -}]); diff --git a/app/docker/views/nodes/node-browser/node-browser-controller.js b/app/docker/views/nodes/node-browser/node-browser-controller.js new file mode 100644 index 000000000..5c55c3e1b --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser-controller.js @@ -0,0 +1,20 @@ +angular.module('portainer.docker').controller('NodeBrowserController', [ + '$stateParams', 'NodeService', 'HttpRequestHelper', 'Notifications', + function NodeBrowserController($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); + }); + } + } +]); diff --git a/app/docker/views/nodes/node-browser/node-browser.html b/app/docker/views/nodes/node-browser/node-browser.html new file mode 100644 index 000000000..2edeae199 --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser.html @@ -0,0 +1,14 @@ + + + + Swarm > {{ $ctrl.node.Hostname }} > browse + + + +
+
+ +
+
diff --git a/app/docker/views/nodes/node-browser/node-browser.js b/app/docker/views/nodes/node-browser/node-browser.js new file mode 100644 index 000000000..3e7269384 --- /dev/null +++ b/app/docker/views/nodes/node-browser/node-browser.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('nodeBrowserView', { + templateUrl: 'app/docker/views/nodes/node-browser/node-browser.html', + controller: 'NodeBrowserController' +}); diff --git a/app/docker/views/nodes/node-details/node-details-view-controller.js b/app/docker/views/nodes/node-details/node-details-view-controller.js new file mode 100644 index 000000000..7267da7f5 --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view-controller.js @@ -0,0 +1,91 @@ +angular.module('portainer.docker').controller('NodeDetailsViewController', [ + '$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, + isAdmin: false + }; + + function initView() { + var applicationState = StateManager.getState(); + ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy; + ctrl.state.isAdmin = Authentication.getUserDetails().role === 1; + ctrl.state.enableHostManagementFeatures = applicationState.application.enableHostManagementFeatures; + + var fetchJobs = ctrl.state.isAdmin && ctrl.state.isAgent; + + var nodeId = $stateParams.id; + $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) { + 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; + }); + } + }); + } + + function buildHostDetails(node) { + return { + os: { + arch: node.PlatformArchitecture, + type: node.PlatformOS + }, + name: node.Hostname, + totalCPU: node.CPUs / 1e9, + totalMemory: node.Memory + }; + } + + function buildEngineDetails(node) { + return { + releaseVersion: node.EngineVersion, + volumePlugins: transformPlugins(node.Plugins, 'Volume'), + networkPlugins: transformPlugins(node.Plugins, 'Network') + }; + } + + function buildNodeDetails(node) { + return { + name: node.Name, + role: node.Role, + managerAddress: node.ManagerAddr, + availability: node.Availability, + status: node.Status, + engineLabels: node.EngineLabels, + nodeLabels: node.Labels + }; + } + + function transformPlugins(pluginsList, type) { + return pluginsList + .filter(function(plugin) { + return plugin.Type === type; + }) + .map(function(plugin) { + return plugin.Name; + }); + } + } +]); diff --git a/app/docker/views/nodes/node-details/node-details-view.html b/app/docker/views/nodes/node-details/node-details-view.html new file mode 100644 index 000000000..c2dd7c7e2 --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view.html @@ -0,0 +1,19 @@ + + + diff --git a/app/docker/views/nodes/node-details/node-details-view.js b/app/docker/views/nodes/node-details/node-details-view.js new file mode 100644 index 000000000..5b1c76e2b --- /dev/null +++ b/app/docker/views/nodes/node-details/node-details-view.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('nodeDetailsView', { + templateUrl: 'app/docker/views/nodes/node-details/node-details-view.html', + controller: 'NodeDetailsViewController' +}); diff --git a/app/docker/views/nodes/node-job/node-job-controller.js b/app/docker/views/nodes/node-job/node-job-controller.js new file mode 100644 index 000000000..9f1173d09 --- /dev/null +++ b/app/docker/views/nodes/node-job/node-job-controller.js @@ -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); + }); + } + } +]); diff --git a/app/docker/views/nodes/node-job/node-job.html b/app/docker/views/nodes/node-job/node-job.html new file mode 100644 index 000000000..90ae92d14 --- /dev/null +++ b/app/docker/views/nodes/node-job/node-job.html @@ -0,0 +1,18 @@ + + + + Swarm > {{ $ctrl.node.Hostname }} > execute job + + + +
+
+ + + + + +
+
diff --git a/app/docker/views/nodes/node-job/node-job.js b/app/docker/views/nodes/node-job/node-job.js new file mode 100644 index 000000000..0b25f9b2c --- /dev/null +++ b/app/docker/views/nodes/node-job/node-job.js @@ -0,0 +1,4 @@ +angular.module('portainer.docker').component('nodeJobView', { + templateUrl: 'app/docker/views/nodes/node-job/node-job.html', + controller: 'NodeJobController' +}); diff --git a/app/docker/views/services/create/createservice.html b/app/docker/views/services/create/createservice.html index 40d830f1a..2003ff97f 100644 --- a/app/docker/views/services/create/createservice.html +++ b/app/docker/views/services/create/createservice.html @@ -354,7 +354,7 @@
diff --git a/app/docker/views/services/create/includes/config.html b/app/docker/views/services/create/includes/config.html index 8083ae2f2..eac52f780 100644 --- a/app/docker/views/services/create/includes/config.html +++ b/app/docker/views/services/create/includes/config.html @@ -10,7 +10,7 @@
config -
diff --git a/app/docker/views/services/create/includes/secret.html b/app/docker/views/services/create/includes/secret.html index aca1f5264..aa6a42375 100644 --- a/app/docker/views/services/create/includes/secret.html +++ b/app/docker/views/services/create/includes/secret.html @@ -15,7 +15,7 @@
secret -
diff --git a/app/docker/views/services/edit/includes/configs.html b/app/docker/views/services/edit/includes/configs.html index db36d0edb..21e4805ba 100644 --- a/app/docker/views/services/edit/includes/configs.html +++ b/app/docker/views/services/edit/includes/configs.html @@ -5,7 +5,7 @@
Add a config: - diff --git a/app/docker/views/services/edit/includes/secrets.html b/app/docker/views/services/edit/includes/secrets.html index 04444aa6d..b6e680881 100644 --- a/app/docker/views/services/edit/includes/secrets.html +++ b/app/docker/views/services/edit/includes/secrets.html @@ -5,7 +5,7 @@
Add a secret: -
diff --git a/app/docker/views/services/logs/serviceLogsController.js b/app/docker/views/services/logs/serviceLogsController.js index 1e44ebb31..deb5c34bc 100644 --- a/app/docker/views/services/logs/serviceLogsController.js +++ b/app/docker/views/services/logs/serviceLogsController.js @@ -3,7 +3,8 @@ angular.module('portainer.docker') function ($scope, $transition$, $interval, ServiceService, Notifications) { $scope.state = { refreshRate: 3, - lineCount: 2000, + lineCount: 100, + sinceTimestamp: '', displayTimestamps: false }; @@ -30,7 +31,7 @@ function ($scope, $transition$, $interval, ServiceService, Notifications) { function setUpdateRepeater() { var refreshRate = $scope.state.refreshRate; $scope.repeater = $interval(function() { - ServiceService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount) + ServiceService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) .then(function success(data) { $scope.logs = data; }) @@ -42,7 +43,7 @@ function ($scope, $transition$, $interval, ServiceService, Notifications) { } function startLogPolling() { - ServiceService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount) + ServiceService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) .then(function success(data) { $scope.logs = data; setUpdateRepeater(); diff --git a/app/docker/views/services/logs/servicelogs.html b/app/docker/views/services/logs/servicelogs.html index 511732046..2544d2d94 100644 --- a/app/docker/views/services/logs/servicelogs.html +++ b/app/docker/views/services/logs/servicelogs.html @@ -6,5 +6,5 @@ diff --git a/app/docker/views/swarm/visualizer/swarmvisualizer.html b/app/docker/views/swarm/visualizer/swarmvisualizer.html index 4f9e7f358..612354e25 100644 --- a/app/docker/views/swarm/visualizer/swarmvisualizer.html +++ b/app/docker/views/swarm/visualizer/swarmvisualizer.html @@ -84,7 +84,7 @@
- {{ node.Hostname }} + {{ node.Name || node.Hostname }} @@ -97,7 +97,7 @@
{{ node.Status }}
-
+
{{ task.ServiceName }}
Image: {{ task.Spec.ContainerSpec.Image | hideshasum }}
Status: {{ task.Status.State }}
diff --git a/app/docker/views/tasks/logs/taskLogsController.js b/app/docker/views/tasks/logs/taskLogsController.js index c25e1b1c2..19768de1d 100644 --- a/app/docker/views/tasks/logs/taskLogsController.js +++ b/app/docker/views/tasks/logs/taskLogsController.js @@ -3,7 +3,8 @@ angular.module('portainer.docker') function ($scope, $transition$, $interval, TaskService, ServiceService, Notifications) { $scope.state = { refreshRate: 3, - lineCount: 2000, + lineCount: 100, + sinceTimestamp: '', displayTimestamps: false }; @@ -30,7 +31,7 @@ function ($scope, $transition$, $interval, TaskService, ServiceService, Notifica function setUpdateRepeater() { var refreshRate = $scope.state.refreshRate; $scope.repeater = $interval(function() { - TaskService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount) + TaskService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) .then(function success(data) { $scope.logs = data; }) @@ -42,7 +43,7 @@ function ($scope, $transition$, $interval, TaskService, ServiceService, Notifica } function startLogPolling() { - TaskService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, $scope.state.lineCount) + TaskService.logs($transition$.params().id, 1, 1, $scope.state.displayTimestamps ? 1 : 0, moment($scope.state.sinceTimestamp).unix(), $scope.state.lineCount) .then(function success(data) { $scope.logs = data; setUpdateRepeater(); diff --git a/app/docker/views/tasks/logs/tasklogs.html b/app/docker/views/tasks/logs/tasklogs.html index abfaa6d45..79c8f3028 100644 --- a/app/docker/views/tasks/logs/tasklogs.html +++ b/app/docker/views/tasks/logs/tasklogs.html @@ -6,5 +6,5 @@ diff --git a/app/docker/views/volumes/browse/browseVolumeController.js b/app/docker/views/volumes/browse/browseVolumeController.js index c8c503bba..05eeb878a 100644 --- a/app/docker/views/volumes/browse/browseVolumeController.js +++ b/app/docker/views/volumes/browse/browseVolumeController.js @@ -1,10 +1,11 @@ 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(); diff --git a/app/docker/views/volumes/browse/browsevolume.html b/app/docker/views/volumes/browse/browsevolume.html index b77beb87a..e420b16e1 100644 --- a/app/docker/views/volumes/browse/browsevolume.html +++ b/app/docker/views/volumes/browse/browsevolume.html @@ -10,6 +10,8 @@
diff --git a/app/docker/views/volumes/volumes.html b/app/docker/views/volumes/volumes.html index 072394f45..958246e49 100644 --- a/app/docker/views/volumes/volumes.html +++ b/app/docker/views/volumes/volumes.html @@ -6,7 +6,7 @@ Volumes - +
diff --git a/app/docker/views/volumes/volumesController.js b/app/docker/views/volumes/volumesController.js index 362c54b4b..3ab620266 100644 --- a/app/docker/views/volumes/volumesController.js +++ b/app/docker/views/volumes/volumesController.js @@ -1,6 +1,6 @@ angular.module('portainer.docker') -.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', -function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper) { +.controller('VolumesController', ['$q', '$scope', '$state', 'VolumeService', 'ServiceService', 'VolumeHelper', 'Notifications', 'HttpRequestHelper', 'EndpointProvider', +function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notifications, HttpRequestHelper, EndpointProvider) { $scope.removeAction = function (selectedItems) { var actionCount = selectedItems.length; @@ -24,6 +24,8 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif }); }; + $scope.offlineMode = false; + function initView() { var endpointProvider = $scope.applicationState.endpoint.mode.provider; var endpointRole = $scope.applicationState.endpoint.mode.role; @@ -35,6 +37,7 @@ function ($q, $scope, $state, VolumeService, ServiceService, VolumeHelper, Notif }) .then(function success(data) { var services = data.services; + $scope.offlineMode = EndpointProvider.offlineMode(); $scope.volumes = data.attached.map(function(volume) { volume.dangling = false; return volume; diff --git a/app/extensions/_module.js b/app/extensions/_module.js new file mode 100644 index 000000000..5a936d2cf --- /dev/null +++ b/app/extensions/_module.js @@ -0,0 +1,3 @@ +angular.module('portainer.extensions', [ + 'portainer.extensions.registrymanagement' +]); diff --git a/app/extensions/registry-management/_module.js b/app/extensions/registry-management/_module.js new file mode 100644 index 000000000..44599be3b --- /dev/null +++ b/app/extensions/registry-management/_module.js @@ -0,0 +1,41 @@ +angular.module('portainer.extensions.registrymanagement', []) +.config(['$stateRegistryProvider', function ($stateRegistryProvider) { + 'use strict'; + + var registryConfiguration = { + name: 'portainer.registries.registry.configure', + url: '/configure', + views: { + 'content@': { + templateUrl: 'app/extensions/registry-management/views/configure/configureregistry.html', + controller: 'ConfigureRegistryController' + } + } + }; + + var registryRepositories = { + name: 'portainer.registries.registry.repositories', + url: '/repositories', + views: { + 'content@': { + templateUrl: 'app/extensions/registry-management/views/repositories/registryRepositories.html', + controller: 'RegistryRepositoriesController' + } + } + }; + + var registryRepositoryTags = { + name: 'portainer.registries.registry.repository', + url: '/:repository', + views: { + 'content@': { + templateUrl: 'app/extensions/registry-management/views/repositories/edit/registryRepository.html', + controller: 'RegistryRepositoryController' + } + } + }; + + $stateRegistryProvider.register(registryConfiguration); + $stateRegistryProvider.register(registryRepositories); + $stateRegistryProvider.register(registryRepositoryTags); +}]); diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html new file mode 100644 index 000000000..c416605a3 --- /dev/null +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -0,0 +1,83 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + Repository + + + + + + Tags count + + + +
+ + + + + {{ item.Name }} + {{ item.TagsCount }}
Loading...
No repository available.
+
+ +
+
+
\ No newline at end of file diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js new file mode 100644 index 000000000..a8cd8a27a --- /dev/null +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.extensions.registrymanagement').component('registryRepositoriesDatatable', { + templateUrl: 'app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<' + } +}); diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html new file mode 100644 index 000000000..20d127d38 --- /dev/null +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html @@ -0,0 +1,112 @@ +
+ + +
+
+ {{ + $ctrl.titleText }} +
+
+
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + Os/Architecture + + Image ID + + + + + + Size + + + + Actions
+ + + + + {{ item.Name }} + {{ item.Os }}/{{ item.Architecture }}{{ item.ImageId | truncate:40 }}{{ item.Size | humansize }} + + + Retag + + + + + + + +
Loading...
No tag available.
+
+ +
+
+
\ No newline at end of file diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js new file mode 100644 index 000000000..5c18c6a1c --- /dev/null +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.js @@ -0,0 +1,14 @@ +angular.module('portainer.extensions.registrymanagement').component('registriesRepositoryTagsDatatable', { + templateUrl: 'app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<', + retagAction: '<' + } +}); diff --git a/app/extensions/registry-management/helpers/localRegistryHelper.js b/app/extensions/registry-management/helpers/localRegistryHelper.js new file mode 100644 index 000000000..b4a0bd22a --- /dev/null +++ b/app/extensions/registry-management/helpers/localRegistryHelper.js @@ -0,0 +1,38 @@ +angular.module('portainer.extensions.registrymanagement') + .factory('RegistryV2Helper', [function RegistryV2HelperFactory() { + 'use strict'; + + var helper = {}; + + function historyRawToParsed(rawHistory) { + var history = []; + for (var i = 0; i < rawHistory.length; i++) { + var item = rawHistory[i]; + history.push(angular.fromJson(item.v1Compatibility)); + } + return history; + } + + helper.manifestsToTag = function (manifests) { + var v1 = manifests.v1; + var v2 = manifests.v2; + + var history = historyRawToParsed(v1.history); + var imageId = history[0].id; + var name = v1.tag; + var os = history[0].os; + var arch = v1.architecture; + var size = v2.layers.reduce(function (a, b) { + return { + size: a.size + b.size + }; + }).size; + var digest = v2.digest; + var repositoryName = v1.name; + var fsLayers = v1.fsLayers; + + return new RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, v2); + }; + + return helper; + }]); diff --git a/app/extensions/registry-management/models/registryRepository.js b/app/extensions/registry-management/models/registryRepository.js new file mode 100644 index 000000000..d3f7f02a7 --- /dev/null +++ b/app/extensions/registry-management/models/registryRepository.js @@ -0,0 +1,4 @@ +function RegistryRepositoryViewModel(data) { + this.Name = data.name; + this.TagsCount = data.tags.length; +} \ No newline at end of file diff --git a/app/extensions/registry-management/models/repositoryTag.js b/app/extensions/registry-management/models/repositoryTag.js new file mode 100644 index 000000000..1139bbf13 --- /dev/null +++ b/app/extensions/registry-management/models/repositoryTag.js @@ -0,0 +1,12 @@ +function RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, manifestv2) { + this.Name = name; + this.ImageId = imageId; + this.Os = os; + this.Architecture = arch; + this.Size = size; + this.Digest = digest; + this.RepositoryName = repositoryName; + this.FsLayers = fsLayers; + this.History = history; + this.ManifestV2 = manifestv2; +} \ No newline at end of file diff --git a/app/extensions/registry-management/rest/catalog.js b/app/extensions/registry-management/rest/catalog.js new file mode 100644 index 000000000..1d6f24be5 --- /dev/null +++ b/app/extensions/registry-management/rest/catalog.js @@ -0,0 +1,23 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryCatalog', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryCatalogFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:action', {}, + { + get: { + method: 'GET', + params: { id: '@id', action: '_catalog' } + }, + ping: { + method: 'GET', + params: { id: '@id' }, timeout: 3500 + }, + pingWithForceNew: { + method: 'GET', + params: { id: '@id' }, timeout: 3500, + headers: { 'X-RegistryManagement-ForceNew': '1' } + } + }, + { + stripTrailingSlashes: false + }); +}]); diff --git a/app/extensions/registry-management/rest/manifest.js b/app/extensions/registry-management/rest/manifest.js new file mode 100644 index 000000000..475a524e6 --- /dev/null +++ b/app/extensions/registry-management/rest/manifest.js @@ -0,0 +1,61 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryManifests', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryManifestsFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/manifests/:tag', {}, { + get: { + method: 'GET', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + }, + headers: { + 'Cache-Control': 'no-cache' + }, + transformResponse: function (data, headers) { + var response = angular.fromJson(data); + response.digest = headers('docker-content-digest'); + return response; + } + }, + getV2: { + method: 'GET', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + }, + headers: { + 'Accept': 'application/vnd.docker.distribution.manifest.v2+json', + 'Cache-Control': 'no-cache' + }, + transformResponse: function (data, headers) { + var response = angular.fromJson(data); + response.digest = headers('docker-content-digest'); + return response; + } + }, + put: { + method: 'PUT', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + }, + headers: { + 'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json' + }, + transformRequest: function (data) { + return angular.toJson(data, 3); + } + }, + delete: { + method: 'DELETE', + params: { + id: '@id', + repository: '@repository', + tag: '@tag' + } + } + }); +}]); diff --git a/app/extensions/registry-management/rest/tags.js b/app/extensions/registry-management/rest/tags.js new file mode 100644 index 000000000..c880305f8 --- /dev/null +++ b/app/extensions/registry-management/rest/tags.js @@ -0,0 +1,10 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryTags', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryTagsFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/tags/list', {}, { + get: { + method: 'GET', + params: { id: '@id', repository: '@repository' } + } + }); +}]); diff --git a/app/extensions/registry-management/services/registryAPIService.js b/app/extensions/registry-management/services/registryAPIService.js new file mode 100644 index 000000000..b766ca568 --- /dev/null +++ b/app/extensions/registry-management/services/registryAPIService.js @@ -0,0 +1,118 @@ +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryV2Service', ['$q', 'RegistryCatalog', 'RegistryTags', 'RegistryManifests', 'RegistryV2Helper', +function RegistryV2ServiceFactory($q, RegistryCatalog, RegistryTags, RegistryManifests, RegistryV2Helper) { + 'use strict'; + var service = {}; + + service.ping = function(id, forceNewConfig) { + if (forceNewConfig) { + return RegistryCatalog.pingWithForceNew({ id: id }).$promise; + } + return RegistryCatalog.ping({ id: id }).$promise; + }; + + service.repositories = function (id) { + var deferred = $q.defer(); + + RegistryCatalog.get({ + id: id + }).$promise + .then(function success(data) { + var promises = []; + for (var i = 0; i < data.repositories.length; i++) { + var repository = data.repositories[i]; + promises.push(RegistryTags.get({ + id: id, + repository: repository + }).$promise); + } + return $q.all(promises); + }) + .then(function success(data) { + var repositories = data.map(function (item) { + if (!item.tags) { + return; + } + return new RegistryRepositoryViewModel(item); + }); + repositories = _.without(repositories, undefined); + deferred.resolve(repositories); + }) + .catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve repositories', + err: err + }); + }); + + return deferred.promise; + }; + + service.tags = function (id, repository) { + var deferred = $q.defer(); + + RegistryTags.get({ + id: id, + repository: repository + }).$promise + .then(function succes(data) { + deferred.resolve(data.tags); + }).catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve tags', + err: err + }); + }); + + return deferred.promise; + }; + + service.tag = function (id, repository, tag) { + var deferred = $q.defer(); + + var promises = { + v1: RegistryManifests.get({ + id: id, + repository: repository, + tag: tag + }).$promise, + v2: RegistryManifests.getV2({ + id: id, + repository: repository, + tag: tag + }).$promise + }; + $q.all(promises) + .then(function success(data) { + var tag = RegistryV2Helper.manifestsToTag(data); + deferred.resolve(tag); + }).catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve tag ' + tag, + err: err + }); + }); + + return deferred.promise; + }; + + service.addTag = function (id, repository, tag, manifest) { + delete manifest.digest; + return RegistryManifests.put({ + id: id, + repository: repository, + tag: tag + }, manifest).$promise; + }; + + service.deleteManifest = function (id, repository, digest) { + return RegistryManifests.delete({ + id: id, + repository: repository, + tag: digest + }).$promise; + }; + + return service; +} +]); diff --git a/app/extensions/registry-management/views/configure/configureRegistryController.js b/app/extensions/registry-management/views/configure/configureRegistryController.js new file mode 100644 index 000000000..bbed8be30 --- /dev/null +++ b/app/extensions/registry-management/views/configure/configureRegistryController.js @@ -0,0 +1,66 @@ +angular.module('portainer.extensions.registrymanagement') +.controller('ConfigureRegistryController', ['$scope', '$state', '$transition$', 'RegistryService', 'RegistryV2Service', 'Notifications', +function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Notifications) { + + $scope.state = { + testInProgress: false, + updateInProgress: false, + validConfiguration : false + }; + + $scope.testConfiguration = testConfiguration; + $scope.updateConfiguration = updateConfiguration; + + function testConfiguration() { + $scope.state.testInProgress = true; + + RegistryService.configureRegistry($scope.registry.Id, $scope.model) + .then(function success() { + return RegistryV2Service.ping($scope.registry.Id, true); + }) + .then(function success() { + Notifications.success('Success', 'Valid management configuration'); + $scope.state.validConfiguration = true; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Invalid management configuration'); + }) + .finally(function final() { + $scope.state.testInProgress = false; + }); + } + + function updateConfiguration() { + $scope.state.updateInProgress = true; + + RegistryService.configureRegistry($scope.registry.Id, $scope.model) + .then(function success() { + Notifications.success('Success', 'Registry management configuration updated'); + $state.go('portainer.registries.registry.repositories', { id: $scope.registry.Id }, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update registry management configuration'); + }) + .finally(function final() { + $scope.state.updateInProgress = false; + }); + } + + function initView() { + var registryId = $transition$.params().id; + + RegistryService.registry(registryId) + .then(function success(data) { + var registry = data; + var model = new RegistryManagementConfigurationDefaultModel(registry); + + $scope.registry = registry; + $scope.model = model; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve registry details'); + }); + } + + initView(); +}]); diff --git a/app/extensions/registry-management/views/configure/configureregistry.html b/app/extensions/registry-management/views/configure/configureregistry.html new file mode 100644 index 000000000..3325230b3 --- /dev/null +++ b/app/extensions/registry-management/views/configure/configureregistry.html @@ -0,0 +1,161 @@ + + + +
Registries > {{ registry.Name }} > Management configuration + + + +
+
+ + +
+
+ Information +
+
+ + The following configuration will be used to access this registry API to provide Portainer management features. + +
+
+ Registry details +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ Required TLS files +
+ +
+ +
+ +
+ + + {{ model.TLSCACertFile.name }} + + + +
+
+ + +
+ +
+ + + {{ model.TLSCertFile.name }} + + + +
+
+ + +
+ +
+ + + {{ model.TLSKeyFile.name }} + + + +
+
+ +
+
+ +
+ Actions +
+
+
+ + +
+
+
+
+
+
+
diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepository.html b/app/extensions/registry-management/views/repositories/edit/registryRepository.html new file mode 100644 index 000000000..bb94f3247 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/edit/registryRepository.html @@ -0,0 +1,88 @@ + + + + + + + + Registries > + {{ registry.Name }} > + {{ repository.Name }} + + + +
+
+ + + + + + + + + + + + + + + + + + + +
Repository + {{ repository.Name }} + +
Tags count{{ repository.Tags.length }}
Images count{{ repository.Images.length }}
+
+
+
+ +
+ + + + +
+
+ +
+ +
+
+
+ + + + {{ $select.selected }} + + + {{ image }} + + +
+
+
+ +
+
+
+
+
+
+
+ +
+
+ +
+
\ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js new file mode 100644 index 000000000..de4521cb1 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js @@ -0,0 +1,150 @@ +angular.module('portainer.app') + .controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', + function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) { + + $scope.state = { + actionInProgress: false + }; + $scope.formValues = { + Tag: '' + }; + $scope.tags = []; + $scope.repository = { + Name: [], + Tags: [], + Images: [] + }; + + $scope.$watch('tags.length', function () { + var images = $scope.tags.map(function (item) { + return item.ImageId; + }); + $scope.repository.Images = _.uniq(images); + }); + + $scope.addTag = function () { + var manifest = $scope.tags.find(function (item) { + return item.ImageId === $scope.formValues.SelectedImage; + }).ManifestV2; + RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest) + .then(function success() { + Notifications.success('Success', 'Tag successfully added'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to add tag'); + }); + }; + + $scope.retagAction = function (tag) { + RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest) + .then(function success() { + var promises = []; + var tagsToAdd = $scope.tags.filter(function (item) { + return item.Digest === tag.Digest; + }); + tagsToAdd.map(function (item) { + var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name; + promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2)); + }); + return $q.all(promises); + }) + .then(function success() { + Notifications.success('Success', 'Tag successfully modified'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to modify tag'); + tag.Modified = false; + tag.NewValue = tag.Value; + }); + }; + + $scope.removeTags = function (selectedItems) { + ModalService.confirmDeletion( + 'Are you sure you want to remove the selected tags ?', + function onConfirm(confirmed) { + if (!confirmed) { + return; + } + var promises = []; + var uniqItems = _.uniqBy(selectedItems, 'Digest'); + uniqItems.map(function (item) { + promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); + }); + $q.all(promises) + .then(function success() { + var promises = []; + var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name'); + tagsToReupload.map(function (item) { + promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2)); + }); + return $q.all(promises); + }) + .then(function success() { + Notifications.success('Success', 'Tags successfully deleted'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete tags'); + }); + }); + }; + + $scope.removeRepository = function () { + ModalService.confirmDeletion( + 'This action will only remove the manifests linked to this repository. You need to manually trigger a garbage collector pass on your registry to remove orphan layers and really remove the images content. THIS ACTION CAN NOT BE UNDONE', + function onConfirm(confirmed) { + if (!confirmed) { + return; + } + var promises = []; + var uniqItems = _.uniqBy($scope.tags, 'Digest'); + uniqItems.map(function (item) { + promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); + }); + $q.all(promises) + .then(function success() { + Notifications.success('Success', 'Repository sucessfully removed'); + $state.go('portainer.registries.registry.repositories', { + id: $scope.registryId + }, { + reload: true + }); + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to delete repository'); + }); + } + ); + }; + + function initView() { + var registryId = $scope.registryId = $transition$.params().id; + var repository = $scope.repository.Name = $transition$.params().repository; + $q.all({ + registry: RegistryService.registry(registryId), + tags: RegistryV2Service.tags(registryId, repository) + }) + .then(function success(data) { + $scope.registry = data.registry; + $scope.repository.Tags = data.tags; + $scope.tags = []; + for (var i = 0; i < data.tags.length; i++) { + var tag = data.tags[i]; + RegistryV2Service.tag(registryId, repository, tag) + .then(function success(data) { + $scope.tags.push(data); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tag information'); + }); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve repository information'); + }); + } + + initView(); + } + ]); diff --git a/app/extensions/registry-management/views/repositories/registryRepositories.html b/app/extensions/registry-management/views/repositories/registryRepositories.html new file mode 100644 index 000000000..6ca3664c7 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/registryRepositories.html @@ -0,0 +1,37 @@ + + + + + + + + Registries > {{ registry.Name }} > Repositories + + + +
+ + +

+ + Portainer was not able to use this registry management features. You might need to update the configuration used by Portainer to access this registry. +

+

Note: Portainer registry management features are only supported with registries exposing the v2 registry API.

+ +
+
+
+ +
+
+ + +
+
diff --git a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js new file mode 100644 index 000000000..ccf4fb3de --- /dev/null +++ b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js @@ -0,0 +1,33 @@ +angular.module('portainer.extensions.registrymanagement') +.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', +function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications) { + + $scope.state = { + displayInvalidConfigurationMessage: false + }; + + function initView() { + var registryId = $transition$.params().id; + + RegistryService.registry(registryId) + .then(function success(data) { + $scope.registry = data; + + RegistryV2Service.ping(registryId, false) + .then(function success() { + return RegistryV2Service.repositories(registryId); + }) + .then(function success(data) { + $scope.repositories = data; + }) + .catch(function error() { + $scope.state.displayInvalidConfigurationMessage = true; + }); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve registry details'); + }); + } + + initView(); +}]); diff --git a/app/extensions/storidge/__module.js b/app/extensions/storidge/__module.js index 34a1b007e..9fe339859 100644 --- a/app/extensions/storidge/__module.js +++ b/app/extensions/storidge/__module.js @@ -1,3 +1,4 @@ +// TODO: legacy extension management angular.module('extension.storidge', []) .config(['$stateRegistryProvider', function ($stateRegistryProvider) { 'use strict'; diff --git a/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html b/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html index e69cf040c..2094f938b 100644 --- a/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html +++ b/app/extensions/storidge/components/cluster-events-datatable/storidgeClusterEventsDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html index 95e8433ba..2a0e35f97 100644 --- a/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html +++ b/app/extensions/storidge/components/nodes-datatable/storidgeNodesDatatable.html @@ -8,7 +8,7 @@
diff --git a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html index f41789d26..594e1d079 100644 --- a/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html +++ b/app/extensions/storidge/components/profiles-datatable/storidgeProfilesDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 828c7ef41..b79553a52 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -198,6 +198,28 @@ angular.module('portainer.app', []) } }; + var extensions = { + name: 'portainer.extensions', + url: '/extensions', + views: { + 'content@': { + templateUrl: 'app/portainer/views/extensions/extensions.html', + controller: 'ExtensionsController' + } + } + }; + + var extension = { + name: 'portainer.extensions.extension', + url: '/extension/:id', + views: { + 'content@': { + templateUrl: 'app/portainer/views/extensions/inspect/extension.html', + controller: 'ExtensionController' + } + } + }; + var registries = { name: 'portainer.registries', url: '/registries', @@ -242,6 +264,39 @@ angular.module('portainer.app', []) } }; + var schedules = { + name: 'portainer.schedules', + url: '/schedules', + views: { + 'content@': { + templateUrl: 'app/portainer/views/schedules/schedules.html', + controller: 'SchedulesController' + } + } + }; + + var schedule = { + name: 'portainer.schedules.schedule', + url: '/:id', + views: { + 'content@': { + templateUrl: 'app/portainer/views/schedules/edit/schedule.html', + controller: 'ScheduleController' + } + } + }; + + var scheduleCreation = { + name: 'portainer.schedules.new', + url: '/new', + views: { + 'content@': { + templateUrl: 'app/portainer/views/schedules/create/createschedule.html', + controller: 'CreateScheduleController' + } + } + }; + var settings = { name: 'portainer.settings', url: '/settings', @@ -287,8 +342,8 @@ angular.module('portainer.app', []) }; var stackCreation = { - name: 'portainer.stacks.new', - url: '/new', + name: 'portainer.newstack', + url: '/newstack', views: { 'content@': { templateUrl: 'app/portainer/views/stacks/create/createstack.html', @@ -302,7 +357,22 @@ angular.module('portainer.app', []) url: '/support', views: { 'content@': { - templateUrl: 'app/portainer/views/support/support.html' + templateUrl: 'app/portainer/views/support/support.html', + controller: 'SupportController' + } + }, + params: { + product: {} + } + }; + + var supportProduct = { + name: 'portainer.support.product', + url: '/product', + views: { + 'content@': { + templateUrl: 'app/portainer/views/support/product/product.html', + controller: 'SupportProductController' } } }; @@ -424,16 +494,22 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(init); $stateRegistryProvider.register(initEndpoint); $stateRegistryProvider.register(initAdmin); + $stateRegistryProvider.register(extensions); + $stateRegistryProvider.register(extension); $stateRegistryProvider.register(registries); $stateRegistryProvider.register(registry); $stateRegistryProvider.register(registryAccess); $stateRegistryProvider.register(registryCreation); + $stateRegistryProvider.register(schedules); + $stateRegistryProvider.register(schedule); + $stateRegistryProvider.register(scheduleCreation); $stateRegistryProvider.register(settings); $stateRegistryProvider.register(settingsAuthentication); $stateRegistryProvider.register(stacks); $stateRegistryProvider.register(stack); $stateRegistryProvider.register(stackCreation); $stateRegistryProvider.register(support); + $stateRegistryProvider.register(supportProduct); $stateRegistryProvider.register(tags); $stateRegistryProvider.register(updatePassword); $stateRegistryProvider.register(users); diff --git a/app/portainer/components/access-table/accessTable.html b/app/portainer/components/access-table/accessTable.html index bf2f366ce..a69147435 100644 --- a/app/portainer/components/access-table/accessTable.html +++ b/app/portainer/components/access-table/accessTable.html @@ -2,7 +2,7 @@
- +
diff --git a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html index ce6122f08..d83793598 100644 --- a/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html +++ b/app/portainer/components/datatables/endpoints-datatable/endpointsDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 7fa0143aa..4f7ef4994 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -11,6 +11,11 @@ function (PaginationService, DatatableService) { selectedItems: [] }; + + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + this.changeOrderBy = function(orderField) { this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; this.state.orderBy = orderField; @@ -49,6 +54,12 @@ function (PaginationService, DatatableService) { this.state.reverseOrder = storedOrder.reverse; this.state.orderBy = storedOrder.orderBy; } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + this.onTextFilterChange(); + } }; function setDefaults(ctrl) { diff --git a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html index 70d55de3d..003469270 100644 --- a/app/portainer/components/datatables/groups-datatable/groupsDatatable.html +++ b/app/portainer/components/datatables/groups-datatable/groupsDatatable.html @@ -17,7 +17,7 @@
diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html index 3dc8f221d..9bc262c40 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.html +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.html @@ -17,7 +17,7 @@
@@ -61,6 +61,12 @@ Manage access + + Browse + + + Browse (extension) + diff --git a/app/portainer/components/datatables/registries-datatable/registriesDatatable.js b/app/portainer/components/datatables/registries-datatable/registriesDatatable.js index a24a717c8..be93a88bd 100644 --- a/app/portainer/components/datatables/registries-datatable/registriesDatatable.js +++ b/app/portainer/components/datatables/registries-datatable/registriesDatatable.js @@ -9,6 +9,7 @@ angular.module('portainer.app').component('registriesDatatable', { orderBy: '@', reverseOrder: '<', accessManagement: '<', - removeAction: '<' + removeAction: '<', + registryManagement: '<' } }); diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html new file mode 100644 index 000000000..818b978a5 --- /dev/null +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html @@ -0,0 +1,97 @@ +
+
+
+ + +
+
+ + {{ $ctrl.titleText }} +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + Endpoint + + + + Id + + + + + + State + + + + + + + + Created + +
+ {{ item.Endpoint.Name }} + + {{ item.Id | truncate: 32 }} + + {{ item.Status }} + + {{ item.Created | getisodatefromtimestamp}} +
Loading...
No tasks available.
+
+ + + +
+
+
diff --git a/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js new file mode 100644 index 000000000..cdd7344c6 --- /dev/null +++ b/app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.docker').component('scheduleTasksDatatable', { + templateUrl: 'app/portainer/components/datatables/schedule-tasks-datatable/scheduleTasksDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + goToContainerLogs: '<' + } +}); diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html new file mode 100644 index 000000000..adccfff95 --- /dev/null +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html @@ -0,0 +1,101 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + Name + + + + + + Cron expression + + + + + + Created + + + +
+ + + + + {{ item.Name }} + {{ item.Name }} + + {{ item.CronExpression }} + {{ item.Created | getisodatefromtimestamp }}
Loading...
No schedule available.
+
+ +
+
+
diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js new file mode 100644 index 000000000..13a9bd34f --- /dev/null +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.app').component('schedulesDatatable', { + templateUrl: 'app/portainer/components/datatables/schedules-datatable/schedulesDatatable.html', + controller: 'SchedulesDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + removeAction: '<' + } +}); diff --git a/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js new file mode 100644 index 000000000..bac777070 --- /dev/null +++ b/app/portainer/components/datatables/schedules-datatable/schedulesDatatableController.js @@ -0,0 +1,58 @@ +angular.module('portainer.app') +.controller('SchedulesDatatableController', ['PaginationService', 'DatatableService', +function (PaginationService, DatatableService) { + + this.state = { + selectAll: false, + orderBy: this.orderBy, + paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), + displayTextFilter: false, + selectedItemCount: 0, + selectedItems: [] + }; + + 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.selectItem = function(item) { + if (item.Checked) { + this.state.selectedItemCount++; + this.state.selectedItems.push(item); + } else { + this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1); + this.state.selectedItemCount--; + } + }; + + this.selectAll = function() { + for (var i = 0; i < this.state.filteredDataSet.length; i++) { + var item = this.state.filteredDataSet[i]; + if (item.JobType ===1 && item.Checked !== this.state.selectAll) { + item.Checked = this.state.selectAll; + this.selectItem(item); + } + } + }; + + this.changePaginationLimit = function() { + PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit); + }; + + this.$onInit = function() { + setDefaults(this); + + var storedOrder = DatatableService.getDataTableOrder(this.tableKey); + if (storedOrder !== null) { + this.state.reverseOrder = storedOrder.reverse; + this.state.orderBy = storedOrder.orderBy; + } + }; + + function setDefaults(ctrl) { + ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; + ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; + } +}]); diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html index 6c8dbbb79..dc18ed8e1 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.html @@ -6,25 +6,25 @@ {{ $ctrl.titleText }}
-
+
-
- + @@ -54,11 +54,12 @@
- + - {{ item.Name }} + {{ item.Name }} + {{ item.Name }} {{ item.Type === 1 ? 'Swarm' : 'Compose' }} diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js index 5526d9112..422d1be82 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatable.js @@ -9,6 +9,7 @@ angular.module('portainer.app').component('stacksDatatable', { orderBy: '@', reverseOrder: '<', showOwnershipColumn: '<', - removeAction: '<' + removeAction: '<', + offlineMode: '<' } }); diff --git a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js index 1b223e286..a1d735964 100644 --- a/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js +++ b/app/portainer/components/datatables/stacks-datatable/stacksDatatableController.js @@ -11,6 +11,10 @@ function (PaginationService, DatatableService) { selectedItems: [] }; + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + }; + this.changeOrderBy = function(orderField) { this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false; this.state.orderBy = orderField; @@ -49,6 +53,11 @@ function (PaginationService, DatatableService) { this.state.reverseOrder = storedOrder.reverse; this.state.orderBy = storedOrder.orderBy; } + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } }; function setDefaults(ctrl) { diff --git a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html index c96b0fa17..e4eade921 100644 --- a/app/portainer/components/datatables/tags-datatable/tagsDatatable.html +++ b/app/portainer/components/datatables/tags-datatable/tagsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html index 9141226ef..0d056e512 100644 --- a/app/portainer/components/datatables/teams-datatable/teamsDatatable.html +++ b/app/portainer/components/datatables/teams-datatable/teamsDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/datatables/users-datatable/usersDatatable.html b/app/portainer/components/datatables/users-datatable/usersDatatable.html index df2051f07..11692974d 100644 --- a/app/portainer/components/datatables/users-datatable/usersDatatable.html +++ b/app/portainer/components/datatables/users-datatable/usersDatatable.html @@ -14,7 +14,7 @@
diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js b/app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js new file mode 100644 index 000000000..62433ee25 --- /dev/null +++ b/app/portainer/components/endpoint-list/endpoint-item/endpoint-item-controller.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').controller('EndpointItemController', [ + function EndpointItemController() { + var ctrl = this; + + ctrl.editEndpoint = editEndpoint; + + function editEndpoint(event) { + event.stopPropagation(); + ctrl.onEdit(ctrl.model.Id); + } + } +]); diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html index 322ab9a90..a2f96a392 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.html @@ -21,8 +21,16 @@ - - Group: {{ $ctrl.model.GroupName }} + + + + Group: {{ $ctrl.model.GroupName }} + + @@ -30,13 +38,13 @@ - {{ $ctrl.model.Snapshots[0].StackCount }} stacks + {{ $ctrl.model.Snapshots[0].StackCount }} {{ $ctrl.model.Snapshots[0].StackCount === 1 ? 'stack' : 'stacks' }} - {{ $ctrl.model.Snapshots[0].ServiceCount }} services + {{ $ctrl.model.Snapshots[0].ServiceCount }} {{ $ctrl.model.Snapshots[0].ServiceCount === 1 ? 'service' : 'services' }} - {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} containers + {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount }} {{ $ctrl.model.Snapshots[0].RunningContainerCount + $ctrl.model.Snapshots[0].StoppedContainerCount === 1 ? 'container' : 'containers' }} - {{ $ctrl.model.Snapshots[0].RunningContainerCount }} @@ -44,10 +52,10 @@ - {{ $ctrl.model.Snapshots[0].VolumeCount }} volumes + {{ $ctrl.model.Snapshots[0].VolumeCount }} {{ $ctrl.model.Snapshots[0].VolumeCount === 1 ? 'volume' : 'volumes' }} - {{ $ctrl.model.Snapshots[0].ImageCount }} images + {{ $ctrl.model.Snapshots[0].ImageCount }} {{ $ctrl.model.Snapshots[0].ImageCount === 1 ? 'image' : 'images' }} @@ -74,6 +82,9 @@ + + {{ $ctrl.model.URL | stripprotocol }} + diff --git a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js index d04fb25cf..450fa089d 100644 --- a/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js +++ b/app/portainer/components/endpoint-list/endpoint-item/endpointItem.js @@ -2,6 +2,9 @@ angular.module('portainer.app').component('endpointItem', { templateUrl: 'app/portainer/components/endpoint-list/endpoint-item/endpointItem.html', bindings: { model: '<', - onSelect: '<' - } + onSelect: '<', + onEdit: '<', + isAdmin:'<' + }, + controller: 'EndpointItemController' }); diff --git a/app/portainer/components/endpoint-list/endpoint-list-controller.js b/app/portainer/components/endpoint-list/endpoint-list-controller.js new file mode 100644 index 000000000..62c284f56 --- /dev/null +++ b/app/portainer/components/endpoint-list/endpoint-list-controller.js @@ -0,0 +1,70 @@ +angular.module('portainer.app').controller('EndpointListController', ['DatatableService', + function EndpointListController(DatatableService) { + var ctrl = this; + ctrl.state = { + textFilter: '', + filteredEndpoints: [] + }; + + ctrl.$onChanges = $onChanges; + ctrl.onTextFilterChange = onTextFilterChange; + + function $onChanges(changesObj) { + handleEndpointsChange(changesObj.endpoints); + } + + function handleEndpointsChange(endpoints) { + if (!endpoints) { + return; + } + if (!endpoints.currentValue) { + return; + } + + onTextFilterChange(); + } + + function onTextFilterChange() { + var filterValue = ctrl.state.textFilter; + ctrl.state.filteredEndpoints = filterEndpoints( + ctrl.endpoints, + filterValue + ); + DatatableService.setDataTableTextFilters(this.tableKey, filterValue); + } + + function filterEndpoints(endpoints, filterValue) { + if (!endpoints || !endpoints.length || !filterValue) { + return endpoints; + } + var keywords = filterValue.split(' '); + return _.filter(endpoints, function(endpoint) { + var statusString = convertStatusToString(endpoint.Status); + return _.every(keywords, function(keyword) { + var lowerCaseKeyword = keyword.toLowerCase(); + 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); + }) || + _.includes(statusString, keyword) + ); + }); + }); + } + + function convertStatusToString(status) { + return status === 1 ? 'up' : 'down'; + } + + this.$onInit = function() { + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + onTextFilterChange(); + } + }; + } +]); diff --git a/app/portainer/components/endpoint-list/endpoint-list.js b/app/portainer/components/endpoint-list/endpoint-list.js index d11d7611d..e0ebc8062 100644 --- a/app/portainer/components/endpoint-list/endpoint-list.js +++ b/app/portainer/components/endpoint-list/endpoint-list.js @@ -1,16 +1,15 @@ angular.module('portainer.app').component('endpointList', { templateUrl: 'app/portainer/components/endpoint-list/endpointList.html', - controller: function() { - this.state = { - textFilter: '' - }; - }, + controller: 'EndpointListController', bindings: { titleText: '@', titleIcon: '@', endpoints: '<', + tableKey: '@', dashboardAction: '<', snapshotAction: '<', - showSnapshotAction: '<' + showSnapshotAction: '<', + editAction: '<', + isAdmin:'<' } }); diff --git a/app/portainer/components/endpoint-list/endpointList.html b/app/portainer/components/endpoint-list/endpointList.html index 9110703f9..f429be03f 100644 --- a/app/portainer/components/endpoint-list/endpointList.html +++ b/app/portainer/components/endpoint-list/endpointList.html @@ -16,19 +16,26 @@
Loading...
-
+
No endpoint available.
diff --git a/app/portainer/components/extension-list/extension-item/extension-item.js b/app/portainer/components/extension-list/extension-item/extension-item.js new file mode 100644 index 000000000..4e11ab7f3 --- /dev/null +++ b/app/portainer/components/extension-list/extension-item/extension-item.js @@ -0,0 +1,8 @@ +angular.module('portainer.app').component('extensionItem', { + templateUrl: 'app/portainer/components/extension-list/extension-item/extensionItem.html', + controller: 'ExtensionItemController', + bindings: { + model: '<', + currentDate: '<' + } +}); diff --git a/app/portainer/components/extension-list/extension-item/extensionItem.html b/app/portainer/components/extension-list/extension-item/extensionItem.html new file mode 100644 index 000000000..95acdb9cb --- /dev/null +++ b/app/portainer/components/extension-list/extension-item/extensionItem.html @@ -0,0 +1,47 @@ + +
+
+ + + + + + + + + + +
+ + + {{ $ctrl.model.Name }} + + + + coming soon + deal + expired + enabled + update available + +
+ + +
+ + + {{ $ctrl.model.ShortDescription }} + + + + Licensed to {{ $ctrl.model.License.Company }} - Expires on {{ $ctrl.model.License.Expiration }} + +
+ +
+ +
+ +
diff --git a/app/portainer/components/extension-list/extension-item/extensionItemController.js b/app/portainer/components/extension-list/extension-item/extensionItemController.js new file mode 100644 index 000000000..810e93990 --- /dev/null +++ b/app/portainer/components/extension-list/extension-item/extensionItemController.js @@ -0,0 +1,20 @@ +angular.module('portainer.app') + .controller('ExtensionItemController', ['$state', + function($state) { + + var ctrl = this; + ctrl.$onInit = $onInit; + ctrl.goToExtensionView = goToExtensionView; + + function goToExtensionView() { + if (ctrl.model.Available) { + $state.go('portainer.extensions.extension', { id: ctrl.model.Id }); + } + } + + function $onInit() { + if (ctrl.currentDate === ctrl.model.License.Expiration) { + ctrl.model.Expired = true; + } + } + }]); diff --git a/app/portainer/components/extension-list/extension-list.js b/app/portainer/components/extension-list/extension-list.js new file mode 100644 index 000000000..6d35f8a68 --- /dev/null +++ b/app/portainer/components/extension-list/extension-list.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('extensionList', { + templateUrl: 'app/portainer/components/extension-list/extensionList.html', + bindings: { + extensions: '<', + currentDate: '<' + } +}); diff --git a/app/portainer/components/extension-list/extensionList.html b/app/portainer/components/extension-list/extensionList.html new file mode 100644 index 000000000..3b76d6024 --- /dev/null +++ b/app/portainer/components/extension-list/extensionList.html @@ -0,0 +1,20 @@ +
+ + + +
+
+ Available extensions +
+
+ +
+ +
+ +
+
+
diff --git a/app/portainer/components/forms/execute-job-form/execute-job-form-controller.js b/app/portainer/components/forms/execute-job-form/execute-job-form-controller.js new file mode 100644 index 000000000..8e3b3d196 --- /dev/null +++ b/app/portainer/components/forms/execute-job-form/execute-job-form-controller.js @@ -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; + }); + } +}]); diff --git a/app/portainer/components/forms/execute-job-form/execute-job-form.html b/app/portainer/components/forms/execute-job-form/execute-job-form.html new file mode 100644 index 000000000..19230f46d --- /dev/null +++ b/app/portainer/components/forms/execute-job-form/execute-job-form.html @@ -0,0 +1,110 @@ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ + This job will run inside a privileged container on the host. You can access the host filesystem under the + /host folder. + +
+ +
+ Job creation +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ Web editor +
+
+
+ + +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a script file from your computer. + +
+
+
+ + + {{ $ctrl.formValues.JobFile.name }} + + +
+
+
+ + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ + diff --git a/app/portainer/components/forms/execute-job-form/execute-job-form.js b/app/portainer/components/forms/execute-job-form/execute-job-form.js new file mode 100644 index 000000000..9dbbf5a52 --- /dev/null +++ b/app/portainer/components/forms/execute-job-form/execute-job-form.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('executeJobForm', { + templateUrl: 'app/portainer/components/forms/execute-job-form/execute-job-form.html', + controller: 'JobFormController', + bindings: { + nodeName: '<' + } +}); diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.html b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html new file mode 100644 index 000000000..9165749f7 --- /dev/null +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html @@ -0,0 +1,81 @@ +
+
+ Azure registry details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ Actions +
+
+
+ +
+
+ + diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.js b/app/portainer/components/forms/registry-form-azure/registry-form-azure.js new file mode 100644 index 000000000..51208f5ba --- /dev/null +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormAzure', { + templateUrl: 'app/portainer/components/forms/registry-form-azure/registry-form-azure.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/registry-form-custom/registry-form-custom.html b/app/portainer/components/forms/registry-form-custom/registry-form-custom.html new file mode 100644 index 000000000..cfe60a94b --- /dev/null +++ b/app/portainer/components/forms/registry-form-custom/registry-form-custom.html @@ -0,0 +1,105 @@ +
+
+ Important notice +
+
+ + Docker requires you to connect to a secure registry. + You can find more information about how to connect to an insecure registry in the Docker documentation. + +
+
+ Custom registry details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+
+ + +
+
+ +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ +
+ +
+ Actions +
+
+
+ +
+
+ + diff --git a/app/portainer/components/forms/registry-form-custom/registry-form-custom.js b/app/portainer/components/forms/registry-form-custom/registry-form-custom.js new file mode 100644 index 000000000..5d5da8de7 --- /dev/null +++ b/app/portainer/components/forms/registry-form-custom/registry-form-custom.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormCustom', { + templateUrl: 'app/portainer/components/forms/registry-form-custom/registry-form-custom.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/registry-form-quay/registry-form-quay.html b/app/portainer/components/forms/registry-form-quay/registry-form-quay.html new file mode 100644 index 000000000..857802bbb --- /dev/null +++ b/app/portainer/components/forms/registry-form-quay/registry-form-quay.html @@ -0,0 +1,48 @@ +
+
+ Quay account details +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ Actions +
+
+
+ +
+
+ + diff --git a/app/portainer/components/forms/registry-form-quay/registry-form-quay.js b/app/portainer/components/forms/registry-form-quay/registry-form-quay.js new file mode 100644 index 000000000..e08453454 --- /dev/null +++ b/app/portainer/components/forms/registry-form-quay/registry-form-quay.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('registryFormQuay', { + templateUrl: 'app/portainer/components/forms/registry-form-quay/registry-form-quay.html', + bindings: { + model: '=', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/schedule-form/schedule-form.js b/app/portainer/components/forms/schedule-form/schedule-form.js new file mode 100644 index 000000000..a483aef37 --- /dev/null +++ b/app/portainer/components/forms/schedule-form/schedule-form.js @@ -0,0 +1,76 @@ +angular.module('portainer.app').component('scheduleForm', { + templateUrl: 'app/portainer/components/forms/schedule-form/scheduleForm.html', + controller: function() { + var ctrl = this; + + ctrl.state = { + formValidationError: '' + }; + + ctrl.scheduleValues = [{ + displayed: 'Every hour', + cron: '0 0 * * *' + }, + { + displayed: 'Every 2 hours', + cron: '0 0 0/2 * *' + }, { + displayed: 'Every day', + cron: '0 0 0 * *' + } + ]; + + ctrl.formValues = { + datetime: ctrl.model.CronExpression ? cronToDatetime(ctrl.model.CronExpression) : moment(), + scheduleValue: ctrl.scheduleValues[0], + cronMethod: 'basic' + }; + + function cronToDatetime(cron) { + strings = cron.split(' '); + if (strings.length !== 5) { + return moment(); + } + return moment(cron, 's m H D M'); + } + + function datetimeToCron(datetime) { + var date = moment(datetime); + return '0 '.concat(date.minutes(), ' ', date.hours(), ' ', date.date(), ' ', (date.month() + 1)); + } + + this.action = function() { + ctrl.state.formValidationError = ''; + + if (ctrl.model.Job.Method === 'editor' && ctrl.model.Job.FileContent === '') { + ctrl.state.formValidationError = 'Script file content must not be empty'; + return; + } + + if (ctrl.formValues.cronMethod === 'basic') { + if (ctrl.model.Recurring === false) { + ctrl.model.CronExpression = datetimeToCron(ctrl.formValues.datetime); + } else { + ctrl.model.CronExpression = ctrl.formValues.scheduleValue.cron; + } + } else { + ctrl.model.Recurring = true; + } + ctrl.formAction(); + }; + + this.editorUpdate = function(cm) { + ctrl.model.Job.FileContent = cm.getValue(); + }; + }, + bindings: { + model: '=', + endpoints: '<', + groups: '<', + addLabelAction: '<', + removeLabelAction: '<', + formAction: '<', + formActionLabel: '@', + actionInProgress: '<' + } +}); diff --git a/app/portainer/components/forms/schedule-form/scheduleForm.html b/app/portainer/components/forms/schedule-form/scheduleForm.html new file mode 100644 index 000000000..0307b5dd5 --- /dev/null +++ b/app/portainer/components/forms/schedule-form/scheduleForm.html @@ -0,0 +1,266 @@ +
+
+ Schedule configuration +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + + +
+ Schedule configuration +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+
+
+
+

This field is required.

+
+
+
+
+
+ + +
+
+ +
+ +
+
+
+
+

This field is required.

+
+
+
+
+
+ +
+ + +
+
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+
+ + You can refer to the following documentation to get more information about the supported cron expression format. + +
+
+ + +
+ Job configuration +
+ +
+ +
+ +
+
+
+
+
+

This field is required.

+
+
+
+ + +
+ +
+ +
+ +
+ +
+
+ + +
+
+ Job content +
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ Web editor +
+
+ + This schedule will be executed via a privileged container on the target hosts. You can access the host filesystem under the + /host folder. + +
+
+
+ +
+
+
+ + +
+
+ Upload +
+
+ + You can upload a script file from your computer. + +
+
+
+ + + {{ $ctrl.model.Job.File.name }} + + +
+
+
+ +
+ Target endpoints +
+ + + + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ + diff --git a/app/portainer/components/forms/template-form/template-form.js b/app/portainer/components/forms/template-form/template-form.js index 2d6e54ee9..feedf72c5 100644 --- a/app/portainer/components/forms/template-form/template-form.js +++ b/app/portainer/components/forms/template-form/template-form.js @@ -41,6 +41,7 @@ angular.module('portainer.app').component('templateForm', { }; this.addEnvVarValue = function(env) { + env.select = env.select || []; env.select.push({ name: '', value: '' }); }; @@ -49,11 +50,7 @@ angular.module('portainer.app').component('templateForm', { }; this.changeEnvVarType = function(env) { - if (env.type === 1) { - env.preset = true; - } else if (env.type === 2) { - env.preset = false; - } + env.preset = env.type === 1; }; }, bindings: { diff --git a/app/portainer/components/group-association-table/groupAssociationTable.html b/app/portainer/components/group-association-table/groupAssociationTable.html index 18e024b77..c457bd295 100644 --- a/app/portainer/components/group-association-table/groupAssociationTable.html +++ b/app/portainer/components/group-association-table/groupAssociationTable.html @@ -2,7 +2,7 @@
- +
diff --git a/app/portainer/components/information-panel-offline/informationPanelOffline.html b/app/portainer/components/information-panel-offline/informationPanelOffline.html new file mode 100644 index 000000000..17942ee5a --- /dev/null +++ b/app/portainer/components/information-panel-offline/informationPanelOffline.html @@ -0,0 +1,15 @@ + + +

+ + This endpoint is currently offline (read-only). Data shown is based on the latest available snapshot. +

+

+ + Last snapshot: {{ $ctrl.snapshotTime | getisodatefromtimestamp }} +

+ +
+
diff --git a/app/portainer/components/information-panel-offline/informationPanelOffline.js b/app/portainer/components/information-panel-offline/informationPanelOffline.js new file mode 100644 index 000000000..e788dfe29 --- /dev/null +++ b/app/portainer/components/information-panel-offline/informationPanelOffline.js @@ -0,0 +1,4 @@ +angular.module('portainer.app').component('informationPanelOffline', { + templateUrl: 'app/portainer/components/information-panel-offline/informationPanelOffline.html', + controller: 'InformationPanelOfflineController' +}); diff --git a/app/portainer/components/information-panel-offline/informationPanelOfflineController.js b/app/portainer/components/information-panel-offline/informationPanelOfflineController.js new file mode 100644 index 000000000..efe75545e --- /dev/null +++ b/app/portainer/components/information-panel-offline/informationPanelOfflineController.js @@ -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'); + }); + } + +}]); diff --git a/app/portainer/components/information-panel/information-panel.js b/app/portainer/components/information-panel/information-panel.js index 083b6d013..2541c9cd1 100644 --- a/app/portainer/components/information-panel/information-panel.js +++ b/app/portainer/components/information-panel/information-panel.js @@ -2,7 +2,7 @@ angular.module('portainer.app').component('informationPanel', { templateUrl: 'app/portainer/components/information-panel/informationPanel.html', bindings: { titleText: '@', - dismissAction: '&' + dismissAction: '&?' }, transclude: true }); diff --git a/app/portainer/components/information-panel/informationPanel.html b/app/portainer/components/information-panel/informationPanel.html index 21f434354..c67863b7e 100644 --- a/app/portainer/components/information-panel/informationPanel.html +++ b/app/portainer/components/information-panel/informationPanel.html @@ -6,7 +6,7 @@ {{ $ctrl.titleText }} - + dismiss diff --git a/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js b/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js new file mode 100644 index 000000000..ecec019c4 --- /dev/null +++ b/app/portainer/components/multi-endpoint-selector/multi-endpoint-selector.js @@ -0,0 +1,9 @@ +angular.module('portainer.app').component('multiEndpointSelector', { + templateUrl: 'app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html', + controller: 'MultiEndpointSelectorController', + bindings: { + 'model': '=', + 'endpoints': '<', + 'groups': '<' + } +}); diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html new file mode 100644 index 000000000..5acf2149d --- /dev/null +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelector.html @@ -0,0 +1,14 @@ + + + + {{ $item.Name }} + ({{ $item.Tags | arraytostr }}) + + + + + {{ endpoint.Name }} + ({{ endpoint.Tags | arraytostr }}) + + + diff --git a/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js new file mode 100644 index 000000000..418682771 --- /dev/null +++ b/app/portainer/components/multi-endpoint-selector/multiEndpointSelectorController.js @@ -0,0 +1,35 @@ +angular.module('portainer.app') +.controller('MultiEndpointSelectorController', function () { + var ctrl = this; + + this.sortGroups = function(groups) { + return _.sortBy(groups, ['name']); + }; + + this.groupEndpoints = function(endpoint) { + for (var i = 0; i < ctrl.availableGroups.length; i++) { + var group = ctrl.availableGroups[i]; + + if (endpoint.GroupId === group.Id) { + return group.Name; + } + } + }; + + this.$onInit = function() { + this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); + }; + + function filterEmptyGroups(groups, endpoints) { + return groups.filter(function f(group) { + for (var i = 0; i < endpoints.length; i++) { + + var endpoint = endpoints[i]; + if (endpoint.GroupId === group.Id) { + return true; + } + } + return false; + }); + } +}); diff --git a/app/portainer/components/product-list/product-item/product-item.js b/app/portainer/components/product-list/product-item/product-item.js new file mode 100644 index 000000000..0b92f5533 --- /dev/null +++ b/app/portainer/components/product-list/product-item/product-item.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('productItem', { + templateUrl: 'app/portainer/components/product-list/product-item/productItem.html', + bindings: { + model: '<', + goTo: '<' + } +}); diff --git a/app/portainer/components/product-list/product-item/productItem.html b/app/portainer/components/product-list/product-item/productItem.html new file mode 100644 index 000000000..fa72a7092 --- /dev/null +++ b/app/portainer/components/product-list/product-item/productItem.html @@ -0,0 +1,41 @@ + +
+
+ + + + + + +
+ + + {{ $ctrl.model.Name }} + + + + expired + enabled + update available + +
+ + +
+ + + {{ $ctrl.model.ShortDescription }} + + + + Licensed to {{ $ctrl.model.License.Company }} - Expires on {{ $ctrl.model.License.Expiration }} + +
+ +
+ +
+ +
diff --git a/app/portainer/components/product-list/product-list.js b/app/portainer/components/product-list/product-list.js new file mode 100644 index 000000000..4a76d1107 --- /dev/null +++ b/app/portainer/components/product-list/product-list.js @@ -0,0 +1,8 @@ +angular.module('portainer.app').component('productList', { + templateUrl: 'app/portainer/components/product-list/productList.html', + bindings: { + titleText: '@', + products: '<', + goTo: '<' + } +}); diff --git a/app/portainer/components/product-list/productList.html b/app/portainer/components/product-list/productList.html new file mode 100644 index 000000000..21c0410af --- /dev/null +++ b/app/portainer/components/product-list/productList.html @@ -0,0 +1,20 @@ +
+ + + +
+
+ {{ $ctrl.titleText }} +
+
+ +
+ +
+ +
+
+
diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js new file mode 100644 index 000000000..4e96696e2 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form-controller.js @@ -0,0 +1,76 @@ +angular.module('portainer.app').controller('StackDuplicationFormController', [ + 'Notifications', + function StackDuplicationFormController(Notifications) { + var ctrl = this; + + ctrl.state = { + duplicationInProgress: false, + migrationInProgress: false + }; + + ctrl.formValues = { + endpoint: null, + newName: '' + }; + + ctrl.isFormValidForDuplication = isFormValidForDuplication; + ctrl.isFormValidForMigration = isFormValidForMigration; + ctrl.duplicateStack = duplicateStack; + ctrl.migrateStack = migrateStack; + ctrl.isMigrationButtonDisabled = isMigrationButtonDisabled; + + function isFormValidForMigration() { + return ctrl.formValues.endpoint && ctrl.formValues.endpoint.Id; + } + + function isFormValidForDuplication() { + return isFormValidForMigration() && ctrl.formValues.newName; + } + + function duplicateStack() { + if (!ctrl.formValues.newName) { + Notifications.error( + 'Failure', + null, + 'Stack name is required for duplication' + ); + return; + } + ctrl.state.duplicationInProgress = true; + ctrl.onDuplicate({ + endpointId: ctrl.formValues.endpoint.Id, + name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined + }) + .finally(function() { + ctrl.state.duplicationInProgress = false; + }); + } + + function migrateStack() { + ctrl.state.migrationInProgress = true; + ctrl.onMigrate({ + endpointId: ctrl.formValues.endpoint.Id, + name: ctrl.formValues.newName ? ctrl.formValues.newName : undefined + }) + .finally(function() { + ctrl.state.migrationInProgress = false; + }); + } + + function isMigrationButtonDisabled() { + return ( + !ctrl.isFormValidForMigration() || + ctrl.state.duplicationInProgress || + ctrl.state.migrationInProgress || + isTargetEndpointAndCurrentEquals() + ); + } + + function isTargetEndpointAndCurrentEquals() { + return ( + ctrl.formValues.endpoint && + ctrl.formValues.endpoint.Id === ctrl.currentEndpointId + ); + } + } +]); diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.html b/app/portainer/components/stack-duplication-form/stack-duplication-form.html new file mode 100644 index 000000000..6e270b7b0 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.html @@ -0,0 +1,43 @@ +
+
+ Stack duplication / migration +
+
+ +

+ This feature allows you to duplicate or migrate this stack. +

+
+
+
+ +
+ + + +
+
+
\ No newline at end of file diff --git a/app/portainer/components/stack-duplication-form/stack-duplication-form.js b/app/portainer/components/stack-duplication-form/stack-duplication-form.js new file mode 100644 index 000000000..7f6180c39 --- /dev/null +++ b/app/portainer/components/stack-duplication-form/stack-duplication-form.js @@ -0,0 +1,12 @@ +angular.module('portainer.app').component('stackDuplicationForm', { + templateUrl: + 'app/portainer/components/stack-duplication-form/stack-duplication-form.html', + controller: 'StackDuplicationFormController', + bindings: { + onDuplicate: '&', + onMigrate: '&', + endpoints: '<', + groups: '<', + currentEndpointId: '<' + } +}); diff --git a/app/portainer/components/template-list/template-list-controller.js b/app/portainer/components/template-list/template-list-controller.js new file mode 100644 index 000000000..48323fd0d --- /dev/null +++ b/app/portainer/components/template-list/template-list-controller.js @@ -0,0 +1,56 @@ +angular.module('portainer.app').controller('TemplateListController', ['DatatableService', + function TemplateListController(DatatableService) { + var ctrl = this; + + this.state = { + textFilter: '', + selectedCategory: '', + categories: [], + showContainerTemplates: true + }; + + this.onTextFilterChange = function() { + DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); + } + + this.updateCategories = function() { + var availableCategories = []; + + for (var i = 0; i < ctrl.templates.length; i++) { + var template = ctrl.templates[i]; + if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) { + availableCategories = availableCategories.concat(template.Categories); + } + } + + this.state.categories = _.sortBy(_.uniq(availableCategories)); + }; + + this.filterByCategory = function(item) { + if (!ctrl.state.selectedCategory) { + return true; + } + + return _.includes(item.Categories, ctrl.state.selectedCategory); + }; + + this.filterByType = function(item) { + if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) { + return true; + } + return false; + }; + + this.$onInit = function() { + if (this.showSwarmStacks) { + this.state.showContainerTemplates = false; + } + this.updateCategories(); + + var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); + if (textFilter !== null) { + this.state.textFilter = textFilter; + } + }; + } +]); diff --git a/app/portainer/components/template-list/template-list.js b/app/portainer/components/template-list/template-list.js index e58a9deda..d4b058ca0 100644 --- a/app/portainer/components/template-list/template-list.js +++ b/app/portainer/components/template-list/template-list.js @@ -1,54 +1,11 @@ angular.module('portainer.app').component('templateList', { templateUrl: 'app/portainer/components/template-list/templateList.html', - controller: function() { - var ctrl = this; - - this.state = { - textFilter: '', - selectedCategory: '', - categories: [], - showContainerTemplates: true - }; - - this.updateCategories = function() { - var availableCategories = []; - - for (var i = 0; i < ctrl.templates.length; i++) { - var template = ctrl.templates[i]; - if ((template.Type === 1 && ctrl.state.showContainerTemplates) || (template.Type === 2 && ctrl.showSwarmStacks) || (template.Type === 3 && !ctrl.showSwarmStacks)) { - availableCategories = availableCategories.concat(template.Categories); - } - } - - this.state.categories = _.sortBy(_.uniq(availableCategories)); - }; - - this.filterByCategory = function(item) { - if (!ctrl.state.selectedCategory) { - return true; - } - - return _.includes(item.Categories, ctrl.state.selectedCategory); - }; - - this.filterByType = function(item) { - if ((item.Type === 1 && ctrl.state.showContainerTemplates) || (item.Type === 2 && ctrl.showSwarmStacks) || (item.Type === 3 && !ctrl.showSwarmStacks)) { - return true; - } - return false; - }; - - this.$onInit = function() { - if (this.showSwarmStacks) { - this.state.showContainerTemplates = false; - } - this.updateCategories(); - }; - }, + controller: 'TemplateListController', bindings: { titleText: '@', titleIcon: '@', templates: '<', + tableKey: '@', selectAction: '<', deleteAction: '<', showSwarmStacks: '<', diff --git a/app/portainer/components/template-list/templateList.html b/app/portainer/components/template-list/templateList.html index 76aa514fb..1f3d818a2 100644 --- a/app/portainer/components/template-list/templateList.html +++ b/app/portainer/components/template-list/templateList.html @@ -37,7 +37,7 @@
diff --git a/app/portainer/interceptors/endpointStatusInterceptor.js b/app/portainer/interceptors/endpointStatusInterceptor.js new file mode 100644 index 000000000..9411f8d62 --- /dev/null +++ b/app/portainer/interceptors/endpointStatusInterceptor.js @@ -0,0 +1,41 @@ +angular.module('portainer.app') + .factory('EndpointStatusInterceptor', ['$q', '$injector', 'EndpointProvider', function ($q, $injector, EndpointProvider) { + 'use strict'; + var interceptor = {}; + + interceptor.response = responseInterceptor; + interceptor.responseError = responseErrorInterceptor; + + function canBeOffline(url) { + return (_.startsWith(url, 'api/') && ( + _.includes(url, '/containers') || + _.includes(url, '/images') || + _.includes(url, '/volumes') || + _.includes(url, '/networks') || + _.includes(url, '/info') || + _.includes(url, '/version') + )); + } + + function responseInterceptor(response) { + var EndpointService = $injector.get('EndpointService'); + var url = response.config.url; + if (response.status === 200 && canBeOffline(url) && EndpointProvider.offlineMode()) { + EndpointProvider.setOfflineMode(false); + EndpointService.updateEndpoint(EndpointProvider.endpointID(), {Status: EndpointProvider.endpointStatusFromOfflineMode(false)}); + } + return response || $q.when(response); + } + + function responseErrorInterceptor(rejection) { + var EndpointService = $injector.get('EndpointService'); + var url = rejection.config.url; + 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)}); + } + return $q.reject(rejection); + } + + return interceptor; + }]); \ No newline at end of file diff --git a/app/portainer/models/extension.js b/app/portainer/models/extension.js new file mode 100644 index 000000000..7e71aafd4 --- /dev/null +++ b/app/portainer/models/extension.js @@ -0,0 +1,17 @@ +function ExtensionViewModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Enabled = data.Enabled; + this.Description = data.Description; + this.Price = data.Price; + this.PriceDescription = data.PriceDescription; + this.Available = data.Available; + this.Deal = data.Deal; + this.ShortDescription = data.ShortDescription; + this.License = data.License; + this.Version = data.Version; + this.UpdateAvailable = data.UpdateAvailable; + this.ShopURL = data.ShopURL; + this.Images = data.Images; + this.Logo = data.Logo; +} diff --git a/app/portainer/models/registry.js b/app/portainer/models/registry.js index 44c3051ac..23287da05 100644 --- a/app/portainer/models/registry.js +++ b/app/portainer/models/registry.js @@ -1,5 +1,6 @@ function RegistryViewModel(data) { this.Id = data.Id; + this.Type = data.Type; this.Name = data.Name; this.URL = data.URL; this.Authentication = data.Authentication; @@ -9,3 +10,44 @@ function RegistryViewModel(data) { this.AuthorizedTeams = data.AuthorizedTeams; this.Checked = false; } + +function RegistryManagementConfigurationDefaultModel(registry) { + this.Authentication = false; + this.Password = ''; + this.TLS = false; + this.TLSSkipVerify = false; + this.TLSCACertFile = null; + this.TLSCertFile = null; + this.TLSKeyFile = null; + + if (registry.Type === 1 || registry.Type === 2 ) { + this.Authentication = true; + this.Username = registry.Username; + this.TLS = true; + } + + if (registry.Type === 3 && registry.Authentication) { + this.Authentication = true; + this.Username = registry.Username; + } +} + +function RegistryDefaultModel() { + this.Type = 3; + this.URL = ''; + this.Name = ''; + this.Authentication = false; + this.Username = ''; + this.Password = ''; +} + +function RegistryCreateRequest(model) { + this.Name = model.Name; + this.Type = model.Type; + this.URL = model.URL; + this.Authentication = model.Authentication; + if (model.Authentication) { + this.Username = model.Username; + this.Password = model.Password; + } +} diff --git a/app/portainer/models/schedule.js b/app/portainer/models/schedule.js new file mode 100644 index 000000000..8b9a88e88 --- /dev/null +++ b/app/portainer/models/schedule.js @@ -0,0 +1,67 @@ +function ScheduleDefaultModel() { + this.Name = ''; + this.Recurring = false; + this.CronExpression = ''; + this.JobType = 1; + this.Job = new ScriptExecutionDefaultJobModel(); +} + +function ScriptExecutionDefaultJobModel() { + this.Image = ''; + this.Endpoints = []; + this.FileContent = ''; + this.File = null; + this.Method = 'editor'; +} + +function ScheduleModel(data) { + this.Id = data.Id; + this.Name = data.Name; + this.Recurring = data.Recurring; + this.JobType = data.JobType; + this.CronExpression = data.CronExpression; + this.Created = data.Created; + if (this.JobType === 1) { + this.Job = new ScriptExecutionJobModel(data.ScriptExecutionJob); + } +} + +function ScriptExecutionJobModel(data) { + this.Image = data.Image; + this.Endpoints = data.Endpoints; + this.FileContent = ''; + this.Method = 'editor'; + this.RetryCount = data.RetryCount; + this.RetryInterval = data.RetryInterval; +} + +function ScriptExecutionTaskModel(data) { + this.Id = data.Id; + this.EndpointId = data.EndpointId; + this.Status = createStatus(data.Status); + this.Created = data.Created; +} + +function ScheduleCreateRequest(model) { + this.Name = model.Name; + this.Recurring = model.Recurring; + this.CronExpression = model.CronExpression; + 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; +} + +function ScheduleUpdateRequest(model) { + this.id = model.Id; + this.Name = model.Name; + this.Recurring = model.Recurring; + this.CronExpression = model.CronExpression; + 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; +} diff --git a/app/portainer/models/settings.js b/app/portainer/models/settings.js index 03af7a686..a4f2e8bf6 100644 --- a/app/portainer/models/settings.js +++ b/app/portainer/models/settings.js @@ -8,6 +8,7 @@ function SettingsViewModel(data) { this.SnapshotInterval = data.SnapshotInterval; this.TemplatesURL = data.TemplatesURL; this.ExternalTemplates = data.ExternalTemplates; + this.EnableHostManagementFeatures = data.EnableHostManagementFeatures; } function LDAPSettingsViewModel(data) { diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js index f1302d391..198622389 100644 --- a/app/portainer/models/status.js +++ b/app/portainer/models/status.js @@ -4,4 +4,5 @@ function StatusViewModel(data) { this.EndpointManagement = data.EndpointManagement; this.Analytics = data.Analytics; this.Version = data.Version; + this.EnabledExtensions = data.EnabledExtensions; } diff --git a/app/portainer/models/template.js b/app/portainer/models/template.js index 0218ce8a2..6b9509958 100644 --- a/app/portainer/models/template.js +++ b/app/portainer/models/template.js @@ -25,7 +25,7 @@ function TemplateCreateRequest(model) { this.Image = model.Image; this.Registry = model.Registry.URL; this.Command = model.Command; - this.Network = model.Network; + this.Network = model.Network && model.Network.Name; this.Privileged = model.Privileged; this.Interactive = model.Interactive; this.RestartPolicy = model.RestartPolicy; diff --git a/app/portainer/rest/endpoint.js b/app/portainer/rest/endpoint.js index 455a27448..068a8ef66 100644 --- a/app/portainer/rest/endpoint.js +++ b/app/portainer/rest/endpoint.js @@ -7,6 +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' } } }); }]); diff --git a/app/portainer/rest/extension.js b/app/portainer/rest/extension.js index 1f9dd75bb..6b4588d68 100644 --- a/app/portainer/rest/extension.js +++ b/app/portainer/rest/extension.js @@ -1,11 +1,12 @@ angular.module('portainer.app') -.factory('Extensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function Extensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { +.factory('Extension', ['$resource', 'API_ENDPOINT_EXTENSIONS', + function ExtensionFactory($resource, API_ENDPOINT_EXTENSIONS) { 'use strict'; - return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/:type', { - endpointId: EndpointProvider.endpointID - }, - { - register: { method: 'POST' }, - deregister: { method: 'DELETE', params: { type: '@type' } } + return $resource(API_ENDPOINT_EXTENSIONS + '/:id/:action', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + delete: { method: 'DELETE', params: { id: '@id' } }, + update: { method: 'POST', params: { id: '@id', action: 'update' } } }); }]); diff --git a/app/portainer/rest/legacyExtension.js b/app/portainer/rest/legacyExtension.js new file mode 100644 index 000000000..1da3abdd7 --- /dev/null +++ b/app/portainer/rest/legacyExtension.js @@ -0,0 +1,12 @@ +// TODO: legacy extension management +angular.module('portainer.app') +.factory('LegacyExtensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function LegacyExtensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) { + 'use strict'; + return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/:type', { + endpointId: EndpointProvider.endpointID + }, + { + register: { method: 'POST' }, + deregister: { method: 'DELETE', params: { type: '@type' } } + }); +}]); diff --git a/app/portainer/rest/registry.js b/app/portainer/rest/registry.js index 56c46a3d0..3c25cbbcf 100644 --- a/app/portainer/rest/registry.js +++ b/app/portainer/rest/registry.js @@ -7,6 +7,7 @@ angular.module('portainer.app') get: { method: 'GET', params: { id: '@id' } }, update: { method: 'PUT', params: { id: '@id' } }, updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } }, - remove: { method: 'DELETE', params: { id: '@id'} } + remove: { method: 'DELETE', params: { id: '@id'} }, + configure: { method: 'POST', params: { id: '@id', action: 'configure' } } }); }]); diff --git a/app/portainer/rest/schedule.js b/app/portainer/rest/schedule.js new file mode 100644 index 000000000..eae5a218f --- /dev/null +++ b/app/portainer/rest/schedule.js @@ -0,0 +1,14 @@ +angular.module('portainer.app') +.factory('Schedules', ['$resource', 'API_ENDPOINT_SCHEDULES', +function SchedulesFactory($resource, API_ENDPOINT_SCHEDULES) { + 'use strict'; + return $resource(API_ENDPOINT_SCHEDULES + '/:id/:action', {}, { + create: { method: 'POST' }, + query: { method: 'GET', isArray: true }, + get: { method: 'GET', params: { id: '@id' } }, + update: { method: 'PUT', params: { id: '@id' } }, + remove: { method: 'DELETE', params: { id: '@id'} }, + file: { method: 'GET', params: { id : '@id', action: 'file' } }, + tasks: { method: 'GET', isArray: true, params: { id : '@id', action: 'tasks' } } + }); +}]); diff --git a/app/portainer/services/api/endpointService.js b/app/portainer/services/api/endpointService.js index aa3d57bb6..f7098ae51 100644 --- a/app/portainer/services/api/endpointService.js +++ b/app/portainer/services/api/endpointService.js @@ -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) { @@ -100,5 +104,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; }]); diff --git a/app/portainer/services/api/extensionService.js b/app/portainer/services/api/extensionService.js index 3cd08a828..b7087f3fc 100644 --- a/app/portainer/services/api/extensionService.js +++ b/app/portainer/services/api/extensionService.js @@ -1,19 +1,65 @@ angular.module('portainer.app') -.factory('ExtensionService', ['Extensions', function ExtensionServiceFactory(Extensions) { +.factory('ExtensionService', ['$q', 'Extension', function ExtensionServiceFactory($q, Extension) { 'use strict'; var service = {}; - service.registerStoridgeExtension = function(url) { - var payload = { - Type: 1, - URL: url - }; - - return Extensions.register(payload).$promise; + service.enable = function(license) { + return Extension.create({ license: license }).$promise; }; - service.deregisterStoridgeExtension = function() { - return Extensions.deregister({ type: 1 }).$promise; + service.update = function(id, version) { + return Extension.update({ id: id, version: version }).$promise; + }; + + service.delete = function(id) { + return Extension.delete({ id: id }).$promise; + }; + + service.extensions = function(store) { + var deferred = $q.defer(); + + Extension.query({ store: store }).$promise + .then(function success(data) { + var extensions = data.map(function (item) { + return new ExtensionViewModel(item); + }); + deferred.resolve(extensions); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve extensions', err: err}); + }); + + return deferred.promise; + }; + + service.extension = function(id) { + var deferred = $q.defer(); + + Extension.get({ id: id }).$promise + .then(function success(data) { + var extension = new ExtensionViewModel(data); + deferred.resolve(extension); + }) + .catch(function error(err) { + deferred.reject({msg: 'Unable to retrieve extension details', err: err}); + }); + + return deferred.promise; + }; + + service.registryManagementEnabled = function() { + var deferred = $q.defer(); + + service.extensions(false) + .then(function onSuccess(extensions) { + var extensionAvailable = _.find(extensions, { Id: 1, Enabled: true }) ? true : false; + deferred.resolve(extensionAvailable); + }) + .catch(function onError(err) { + deferred.reject(err); + }); + + return deferred.promise; }; return service; diff --git a/app/portainer/services/api/legacyExtensionService.js b/app/portainer/services/api/legacyExtensionService.js new file mode 100644 index 000000000..38d4df65e --- /dev/null +++ b/app/portainer/services/api/legacyExtensionService.js @@ -0,0 +1,21 @@ +// TODO: legacy extension management +angular.module('portainer.app') +.factory('LegacyExtensionService', ['LegacyExtensions', function LegacyExtensionServiceFactory(LegacyExtensions) { + 'use strict'; + var service = {}; + + service.registerStoridgeExtension = function(url) { + var payload = { + Type: 1, + URL: url + }; + + return LegacyExtensions.register(payload).$promise; + }; + + service.deregisterStoridgeExtension = function() { + return LegacyExtensions.deregister({ type: 1 }).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/api/registryService.js b/app/portainer/services/api/registryService.js index ff5d00f18..1d09c31d8 100644 --- a/app/portainer/services/api/registryService.js +++ b/app/portainer/services/api/registryService.js @@ -1,5 +1,5 @@ angular.module('portainer.app') -.factory('RegistryService', ['$q', 'Registries', 'DockerHubService', 'RegistryHelper', 'ImageHelper', function RegistryServiceFactory($q, Registries, DockerHubService, RegistryHelper, ImageHelper) { +.factory('RegistryService', ['$q', 'Registries', 'DockerHubService', 'RegistryHelper', 'ImageHelper', 'FileUploadService', function RegistryServiceFactory($q, Registries, DockerHubService, RegistryHelper, ImageHelper, FileUploadService) { 'use strict'; var service = {}; @@ -54,17 +54,13 @@ angular.module('portainer.app') return Registries.update({ id: registry.Id }, registry).$promise; }; - service.createRegistry = function(name, URL, authentication, username, password) { - var payload = { - Name: name, - URL: URL, - Authentication: authentication - }; - if (authentication) { - payload.Username = username; - payload.Password = password; - } - return Registries.create({}, payload).$promise; + service.configureRegistry = function(id, registryManagementConfigurationModel) { + return FileUploadService.configureRegistry(id, registryManagementConfigurationModel); + }; + + service.createRegistry = function(model) { + var payload = new RegistryCreateRequest(model); + return Registries.create(payload).$promise; }; service.retrieveRegistryFromRepository = function(repository) { diff --git a/app/portainer/services/api/scheduleService.js b/app/portainer/services/api/scheduleService.js new file mode 100644 index 000000000..3f5d81de2 --- /dev/null +++ b/app/portainer/services/api/scheduleService.js @@ -0,0 +1,80 @@ +angular.module('portainer.app') +.factory('ScheduleService', ['$q', 'Schedules', 'FileUploadService', +function ScheduleService($q, Schedules, FileUploadService) { + 'use strict'; + var service = {}; + + service.schedule = function(scheduleId) { + var deferred = $q.defer(); + + Schedules.get({ id: scheduleId }).$promise + .then(function success(data) { + var schedule = new ScheduleModel(data); + deferred.resolve(schedule); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve schedule', err: err }); + }); + + return deferred.promise; + }; + + service.schedules = function() { + var deferred = $q.defer(); + + Schedules.query().$promise + .then(function success(data) { + var schedules = data.map(function (item) { + return new ScheduleModel(item); + }); + deferred.resolve(schedules); + }) + .catch(function error(err) { + deferred.reject({ msg: 'Unable to retrieve schedules', err: err }); + }); + + 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; + }; + + service.createScheduleFromFileUpload = function(model) { + var payload = new ScheduleCreateRequest(model); + return FileUploadService.createSchedule(payload); + }; + + service.updateSchedule = function(model) { + var payload = new ScheduleUpdateRequest(model); + return Schedules.update(payload).$promise; + }; + + service.deleteSchedule = function(scheduleId) { + return Schedules.remove({ id: scheduleId }).$promise; + }; + + service.getScriptFile = function(scheduleId) { + return Schedules.file({ id: scheduleId }).$promise; + }; + + return service; +}]); diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index dafbc4379..37b7c10a9 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -4,6 +4,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic 'use strict'; var service = {}; + service.stack = function(id) { var deferred = $q.defer(); @@ -33,7 +34,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.migrateSwarmStack = function(stack, targetEndpointId) { + service.migrateSwarmStack = function(stack, targetEndpointId, newName) { var deferred = $q.defer(); EndpointProvider.setEndpointID(targetEndpointId); @@ -45,8 +46,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic deferred.reject({ msg: 'Target endpoint is located in the same Swarm cluster as the current endpoint', err: null }); return; } - - return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id }).$promise; + return Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, SwarmID: swarm.Id, Name: newName }).$promise; }) .then(function success() { deferred.resolve(); @@ -61,12 +61,12 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; - service.migrateComposeStack = function(stack, targetEndpointId) { + service.migrateComposeStack = function(stack, targetEndpointId, newName) { var deferred = $q.defer(); EndpointProvider.setEndpointID(targetEndpointId); - Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId }).$promise + Stack.migrate({ id: stack.Id, endpointId: stack.EndpointId }, { EndpointID: targetEndpointId, Name: newName }).$promise .then(function success() { deferred.resolve(); }) @@ -258,8 +258,7 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic var deferred = $q.defer(); SwarmService.swarm() - .then(function success(data) { - var swarm = data; + .then(function success(swarm) { var payload = { Name: name, SwarmID: swarm.Id, @@ -321,5 +320,10 @@ function StackServiceFactory($q, Stack, ResourceControlService, FileUploadServic return deferred.promise; }; + service.duplicateStack = function duplicateStack(name, stackFileContent, env, endpointId, type) { + var action = type === 1 ? service.createSwarmStackFromFileContent : service.createComposeStackFromFileContent; + return action(name, stackFileContent, env, endpointId); + }; + return service; }]); diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index e0fb48cc1..0e9f19a93 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -2,43 +2,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; }]); diff --git a/app/portainer/services/chartService.js b/app/portainer/services/chartService.js index 4b10c9a83..079180ab2 100644 --- a/app/portainer/services/chartService.js +++ b/app/portainer/services/chartService.js @@ -1,14 +1,14 @@ 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, @@ -17,7 +17,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); } @@ -26,142 +26,194 @@ 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 LimitChartItems(chart, CHART_LIMIT) { + 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(); + } } - return value.toFixed(1) + 'B'; - } - function percentageBasedAxisLabel(value) { - if (value > 1) { - return Math.round(value) + '%'; + 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) + '%'; - } - function percentageBasedTooltipLabel(label, value) { - var processedValue = 0; - if (value > 1) { - processedValue = Math.round(value); - } else { - processedValue = value.toFixed(1); + service.UpdateMemoryChart = function UpdateChart(label, memoryValue, cacheValue, chart) { + chart.data.labels.push(label); + chart.data.datasets[0].data.push(memoryValue); + + if(cacheValue) { + chart.data.datasets[1].data.push(cacheValue); + } else { // cache values are not available for Windows + chart.data.datasets.splice(1, 1); + } + + LimitChartItems(chart); + + 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); + + LimitChartItems(chart); + + 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 label + ': ' + processedValue + '%'; - } - return service; -}]); + function byteBasedAxisLabel(value) { + if (value > 5) { + return filesize(value, { base: 10, round: 1 }); + } + return value.toFixed(1) + 'B'; + } + + 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; + }]); diff --git a/app/portainer/services/datatableService.js b/app/portainer/services/datatableService.js index 2842c948d..6b2e08224 100644 --- a/app/portainer/services/datatableService.js +++ b/app/portainer/services/datatableService.js @@ -13,6 +13,14 @@ function DatatableServiceFactory(LocalStorage) { return LocalStorage.getDataTableSettings(key); }; + service.setDataTableTextFilters = function(key, filters) { + LocalStorage.storeDataTableTextFilters(key, filters); + }; + + service.getDataTableTextFilters = function(key) { + return LocalStorage.getDataTableTextFilters(key); + }; + service.setDataTableFilters = function(key, filters) { LocalStorage.storeDataTableFilters(key, filters); }; diff --git a/app/portainer/services/endpointProvider.js b/app/portainer/services/endpointProvider.js index 21d635d64..ebd89497e 100644 --- a/app/portainer/services/endpointProvider.js +++ b/app/portainer/services/endpointProvider.js @@ -7,19 +7,31 @@ angular.module('portainer.app') service.initialize = function() { var endpointID = LocalStorage.getEndpointID(); var endpointPublicURL = LocalStorage.getEndpointPublicURL(); + var offlineMode = LocalStorage.getOfflineMode(); + if (endpointID) { endpoint.ID = endpointID; } if (endpointPublicURL) { endpoint.PublicURL = endpointPublicURL; } + if (offlineMode) { + endpoint.OfflineMode = offlineMode; + } }; service.clean = function() { endpoint = {}; }; + service.endpoint = function() { + return endpoint; + }; + service.endpointID = function() { + if (endpoint.ID === undefined) { + endpoint.ID = LocalStorage.getEndpointID(); + } return endpoint.ID; }; @@ -29,6 +41,9 @@ angular.module('portainer.app') }; service.endpointPublicURL = function() { + if (endpoint.PublicURL === undefined) { + endpoint.PublicURL = LocalStorage.getEndpointPublicURL(); + } return endpoint.PublicURL; }; @@ -37,5 +52,40 @@ angular.module('portainer.app') LocalStorage.storeEndpointPublicURL(publicURL); }; + service.endpoints = function() { + return LocalStorage.getEndpoints(); + }; + + service.setEndpoints = function(data) { + LocalStorage.storeEndpoints(data); + }; + + service.offlineMode = function() { + return endpoint.OfflineMode; + }; + + service.endpointStatusFromOfflineMode = function(isOffline) { + return isOffline ? 2 : 1; + }; + + service.setOfflineMode = function(isOffline) { + endpoint.OfflineMode = isOffline; + LocalStorage.storeOfflineMode(isOffline); + }; + + service.setOfflineModeFromStatus = function(status) { + var isOffline = status !== 1; + endpoint.OfflineMode = isOffline; + LocalStorage.storeOfflineMode(isOffline); + }; + + service.currentEndpoint = function() { + var endpointId = endpoint.ID; + var endpoints = LocalStorage.getEndpoints(); + return _.find(endpoints, function (item) { + return item.Id === endpointId; + }); + }; + return service; }]); diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index fdbf9ff54..512445fc2 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -35,7 +35,23 @@ angular.module('portainer.app') 'Content-Type': file.type }, data: file, - ignoreLoadingBar: true + ignoreLoadingBar: true, + transformResponse: genericHandler + }); + }; + + service.createSchedule = function(payload) { + return Upload.upload({ + url: 'api/schedules?method=file', + data: { + file: payload.File, + Name: payload.Name, + CronExpression: payload.CronExpression, + Image: payload.Image, + Endpoints: Upload.json(payload.Endpoints), + RetryCount: payload.RetryCount, + RetryInterval: payload.RetryInterval + } }); }; @@ -64,6 +80,24 @@ angular.module('portainer.app') }); }; + service.configureRegistry = function(registryId, registryManagementConfigurationModel) { + return Upload.upload({ + url: 'api/registries/' + registryId + '/configure', + data: registryManagementConfigurationModel + }); + }; + + 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', diff --git a/app/portainer/services/extensionManager.js b/app/portainer/services/legacyExtensionManager.js similarity index 79% rename from app/portainer/services/extensionManager.js rename to app/portainer/services/legacyExtensionManager.js index b0cde467e..f56549ab5 100644 --- a/app/portainer/services/extensionManager.js +++ b/app/portainer/services/legacyExtensionManager.js @@ -1,12 +1,18 @@ +// TODO: legacy extension management angular.module('portainer.app') -.factory('ExtensionManager', ['$q', 'PluginService', 'SystemService', 'ExtensionService', -function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionService) { +.factory('LegacyExtensionManager', ['$q', 'PluginService', 'SystemService', 'LegacyExtensionService', +function ExtensionManagerFactory($q, PluginService, SystemService, LegacyExtensionService) { 'use strict'; var service = {}; - service.initEndpointExtensions = function() { + service.initEndpointExtensions = function(endpoint) { var deferred = $q.defer(); + if (endpoint.Status !== 1) { + deferred.resolve([]); + return deferred.promise; + } + SystemService.version() .then(function success(data) { var endpointAPIVersion = parseFloat(data.ApiVersion); @@ -55,7 +61,7 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ .then(function success(data) { var managerIP = data.Swarm.NodeAddr; var storidgeAPIURL = 'tcp://' + managerIP + ':8282'; - return ExtensionService.registerStoridgeExtension(storidgeAPIURL); + return LegacyExtensionService.registerStoridgeExtension(storidgeAPIURL); }) .then(function success(data) { deferred.resolve(data); @@ -68,7 +74,7 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ } function deregisterStoridgeExtension() { - return ExtensionService.deregisterStoridgeExtension(); + return LegacyExtensionService.deregisterStoridgeExtension(); } return service; diff --git a/app/portainer/services/localStorage.js b/app/portainer/services/localStorage.js index b104b4076..bc63fec00 100644 --- a/app/portainer/services/localStorage.js +++ b/app/portainer/services/localStorage.js @@ -14,6 +14,18 @@ angular.module('portainer.app') getEndpointPublicURL: function() { return localStorageService.get('ENDPOINT_PUBLIC_URL'); }, + storeOfflineMode: function(isOffline) { + localStorageService.set('ENDPOINT_OFFLINE_MODE', isOffline); + }, + getOfflineMode: function() { + return localStorageService.get('ENDPOINT_OFFLINE_MODE'); + }, + storeEndpoints: function(data) { + localStorageService.set('ENDPOINTS_DATA', data); + }, + getEndpoints: function() { + return localStorageService.get('ENDPOINTS_DATA'); + }, storeEndpointState: function(state) { localStorageService.set('ENDPOINT_STATE', state); }, @@ -53,6 +65,12 @@ angular.module('portainer.app') storeDataTableOrder: function(key, data) { localStorageService.set('datatable_order_' + key, data); }, + getDataTableTextFilters: function(key) { + return localStorageService.get('datatable_text_filter_' + key); + }, + storeDataTableTextFilters: function(key, data) { + localStorageService.set('datatable_text_filter_' + key, data); + }, getDataTableFilters: function(key) { return localStorageService.get('datatable_filters_' + key); }, @@ -89,6 +107,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(); } diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index f08a02962..082dcf493 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -25,6 +25,14 @@ angular.module('portainer.app') return buttons; }; + service.enlargeImage = function(image) { + bootbox.dialog({ + message: '', + className: 'image-zoom-modal', + onEscape: true + }); + }; + service.confirm = function(options){ var box = bootbox.confirm({ title: options.title, diff --git a/app/portainer/services/notifications.js b/app/portainer/services/notifications.js index 85de16def..d1cd8eb80 100644 --- a/app/portainer/services/notifications.js +++ b/app/portainer/services/notifications.js @@ -13,7 +13,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; diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index 787f67328..5a229c339 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -1,6 +1,6 @@ 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 = {}; @@ -43,6 +43,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin LocalStorage.storeApplicationState(state.application); }; + manager.updateEnableHostManagementFeatures = function(enableHostManagementFeatures) { + state.application.enableHostManagementFeatures = enableHostManagementFeatures; + LocalStorage.storeApplicationState(state.application); + }; + function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; @@ -51,6 +56,7 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin state.application.version = status.Version; state.application.logo = settings.LogoURL; state.application.snapshotInterval = settings.SnapshotInterval; + state.application.enableHostManagementFeatures = settings.EnableHostManagementFeatures; state.application.validity = moment().unix(); } @@ -135,11 +141,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return extensions; } - manager.updateEndpointState = function(name, type, extensions) { + manager.updateEndpointState = function(endpoint, extensions) { var deferred = $q.defer(); - if (type === 3) { - state.endpoint.name = name; + if (endpoint.Type === 3) { + state.endpoint.name = endpoint.Name; state.endpoint.mode = { provider: 'AZURE' }; LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); @@ -147,16 +153,24 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin } $q.all({ - version: SystemService.version(), - info: SystemService.info() + version: endpoint.Status === 1 ? SystemService.version() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Version), + info: endpoint.Status === 1 ? SystemService.info() : $q.when(endpoint.Snapshots[0].SnapshotRaw.Info) }) .then(function success(data) { - var endpointMode = InfoHelper.determineEndpointMode(data.info, type); + var endpointMode = InfoHelper.determineEndpointMode(data.info, endpoint.Type); var endpointAPIVersion = parseFloat(data.version.ApiVersion); state.endpoint.mode = endpointMode; - state.endpoint.name = name; + state.endpoint.name = endpoint.Name; state.endpoint.apiVersion = endpointAPIVersion; state.endpoint.extensions = assignExtensions(extensions); + + if (endpointMode.agentProxy && endpoint.Status === 1) { + return AgentPingService.ping().then(function onPingSuccess(data) { + state.endpoint.agentApiVersion = data.version; + }); + } + + }).then(function () { LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); }) @@ -170,5 +184,9 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return deferred.promise; }; + manager.getAgentApiVersion = function getAgentApiVersion() { + return state.endpoint.agentApiVersion; + }; + return manager; }]); diff --git a/app/portainer/views/endpoints/create/createEndpointController.js b/app/portainer/views/endpoints/create/createEndpointController.js index 13fc1ad16..c682bee1b 100644 --- a/app/portainer/views/endpoints/create/createEndpointController.js +++ b/app/portainer/views/endpoints/create/createEndpointController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'EndpointService', 'GroupService', 'TagService', 'Notifications', -function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService, Notifications) { +.controller('CreateEndpointController', ['$q', '$scope', '$state', '$filter', 'clipboard', 'EndpointService', 'GroupService', 'TagService', 'Notifications', +function ($q, $scope, $state, $filter, clipboard, EndpointService, GroupService, TagService, Notifications) { $scope.state = { EnvironmentType: 'docker', @@ -19,6 +19,12 @@ function ($q, $scope, $state, $filter, EndpointService, GroupService, TagService Tags: [] }; + $scope.copyAgentCommand = function() { + clipboard.copyText('curl -L https://portainer.io/download/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent'); + $('#copyNotification').show(); + $('#copyNotification').fadeOut(2000); + }; + $scope.addDockerEndpoint = function() { var name = $scope.formValues.Name; var URL = $filter('stripprotocol')($scope.formValues.URL); diff --git a/app/portainer/views/endpoints/create/createendpoint.html b/app/portainer/views/endpoints/create/createendpoint.html index ff8e7c232..4b7a762bd 100644 --- a/app/portainer/views/endpoints/create/createendpoint.html +++ b/app/portainer/views/endpoints/create/createendpoint.html @@ -64,8 +64,16 @@
- If you have started Portainer in the same overlay network as the agent, you can use tasks.AGENT_SERVICE_NAME:AGENT_SERVICE_PORT as the - endpoint URL format. + Ensure that you have deployed the Portainer agent in your cluster first. You can use execute the following command on any manager node to deploy it. +
+ + curl -L https://portainer.io/download/agent-stack.yml -o agent-stack.yml && docker stack deploy --compose-file=agent-stack.yml portainer-agent + + Copy + + + +
diff --git a/app/portainer/views/extensions/extensions.html b/app/portainer/views/extensions/extensions.html new file mode 100644 index 000000000..f2ff0377e --- /dev/null +++ b/app/portainer/views/extensions/extensions.html @@ -0,0 +1,125 @@ + + + Portainer extensions + + + + +

+ Portainer CE is a great way of managing clusters, provisioning containers and services and + managing container environment lifecycles. To extend the benefit of Portainer CE even + more, and to address the needs of larger, complex or critical environments, the Portainer + team provides a growing range of low-cost Extensions. +

+ +

+ As the diagram shows, running a successful production container environment requires a + range of capability across a number of complex technical areas. +

+ +

+ extensions overview +

+ +

+ Available through a simple subscription process from the menu, Portainer Extensions + answers this need and provides a simple way to enhance the functionality that Portainer + makes available through incremental capability in important areas. +

+ +

+ The vision for Portainer is to be the standard management layer for container platforms. In + order to achieve this vision, Portainer CE will be extended across a range of new functional + areas. In order to ensure that Portainer remains the best choice for managing production + container platforms, the Portainer team have chosen a modular extensible design approach, + where additional capability can be added to the Portainer CE core as needed, and at very + low cost. +

+ +

+ The advantage of an extensible design is clear: While a range of capability is available, only + necessary functionality is added as and when needed. +

+ +

+ Our first extension is Registry Manager, available now. Others (such as + Single Sign On and Operations Management) are scheduled for the early part of 2019. +

+ +

+ Portainer CE is the core of the Portainer management environments. Portainer CE will + continue to be developed and made freely available as part of our deep commitment to our + Open Source heritage and our user community. Portainer CE will always deliver great + functionality and remain the industry standard toolset for managing container-based + platforms. +

+
+
+ +
+
+ + + + +
+
+ Enable extension +
+ +
+
+ + Ensure that you have a valid license. + +
+
+ +
+ +
+ +
+
+ +
+
+
+

Invalid license format.

+
+
+
+ +
+
+ +
+
+ + + +
+
+
+
+ +
+
+ +
+
+ + + +

+ + Portainer must be connected to the Internet to fetch the list of available extensions. +

+
+
diff --git a/app/portainer/views/extensions/extensionsController.js b/app/portainer/views/extensions/extensionsController.js new file mode 100644 index 000000000..cb1a19d8a --- /dev/null +++ b/app/portainer/views/extensions/extensionsController.js @@ -0,0 +1,59 @@ +angular.module('portainer.app') + .controller('ExtensionsController', ['$scope', '$state', 'ExtensionService', 'Notifications', + function($scope, $state, ExtensionService, Notifications) { + + $scope.state = { + actionInProgress: false, + currentDate: moment().format('YYYY-MM-dd') + }; + + $scope.formValues = { + License: '' + }; + + function initView() { + ExtensionService.extensions(true) + .then(function onSuccess(data) { + $scope.extensions = data; + }) + .catch(function onError(err) { + $scope.extensions = []; + Notifications.error('Failure', err, 'Unable to access extension store'); + }); + } + + $scope.enableExtension = function() { + var license = $scope.formValues.License; + + $scope.state.actionInProgress = true; + ExtensionService.enable(license) + .then(function onSuccess() { + Notifications.success('Extension successfully enabled'); + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to enable extension'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + }; + + + $scope.isValidLicenseFormat = function(form) { + var valid = true; + + if (!$scope.formValues.License) { + return; + } + + if (isNaN($scope.formValues.License[0])) { + valid = false; + } + + form.extension_license.$setValidity('invalidLicense', valid); + }; + + + initView(); + }]); diff --git a/app/portainer/views/extensions/inspect/extension.html b/app/portainer/views/extensions/inspect/extension.html new file mode 100644 index 000000000..0a83740d9 --- /dev/null +++ b/app/portainer/views/extensions/inspect/extension.html @@ -0,0 +1,134 @@ + + + + Portainer extensions > {{ extension.Name }} + + + +
+
+ + + +
+ +
+ +
+
+ {{ extension.Name }} extension +
+ +
+ By Portainer.io +
+
+ +
+
+ {{ extension.ShortDescription }} +
+
+
+ +
+ +
+ +
+ {{ extension.Enabled ? 'Enabled' : extension.Price }} +
+ +
+ {{ extension.PriceDescription }} +
+ +
+ +
+ +
+
+ + + + + +
+ Coming soon +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ + +
+ + Description + +
+
+
+
+
+
+

+ + Description for this extension unavailable at the moment. +

+
+
+
+
+
+
+ +
+
+ + +
+ + Screenshots + +
+
+
+ +
+
+
+
+
+
diff --git a/app/portainer/views/extensions/inspect/extensionController.js b/app/portainer/views/extensions/inspect/extensionController.js new file mode 100644 index 000000000..2f293e690 --- /dev/null +++ b/app/portainer/views/extensions/inspect/extensionController.js @@ -0,0 +1,63 @@ +angular.module('portainer.app') +.controller('ExtensionController', ['$q', '$scope', '$transition$', '$state', 'ExtensionService', 'Notifications', 'ModalService', +function ($q, $scope, $transition$, $state, ExtensionService, Notifications, ModalService) { + + $scope.state = { + updateInProgress: false, + deleteInProgress: false + }; + + $scope.formValues = { + instances: 1 + }; + + $scope.updateExtension = updateExtension; + $scope.deleteExtension = deleteExtension; + $scope.enlargeImage = enlargeImage; + + function enlargeImage(image) { + ModalService.enlargeImage(image); + } + + function deleteExtension(extension) { + $scope.state.deleteInProgress = true; + ExtensionService.delete(extension.Id) + .then(function onSuccess() { + Notifications.success('Extension successfully deleted'); + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to delete extension'); + }) + .finally(function final() { + $scope.state.deleteInProgress = false; + }); + } + + function updateExtension(extension) { + $scope.state.updateInProgress = true; + ExtensionService.update(extension.Id, extension.Version) + .then(function onSuccess() { + Notifications.success('Extension successfully updated'); + $state.reload(); + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to update extension'); + }) + .finally(function final() { + $scope.state.updateInProgress = false; + }); + } + + function initView() { + ExtensionService.extension($transition$.params().id) + .then(function onSuccess(extension) { + $scope.extension = extension; + }) + .catch(function onError(err) { + Notifications.error('Failure', err, 'Unable to retrieve extension information'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html index e9de46386..b12d8e749 100644 --- a/app/portainer/views/home/home.html +++ b/app/portainer/views/home/home.html @@ -8,7 +8,7 @@ @@ -17,22 +17,13 @@ + ng-if="!isAdmin && endpoints.length === 0" + title-text="Information"> -

- Welcome to Portainer ! Click on any endpoint in the list below to access management features. -

-

+

You do not have access to any environment. Please contact your administrator.

- -

- - Endpoint snapshot is disabled. -

@@ -40,10 +31,12 @@
diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js index 684c1ff7e..f25e5b51f 100644 --- a/app/portainer/views/home/homeController.js +++ b/app/portainer/views/home/homeController.js @@ -1,44 +1,71 @@ angular.module('portainer.app') -.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager', 'ModalService', 'MotdService', -function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager, ModalService, MotdService) { +.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService', +function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) { - $scope.goToDashboard = function(endpoint) { - EndpointProvider.setEndpointID(endpoint.Id); - EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); - if (endpoint.Type === 3) { - switchToAzureEndpoint(endpoint); - } else { - switchToDockerEndpoint(endpoint); - } + $scope.goToEdit = function(id) { + $state.go('portainer.endpoints.endpoint', { id: id }); }; - $scope.dismissImportantInformation = function(hash) { + $scope.goToDashboard = function (endpoint) { + if (endpoint.Type === 3) { + return switchToAzureEndpoint(endpoint); + } + + checkEndpointStatus(endpoint) + .then(function sucess() { + return switchToDockerEndpoint(endpoint); + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify endpoint status'); + }); + }; + + $scope.dismissImportantInformation = function (hash) { StateManager.dismissImportantInformation(hash); }; - $scope.dismissInformationPanel = function(id) { + $scope.dismissInformationPanel = function (id) { StateManager.dismissInformationPanel(id); }; - function triggerSnapshot() { - EndpointService.snapshot() - .then(function success() { - Notifications.success('Success', 'Endpoints updated'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); - }); - } - - $scope.triggerSnapshot = function() { + $scope.triggerSnapshot = function () { ModalService.confirmEndpointSnapshot(function (result) { - if(!result) { return; } + if (!result) { + return; + } triggerSnapshot(); }); }; + function checkEndpointStatus(endpoint) { + var deferred = $q.defer(); + + var status = 1; + SystemService.ping(endpoint.Id) + .then(function sucess() { + status = 1; + }).catch(function error() { + status = 2; + }).finally(function () { + if (endpoint.Status === status) { + deferred.resolve(endpoint); + return deferred.promise; + } + + EndpointService.updateEndpoint(endpoint.Id, { Status: status }) + .then(function sucess() { + deferred.resolve(endpoint); + }).catch(function error(err) { + deferred.reject({msg: 'Unable to update endpoint status', err: err}); + }); + }); + + return deferred.promise; + } + function switchToAzureEndpoint(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); StateManager.updateEndpointState(endpoint.Name, endpoint.Type, []) .then(function success() { $state.go('azure.dashboard'); @@ -49,10 +76,21 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G } function switchToDockerEndpoint(endpoint) { - ExtensionManager.initEndpointExtensions(endpoint.Id) + if (endpoint.Status === 2 && endpoint.Snapshots[0] && endpoint.Snapshots[0].Swarm === true) { + Notifications.error('Failure', '', 'Endpoint is unreachable. Connect to another swarm manager.'); + return; + } else if (endpoint.Status === 2 && !endpoint.Snapshots[0]) { + Notifications.error('Failure', '', 'Endpoint is unreachable and there is no snapshot available for offline browsing.'); + return; + } + + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + EndpointProvider.setOfflineModeFromStatus(endpoint.Status); + LegacyExtensionManager.initEndpointExtensions(endpoint) .then(function success(data) { var extensions = data; - return StateManager.updateEndpointState(endpoint.Name, endpoint.Type, extensions); + return StateManager.updateEndpointState(endpoint, extensions); }) .then(function success() { $state.go('docker.dashboard'); @@ -62,6 +100,17 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G }); } + function triggerSnapshot() { + EndpointService.snapshotEndpoints() + .then(function success() { + Notifications.success('Success', 'Endpoints updated'); + $state.reload(); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'An error occured during endpoint snapshot'); + }); + } + function initView() { $scope.isAdmin = Authentication.getUserDetails().role === 1; @@ -79,6 +128,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G var groups = data.groups; EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); $scope.endpoints = endpoints; + EndpointProvider.setEndpoints(endpoints); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 569bb1491..d8f9585d0 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -41,4 +41,16 @@ function ($scope, $state, Notifications, Authentication, StateManager, UserServi }); }; + function createAdministratorFlow() { + UserService.administratorExists() + .then(function success(exists) { + if (exists) { + $state.go('portainer.home'); + } + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to verify administrator account existence'); + }); + } + createAdministratorFlow(); }]); diff --git a/app/portainer/views/main/mainController.js b/app/portainer/views/main/mainController.js index 3eb3e9cde..d6c76f982 100644 --- a/app/portainer/views/main/mainController.js +++ b/app/portainer/views/main/mainController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('MainController', ['$scope', '$cookieStore', 'StateManager', -function ($scope, $cookieStore, StateManager) { +.controller('MainController', ['$scope', '$cookieStore', 'StateManager', 'EndpointProvider', +function ($scope, $cookieStore, StateManager, EndpointProvider) { /** * Sidebar Toggle & Cookie Control @@ -11,6 +11,7 @@ function ($scope, $cookieStore, StateManager) { }; $scope.applicationState = StateManager.getState(); + $scope.endpointState = EndpointProvider.endpoint(); $scope.$watch($scope.getWidth, function(newValue) { if (newValue >= mobileView) { diff --git a/app/portainer/views/registries/create/createRegistryController.js b/app/portainer/views/registries/create/createRegistryController.js index ef6212087..059f8b428 100644 --- a/app/portainer/views/registries/create/createRegistryController.js +++ b/app/portainer/views/registries/create/createRegistryController.js @@ -2,40 +2,38 @@ angular.module('portainer.app') .controller('CreateRegistryController', ['$scope', '$state', 'RegistryService', 'Notifications', function ($scope, $state, RegistryService, Notifications) { + $scope.selectQuayRegistry = selectQuayRegistry; + $scope.selectAzureRegistry = selectAzureRegistry; + $scope.selectCustomRegistry = selectCustomRegistry; + $scope.create = createRegistry; + $scope.state = { - RegistryType: 'quay', actionInProgress: false }; - $scope.formValues = { - Name: 'Quay', - URL: 'quay.io', - Authentication: true, - Username: '', - Password: '' - }; + function selectQuayRegistry() { + $scope.model.Name = 'Quay'; + $scope.model.URL = 'quay.io'; + $scope.model.Authentication = true; + } - $scope.selectQuayRegistry = function() { - $scope.formValues.Name = 'Quay'; - $scope.formValues.URL = 'quay.io'; - $scope.formValues.Authentication = true; - }; + function selectAzureRegistry() { + $scope.model.Name = ''; + $scope.model.URL = ''; + $scope.model.Authentication = true; + } - $scope.selectCustomRegistry = function() { - $scope.formValues.Name = ''; - $scope.formValues.URL = ''; - $scope.formValues.Authentication = false; - }; + function selectCustomRegistry() { + $scope.model.Name = ''; + $scope.model.URL = ''; + $scope.model.Authentication = false; + } - $scope.addRegistry = function() { - var registryName = $scope.formValues.Name; - var registryURL = $scope.formValues.URL.replace(/^https?\:\/\//i, ''); - var authentication = $scope.formValues.Authentication; - var username = $scope.formValues.Username; - var password = $scope.formValues.Password; + function createRegistry() { + $scope.model.URL = $scope.model.URL.replace(/^https?\:\/\//i, ''); $scope.state.actionInProgress = true; - RegistryService.createRegistry(registryName, registryURL, authentication, username, password) + RegistryService.createRegistry($scope.model) .then(function success() { Notifications.success('Registry successfully created'); $state.go('portainer.registries'); @@ -46,5 +44,11 @@ function ($scope, $state, RegistryService, Notifications) { .finally(function final() { $scope.state.actionInProgress = false; }); - }; + } + + function initView() { + $scope.model = new RegistryDefaultModel(); + } + + initView(); }]); diff --git a/app/portainer/views/registries/create/createregistry.html b/app/portainer/views/registries/create/createregistry.html index e4e1499b0..fd6f33e43 100644 --- a/app/portainer/views/registries/create/createregistry.html +++ b/app/portainer/views/registries/create/createregistry.html @@ -13,11 +13,13 @@
Registry provider
+
+
- +
-
- Important notice -
-
- - Docker requires you to connect to a secure registry. - You can find more information about how to connect to an insecure registry in the Docker documentation. - -
-
- Registry details -
- -
- -
- -
-
- - -
- -
- -
-
- - -
-
- - -
-
- - -
- -
- -
- -
-
- - -
- -
- -
-
- -
- -
- Actions -
-
-
- -
-
+ + + + + + + diff --git a/app/portainer/views/registries/registries.html b/app/portainer/views/registries/registries.html index 9a01180ab..77ef9b773 100644 --- a/app/portainer/views/registries/registries.html +++ b/app/portainer/views/registries/registries.html @@ -73,9 +73,10 @@
diff --git a/app/portainer/views/registries/registriesController.js b/app/portainer/views/registries/registriesController.js index 317091841..373a01bdf 100644 --- a/app/portainer/views/registries/registriesController.js +++ b/app/portainer/views/registries/registriesController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', -function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications) { +.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', 'ExtensionService', +function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, ExtensionService) { $scope.state = { actionInProgress: false @@ -60,11 +60,13 @@ function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, N function initView() { $q.all({ registries: RegistryService.registries(), - dockerhub: DockerHubService.dockerhub() + dockerhub: DockerHubService.dockerhub(), + registryManagement: ExtensionService.registryManagementEnabled() }) .then(function success(data) { $scope.registries = data.registries; $scope.dockerhub = data.dockerhub; + $scope.registryManagementAvailable = data.registryManagement; }) .catch(function error(err) { $scope.registries = []; diff --git a/app/portainer/views/schedules/create/createScheduleController.js b/app/portainer/views/schedules/create/createScheduleController.js new file mode 100644 index 000000000..03211d804 --- /dev/null +++ b/app/portainer/views/schedules/create/createScheduleController.js @@ -0,0 +1,52 @@ +angular.module('portainer.app') +.controller('CreateScheduleController', ['$q', '$scope', '$state', 'Notifications', 'EndpointService', 'GroupService', 'ScheduleService', +function ($q, $scope, $state, Notifications, EndpointService, GroupService, ScheduleService) { + + $scope.state = { + actionInProgress: false + }; + + $scope.create = create; + + function create() { + var model = $scope.model; + + $scope.state.actionInProgress = true; + createSchedule(model) + .then(function success() { + Notifications.success('Schedule successfully created'); + $state.go('portainer.schedules', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to create schedule'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + function createSchedule(model) { + if (model.Job.Method === 'editor') { + return ScheduleService.createScheduleFromFileContent(model); + } + return ScheduleService.createScheduleFromFileUpload(model); + } + + function initView() { + $scope.model = new ScheduleDefaultModel(); + + $q.all({ + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + $scope.endpoints = data.endpoints; + $scope.groups = data.groups; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/schedules/create/createschedule.html b/app/portainer/views/schedules/create/createschedule.html new file mode 100644 index 000000000..801cc41ca --- /dev/null +++ b/app/portainer/views/schedules/create/createschedule.html @@ -0,0 +1,23 @@ + + + + Schedules > Add schedule + + + +
+
+ + + + + +
+
diff --git a/app/portainer/views/schedules/edit/schedule.html b/app/portainer/views/schedules/edit/schedule.html new file mode 100644 index 000000000..fb7aa5625 --- /dev/null +++ b/app/portainer/views/schedules/edit/schedule.html @@ -0,0 +1,66 @@ + + + + + + + + Schedules > {{ ::schedule.Name }} + + + +
+
+ + + + + + + Configuration + + + + + + + + + Tasks + + +
+ Information +
+
+ + Tasks are retrieved across all endpoints via snapshots. Data available in this view might not be up-to-date. + +
+ +
+ Tasks +
+ +
+ +
+
+
+
+
diff --git a/app/portainer/views/schedules/edit/scheduleController.js b/app/portainer/views/schedules/edit/scheduleController.js new file mode 100644 index 000000000..06313f3ab --- /dev/null +++ b/app/portainer/views/schedules/edit/scheduleController.js @@ -0,0 +1,78 @@ +angular.module('portainer.app') +.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; + + $scope.state.actionInProgress = true; + ScheduleService.updateSchedule(model) + .then(function success() { + Notifications.success('Schedule successfully updated'); + $state.go('portainer.schedules', {}, {reload: true}); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to update schedule'); + }) + .finally(function final() { + $scope.state.actionInProgress = false; + }); + } + + 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; + + $q.all({ + schedule: ScheduleService.schedule(id), + file: ScheduleService.getScriptFile(id), + tasks: ScheduleService.scriptExecutionTasks(id), + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + 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; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint list'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/schedules/schedules.html b/app/portainer/views/schedules/schedules.html new file mode 100644 index 000000000..507640db8 --- /dev/null +++ b/app/portainer/views/schedules/schedules.html @@ -0,0 +1,19 @@ + + + + + + + Schedules + + +
+
+ +
+
diff --git a/app/portainer/views/schedules/schedulesController.js b/app/portainer/views/schedules/schedulesController.js new file mode 100644 index 000000000..2a8632613 --- /dev/null +++ b/app/portainer/views/schedules/schedulesController.js @@ -0,0 +1,50 @@ +angular.module('portainer.app') +.controller('SchedulesController', ['$scope', '$state', 'Notifications', 'ModalService', 'ScheduleService', +function ($scope, $state, Notifications, ModalService, ScheduleService) { + + $scope.removeAction = removeAction; + + function removeAction(selectedItems) { + ModalService.confirmDeletion( + 'Do you want to remove the selected schedule(s) ?', + function onConfirm(confirmed) { + if(!confirmed) { return; } + deleteSelectedSchedules(selectedItems); + } + ); + } + + function deleteSelectedSchedules(schedules) { + var actionCount = schedules.length; + angular.forEach(schedules, function (schedule) { + ScheduleService.deleteSchedule(schedule.Id) + .then(function success() { + Notifications.success('Schedule successfully removed', schedule.Name); + var index = $scope.schedules.indexOf(schedule); + $scope.schedules.splice(index, 1); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to remove schedule ' + schedule.Name); + }) + .finally(function final() { + --actionCount; + if (actionCount === 0) { + $state.reload(); + } + }); + }); + } + + function initView() { + ScheduleService.schedules() + .then(function success(data) { + $scope.schedules = data; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve schedules'); + $scope.schedules = []; + }); + } + + initView(); +}]); diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index e1939ee9e..1df7d0e7d 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -101,6 +101,17 @@ +
+
+ + +
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index e1b7156e2..81ed2588e 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -12,7 +12,8 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { restrictBindMounts: false, restrictPrivilegedMode: false, labelName: '', - labelValue: '' + labelValue: '', + enableHostManagementFeatures: false }; $scope.removeFilteredContainerLabel = function(index) { @@ -46,6 +47,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { settings.AllowBindMountsForRegularUsers = !$scope.formValues.restrictBindMounts; settings.AllowPrivilegedModeForRegularUsers = !$scope.formValues.restrictPrivilegedMode; + settings.EnableHostManagementFeatures = $scope.formValues.enableHostManagementFeatures; $scope.state.actionInProgress = true; updateSettings(settings); @@ -57,6 +59,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { Notifications.success('Settings updated'); StateManager.updateLogo(settings.LogoURL); StateManager.updateSnapshotInterval(settings.SnapshotInterval); + StateManager.updateEnableHostManagementFeatures(settings.EnableHostManagementFeatures); $state.reload(); }) .catch(function error(err) { @@ -80,6 +83,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager) { } $scope.formValues.restrictBindMounts = !settings.AllowBindMountsForRegularUsers; $scope.formValues.restrictPrivilegedMode = !settings.AllowPrivilegedModeForRegularUsers; + $scope.formValues.enableHostManagementFeatures = settings.EnableHostManagementFeatures; }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to retrieve application settings'); diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index d105549c5..59ccaadff 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -22,6 +22,7 @@ swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'" standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'" admin-access="!applicationState.application.authentication || isAdmin" + offline-mode="endpointState.OfflineMode" >
+ + +