From b6792461a4d9606327c6bde46c9fc0f0174ae1c8 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Wed, 11 Jul 2018 10:39:20 +0200 Subject: [PATCH] feat(home): add a new home view (#2033) --- api/cli/cli.go | 18 +++ api/cli/defaults.go | 32 +++-- api/cli/defaults_windows.go | 32 +++-- api/cmd/portainer/main.go | 59 ++++++-- api/cron/job_endpoint_snapshot.go | 60 ++++++++ ...{endpoint_sync.go => job_endpoint_sync.go} | 29 ++-- api/cron/scheduler.go | 87 +++++++++++ api/cron/watcher.go | 40 ------ api/docker/client.go | 103 +++++++++++++ api/docker/snapshot.go | 135 ++++++++++++++++++ api/docker/snapshotter.go | 27 ++++ api/http/handler/endpoints/endpoint_create.go | 6 + api/http/handler/settings/handler.go | 1 + api/http/handler/settings/settings_public.go | 2 + api/http/handler/settings/settings_update.go | 57 +++++--- api/http/server.go | 2 + api/portainer.go | 49 ++++++- .../docker-sidebar-content.js | 5 +- app/docker/views/dashboard/dashboard.html | 4 +- app/portainer/__module.js | 12 ++ .../components/datatables/datatable.css | 5 +- .../endpointsSnapshotDatatable.html | 113 +++++++++++++++ .../endpointsSnapshotDatatable.js | 13 ++ .../snapshot-details/snapshotDetails.html | 29 ++++ .../snapshot-details/snapshotDetails.js | 6 + .../information-panel/information-panel.js | 7 + .../information-panel/informationPanel.html | 14 ++ .../sidebar-endpoint-selector.js | 9 -- .../sidebarEndpointSelector.html | 27 ---- .../sidebarEndpointSelectorController.js | 34 ----- app/portainer/filters/filters.js | 9 ++ app/portainer/models/settings/settings.js | 1 + app/portainer/models/status.js | 1 + app/portainer/services/stateManager.js | 15 +- app/portainer/views/auth/authController.js | 55 ++----- app/portainer/views/home/home.html | 37 +++++ app/portainer/views/home/homeController.js | 58 ++++++++ .../views/init/admin/initAdminController.js | 19 +-- .../init/endpoint/initEndpointController.js | 35 +---- app/portainer/views/settings/settings.html | 8 ++ .../views/settings/settingsController.js | 1 + app/portainer/views/sidebar/sidebar.html | 31 ++-- .../views/sidebar/sidebarController.js | 86 +---------- assets/css/app.css | 4 +- package.json | 2 +- yarn.lock | 6 +- 46 files changed, 990 insertions(+), 395 deletions(-) create mode 100644 api/cron/job_endpoint_snapshot.go rename api/cron/{endpoint_sync.go => job_endpoint_sync.go} (81%) create mode 100644 api/cron/scheduler.go delete mode 100644 api/cron/watcher.go create mode 100644 api/docker/client.go create mode 100644 api/docker/snapshot.go create mode 100644 api/docker/snapshotter.go create mode 100644 app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.html create mode 100644 app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.js create mode 100644 app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.html create mode 100644 app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.js create mode 100644 app/portainer/components/information-panel/information-panel.js create mode 100644 app/portainer/components/information-panel/informationPanel.html delete mode 100644 app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js delete mode 100644 app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html delete mode 100644 app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js create mode 100644 app/portainer/views/home/home.html create mode 100644 app/portainer/views/home/homeController.js diff --git a/api/cli/cli.go b/api/cli/cli.go index 31ae297fe..c1792a29d 100644 --- a/api/cli/cli.go +++ b/api/cli/cli.go @@ -21,6 +21,7 @@ const ( errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file") errTemplateFileNotFound = portainer.Error("Unable to locate template file on disk") errInvalidSyncInterval = portainer.Error("Invalid synchronization interval") + errInvalidSnapshotInterval = portainer.Error("Invalid snapshot interval") errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints") errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file") errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file") @@ -47,6 +48,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) { SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(), SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(), SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(), + Snapshot: kingpin.Flag("snapshot", "Start a background job to create endpoint snapshots").Default(defaultSnapshot).Bool(), + SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(), AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(), AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(), Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')), @@ -95,6 +98,11 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error { return err } + err = validateSnapshotInterval(*flags.SnapshotInterval) + if err != nil { + return err + } + if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") { return errNoAuthExcludeAdminPassword } @@ -156,3 +164,13 @@ func validateSyncInterval(syncInterval string) error { } return nil } + +func validateSnapshotInterval(snapshotInterval string) error { + if snapshotInterval != defaultSnapshotInterval { + _, err := time.ParseDuration(snapshotInterval) + if err != nil { + return errInvalidSnapshotInterval + } + } + return nil +} diff --git a/api/cli/defaults.go b/api/cli/defaults.go index 1c674e5ec..1913e4915 100644 --- a/api/cli/defaults.go +++ b/api/cli/defaults.go @@ -3,19 +3,21 @@ package cli const ( - defaultBindAddress = ":9000" - defaultDataDirectory = "/data" - defaultAssetsDirectory = "./" - defaultNoAuth = "false" - defaultNoAnalytics = "false" - defaultTLS = "false" - defaultTLSSkipVerify = "false" - defaultTLSCACertPath = "/certs/ca.pem" - defaultTLSCertPath = "/certs/cert.pem" - defaultTLSKeyPath = "/certs/key.pem" - defaultSSL = "false" - defaultSSLCertPath = "/certs/portainer.crt" - defaultSSLKeyPath = "/certs/portainer.key" - defaultSyncInterval = "60s" - defaultTemplateFile = "/templates.json" + defaultBindAddress = ":9000" + defaultDataDirectory = "/data" + defaultAssetsDirectory = "./" + defaultNoAuth = "false" + defaultNoAnalytics = "false" + defaultTLS = "false" + defaultTLSSkipVerify = "false" + defaultTLSCACertPath = "/certs/ca.pem" + defaultTLSCertPath = "/certs/cert.pem" + defaultTLSKeyPath = "/certs/key.pem" + defaultSSL = "false" + defaultSSLCertPath = "/certs/portainer.crt" + defaultSSLKeyPath = "/certs/portainer.key" + defaultSyncInterval = "60s" + defaultSnapshot = "true" + defaultSnapshotInterval = "5m" + defaultTemplateFile = "/templates.json" ) diff --git a/api/cli/defaults_windows.go b/api/cli/defaults_windows.go index 5f74911e6..e2ee01795 100644 --- a/api/cli/defaults_windows.go +++ b/api/cli/defaults_windows.go @@ -1,19 +1,21 @@ package cli const ( - defaultBindAddress = ":9000" - defaultDataDirectory = "C:\\data" - defaultAssetsDirectory = "./" - defaultNoAuth = "false" - defaultNoAnalytics = "false" - defaultTLS = "false" - defaultTLSSkipVerify = "false" - defaultTLSCACertPath = "C:\\certs\\ca.pem" - defaultTLSCertPath = "C:\\certs\\cert.pem" - defaultTLSKeyPath = "C:\\certs\\key.pem" - defaultSSL = "false" - defaultSSLCertPath = "C:\\certs\\portainer.crt" - defaultSSLKeyPath = "C:\\certs\\portainer.key" - defaultSyncInterval = "60s" - defaultTemplateFile = "/templates.json" + defaultBindAddress = ":9000" + defaultDataDirectory = "C:\\data" + defaultAssetsDirectory = "./" + defaultNoAuth = "false" + defaultNoAnalytics = "false" + defaultTLS = "false" + defaultTLSSkipVerify = "false" + defaultTLSCACertPath = "C:\\certs\\ca.pem" + defaultTLSCertPath = "C:\\certs\\cert.pem" + defaultTLSKeyPath = "C:\\certs\\key.pem" + defaultSSL = "false" + defaultSSLCertPath = "C:\\certs\\portainer.crt" + defaultSSLKeyPath = "C:\\certs\\portainer.key" + defaultSyncInterval = "60s" + defaultSnapshot = "true" + defaultSnapshotInterval = "5m" + defaultTemplateFile = "/templates.json" ) diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 9809828c4..f2acf6615 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -9,6 +9,7 @@ import ( "github.com/portainer/portainer/cli" "github.com/portainer/portainer/cron" "github.com/portainer/portainer/crypto" + "github.com/portainer/portainer/docker" "github.com/portainer/portainer/exec" "github.com/portainer/portainer/filesystem" "github.com/portainer/portainer/git" @@ -101,25 +102,37 @@ func initGitService() portainer.GitService { return &git.Service{} } -func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool { - authorizeEndpointMgmt := true - if externalEnpointFile != "" { - authorizeEndpointMgmt = false +func initClientFactory(signatureService portainer.DigitalSignatureService) *docker.ClientFactory { + return docker.NewClientFactory(signatureService) +} + +func initJobScheduler(endpointService portainer.EndpointService, clientFactory *docker.ClientFactory, flags *portainer.CLIFlags) (portainer.JobScheduler, error) { + jobScheduler := cron.NewJobScheduler(endpointService, clientFactory) + + if *flags.ExternalEndpoints != "" { log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.") - endpointWatcher := cron.NewWatcher(endpointService, syncInterval) - err := endpointWatcher.WatchEndpointFile(externalEnpointFile) + err := jobScheduler.ScheduleEndpointSyncJob(*flags.ExternalEndpoints, *flags.SyncInterval) if err != nil { - log.Fatal(err) + return nil, err + } + } + + if *flags.Snapshot { + err := jobScheduler.ScheduleSnapshotJob(*flags.SnapshotInterval) + if err != nil { + return nil, err } } - return authorizeEndpointMgmt + + return jobScheduler, nil } -func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status { +func initStatus(endpointManagement, snapshot bool, flags *portainer.CLIFlags) *portainer.Status { return &portainer.Status{ Analytics: !*flags.NoAnalytics, Authentication: !*flags.NoAuth, - EndpointManagement: authorizeEndpointMgmt, + EndpointManagement: endpointManagement, + Snapshot: snapshot, Version: portainer.APIVersion, } } @@ -154,6 +167,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL }, AllowBindMountsForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true, + SnapshotInterval: *flags.SnapshotInterval, } if *flags.Labels != nil { @@ -283,6 +297,8 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, Tags: []string{}, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, } if strings.HasPrefix(endpoint.URL, "tcp://") { @@ -322,6 +338,8 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, Tags: []string{}, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, } return endpointService.CreateEndpoint(endpoint) @@ -366,9 +384,21 @@ func main() { gitService := initGitService() - authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval) + clientFactory := initClientFactory(digitalSignatureService) + + jobScheduler, err := initJobScheduler(store.EndpointService, clientFactory, flags) + if err != nil { + log.Fatal(err) + } + + jobScheduler.Start() + + endpointManagement := true + if *flags.ExternalEndpoints != "" { + endpointManagement = false + } - err := initKeyPair(fileService, digitalSignatureService) + err = initKeyPair(fileService, digitalSignatureService) if err != nil { log.Fatal(err) } @@ -395,7 +425,7 @@ func main() { log.Fatal(err) } - applicationStatus := initStatus(authorizeEndpointMgmt, flags) + applicationStatus := initStatus(endpointManagement, *flags.Snapshot, flags) err = initEndpoint(flags, store.EndpointService) if err != nil { @@ -443,7 +473,7 @@ func main() { BindAddress: *flags.Addr, AssetsPath: *flags.Assets, AuthDisabled: *flags.NoAuth, - EndpointManagement: authorizeEndpointMgmt, + EndpointManagement: endpointManagement, UserService: store.UserService, TeamService: store.TeamService, TeamMembershipService: store.TeamMembershipService, @@ -464,6 +494,7 @@ func main() { LDAPService: ldapService, GitService: gitService, SignatureService: digitalSignatureService, + JobScheduler: jobScheduler, SSL: *flags.SSL, SSLCert: *flags.SSLCert, SSLKey: *flags.SSLKey, diff --git a/api/cron/job_endpoint_snapshot.go b/api/cron/job_endpoint_snapshot.go new file mode 100644 index 000000000..ff7cc333b --- /dev/null +++ b/api/cron/job_endpoint_snapshot.go @@ -0,0 +1,60 @@ +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/endpoint_sync.go b/api/cron/job_endpoint_sync.go similarity index 81% rename from api/cron/endpoint_sync.go rename to api/cron/job_endpoint_sync.go index 8d55b4a0e..9fbf595f3 100644 --- a/api/cron/endpoint_sync.go +++ b/api/cron/job_endpoint_sync.go @@ -4,7 +4,6 @@ import ( "encoding/json" "io/ioutil" "log" - "os" "strings" "github.com/portainer/portainer" @@ -12,7 +11,6 @@ import ( type ( endpointSyncJob struct { - logger *log.Logger endpointService portainer.EndpointService endpointFilePath string } @@ -41,15 +39,14 @@ const ( func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob { return endpointSyncJob{ - logger: log.New(os.Stderr, "", log.LstdFlags), endpointService: endpointService, endpointFilePath: endpointFilePath, } } -func endpointSyncError(err error, logger *log.Logger) bool { +func endpointSyncError(err error) bool { if err != nil { - logger.Printf("Endpoint synchronization error: %s", err) + log.Printf("cron error: synchronization job error (err=%s)\n", err) return true } return false @@ -140,23 +137,23 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port if fidx != -1 { endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx]) if endpoint != nil { - job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL) + log.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL) endpointsToUpdate = append(endpointsToUpdate, endpoint) } } else { - job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL) + log.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL) endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx]) } } for idx, endpoint := range fileEndpoints { if !isValidEndpoint(&endpoint) { - job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL) + log.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL) continue } sidx := endpointExists(&fileEndpoints[idx], storedEndpoints) if sidx == -1 { - job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL) + log.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL) endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx]) } } @@ -170,13 +167,13 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port func (job endpointSyncJob) Sync() error { data, err := ioutil.ReadFile(job.endpointFilePath) - if endpointSyncError(err, job.logger) { + if endpointSyncError(err) { return err } var fileEndpoints []fileEndpoint err = json.Unmarshal(data, &fileEndpoints) - if endpointSyncError(err, job.logger) { + if endpointSyncError(err) { return err } @@ -185,7 +182,7 @@ func (job endpointSyncJob) Sync() error { } storedEndpoints, err := job.endpointService.Endpoints() - if endpointSyncError(err, job.logger) { + if endpointSyncError(err) { return err } @@ -194,16 +191,16 @@ func (job endpointSyncJob) Sync() error { sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints) if sync.requireSync() { err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete) - if endpointSyncError(err, job.logger) { + if endpointSyncError(err) { return err } - job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete)) + 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() { - job.logger.Println("Endpoint synchronization job started.") + log.Println("cron: synchronization job started") err := job.Sync() - endpointSyncError(err, job.logger) + endpointSyncError(err) } diff --git a/api/cron/scheduler.go b/api/cron/scheduler.go new file mode 100644 index 000000000..3847e6bbd --- /dev/null +++ b/api/cron/scheduler.go @@ -0,0 +1,87 @@ +package cron + +import ( + "log" + + "github.com/portainer/portainer" + "github.com/portainer/portainer/docker" + "github.com/robfig/cron" +) + +// JobScheduler represents a service for managing crons. +type JobScheduler struct { + cron *cron.Cron + endpointService portainer.EndpointService + snapshotter portainer.Snapshotter + + endpointFilePath string + endpointSyncInterval string +} + +// NewJobScheduler initializes a new service. +func NewJobScheduler(endpointService portainer.EndpointService, clientFactory *docker.ClientFactory) *JobScheduler { + return &JobScheduler{ + cron: cron.New(), + endpointService: endpointService, + snapshotter: docker.NewSnapshotter(clientFactory), + } +} + +// 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) +} + +// ScheduleSnapshotJob schedules a cron job to create endpoint snapshots +func (scheduler *JobScheduler) ScheduleSnapshotJob(interval string) error { + job := newEndpointSnapshotJob(scheduler.endpointService, scheduler.snapshotter) + + 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") + } + } + + scheduler.cron.Start() +} + +// Start starts the scheduled jobs +func (scheduler *JobScheduler) Start() { + if len(scheduler.cron.Entries()) > 0 { + scheduler.cron.Start() + } +} diff --git a/api/cron/watcher.go b/api/cron/watcher.go deleted file mode 100644 index 6b44ff5ce..000000000 --- a/api/cron/watcher.go +++ /dev/null @@ -1,40 +0,0 @@ -package cron - -import ( - "github.com/portainer/portainer" - "github.com/robfig/cron" -) - -// Watcher represents a service for managing crons. -type Watcher struct { - Cron *cron.Cron - EndpointService portainer.EndpointService - syncInterval string -} - -// NewWatcher initializes a new service. -func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher { - return &Watcher{ - Cron: cron.New(), - EndpointService: endpointService, - syncInterval: syncInterval, - } -} - -// WatchEndpointFile starts a cron job to synchronize the endpoints from a file -func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error { - job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService) - - err := job.Sync() - if err != nil { - return err - } - - err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job) - if err != nil { - return err - } - - watcher.Cron.Start() - return nil -} diff --git a/api/docker/client.go b/api/docker/client.go new file mode 100644 index 000000000..4538f981e --- /dev/null +++ b/api/docker/client.go @@ -0,0 +1,103 @@ +package docker + +import ( + "net/http" + "strings" + "time" + + "github.com/docker/docker/client" + "github.com/portainer/portainer" + "github.com/portainer/portainer/crypto" +) + +const ( + unsupportedEnvironmentType = portainer.Error("Environment not supported") +) + +// ClientFactory is used to create Docker clients +type ClientFactory struct { + signatureService portainer.DigitalSignatureService +} + +// NewClientFactory returns a new instance of a ClientFactory +func NewClientFactory(signatureService portainer.DigitalSignatureService) *ClientFactory { + return &ClientFactory{ + signatureService: signatureService, + } +} + +// 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) { + if endpoint.Type == portainer.AzureEnvironment { + return nil, unsupportedEnvironmentType + } else if endpoint.Type == portainer.AgentOnDockerEnvironment { + return createAgentClient(endpoint, factory.signatureService) + } + + if strings.HasPrefix(endpoint.URL, "unix://") { + return createUnixSocketClient(endpoint) + } + return createTCPClient(endpoint) +} + +func createUnixSocketClient(endpoint *portainer.Endpoint) (*client.Client, error) { + return client.NewClientWithOpts( + client.WithHost(endpoint.URL), + client.WithVersion(portainer.SupportedDockerAPIVersion), + ) +} + +func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) { + httpCli, err := httpClient(endpoint) + if err != nil { + return nil, err + } + + return client.NewClientWithOpts( + client.WithHost(endpoint.URL), + client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithHTTPClient(httpCli), + ) +} + +func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) { + httpCli, err := httpClient(endpoint) + if err != nil { + return nil, err + } + + signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage) + if err != nil { + return nil, err + } + + headers := map[string]string{ + portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(), + portainer.PortainerAgentSignatureHeader: signature, + } + + return client.NewClientWithOpts( + client.WithHost(endpoint.URL), + client.WithVersion(portainer.SupportedDockerAPIVersion), + client.WithHTTPClient(httpCli), + client.WithHTTPHeaders(headers), + ) +} + +func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) { + transport := &http.Transport{} + + if endpoint.TLSConfig.TLS { + tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify) + if err != nil { + return nil, err + } + transport.TLSClientConfig = tlsConfig + } + + return &http.Client{ + Timeout: time.Second * 10, + Transport: transport, + }, nil +} diff --git a/api/docker/snapshot.go b/api/docker/snapshot.go new file mode 100644 index 000000000..5f37ad985 --- /dev/null +++ b/api/docker/snapshot.go @@ -0,0 +1,135 @@ +package docker + +import ( + "context" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/client" + "github.com/portainer/portainer" +) + +func snapshot(cli *client.Client) (*portainer.Snapshot, error) { + _, err := cli.Ping(context.Background()) + if err != nil { + return nil, err + } + + snapshot := &portainer.Snapshot{ + StackCount: 0, + } + + err = snapshotInfo(snapshot, cli) + if err != nil { + return nil, err + } + + if snapshot.Swarm { + err = snapshotSwarmServices(snapshot, cli) + if err != nil { + return nil, err + } + } + + err = snapshotContainers(snapshot, cli) + if err != nil { + return nil, err + } + + err = snapshotImages(snapshot, cli) + if err != nil { + return nil, err + } + + err = snapshotVolumes(snapshot, cli) + if err != nil { + return nil, err + } + + snapshot.Time = time.Now().Unix() + return snapshot, nil +} + +func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error { + info, err := cli.Info(context.Background()) + if err != nil { + return err + } + + snapshot.Swarm = info.Swarm.ControlAvailable + snapshot.DockerVersion = info.ServerVersion + snapshot.TotalCPU = info.NCPU + snapshot.TotalMemory = info.MemTotal + return nil +} + +func snapshotSwarmServices(snapshot *portainer.Snapshot, cli *client.Client) error { + stacks := make(map[string]struct{}) + + services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{}) + if err != nil { + return err + } + + for _, service := range services { + for k, v := range service.Spec.Labels { + if k == "com.docker.stack.namespace" { + stacks[v] = struct{}{} + } + } + } + + snapshot.ServiceCount = len(services) + snapshot.StackCount += len(stacks) + return nil +} + +func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error { + containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true}) + if err != nil { + return err + } + + runningContainers := 0 + stoppedContainers := 0 + stacks := make(map[string]struct{}) + for _, container := range containers { + if container.State == "exited" { + stoppedContainers++ + } else if container.State == "running" { + runningContainers++ + } + + for k, v := range container.Labels { + if k == "com.docker.compose.project" { + stacks[v] = struct{}{} + } + } + } + + snapshot.RunningContainerCount = runningContainers + snapshot.StoppedContainerCount = stoppedContainers + snapshot.StackCount += len(stacks) + return nil +} + +func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error { + images, err := cli.ImageList(context.Background(), types.ImageListOptions{}) + if err != nil { + return err + } + + snapshot.ImageCount = len(images) + return nil +} + +func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error { + volumes, err := cli.VolumeList(context.Background(), filters.Args{}) + if err != nil { + return err + } + + snapshot.VolumeCount = len(volumes.Volumes) + return nil +} diff --git a/api/docker/snapshotter.go b/api/docker/snapshotter.go new file mode 100644 index 000000000..b8f571d37 --- /dev/null +++ b/api/docker/snapshotter.go @@ -0,0 +1,27 @@ +package docker + +import ( + "github.com/portainer/portainer" +) + +// Snapshotter represents a service used to create endpoint snapshots +type Snapshotter struct { + clientFactory *ClientFactory +} + +// NewSnapshotter returns a new Snapshotter instance +func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter { + return &Snapshotter{ + clientFactory: clientFactory, + } +} + +// CreateSnapshot creates a snapshot of a specific endpoint +func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) { + cli, err := snapshotter.clientFactory.CreateClient(endpoint) + if err != nil { + return nil, err + } + + return snapshot(cli) +} diff --git a/api/http/handler/endpoints/endpoint_create.go b/api/http/handler/endpoints/endpoint_create.go index 7827068cb..55f0ed879 100644 --- a/api/http/handler/endpoints/endpoint_create.go +++ b/api/http/handler/endpoints/endpoint_create.go @@ -177,6 +177,8 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po Extensions: []portainer.EndpointExtension{}, AzureCredentials: credentials, Tags: payload.Tags, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, } err = handler.EndpointService.CreateEndpoint(endpoint) @@ -213,6 +215,8 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, Tags: payload.Tags, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, } err := handler.EndpointService.CreateEndpoint(endpoint) @@ -253,6 +257,8 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload) AuthorizedTeams: []portainer.TeamID{}, Extensions: []portainer.EndpointExtension{}, Tags: payload.Tags, + Status: portainer.EndpointStatusUp, + Snapshots: []portainer.Snapshot{}, } err = handler.EndpointService.CreateEndpoint(endpoint) diff --git a/api/http/handler/settings/handler.go b/api/http/handler/settings/handler.go index 0850ec83e..58c020877 100644 --- a/api/http/handler/settings/handler.go +++ b/api/http/handler/settings/handler.go @@ -15,6 +15,7 @@ type Handler struct { SettingsService portainer.SettingsService LDAPService portainer.LDAPService FileService portainer.FileService + JobScheduler portainer.JobScheduler } // NewHandler creates a handler to manage settings operations. diff --git a/api/http/handler/settings/settings_public.go b/api/http/handler/settings/settings_public.go index c2ee2a616..690688079 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"` + SnapshotInterval string `json:"SnapshotInterval"` } // GET request on /api/settings/public @@ -27,6 +28,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) * AuthenticationMethod: settings.AuthenticationMethod, AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers, AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers, + SnapshotInterval: settings.SnapshotInterval, } return response.JSON(w, publicSettings) diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 51341658d..827818fa7 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -12,22 +12,20 @@ import ( ) type settingsUpdatePayload struct { - LogoURL string + LogoURL *string BlackListedLabels []portainer.Pair - AuthenticationMethod int - LDAPSettings portainer.LDAPSettings - AllowBindMountsForRegularUsers bool - AllowPrivilegedModeForRegularUsers bool + AuthenticationMethod *int + LDAPSettings *portainer.LDAPSettings + AllowBindMountsForRegularUsers *bool + AllowPrivilegedModeForRegularUsers *bool + SnapshotInterval *string } func (payload *settingsUpdatePayload) Validate(r *http.Request) error { - if payload.AuthenticationMethod == 0 { - return portainer.Error("Invalid authentication method") - } - if payload.AuthenticationMethod != 1 && payload.AuthenticationMethod != 2 { + if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 { return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)") } - if !govalidator.IsNull(payload.LogoURL) && !govalidator.IsURL(payload.LogoURL) { + if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) { return portainer.Error("Invalid logo URL. Must correspond to a valid URL format") } return nil @@ -41,15 +39,40 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - settings := &portainer.Settings{ - LogoURL: payload.LogoURL, - BlackListedLabels: payload.BlackListedLabels, - LDAPSettings: payload.LDAPSettings, - AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers, - AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers, + settings, err := handler.SettingsService.Settings() + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err} + } + + if payload.AuthenticationMethod != nil { + settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod) + } + + if payload.LogoURL != nil { + settings.LogoURL = *payload.LogoURL + } + + if payload.BlackListedLabels != nil { + settings.BlackListedLabels = payload.BlackListedLabels + } + + if payload.LDAPSettings != nil { + settings.LDAPSettings = *payload.LDAPSettings + } + + if payload.AllowBindMountsForRegularUsers != nil { + settings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers + } + + if payload.AllowPrivilegedModeForRegularUsers != nil { + settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers + } + + if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval { + settings.SnapshotInterval = *payload.SnapshotInterval + handler.JobScheduler.UpdateSnapshotJob(settings.SnapshotInterval) } - settings.AuthenticationMethod = portainer.AuthenticationMethod(payload.AuthenticationMethod) tlsError := handler.updateTLS(settings) if tlsError != nil { return tlsError diff --git a/api/http/server.go b/api/http/server.go index 53f438514..a6941d1f3 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -40,6 +40,7 @@ type Server struct { ComposeStackManager portainer.ComposeStackManager CryptoService portainer.CryptoService SignatureService portainer.DigitalSignatureService + JobScheduler portainer.JobScheduler DockerHubService portainer.DockerHubService EndpointService portainer.EndpointService EndpointGroupService portainer.EndpointGroupService @@ -120,6 +121,7 @@ func (server *Server) Start() error { settingsHandler.SettingsService = server.SettingsService settingsHandler.LDAPService = server.LDAPService settingsHandler.FileService = server.FileService + settingsHandler.JobScheduler = server.JobScheduler var stackHandler = stacks.NewHandler(requestBouncer) stackHandler.FileService = server.FileService diff --git a/api/portainer.go b/api/portainer.go index 07172ca89..b4a9b4e5f 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -31,12 +31,15 @@ type ( SSLCert *string SSLKey *string SyncInterval *string + Snapshot *bool + SnapshotInterval *string } // Status represents the application status. Status struct { Authentication bool `json:"Authentication"` EndpointManagement bool `json:"EndpointManagement"` + Snapshot bool `json:"Snapshot"` Analytics bool `json:"Analytics"` Version string `json:"Version"` } @@ -75,6 +78,8 @@ type ( LDAPSettings LDAPSettings `json:"LDAPSettings"` AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"` AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"` + SnapshotInterval string `json:"SnapshotInterval"` + // Deprecated fields DisplayDonationHeader bool DisplayExternalContributors bool @@ -177,6 +182,9 @@ type ( // EndpointType represents the type of an endpoint. EndpointType int + // EndpointStatus represents the status of an endpoint + EndpointStatus int + // Endpoint represents a Docker endpoint with all the info required // to connect to it. Endpoint struct { @@ -192,6 +200,8 @@ type ( Extensions []EndpointExtension `json:"Extensions"` AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"` Tags []string `json:"Tags"` + Status EndpointStatus `json:"Status"` + Snapshots []Snapshot `json:"Snapshots"` // Deprecated fields // Deprecated in DBVersion == 4 @@ -209,6 +219,21 @@ type ( AuthenticationKey string `json:"AuthenticationKey"` } + // 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"` + } + // EndpointGroupID represents an endpoint group identifier. EndpointGroupID int @@ -539,9 +564,17 @@ type ( ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error } - // EndpointWatcher represents a service to synchronize the endpoints via an external source. - EndpointWatcher interface { - WatchEndpointFile(endpointFilePath string) error + // 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) + Start() + } + + // Snapshotter represents a service used to create endpoint snapshots. + Snapshotter interface { + CreateSnapshot(endpoint *Endpoint) (*Snapshot, error) } // LDAPService represents a service used to authenticate users against a LDAP/AD. @@ -581,6 +614,8 @@ const ( // PortainerAgentSignatureMessage represents the message used to create a digital signature // to be used when communicating with an agent PortainerAgentSignatureMessage = "Portainer-App" + // SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer. + SupportedDockerAPIVersion = "1.24" ) const ( @@ -673,3 +708,11 @@ const ( // ComposeStackTemplate represents a template used to deploy a Compose stack ComposeStackTemplate ) + +const ( + _ EndpointStatus = iota + // EndpointStatusUp is used to represent an available endpoint + EndpointStatusUp + // EndpointStatusDown is used to represent an unavailable endpoint + EndpointStatusDown +) diff --git a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js index 6530a4bfc..325a3497d 100644 --- a/app/docker/components/dockerSidebarContent/docker-sidebar-content.js +++ b/app/docker/components/dockerSidebarContent/docker-sidebar-content.js @@ -4,9 +4,6 @@ angular.module('portainer.docker').component('dockerSidebarContent', { 'endpointApiVersion': '<', 'swarmManagement': '<', 'standaloneManagement': '<', - 'adminAccess': '<', - 'externalContributions': '<', - 'sidebarToggledOn': '<', - 'currentState': '<' + 'adminAccess': '<' } }); diff --git a/app/docker/views/dashboard/dashboard.html b/app/docker/views/dashboard/dashboard.html index 39513fef4..d93608e54 100644 --- a/app/docker/views/dashboard/dashboard.html +++ b/app/docker/views/dashboard/dashboard.html @@ -1,6 +1,6 @@ - - Dashboard + + Endpoint summary
diff --git a/app/portainer/__module.js b/app/portainer/__module.js index 9d8af9039..a2ad9053d 100644 --- a/app/portainer/__module.js +++ b/app/portainer/__module.js @@ -187,6 +187,17 @@ angular.module('portainer.app', []) } }; + var home = { + name: 'portainer.home', + url: '/home', + views: { + 'content@': { + templateUrl: 'app/portainer/views/home/home.html', + controller: 'HomeController' + } + } + }; + var registries = { name: 'portainer.registries', url: '/registries', @@ -404,6 +415,7 @@ angular.module('portainer.app', []) $stateRegistryProvider.register(group); $stateRegistryProvider.register(groupAccess); $stateRegistryProvider.register(groupCreation); + $stateRegistryProvider.register(home); $stateRegistryProvider.register(registries); $stateRegistryProvider.register(registry); $stateRegistryProvider.register(registryAccess); diff --git a/app/portainer/components/datatables/datatable.css b/app/portainer/components/datatables/datatable.css index d30d10ba5..8f95085d5 100644 --- a/app/portainer/components/datatables/datatable.css +++ b/app/portainer/components/datatables/datatable.css @@ -30,8 +30,9 @@ } .datatable .searchBar { - border-top: 1px solid #f6f6f6; - padding: 10px; + border-top: 1px solid #d2d1d1; + border-bottom: 1px solid #d2d1d1; + padding: 8px; } .datatable .searchInput { diff --git a/app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.html b/app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.html new file mode 100644 index 000000000..05881c6bd --- /dev/null +++ b/app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.html @@ -0,0 +1,113 @@ +
+ + +
+
+ {{ $ctrl.titleText }} +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Name + + + + + + Group + + + + + + Status + + + + + + Type + + + + + + Last snapshot + + + +
+ {{ item.Name }} + {{ item.GroupName }} + {{ item.Status === 1 ? 'up' : 'down' }} + + + + {{ item.Type | endpointtypename }} + + + + {{ item.Snapshots[0].Time | getisodatefromtimestamp }} + + - +
+ +
Loading...
No endpoint available.
+
+ +
+
+
diff --git a/app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.js b/app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.js new file mode 100644 index 000000000..a7d9c7711 --- /dev/null +++ b/app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.js @@ -0,0 +1,13 @@ +angular.module('portainer.app').component('endpointsSnapshotDatatable', { + templateUrl: 'app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.html', + controller: 'GenericDatatableController', + bindings: { + titleText: '@', + titleIcon: '@', + dataset: '<', + tableKey: '@', + orderBy: '@', + reverseOrder: '<', + dashboardAction: '<' + } +}); diff --git a/app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.html b/app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.html new file mode 100644 index 000000000..00efe0ae9 --- /dev/null +++ b/app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.html @@ -0,0 +1,29 @@ + + + {{ $ctrl.snapshot.StackCount }} stacks + + + {{ $ctrl.snapshot.ServiceCount }} services + + + {{ $ctrl.snapshot.RunningContainerCount + $ctrl.snapshot.StoppedContainerCount }} containers + + - + {{ $ctrl.snapshot.RunningContainerCount }} + {{ $ctrl.snapshot.StoppedContainerCount }} + + + + {{ $ctrl.snapshot.VolumeCount }} volumes + + + {{ $ctrl.snapshot.ImageCount }} images + + + {{ $ctrl.snapshot.TotalMemory | humansize }} + {{ $ctrl.snapshot.TotalCPU }} + + + {{ $ctrl.snapshot.Swarm ? 'Swarm' : 'Standalone' }} {{ $ctrl.snapshot.DockerVersion }} + + diff --git a/app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.js b/app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.js new file mode 100644 index 000000000..1190be07e --- /dev/null +++ b/app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.js @@ -0,0 +1,6 @@ +angular.module('portainer.app').component('snapshotDetails', { + templateUrl: 'app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.html', + bindings: { + snapshot: '<' + } +}); diff --git a/app/portainer/components/information-panel/information-panel.js b/app/portainer/components/information-panel/information-panel.js new file mode 100644 index 000000000..ea014f619 --- /dev/null +++ b/app/portainer/components/information-panel/information-panel.js @@ -0,0 +1,7 @@ +angular.module('portainer.app').component('informationPanel', { + templateUrl: 'app/portainer/components/information-panel/informationPanel.html', + bindings: { + titleText: '@' + }, + transclude: true +}); diff --git a/app/portainer/components/information-panel/informationPanel.html b/app/portainer/components/information-panel/informationPanel.html new file mode 100644 index 000000000..7407ec823 --- /dev/null +++ b/app/portainer/components/information-panel/informationPanel.html @@ -0,0 +1,14 @@ +
+
+ + +
+ {{ $ctrl.titleText }} +
+
+ +
+
+
+
+
diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js b/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js deleted file mode 100644 index 32f5ec116..000000000 --- a/app/portainer/components/sidebar-endpoint-selector/sidebar-endpoint-selector.js +++ /dev/null @@ -1,9 +0,0 @@ -angular.module('portainer.app').component('sidebarEndpointSelector', { - templateUrl: 'app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html', - controller: 'SidebarEndpointSelectorController', - bindings: { - 'endpoints': '<', - 'groups': '<', - 'selectEndpoint': '<' - } -}); diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html deleted file mode 100644 index 79332e2b0..000000000 --- a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
- -
-
-
- - -
-
- - -
-
-
diff --git a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js b/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js deleted file mode 100644 index ff8d54a57..000000000 --- a/app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelectorController.js +++ /dev/null @@ -1,34 +0,0 @@ -angular.module('portainer.app') -.controller('SidebarEndpointSelectorController', function () { - var ctrl = this; - - this.state = { - show: false, - selectedGroup: null, - selectedEndpoint: null - }; - - this.selectGroup = function() { - this.availableEndpoints = this.endpoints.filter(function f(endpoint) { - return endpoint.GroupId === ctrl.state.selectedGroup.Id; - }); - }; - - this.$onInit = function() { - this.availableGroups = filterEmptyGroups(this.groups, this.endpoints); - this.availableEndpoints = 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/filters/filters.js b/app/portainer/filters/filters.js index bda52fe63..b7d0eef52 100644 --- a/app/portainer/filters/filters.js +++ b/app/portainer/filters/filters.js @@ -138,4 +138,13 @@ angular.module('portainer.app') return 'fa fa-eye'; } }; +}) +.filter('endpointstatusbadge', function () { + 'use strict'; + return function (status) { + if (status === 2) { + return 'danger'; + } + return 'success'; + }; }); diff --git a/app/portainer/models/settings/settings.js b/app/portainer/models/settings/settings.js index 3597dd33b..a791a4ca2 100644 --- a/app/portainer/models/settings/settings.js +++ b/app/portainer/models/settings/settings.js @@ -5,4 +5,5 @@ function SettingsViewModel(data) { this.LDAPSettings = data.LDAPSettings; this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers; this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers; + this.SnapshotInterval = data.SnapshotInterval; } diff --git a/app/portainer/models/status.js b/app/portainer/models/status.js index 292971f26..f1302d391 100644 --- a/app/portainer/models/status.js +++ b/app/portainer/models/status.js @@ -1,5 +1,6 @@ function StatusViewModel(data) { this.Authentication = data.Authentication; + this.Snapshot = data.Snapshot; this.EndpointManagement = data.EndpointManagement; this.Analytics = data.Analytics; this.Version = data.Version; diff --git a/app/portainer/services/stateManager.js b/app/portainer/services/stateManager.js index afa35f2f1..557c0853c 100644 --- a/app/portainer/services/stateManager.js +++ b/app/portainer/services/stateManager.js @@ -25,12 +25,19 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin LocalStorage.storeApplicationState(state.application); }; + manager.updateSnapshotInterval = function(interval) { + state.application.snapshotInterval = interval; + LocalStorage.storeApplicationState(state.application); + }; + function assignStateFromStatusAndSettings(status, settings) { state.application.authentication = status.Authentication; state.application.analytics = status.Analytics; state.application.endpointManagement = status.EndpointManagement; + state.application.snapshot = status.Snapshot; state.application.version = status.Version; state.application.logo = settings.LogoURL; + state.application.snapshotInterval = settings.SnapshotInterval; state.application.validity = moment().unix(); } @@ -110,14 +117,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin return extensions; } - manager.updateEndpointState = function(loading, type, extensions) { + manager.updateEndpointState = function(name, type, extensions) { var deferred = $q.defer(); - if (loading) { - state.loading = true; - } - if (type === 3) { + state.endpoint.name = name; state.endpoint.mode = { provider: 'AZURE' }; LocalStorage.storeEndpointState(state.endpoint); deferred.resolve(); @@ -132,6 +136,7 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin var endpointMode = InfoHelper.determineEndpointMode(data.info, type); var endpointAPIVersion = parseFloat(data.version.ApiVersion); state.endpoint.mode = endpointMode; + state.endpoint.name = name; state.endpoint.apiVersion = endpointAPIVersion; state.endpoint.extensions = assignExtensions(extensions); LocalStorage.storeEndpointState(state.endpoint); diff --git a/app/portainer/views/auth/authController.js b/app/portainer/views/auth/authController.js index ab65f2163..3642bbf26 100644 --- a/app/portainer/views/auth/authController.js +++ b/app/portainer/views/auth/authController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('AuthenticationController', ['$scope', '$state', '$transition$', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'UserService', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'SettingsService', 'ExtensionManager', -function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentication, Users, UserService, EndpointService, StateManager, EndpointProvider, Notifications, SettingsService, ExtensionManager) { +.controller('AuthenticationController', ['$scope', '$state', '$transition$', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService', +function ($scope, $state, $transition$, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService) { $scope.logo = StateManager.getState().application.logo; @@ -13,47 +13,13 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica AuthenticationError: '' }; - function redirectToDockerDashboard(endpoint) { - ExtensionManager.initEndpointExtensions(endpoint.Id) - .then(function success(data) { - var extensions = data; - return StateManager.updateEndpointState(true, endpoint.Type, extensions); - }) - .then(function success(data) { - $state.go('docker.dashboard'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); - }); - } - - function redirectToAzureDashboard(endpoint) { - StateManager.updateEndpointState(false, endpoint.Type, []) - .then(function success(data) { - $state.go('azure.dashboard'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); - }); - } - - function redirectToDashboard(endpoint) { - EndpointProvider.setEndpointID(endpoint.Id); - - if (endpoint.Type === 3) { - return redirectToAzureDashboard(endpoint); - } - redirectToDockerDashboard(endpoint); - } - function unauthenticatedFlow() { EndpointService.endpoints() .then(function success(data) { - var endpoints = data; - if (endpoints.length > 0) { - redirectToDashboard(endpoints[0]); - } else { + if (endpoints.length === 0) { $state.go('portainer.init.endpoint'); + } else { + $state.go('portainer.home'); } }) .catch(function error(err) { @@ -92,13 +58,10 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica .then(function success(data) { var endpoints = data; var userDetails = Authentication.getUserDetails(); - if (endpoints.length > 0) { - redirectToDashboard(endpoints[0]); - } else if (endpoints.length === 0 && userDetails.role === 1) { + if (endpoints.length === 0 && userDetails.role === 1) { $state.go('portainer.init.endpoint'); - } else if (endpoints.length === 0 && userDetails.role === 2) { - Authentication.logout(); - $scope.state.AuthenticationError = 'User not allowed. Please contact your administrator.'; + } else { + $state.go('portainer.home'); } }) .catch(function error() { @@ -114,7 +77,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica } if (Authentication.isAuthenticated()) { - $state.go('docker.dashboard'); + $state.go('portainer.home'); } var authenticationEnabled = $scope.applicationState.application.authentication; diff --git a/app/portainer/views/home/home.html b/app/portainer/views/home/home.html new file mode 100644 index 000000000..eabdfe8a0 --- /dev/null +++ b/app/portainer/views/home/home.html @@ -0,0 +1,37 @@ + + + + + + + Endpoints + + + + +

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

+
+
+ + + +

+ + Endpoint snapshot is disabled. +

+
+
+ +
+
+ +
+
diff --git a/app/portainer/views/home/homeController.js b/app/portainer/views/home/homeController.js new file mode 100644 index 000000000..f9d3bfe5f --- /dev/null +++ b/app/portainer/views/home/homeController.js @@ -0,0 +1,58 @@ +angular.module('portainer.app') +.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager', +function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager) { + + $scope.goToDashboard = function(endpoint) { + EndpointProvider.setEndpointID(endpoint.Id); + EndpointProvider.setEndpointPublicURL(endpoint.PublicURL); + if (endpoint.Type === 3) { + switchToAzureEndpoint(endpoint); + } else { + switchToDockerEndpoint(endpoint); + } + }; + + function switchToAzureEndpoint(endpoint) { + StateManager.updateEndpointState(endpoint.Name, endpoint.Type, []) + .then(function success() { + $state.go('azure.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Azure endpoint'); + }); + } + + function switchToDockerEndpoint(endpoint) { + ExtensionManager.initEndpointExtensions(endpoint.Id) + .then(function success(data) { + var extensions = data; + return StateManager.updateEndpointState(endpoint.Name, endpoint.Type, extensions); + }) + .then(function success() { + $state.go('docker.dashboard'); + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint'); + }); + } + + function initView() { + $scope.isAdmin = Authentication.getUserDetails().role === 1; + + $q.all({ + endpoints: EndpointService.endpoints(), + groups: GroupService.groups() + }) + .then(function success(data) { + var endpoints = data.endpoints; + var groups = data.groups; + EndpointHelper.mapGroupNameToEndpoint(endpoints, groups); + $scope.endpoints = endpoints; + }) + .catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve endpoint information'); + }); + } + + initView(); +}]); diff --git a/app/portainer/views/init/admin/initAdminController.js b/app/portainer/views/init/admin/initAdminController.js index 4ea75c050..e43f8a0ec 100644 --- a/app/portainer/views/init/admin/initAdminController.js +++ b/app/portainer/views/init/admin/initAdminController.js @@ -1,6 +1,6 @@ angular.module('portainer.app') -.controller('InitAdminController', ['$scope', '$state', '$sanitize', 'Notifications', 'Authentication', 'StateManager', 'UserService', 'EndpointService', 'EndpointProvider', 'ExtensionManager', -function ($scope, $state, $sanitize, Notifications, Authentication, StateManager, UserService, EndpointService, EndpointProvider, ExtensionManager) { +.controller('InitAdminController', ['$scope', '$state', '$sanitize', 'Notifications', 'Authentication', 'StateManager', 'UserService', 'EndpointService', +function ($scope, $state, $sanitize, Notifications, Authentication, StateManager, UserService, EndpointService) { $scope.logo = StateManager.getState().application.logo; @@ -30,20 +30,7 @@ function ($scope, $state, $sanitize, Notifications, Authentication, StateManager if (data.length === 0) { $state.go('portainer.init.endpoint'); } else { - var endpoint = data[0]; - endpointID = endpoint.Id; - EndpointProvider.setEndpointID(endpointID); - ExtensionManager.initEndpointExtensions(endpointID) - .then(function success(data) { - var extensions = data; - return StateManager.updateEndpointState(false, endpoint.Type, extensions); - }) - .then(function success() { - $state.go('docker.dashboard'); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to connect to Docker environment'); - }); + $state.go('portainer.home'); } }) .catch(function error(err) { diff --git a/app/portainer/views/init/endpoint/initEndpointController.js b/app/portainer/views/init/endpoint/initEndpointController.js index a7e8f04bc..0c7bf1a03 100644 --- a/app/portainer/views/init/endpoint/initEndpointController.js +++ b/app/portainer/views/init/endpoint/initEndpointController.js @@ -1,9 +1,9 @@ angular.module('portainer.app') -.controller('InitEndpointController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'ExtensionManager', -function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notifications, ExtensionManager) { +.controller('InitEndpointController', ['$scope', '$state', 'EndpointService', 'StateManager', 'Notifications', +function ($scope, $state, EndpointService, StateManager, Notifications) { if (!_.isEmpty($scope.applicationState.endpoint)) { - $state.go('docker.dashboard'); + $state.go('portainer.home'); } $scope.logo = StateManager.getState().application.logo; @@ -36,16 +36,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.state.actionInProgress = true; EndpointService.createLocalEndpoint() .then(function success(data) { - endpoint = data; - EndpointProvider.setEndpointID(endpoint.Id); - return ExtensionManager.initEndpointExtensions(endpoint.Id); - }) - .then(function success(data) { - var extensions = data; - return StateManager.updateEndpointState(false, endpoint.Type, extensions); - }) - .then(function success(data) { - $state.go('docker.dashboard'); + $state.go('portainer.home'); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to connect to the Docker environment'); @@ -92,12 +83,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.state.actionInProgress = true; EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, 1, []) .then(function success(data) { - endpoint = data; - EndpointProvider.setEndpointID(endpoint.Id); - return StateManager.updateEndpointState(false, endpoint.Type, []); - }) - .then(function success(data) { - $state.go('azure.dashboard'); + $state.go('portainer.home'); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to connect to the Azure environment'); @@ -112,16 +98,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif $scope.state.actionInProgress = true; EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, [], TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile) .then(function success(data) { - endpoint = data; - EndpointProvider.setEndpointID(endpoint.Id); - return ExtensionManager.initEndpointExtensions(endpoint.Id); - }) - .then(function success(data) { - var extensions = data; - return StateManager.updateEndpointState(false, endpoint.Type, extensions); - }) - .then(function success(data) { - $state.go('docker.dashboard'); + $state.go('portainer.home'); }) .catch(function error(err) { Notifications.error('Failure', err, 'Unable to connect to the Docker environment'); diff --git a/app/portainer/views/settings/settings.html b/app/portainer/views/settings/settings.html index c2e92f68d..741f8d6cb 100644 --- a/app/portainer/views/settings/settings.html +++ b/app/portainer/views/settings/settings.html @@ -9,6 +9,14 @@
+ +
+ +
+ +
+
+
diff --git a/app/portainer/views/settings/settingsController.js b/app/portainer/views/settings/settingsController.js index 8885037c5..c2b7b2127 100644 --- a/app/portainer/views/settings/settingsController.js +++ b/app/portainer/views/settings/settingsController.js @@ -51,6 +51,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_ .then(function success(data) { Notifications.success('Settings updated'); StateManager.updateLogo(settings.LogoURL); + StateManager.updateSnapshotInterval(settings.SnapshotInterval); $state.reload(); }) .catch(function error(err) { diff --git a/app/portainer/views/sidebar/sidebar.html b/app/portainer/views/sidebar/sidebar.html index 32c5bed32..d105549c5 100644 --- a/app/portainer/views/sidebar/sidebar.html +++ b/app/portainer/views/sidebar/sidebar.html @@ -1,35 +1,32 @@