Merge branch 'release/1.22.0'

pull/3052/head 1.22.0
Anthony Lapenna 2019-07-26 14:01:58 +12:00
commit 0b2a76d75a
262 changed files with 4183 additions and 1319 deletions

View File

@ -21,24 +21,18 @@ Also, be sure to check our FAQ and documentation first: https://portainer.readth
--> -->
**Bug description** **Bug description**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**Expected behavior** **Expected behavior**
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
Briefly describe what you were expecting.
**Steps to reproduce the issue:** **Steps to reproduce the issue:**
Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
**Technical details:** **Technical details:**
* Portainer version: * Portainer version:
* Docker version (managed by Portainer): * Docker version (managed by Portainer):
* Platform (windows/linux): * Platform (windows/linux):

54
.github/stale.yml vendored Normal file
View File

@ -0,0 +1,54 @@
# Config for Stalebot, limited to only `issues`
only: issues
# Issues config
issues:
daysUntilStale: 60
daysUntilClose: 7
# Limit the number of actions per hour, from 1-30. Default is 30
limitPerRun: 30
# Issues with these labels will never be considered stale
exemptLabels:
- kind/enhancement
- kind/feature
- kind/question
- kind/style
- bug/need-confirmation
- bug/confirmed
- status/discuss
# Only issues with all of these labels are checked if stale. Defaults to `[]` (disabled)
onlyLabels: []
# Set to true to ignore issues in a project (defaults to false)
exemptProjects: true
# Set to true to ignore issues in a milestone (defaults to false)
exemptMilestones: true
# Set to true to ignore issues with an assignee (defaults to false)
exemptAssignees: true
# Label to use when marking an issue as stale
staleLabel: status/stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been marked as stale as it has not had recent activity,
it will be closed if no further activity occurs in the next 7 days.
If you believe that it has been incorrectly labelled as stale,
leave a comment and the label will be removed.
# Comment to post when removing the stale label.
# unmarkComment: >
# Your comment here.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: >
Since no further activity has appeared on this issue it will be closed.
If you believe that it has been incorrectly closed, leave a comment
and mention @itsconquest. One of our staff will then review the issue.
Note - If it is an old bug report, make sure that it is reproduceable in the
latest version of Portainer as it may have already been fixed.

View File

@ -5,6 +5,8 @@ import (
"path" "path"
"time" "time"
"github.com/portainer/portainer/api/bolt/tunnelserver"
"github.com/boltdb/bolt" "github.com/boltdb/bolt"
"github.com/portainer/portainer/api" "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/dockerhub" "github.com/portainer/portainer/api/bolt/dockerhub"
@ -51,6 +53,7 @@ type Store struct {
TeamMembershipService *teammembership.Service TeamMembershipService *teammembership.Service
TeamService *team.Service TeamService *team.Service
TemplateService *template.Service TemplateService *template.Service
TunnelServerService *tunnelserver.Service
UserService *user.Service UserService *user.Service
VersionService *version.Service VersionService *version.Service
WebhookService *webhook.Service WebhookService *webhook.Service
@ -220,6 +223,12 @@ func (store *Store) initServices() error {
} }
store.TemplateService = templateService store.TemplateService = templateService
tunnelServerService, err := tunnelserver.NewService(store.db)
if err != nil {
return err
}
store.TunnelServerService = tunnelServerService
userService, err := user.NewService(store.db) userService, err := user.NewService(store.db)
if err != nil { if err != nil {
return err return err

View File

@ -1,10 +1,9 @@
package endpoint package endpoint
import ( import (
"github.com/boltdb/bolt"
"github.com/portainer/portainer/api" "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/internal" "github.com/portainer/portainer/api/bolt/internal"
"github.com/boltdb/bolt"
) )
const ( const (
@ -64,7 +63,7 @@ func (service *Service) Endpoints() ([]portainer.Endpoint, error) {
cursor := bucket.Cursor() cursor := bucket.Cursor()
for k, v := cursor.First(); k != nil; k, v = cursor.Next() { for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
var endpoint portainer.Endpoint var endpoint portainer.Endpoint
err := internal.UnmarshalObject(v, &endpoint) err := internal.UnmarshalObjectWithJsoniter(v, &endpoint)
if err != nil { if err != nil {
return err return err
} }

View File

@ -2,6 +2,8 @@ package internal
import ( import (
"encoding/json" "encoding/json"
jsoniter "github.com/json-iterator/go"
) )
// MarshalObject encodes an object to binary format // MarshalObject encodes an object to binary format
@ -13,3 +15,11 @@ func MarshalObject(object interface{}) ([]byte, error) {
func UnmarshalObject(data []byte, object interface{}) error { func UnmarshalObject(data []byte, object interface{}) error {
return json.Unmarshal(data, object) return json.Unmarshal(data, object)
} }
// UnmarshalObjectWithJsoniter decodes an object from binary data
// using the jsoniter library. It is mainly used to accelerate endpoint
// decoding at the moment.
func UnmarshalObjectWithJsoniter(data []byte, object interface{}) error {
var jsoni = jsoniter.ConfigCompatibleWithStandardLibrary
return jsoni.Unmarshal(data, &object)
}

View File

@ -0,0 +1,16 @@
package migrator
import portainer "github.com/portainer/portainer/api"
func (m *Migrator) updateSettingsToDBVersion19() error {
legacySettings, err := m.settingsService.Settings()
if err != nil {
return err
}
if legacySettings.EdgeAgentCheckinInterval == 0 {
legacySettings.EdgeAgentCheckinInterval = portainer.DefaultEdgeAgentCheckinIntervalInSeconds
}
return m.settingsService.UpdateSettings(legacySettings)
}

View File

@ -249,5 +249,13 @@ func (m *Migrator) Migrate() error {
} }
} }
// Portainer 1.22.0
if m.currentDBVersion < 19 {
err := m.updateSettingsToDBVersion19()
if err != nil {
return err
}
}
return m.versionService.StoreDBVersion(portainer.DBVersion) return m.versionService.StoreDBVersion(portainer.DBVersion)
} }

View File

@ -0,0 +1,48 @@
package tunnelserver
import (
"github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt/internal"
"github.com/boltdb/bolt"
)
const (
// BucketName represents the name of the bucket where this service stores data.
BucketName = "tunnel_server"
infoKey = "INFO"
)
// 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
}
// Info retrieve the TunnelServerInfo object.
func (service *Service) Info() (*portainer.TunnelServerInfo, error) {
var info portainer.TunnelServerInfo
err := internal.GetObject(service.db, BucketName, []byte(infoKey), &info)
if err != nil {
return nil, err
}
return &info, nil
}
// UpdateInfo persists a TunnelServerInfo object.
func (service *Service) UpdateInfo(settings *portainer.TunnelServerInfo) error {
return internal.UpdateObject(service.db, BucketName, []byte(infoKey), settings)
}

24
api/chisel/key.go Normal file
View File

@ -0,0 +1,24 @@
package chisel
import (
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// GenerateEdgeKey will generate a key that can be used by an Edge agent to register with a Portainer instance.
// The key represents the following data in this particular format:
// portainer_instance_url|tunnel_server_addr|tunnel_server_fingerprint|endpoint_ID
// The key returned by this function is a base64 encoded version of the data.
func (service *Service) GenerateEdgeKey(url, host string, endpointIdentifier int) string {
keyInformation := []string{
url,
fmt.Sprintf("%s:%s", host, service.serverPort),
service.serverFingerprint,
strconv.Itoa(endpointIdentifier),
}
key := strings.Join(keyInformation, "|")
return base64.RawStdEncoding.EncodeToString([]byte(key))
}

47
api/chisel/schedules.go Normal file
View File

@ -0,0 +1,47 @@
package chisel
import (
"strconv"
portainer "github.com/portainer/portainer/api"
)
// AddSchedule register a schedule inside the tunnel details associated to an endpoint.
func (service *Service) AddSchedule(endpointID portainer.EndpointID, schedule *portainer.EdgeSchedule) {
tunnel := service.GetTunnelDetails(endpointID)
existingScheduleIndex := -1
for idx, existingSchedule := range tunnel.Schedules {
if existingSchedule.ID == schedule.ID {
existingScheduleIndex = idx
break
}
}
if existingScheduleIndex == -1 {
tunnel.Schedules = append(tunnel.Schedules, *schedule)
} else {
tunnel.Schedules[existingScheduleIndex] = *schedule
}
key := strconv.Itoa(int(endpointID))
service.tunnelDetailsMap.Set(key, tunnel)
}
// RemoveSchedule will remove the specified schedule from each tunnel it was registered with.
func (service *Service) RemoveSchedule(scheduleID portainer.ScheduleID) {
for item := range service.tunnelDetailsMap.IterBuffered() {
tunnelDetails := item.Val.(*portainer.TunnelDetails)
updatedSchedules := make([]portainer.EdgeSchedule, 0)
for _, schedule := range tunnelDetails.Schedules {
if schedule.ID == scheduleID {
continue
}
updatedSchedules = append(updatedSchedules, schedule)
}
tunnelDetails.Schedules = updatedSchedules
service.tunnelDetailsMap.Set(item.Key, tunnelDetails)
}
}

191
api/chisel/service.go Normal file
View File

@ -0,0 +1,191 @@
package chisel
import (
"fmt"
"log"
"strconv"
"time"
"github.com/dchest/uniuri"
cmap "github.com/orcaman/concurrent-map"
chserver "github.com/jpillora/chisel/server"
portainer "github.com/portainer/portainer/api"
)
const (
tunnelCleanupInterval = 10 * time.Second
requiredTimeout = 15 * time.Second
activeTimeout = 4*time.Minute + 30*time.Second
)
// Service represents a service to manage the state of multiple reverse tunnels.
// It is used to start a reverse tunnel server and to manage the connection status of each tunnel
// connected to the tunnel server.
type Service struct {
serverFingerprint string
serverPort string
tunnelDetailsMap cmap.ConcurrentMap
endpointService portainer.EndpointService
tunnelServerService portainer.TunnelServerService
snapshotter portainer.Snapshotter
chiselServer *chserver.Server
}
// NewService returns a pointer to a new instance of Service
func NewService(endpointService portainer.EndpointService, tunnelServerService portainer.TunnelServerService) *Service {
return &Service{
tunnelDetailsMap: cmap.New(),
endpointService: endpointService,
tunnelServerService: tunnelServerService,
}
}
// StartTunnelServer starts a tunnel server on the specified addr and port.
// It uses a seed to generate a new private/public key pair. If the seed cannot
// be found inside the database, it will generate a new one randomly and persist it.
// It starts the tunnel status verification process in the background.
// The snapshotter is used in the tunnel status verification process.
func (service *Service) StartTunnelServer(addr, port string, snapshotter portainer.Snapshotter) error {
keySeed, err := service.retrievePrivateKeySeed()
if err != nil {
return err
}
config := &chserver.Config{
Reverse: true,
KeySeed: keySeed,
}
chiselServer, err := chserver.NewServer(config)
if err != nil {
return err
}
service.serverFingerprint = chiselServer.GetFingerprint()
service.serverPort = port
err = chiselServer.Start(addr, port)
if err != nil {
return err
}
service.chiselServer = chiselServer
// TODO: work-around Chisel default behavior.
// By default, Chisel will allow anyone to connect if no user exists.
username, password := generateRandomCredentials()
err = service.chiselServer.AddUser(username, password, "127.0.0.1")
if err != nil {
return err
}
service.snapshotter = snapshotter
go service.startTunnelVerificationLoop()
return nil
}
func (service *Service) retrievePrivateKeySeed() (string, error) {
var serverInfo *portainer.TunnelServerInfo
serverInfo, err := service.tunnelServerService.Info()
if err == portainer.ErrObjectNotFound {
keySeed := uniuri.NewLen(16)
serverInfo = &portainer.TunnelServerInfo{
PrivateKeySeed: keySeed,
}
err := service.tunnelServerService.UpdateInfo(serverInfo)
if err != nil {
return "", err
}
} else if err != nil {
return "", err
}
return serverInfo.PrivateKeySeed, nil
}
func (service *Service) startTunnelVerificationLoop() {
log.Printf("[DEBUG] [chisel, monitoring] [check_interval_seconds: %f] [message: starting tunnel management process]", tunnelCleanupInterval.Seconds())
ticker := time.NewTicker(tunnelCleanupInterval)
stopSignal := make(chan struct{})
for {
select {
case <-ticker.C:
service.checkTunnels()
case <-stopSignal:
ticker.Stop()
return
}
}
}
func (service *Service) checkTunnels() {
for item := range service.tunnelDetailsMap.IterBuffered() {
tunnel := item.Val.(*portainer.TunnelDetails)
if tunnel.LastActivity.IsZero() || tunnel.Status == portainer.EdgeAgentIdle {
continue
}
elapsed := time.Since(tunnel.LastActivity)
log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [message: endpoint tunnel monitoring]", item.Key, tunnel.Status, elapsed.Seconds())
if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed.Seconds() < requiredTimeout.Seconds() {
continue
} else if tunnel.Status == portainer.EdgeAgentManagementRequired && elapsed.Seconds() > requiredTimeout.Seconds() {
log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [timeout_seconds: %f] [message: REQUIRED state timeout exceeded]", item.Key, tunnel.Status, elapsed.Seconds(), requiredTimeout.Seconds())
}
if tunnel.Status == portainer.EdgeAgentActive && elapsed.Seconds() < activeTimeout.Seconds() {
continue
} else if tunnel.Status == portainer.EdgeAgentActive && elapsed.Seconds() > activeTimeout.Seconds() {
log.Printf("[DEBUG] [chisel,monitoring] [endpoint_id: %s] [status: %s] [status_time_seconds: %f] [timeout_seconds: %f] [message: ACTIVE state timeout exceeded]", item.Key, tunnel.Status, elapsed.Seconds(), activeTimeout.Seconds())
endpointID, err := strconv.Atoi(item.Key)
if err != nil {
log.Printf("[ERROR] [chisel,snapshot,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err)
}
err = service.snapshotEnvironment(portainer.EndpointID(endpointID), tunnel.Port)
if err != nil {
log.Printf("[ERROR] [snapshot] Unable to snapshot Edge endpoint (id: %s): %s", item.Key, err)
}
}
if len(tunnel.Schedules) > 0 {
endpointID, err := strconv.Atoi(item.Key)
if err != nil {
log.Printf("[ERROR] [chisel,conversion] Invalid endpoint identifier (id: %s): %s", item.Key, err)
continue
}
service.SetTunnelStatusToIdle(portainer.EndpointID(endpointID))
} else {
service.tunnelDetailsMap.Remove(item.Key)
}
}
}
func (service *Service) snapshotEnvironment(endpointID portainer.EndpointID, tunnelPort int) error {
endpoint, err := service.endpointService.Endpoint(portainer.EndpointID(endpointID))
if err != nil {
return err
}
endpointURL := endpoint.URL
endpoint.URL = fmt.Sprintf("tcp://localhost:%d", tunnelPort)
snapshot, err := service.snapshotter.CreateSnapshot(endpoint)
if err != nil {
return err
}
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
endpoint.URL = endpointURL
return service.endpointService.UpdateEndpoint(endpoint.ID, endpoint)
}

144
api/chisel/tunnel.go Normal file
View File

@ -0,0 +1,144 @@
package chisel
import (
"encoding/base64"
"fmt"
"math/rand"
"strconv"
"strings"
"time"
"github.com/portainer/libcrypto"
"github.com/dchest/uniuri"
portainer "github.com/portainer/portainer/api"
)
const (
minAvailablePort = 49152
maxAvailablePort = 65535
)
// getUnusedPort is used to generate an unused random port in the dynamic port range.
// Dynamic ports (also called private ports) are 49152 to 65535.
func (service *Service) getUnusedPort() int {
port := randomInt(minAvailablePort, maxAvailablePort)
for item := range service.tunnelDetailsMap.IterBuffered() {
tunnel := item.Val.(*portainer.TunnelDetails)
if tunnel.Port == port {
return service.getUnusedPort()
}
}
return port
}
func randomInt(min, max int) int {
return min + rand.Intn(max-min)
}
// GetTunnelDetails returns information about the tunnel associated to an endpoint.
func (service *Service) GetTunnelDetails(endpointID portainer.EndpointID) *portainer.TunnelDetails {
key := strconv.Itoa(int(endpointID))
if item, ok := service.tunnelDetailsMap.Get(key); ok {
tunnelDetails := item.(*portainer.TunnelDetails)
return tunnelDetails
}
schedules := make([]portainer.EdgeSchedule, 0)
return &portainer.TunnelDetails{
Status: portainer.EdgeAgentIdle,
Port: 0,
Schedules: schedules,
Credentials: "",
}
}
// SetTunnelStatusToActive update the status of the tunnel associated to the specified endpoint.
// It sets the status to ACTIVE.
func (service *Service) SetTunnelStatusToActive(endpointID portainer.EndpointID) {
tunnel := service.GetTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentActive
tunnel.Credentials = ""
tunnel.LastActivity = time.Now()
key := strconv.Itoa(int(endpointID))
service.tunnelDetailsMap.Set(key, tunnel)
}
// SetTunnelStatusToIdle update the status of the tunnel associated to the specified endpoint.
// It sets the status to IDLE.
// It removes any existing credentials associated to the tunnel.
func (service *Service) SetTunnelStatusToIdle(endpointID portainer.EndpointID) {
tunnel := service.GetTunnelDetails(endpointID)
tunnel.Status = portainer.EdgeAgentIdle
tunnel.Port = 0
tunnel.LastActivity = time.Now()
credentials := tunnel.Credentials
if credentials != "" {
tunnel.Credentials = ""
service.chiselServer.DeleteUser(strings.Split(credentials, ":")[0])
}
key := strconv.Itoa(int(endpointID))
service.tunnelDetailsMap.Set(key, tunnel)
}
// SetTunnelStatusToRequired update the status of the tunnel associated to the specified endpoint.
// It sets the status to REQUIRED.
// If no port is currently associated to the tunnel, it will associate a random unused port to the tunnel
// and generate temporary credentials that can be used to establish a reverse tunnel on that port.
// Credentials are encrypted using the Edge ID associated to the endpoint.
func (service *Service) SetTunnelStatusToRequired(endpointID portainer.EndpointID) error {
tunnel := service.GetTunnelDetails(endpointID)
if tunnel.Port == 0 {
endpoint, err := service.endpointService.Endpoint(endpointID)
if err != nil {
return err
}
tunnel.Status = portainer.EdgeAgentManagementRequired
tunnel.Port = service.getUnusedPort()
tunnel.LastActivity = time.Now()
username, password := generateRandomCredentials()
authorizedRemote := fmt.Sprintf("^R:0.0.0.0:%d$", tunnel.Port)
err = service.chiselServer.AddUser(username, password, authorizedRemote)
if err != nil {
return err
}
credentials, err := encryptCredentials(username, password, endpoint.EdgeID)
if err != nil {
return err
}
tunnel.Credentials = credentials
key := strconv.Itoa(int(endpointID))
service.tunnelDetailsMap.Set(key, tunnel)
}
return nil
}
func generateRandomCredentials() (string, string) {
username := uniuri.NewLen(8)
password := uniuri.NewLen(8)
return username, password
}
func encryptCredentials(username, password, key string) (string, error) {
credentials := fmt.Sprintf("%s:%s", username, password)
encryptedCredentials, err := libcrypto.Encrypt([]byte(credentials), []byte(key))
if err != nil {
return "", err
}
return base64.RawStdEncoding.EncodeToString(encryptedCredentials), nil
}

View File

@ -33,6 +33,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
flags := &portainer.CLIFlags{ flags := &portainer.CLIFlags{
Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(), Addr: kingpin.Flag("bind", "Address and port to serve Portainer").Default(defaultBindAddress).Short('p').String(),
TunnelAddr: kingpin.Flag("tunnel-addr", "Address to serve the tunnel server").Default(defaultTunnelServerAddress).String(),
TunnelPort: kingpin.Flag("tunnel-port", "Port to serve the tunnel server").Default(defaultTunnelServerPort).String(),
Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(), Assets: kingpin.Flag("assets", "Path to the assets").Default(defaultAssetsDirectory).Short('a').String(),
Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(), Data: kingpin.Flag("data", "Path to the folder where the data is stored").Default(defaultDataDirectory).Short('d').String(),
EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(), EndpointURL: kingpin.Flag("host", "Endpoint URL").Short('H').String(),

View File

@ -4,6 +4,8 @@ package cli
const ( const (
defaultBindAddress = ":9000" defaultBindAddress = ":9000"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "/data" defaultDataDirectory = "/data"
defaultAssetsDirectory = "./" defaultAssetsDirectory = "./"
defaultNoAuth = "false" defaultNoAuth = "false"

View File

@ -2,6 +2,8 @@ package cli
const ( const (
defaultBindAddress = ":9000" defaultBindAddress = ":9000"
defaultTunnelServerAddress = "0.0.0.0"
defaultTunnelServerPort = "8000"
defaultDataDirectory = "C:\\data" defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "./" defaultAssetsDirectory = "./"
defaultNoAuth = "false" defaultNoAuth = "false"

View File

@ -2,10 +2,13 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/portainer/portainer/api/chisel"
"github.com/portainer/portainer/api" "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/bolt" "github.com/portainer/portainer/api/bolt"
"github.com/portainer/portainer/api/cli" "github.com/portainer/portainer/api/cli"
@ -20,8 +23,6 @@ import (
"github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/api/ldap" "github.com/portainer/portainer/api/ldap"
"github.com/portainer/portainer/api/libcompose" "github.com/portainer/portainer/api/libcompose"
"log"
) )
func initCLI() *portainer.CLIFlags { func initCLI() *portainer.CLIFlags {
@ -69,12 +70,12 @@ func initStore(dataStorePath string, fileService portainer.FileService) *bolt.St
return store return store
} }
func initComposeStackManager(dataStorePath string) portainer.ComposeStackManager { func initComposeStackManager(dataStorePath string, reverseTunnelService portainer.ReverseTunnelService) portainer.ComposeStackManager {
return libcompose.NewComposeStackManager(dataStorePath) return libcompose.NewComposeStackManager(dataStorePath, reverseTunnelService)
} }
func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (portainer.SwarmStackManager, error) { func initSwarmStackManager(assetsPath string, dataStorePath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (portainer.SwarmStackManager, error) {
return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService) return exec.NewSwarmStackManager(assetsPath, dataStorePath, signatureService, fileService, reverseTunnelService)
} }
func initJWTService(authenticationEnabled bool) portainer.JWTService { func initJWTService(authenticationEnabled bool) portainer.JWTService {
@ -104,8 +105,8 @@ func initGitService() portainer.GitService {
return &git.Service{} return &git.Service{}
} }
func initClientFactory(signatureService portainer.DigitalSignatureService) *docker.ClientFactory { func initClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *docker.ClientFactory {
return docker.NewClientFactory(signatureService) return docker.NewClientFactory(signatureService, reverseTunnelService)
} }
func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter { func initSnapshotter(clientFactory *docker.ClientFactory) portainer.Snapshotter {
@ -196,7 +197,7 @@ func loadEndpointSyncSystemSchedule(jobScheduler portainer.JobScheduler, schedul
return scheduleService.CreateSchedule(endpointSyncSchedule) return scheduleService.CreateSchedule(endpointSyncSchedule)
} }
func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService) error { func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService portainer.JobService, scheduleService portainer.ScheduleService, endpointService portainer.EndpointService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) error {
schedules, err := scheduleService.Schedules() schedules, err := scheduleService.Schedules()
if err != nil { if err != nil {
return err return err
@ -213,6 +214,13 @@ func loadSchedulesFromDatabase(jobScheduler portainer.JobScheduler, jobService p
return err return err
} }
} }
if schedule.EdgeSchedule != nil {
for _, endpointID := range schedule.EdgeSchedule.Endpoints {
reverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule)
}
}
} }
return nil return nil
@ -265,6 +273,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
AllowPrivilegedModeForRegularUsers: true, AllowPrivilegedModeForRegularUsers: true,
EnableHostManagementFeatures: false, EnableHostManagementFeatures: false,
SnapshotInterval: *flags.SnapshotInterval, SnapshotInterval: *flags.SnapshotInterval,
EdgeAgentCheckinInterval: portainer.DefaultEdgeAgentCheckinIntervalInSeconds,
} }
if *flags.Templates != "" { if *flags.Templates != "" {
@ -540,7 +549,9 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
clientFactory := initClientFactory(digitalSignatureService) reverseTunnelService := chisel.NewService(store.EndpointService, store.TunnelServerService)
clientFactory := initClientFactory(digitalSignatureService, reverseTunnelService)
jobService := initJobService(clientFactory) jobService := initJobService(clientFactory)
@ -551,12 +562,12 @@ func main() {
endpointManagement = false endpointManagement = false
} }
swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService) swarmStackManager, err := initSwarmStackManager(*flags.Assets, *flags.Data, digitalSignatureService, fileService, reverseTunnelService)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
composeStackManager := initComposeStackManager(*flags.Data) composeStackManager := initComposeStackManager(*flags.Data, reverseTunnelService)
err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile) err = initTemplates(store.TemplateService, fileService, *flags.Templates, *flags.TemplateFile)
if err != nil { if err != nil {
@ -570,7 +581,7 @@ func main() {
jobScheduler := initJobScheduler() jobScheduler := initJobScheduler()
err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService) err = loadSchedulesFromDatabase(jobScheduler, jobService, store.ScheduleService, store.EndpointService, fileService, reverseTunnelService)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
@ -658,7 +669,13 @@ func main() {
go terminateIfNoAdminCreated(store.UserService) go terminateIfNoAdminCreated(store.UserService)
} }
err = reverseTunnelService.StartTunnelServer(*flags.TunnelAddr, *flags.TunnelPort, snapshotter)
if err != nil {
log.Fatal(err)
}
var server portainer.Server = &http.Server{ var server portainer.Server = &http.Server{
ReverseTunnelService: reverseTunnelService,
Status: applicationStatus, Status: applicationStatus,
BindAddress: *flags.Addr, BindAddress: *flags.Addr,
AssetsPath: *flags.Assets, AssetsPath: *flags.Assets,

View File

@ -53,7 +53,7 @@ func (runner *SnapshotJobRunner) Run() {
} }
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment { if endpoint.Type == portainer.AzureEnvironment || endpoint.Type == portainer.EdgeAgentEnvironment {
continue continue
} }

View File

@ -8,6 +8,8 @@ import (
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"math/big" "math/big"
"github.com/portainer/libcrypto"
) )
const ( const (
@ -111,7 +113,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) {
message = service.secret message = service.secret
} }
hash := HashFromBytes([]byte(message)) hash := libcrypto.HashFromBytes([]byte(message))
r := big.NewInt(0) r := big.NewInt(0)
s := big.NewInt(0) s := big.NewInt(0)

View File

@ -1,10 +0,0 @@
package crypto
import "crypto/md5"
// HashFromBytes returns the hash of the specified data
func HashFromBytes(data []byte) []byte {
digest := md5.New()
digest.Write(data)
return digest.Sum(nil)
}

View File

@ -1,6 +1,7 @@
package docker package docker
import ( import (
"fmt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -12,17 +13,20 @@ import (
const ( const (
unsupportedEnvironmentType = portainer.Error("Environment not supported") unsupportedEnvironmentType = portainer.Error("Environment not supported")
defaultDockerRequestTimeout = 60
) )
// ClientFactory is used to create Docker clients // ClientFactory is used to create Docker clients
type ClientFactory struct { type ClientFactory struct {
signatureService portainer.DigitalSignatureService signatureService portainer.DigitalSignatureService
reverseTunnelService portainer.ReverseTunnelService
} }
// NewClientFactory returns a new instance of a ClientFactory // NewClientFactory returns a new instance of a ClientFactory
func NewClientFactory(signatureService portainer.DigitalSignatureService) *ClientFactory { func NewClientFactory(signatureService portainer.DigitalSignatureService, reverseTunnelService portainer.ReverseTunnelService) *ClientFactory {
return &ClientFactory{ return &ClientFactory{
signatureService: signatureService, signatureService: signatureService,
reverseTunnelService: reverseTunnelService,
} }
} }
@ -34,6 +38,8 @@ func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint, nodeNam
return nil, unsupportedEnvironmentType return nil, unsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment { } else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService, nodeName) return createAgentClient(endpoint, factory.signatureService, nodeName)
} else if endpoint.Type == portainer.EdgeAgentEnvironment {
return createEdgeClient(endpoint, factory.reverseTunnelService, nodeName)
} }
if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") { if strings.HasPrefix(endpoint.URL, "unix://") || strings.HasPrefix(endpoint.URL, "npipe://") {
@ -62,6 +68,28 @@ func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
) )
} }
func createEdgeClient(endpoint *portainer.Endpoint, reverseTunnelService portainer.ReverseTunnelService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
if err != nil {
return nil, err
}
headers := map[string]string{}
if nodeName != "" {
headers[portainer.PortainerAgentTargetHeader] = nodeName
}
tunnel := reverseTunnelService.GetTunnelDetails(endpoint.ID)
endpointURL := fmt.Sprintf("http://localhost:%d", tunnel.Port)
return client.NewClientWithOpts(
client.WithHost(endpointURL),
client.WithVersion(portainer.SupportedDockerAPIVersion),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)
}
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) { func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService, nodeName string) (*client.Client, error) {
httpCli, err := httpClient(endpoint) httpCli, err := httpClient(endpoint)
if err != nil { if err != nil {
@ -103,6 +131,6 @@ func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
return &http.Client{ return &http.Client{
Transport: transport, Transport: transport,
Timeout: 30 * time.Second, Timeout: defaultDockerRequestTimeout * time.Second,
}, nil }, nil
} }

View File

@ -3,6 +3,7 @@ package exec
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path" "path"
@ -17,16 +18,18 @@ type SwarmStackManager struct {
dataPath string dataPath string
signatureService portainer.DigitalSignatureService signatureService portainer.DigitalSignatureService
fileService portainer.FileService fileService portainer.FileService
reverseTunnelService portainer.ReverseTunnelService
} }
// NewSwarmStackManager initializes a new SwarmStackManager service. // NewSwarmStackManager initializes a new SwarmStackManager service.
// It also updates the configuration of the Docker CLI binary. // It also updates the configuration of the Docker CLI binary.
func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService) (*SwarmStackManager, error) { func NewSwarmStackManager(binaryPath, dataPath string, signatureService portainer.DigitalSignatureService, fileService portainer.FileService, reverseTunnelService portainer.ReverseTunnelService) (*SwarmStackManager, error) {
manager := &SwarmStackManager{ manager := &SwarmStackManager{
binaryPath: binaryPath, binaryPath: binaryPath,
dataPath: dataPath, dataPath: dataPath,
signatureService: signatureService, signatureService: signatureService,
fileService: fileService, fileService: fileService,
reverseTunnelService: reverseTunnelService,
} }
err := manager.updateDockerCLIConfiguration(dataPath) err := manager.updateDockerCLIConfiguration(dataPath)
@ -39,7 +42,7 @@ func NewSwarmStackManager(binaryPath, dataPath string, signatureService portaine
// Login executes the docker login command against a list of registries (including DockerHub). // Login executes the docker login command against a list of registries (including DockerHub).
func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) { func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registries []portainer.Registry, endpoint *portainer.Endpoint) {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
for _, registry := range registries { for _, registry := range registries {
if registry.Authentication { if registry.Authentication {
registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL) registryArgs := append(args, "login", "--username", registry.Username, "--password", registry.Password, registry.URL)
@ -55,7 +58,7 @@ func (manager *SwarmStackManager) Login(dockerhub *portainer.DockerHub, registri
// Logout executes the docker logout command. // Logout executes the docker logout command.
func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
args = append(args, "logout") args = append(args, "logout")
return runCommandAndCaptureStdErr(command, args, nil, "") return runCommandAndCaptureStdErr(command, args, nil, "")
} }
@ -63,7 +66,7 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error {
// Deploy executes the docker stack deploy command. // Deploy executes the docker stack deploy command.
func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error { func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, endpoint *portainer.Endpoint) error {
stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint) stackFilePath := path.Join(stack.ProjectPath, stack.EntryPoint)
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
if prune { if prune {
args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name) args = append(args, "stack", "deploy", "--prune", "--with-registry-auth", "--compose-file", stackFilePath, stack.Name)
@ -82,7 +85,7 @@ func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, end
// Remove executes the docker stack rm command. // Remove executes the docker stack rm command.
func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (manager *SwarmStackManager) Remove(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
command, args := prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint) command, args := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.dataPath, endpoint)
args = append(args, "stack", "rm", stack.Name) args = append(args, "stack", "rm", stack.Name)
return runCommandAndCaptureStdErr(command, args, nil, "") return runCommandAndCaptureStdErr(command, args, nil, "")
} }
@ -106,7 +109,7 @@ func runCommandAndCaptureStdErr(command string, args []string, env []string, wor
return nil return nil
} }
func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) { func (manager *SwarmStackManager) prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portainer.Endpoint) (string, []string) {
// Assume Linux as a default // Assume Linux as a default
command := path.Join(binaryPath, "docker") command := path.Join(binaryPath, "docker")
@ -116,7 +119,14 @@ func prepareDockerCommandAndArgs(binaryPath, dataPath string, endpoint *portaine
args := make([]string, 0) args := make([]string, 0)
args = append(args, "--config", dataPath) args = append(args, "--config", dataPath)
args = append(args, "-H", endpoint.URL)
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentEnvironment {
tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID)
endpointURL = fmt.Sprintf("tcp://localhost:%d", tunnel.Port)
}
args = append(args, "-H", endpointURL)
if endpoint.TLSConfig.TLS { if endpoint.TLSConfig.TLS {
args = append(args, "--tls") args = append(args, "--tls")

View File

@ -53,12 +53,18 @@ func (handler *Handler) endpointGroupCreate(w http.ResponseWriter, r *http.Reque
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
} }
for _, id := range payload.AssociatedEndpoints {
for _, endpoint := range endpoints { for _, endpoint := range endpoints {
if endpoint.GroupID == portainer.EndpointGroupID(1) { if endpoint.ID == id {
err = handler.checkForGroupAssignment(endpoint, endpointGroup.ID, payload.AssociatedEndpoints) endpoint.GroupID = endpointGroup.ID
err := handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err}
} }
break
}
} }
} }

View File

@ -0,0 +1,46 @@
package endpointgroups
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
)
// PUT request on /api/endpoint_groups/:id/endpoints/:endpointId
func (handler *Handler) endpointGroupAddEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err}
}
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
endpointGroup, err := handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err}
}
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}
}
endpoint.GroupID = endpointGroup.ID
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
return response.Empty(w)
}

View File

@ -0,0 +1,46 @@
package endpointgroups
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
)
// DELETE request on /api/endpoint_groups/:id/endpoints/:endpointId
func (handler *Handler) endpointGroupDeleteEndpoint(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpointGroupID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint group identifier route variable", err}
}
endpointID, err := request.RetrieveNumericRouteVariableValue(r, "endpointId")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint identifier route variable", err}
}
_, err = handler.EndpointGroupService.EndpointGroup(portainer.EndpointGroupID(endpointGroupID))
if err == portainer.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an endpoint group with the specified identifier inside the database", err}
} else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint group with the specified identifier inside the database", err}
}
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}
}
endpoint.GroupID = portainer.EndpointGroupID(1)
err = handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint changes inside the database", err}
}
return response.Empty(w)
}

View File

@ -12,7 +12,6 @@ import (
type endpointGroupUpdatePayload struct { type endpointGroupUpdatePayload struct {
Name string Name string
Description string Description string
AssociatedEndpoints []portainer.EndpointID
Tags []string Tags []string
UserAccessPolicies portainer.UserAccessPolicies UserAccessPolicies portainer.UserAccessPolicies
TeamAccessPolicies portainer.TeamAccessPolicies TeamAccessPolicies portainer.TeamAccessPolicies
@ -67,19 +66,5 @@ func (handler *Handler) endpointGroupUpdate(w http.ResponseWriter, r *http.Reque
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint group changes inside the database", err}
} }
if payload.AssociatedEndpoints != nil {
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 {
err = handler.updateEndpointGroup(endpoint, portainer.EndpointGroupID(endpointGroupID), payload.AssociatedEndpoints)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update endpoint", err}
}
}
}
return response.JSON(w, endpointGroup) return response.JSON(w, endpointGroup)
} }

View File

@ -31,37 +31,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut) bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupUpdate))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}", h.Handle("/endpoint_groups/{id}",
bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete) bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDelete))).Methods(http.MethodDelete)
h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}",
bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupAddEndpoint))).Methods(http.MethodPut)
h.Handle("/endpoint_groups/{id}/endpoints/{endpointId}",
bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointGroupDeleteEndpoint))).Methods(http.MethodDelete)
return h return h
} }
func (handler *Handler) checkForGroupUnassignment(endpoint portainer.Endpoint, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
return nil
}
}
endpoint.GroupID = portainer.EndpointGroupID(1)
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
func (handler *Handler) checkForGroupAssignment(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
for _, id := range associatedEndpoints {
if id == endpoint.ID {
endpoint.GroupID = groupID
return handler.EndpointService.UpdateEndpoint(endpoint.ID, &endpoint)
}
}
return nil
}
func (handler *Handler) updateEndpointGroup(endpoint portainer.Endpoint, groupID portainer.EndpointGroupID, associatedEndpoints []portainer.EndpointID) error {
if endpoint.GroupID == groupID {
return handler.checkForGroupUnassignment(endpoint, associatedEndpoints)
} else if endpoint.GroupID == portainer.EndpointGroupID(1) {
return handler.checkForGroupAssignment(endpoint, groupID, associatedEndpoints)
}
return nil
}

View File

@ -13,7 +13,9 @@ type Handler struct {
*mux.Router *mux.Router
requestBouncer *security.RequestBouncer requestBouncer *security.RequestBouncer
EndpointService portainer.EndpointService EndpointService portainer.EndpointService
SettingsService portainer.SettingsService
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
ReverseTunnelService portainer.ReverseTunnelService
} }
// NewHandler creates a handler to proxy requests to external APIs. // NewHandler creates a handler to proxy requests to external APIs.

View File

@ -29,7 +29,7 @@ func (handler *Handler) proxyRequestsToAzureAPI(w http.ResponseWriter, r *http.R
} }
var proxy http.Handler var proxy http.Handler
proxy = handler.ProxyManager.GetProxy(string(endpointID)) proxy = handler.ProxyManager.GetProxy(endpoint)
if proxy == nil { if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil { if err != nil {

View File

@ -3,6 +3,7 @@ package endpointproxy
import ( import (
"errors" "errors"
"strconv" "strconv"
"time"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
@ -24,7 +25,7 @@ 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} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an endpoint with the specified identifier inside the database", err}
} }
if endpoint.Status == portainer.EndpointStatusDown { if endpoint.Type != portainer.EdgeAgentEnvironment && endpoint.Status == portainer.EndpointStatusDown {
return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")} return &httperror.HandlerError{http.StatusServiceUnavailable, "Unable to query endpoint", errors.New("Endpoint is down")}
} }
@ -33,8 +34,32 @@ func (handler *Handler) proxyRequestsToDockerAPI(w http.ResponseWriter, r *http.
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
} }
if endpoint.Type == portainer.EdgeAgentEnvironment {
if endpoint.EdgeID == "" {
return &httperror.HandlerError{http.StatusInternalServerError, "No Edge agent registered with the endpoint", errors.New("No agent available")}
}
tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID)
if tunnel.Status == portainer.EdgeAgentIdle {
handler.ProxyManager.DeleteProxy(endpoint)
err := handler.ReverseTunnelService.SetTunnelStatusToRequired(endpoint.ID)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update tunnel status", err}
}
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
waitForAgentToConnect := time.Duration(settings.EdgeAgentCheckinInterval) * time.Second
time.Sleep(waitForAgentToConnect * 2)
}
}
var proxy http.Handler var proxy http.Handler
proxy = handler.ProxyManager.GetProxy(string(endpointID)) proxy = handler.ProxyManager.GetProxy(endpoint)
if proxy == nil { if proxy == nil {
proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint) proxy, err = handler.ProxyManager.CreateAndRegisterProxy(endpoint)
if err != nil { if err != nil {

View File

@ -1,8 +1,11 @@
package endpoints package endpoints
import ( import (
"errors"
"log" "log"
"net"
"net/http" "net/http"
"net/url"
"runtime" "runtime"
"strconv" "strconv"
@ -41,7 +44,7 @@ func (payload *endpointCreatePayload) Validate(r *http.Request) error {
endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false) endpointType, err := request.RetrieveNumericMultiPartFormValue(r, "EndpointType", false)
if err != nil || endpointType == 0 { if err != nil || endpointType == 0 {
return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment) or 3 (Azure environment)") return portainer.Error("Invalid endpoint type value. Value must be one of: 1 (Docker environment), 2 (Agent environment), 3 (Azure environment) or 4 (Edge Agent environment)")
} }
payload.EndpointType = endpointType payload.EndpointType = endpointType
@ -149,6 +152,8 @@ func (handler *Handler) endpointCreate(w http.ResponseWriter, r *http.Request) *
func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { func (handler *Handler) createEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment { if portainer.EndpointType(payload.EndpointType) == portainer.AzureEnvironment {
return handler.createAzureEndpoint(payload) return handler.createAzureEndpoint(payload)
} else if portainer.EndpointType(payload.EndpointType) == portainer.EdgeAgentEnvironment {
return handler.createEdgeAgentEndpoint(payload)
} }
if payload.TLS { if payload.TLS {
@ -195,6 +200,52 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
return endpoint, nil return endpoint, nil
} }
func (handler *Handler) createEdgeAgentEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
endpointType := portainer.EdgeAgentEnvironment
endpointID := handler.EndpointService.GetNextIdentifier()
portainerURL, err := url.Parse(payload.URL)
if err != nil {
return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint URL", err}
}
portainerHost, _, err := net.SplitHostPort(portainerURL.Host)
if err != nil {
portainerHost = portainerURL.Host
}
if portainerHost == "localhost" {
return nil, &httperror.HandlerError{http.StatusBadRequest, "Invalid endpoint URL", errors.New("cannot use localhost as endpoint URL")}
}
edgeKey := handler.ReverseTunnelService.GenerateEdgeKey(payload.URL, portainerHost, endpointID)
endpoint := &portainer.Endpoint{
ID: portainer.EndpointID(endpointID),
Name: payload.Name,
URL: portainerHost,
Type: endpointType,
GroupID: portainer.EndpointGroupID(payload.GroupID),
TLSConfig: portainer.TLSConfiguration{
TLS: false,
},
AuthorizedUsers: []portainer.UserID{},
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: payload.Tags,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
EdgeKey: edgeKey,
}
err = handler.EndpointService.CreateEndpoint(endpoint)
if err != nil {
return nil, &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist endpoint inside the database", err}
}
return endpoint, nil
}
func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) { func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload) (*portainer.Endpoint, *httperror.HandlerError) {
endpointType := portainer.DockerEnvironment endpointType := portainer.DockerEnvironment

View File

@ -41,7 +41,7 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove endpoint from the database", err}
} }
handler.ProxyManager.DeleteProxy(string(endpointID)) handler.ProxyManager.DeleteProxy(endpoint)
return response.Empty(w) return response.Empty(w)
} }

View File

@ -2,24 +2,43 @@ package endpoints
import ( import (
"net/http" "net/http"
"strconv"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/libhttp/request"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
) )
// GET request on /api/endpoints // GET request on /api/endpoints?(start=<start>)&(limit=<limit>)&(search=<search>)&(groupId=<groupId)
func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
endpoints, err := handler.EndpointService.Endpoints() start, _ := request.RetrieveNumericQueryParameter(r, "start", true)
if err != nil { if start != 0 {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err} start--
} }
search, _ := request.RetrieveQueryParameter(r, "search", true)
if search != "" {
search = strings.ToLower(search)
}
groupID, _ := request.RetrieveNumericQueryParameter(r, "groupId", true)
limit, _ := request.RetrieveNumericQueryParameter(r, "limit", true)
endpointGroups, err := handler.EndpointGroupService.EndpointGroups() endpointGroups, err := handler.EndpointGroupService.EndpointGroups()
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint groups from the database", err}
} }
endpoints, err := handler.EndpointService.Endpoints()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoints from the database", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
@ -27,9 +46,113 @@ func (handler *Handler) endpointList(w http.ResponseWriter, r *http.Request) *ht
filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext) filteredEndpoints := security.FilterEndpoints(endpoints, endpointGroups, securityContext)
for idx := range filteredEndpoints { if groupID != 0 {
hideFields(&filteredEndpoints[idx]) filteredEndpoints = filterEndpointsByGroupID(filteredEndpoints, portainer.EndpointGroupID(groupID))
} }
return response.JSON(w, filteredEndpoints) if search != "" {
filteredEndpoints = filterEndpointsBySearchCriteria(filteredEndpoints, endpointGroups, search)
}
filteredEndpointCount := len(filteredEndpoints)
paginatedEndpoints := paginateEndpoints(filteredEndpoints, start, limit)
for idx := range paginatedEndpoints {
hideFields(&paginatedEndpoints[idx])
}
w.Header().Set("X-Total-Count", strconv.Itoa(filteredEndpointCount))
return response.JSON(w, paginatedEndpoints)
}
func paginateEndpoints(endpoints []portainer.Endpoint, start, limit int) []portainer.Endpoint {
if limit == 0 {
return endpoints
}
endpointCount := len(endpoints)
if start > endpointCount {
start = endpointCount
}
end := start + limit
if end > endpointCount {
end = endpointCount
}
return endpoints[start:end]
}
func filterEndpointsByGroupID(endpoints []portainer.Endpoint, endpointGroupID portainer.EndpointGroupID) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if endpoint.GroupID == endpointGroupID {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func filterEndpointsBySearchCriteria(endpoints []portainer.Endpoint, endpointGroups []portainer.EndpointGroup, searchCriteria string) []portainer.Endpoint {
filteredEndpoints := make([]portainer.Endpoint, 0)
for _, endpoint := range endpoints {
if endpointMatchSearchCriteria(&endpoint, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
continue
}
if endpointGroupMatchSearchCriteria(&endpoint, endpointGroups, searchCriteria) {
filteredEndpoints = append(filteredEndpoints, endpoint)
}
}
return filteredEndpoints
}
func endpointMatchSearchCriteria(endpoint *portainer.Endpoint, searchCriteria string) bool {
if strings.Contains(strings.ToLower(endpoint.Name), searchCriteria) {
return true
}
if strings.Contains(strings.ToLower(endpoint.URL), searchCriteria) {
return true
}
if endpoint.Status == portainer.EndpointStatusUp && searchCriteria == "up" {
return true
} else if endpoint.Status == portainer.EndpointStatusDown && searchCriteria == "down" {
return true
}
for _, tag := range endpoint.Tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
return false
}
func endpointGroupMatchSearchCriteria(endpoint *portainer.Endpoint, endpointGroups []portainer.EndpointGroup, searchCriteria string) bool {
for _, group := range endpointGroups {
if group.ID == endpoint.GroupID {
if strings.Contains(strings.ToLower(group.Name), searchCriteria) {
return true
}
for _, tag := range group.Tags {
if strings.Contains(strings.ToLower(tag), searchCriteria) {
return true
}
}
}
}
return false
} }

View File

@ -0,0 +1,77 @@
package endpoints
import (
"errors"
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api"
)
type endpointStatusInspectResponse struct {
Status string `json:"status"`
Port int `json:"port"`
Schedules []portainer.EdgeSchedule `json:"schedules"`
CheckinInterval int `json:"checkin"`
Credentials string `json:"credentials"`
}
// GET request on /api/endpoints/:id/status
func (handler *Handler) endpointStatusInspect(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}
}
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}
}
if endpoint.Type != portainer.EdgeAgentEnvironment {
return &httperror.HandlerError{http.StatusInternalServerError, "Status unavailable for non Edge agent endpoints", errors.New("Status unavailable")}
}
edgeIdentifier := r.Header.Get(portainer.PortainerAgentEdgeIDHeader)
if edgeIdentifier == "" {
return &httperror.HandlerError{http.StatusForbidden, "Missing Edge identifier", errors.New("missing Edge identifier")}
}
if endpoint.EdgeID != "" && endpoint.EdgeID != edgeIdentifier {
return &httperror.HandlerError{http.StatusForbidden, "Invalid Edge identifier", errors.New("invalid Edge identifier")}
}
if endpoint.EdgeID == "" {
endpoint.EdgeID = edgeIdentifier
err := handler.EndpointService.UpdateEndpoint(endpoint.ID, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to Unable to persist endpoint changes inside the database", err}
}
}
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve settings from the database", err}
}
tunnel := handler.ReverseTunnelService.GetTunnelDetails(endpoint.ID)
statusResponse := endpointStatusInspectResponse{
Status: tunnel.Status,
Port: tunnel.Port,
Schedules: tunnel.Schedules,
CheckinInterval: settings.EdgeAgentCheckinInterval,
Credentials: tunnel.Credentials,
}
if tunnel.Status == portainer.EdgeAgentManagementRequired {
handler.ReverseTunnelService.SetTunnelStatusToActive(endpoint.ID)
}
return response.JSON(w, statusResponse)
}

View File

@ -19,6 +19,9 @@ const (
func hideFields(endpoint *portainer.Endpoint) { func hideFields(endpoint *portainer.Endpoint) {
endpoint.AzureCredentials = portainer.AzureCredentials{} endpoint.AzureCredentials = portainer.AzureCredentials{}
if len(endpoint.Snapshots) > 0 {
endpoint.Snapshots[0].SnapshotRaw = portainer.SnapshotRaw{}
}
} }
// Handler is the HTTP handler used to handle endpoint operations. // Handler is the HTTP handler used to handle endpoint operations.
@ -32,6 +35,8 @@ type Handler struct {
ProxyManager *proxy.Manager ProxyManager *proxy.Manager
Snapshotter portainer.Snapshotter Snapshotter portainer.Snapshotter
JobService portainer.JobService JobService portainer.JobService
ReverseTunnelService portainer.ReverseTunnelService
SettingsService portainer.SettingsService
} }
// NewHandler creates a handler to manage endpoint operations. // NewHandler creates a handler to manage endpoint operations.
@ -62,5 +67,8 @@ func NewHandler(bouncer *security.RequestBouncer, authorizeEndpointManagement bo
bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost) bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointJob))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/snapshot", h.Handle("/endpoints/{id}/snapshot",
bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost) bouncer.AuthorizedAccess(httperror.LoggerHandler(h.endpointSnapshot))).Methods(http.MethodPost)
h.Handle("/endpoints/{id}/status",
bouncer.PublicAccess(httperror.LoggerHandler(h.endpointStatusInspect))).Methods(http.MethodGet)
return h return h
} }

View File

@ -5,9 +5,9 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/portainer/libcrypto"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
"github.com/portainer/portainer/api" "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/client"
) )
@ -42,7 +42,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
message := strings.Join(data.Message, "\n") message := strings.Join(data.Message, "\n")
hash := crypto.HashFromBytes([]byte(message)) hash := libcrypto.HashFromBytes([]byte(message))
resp := motdResponse{ resp := motdResponse{
Title: data.Title, Title: data.Title,
Message: message, Message: message,

View File

@ -18,6 +18,7 @@ type Handler struct {
FileService portainer.FileService FileService portainer.FileService
JobService portainer.JobService JobService portainer.JobService
JobScheduler portainer.JobScheduler JobScheduler portainer.JobScheduler
ReverseTunnelService portainer.ReverseTunnelService
} }
// NewHandler creates a handler to manage schedule operations. // NewHandler creates a handler to manage schedule operations.

View File

@ -1,9 +1,11 @@
package schedules package schedules
import ( import (
"encoding/base64"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
@ -113,7 +115,7 @@ func (payload *scheduleCreateFromFileContentPayload) Validate(r *http.Request) e
return nil return nil
} }
// POST /api/schedules?method=file/string // POST /api/schedules?method=file|string
func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) scheduleCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
settings, err := handler.SettingsService.Settings() settings, err := handler.SettingsService.Settings()
if err != nil { if err != nil {
@ -219,6 +221,46 @@ func (handler *Handler) createScheduleObjectFromFileContentPayload(payload *sche
} }
func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error { func (handler *Handler) addAndPersistSchedule(schedule *portainer.Schedule, file []byte) error {
nonEdgeEndpointIDs := make([]portainer.EndpointID, 0)
edgeEndpointIDs := make([]portainer.EndpointID, 0)
edgeCronExpression := strings.Split(schedule.CronExpression, " ")
if len(edgeCronExpression) == 6 {
edgeCronExpression = edgeCronExpression[1:]
}
for _, ID := range schedule.ScriptExecutionJob.Endpoints {
endpoint, err := handler.EndpointService.Endpoint(ID)
if err != nil {
return err
}
if endpoint.Type != portainer.EdgeAgentEnvironment {
nonEdgeEndpointIDs = append(nonEdgeEndpointIDs, endpoint.ID)
} else {
edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID)
}
}
if len(edgeEndpointIDs) > 0 {
edgeSchedule := &portainer.EdgeSchedule{
ID: schedule.ID,
CronExpression: strings.Join(edgeCronExpression, " "),
Script: base64.RawStdEncoding.EncodeToString(file),
Endpoints: edgeEndpointIDs,
Version: 1,
}
for _, endpointID := range edgeEndpointIDs {
handler.ReverseTunnelService.AddSchedule(endpointID, edgeSchedule)
}
schedule.EdgeSchedule = edgeSchedule
}
schedule.ScriptExecutionJob.Endpoints = nonEdgeEndpointIDs
scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file) scriptPath, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(int(schedule.ID)), file)
if err != nil { if err != nil {
return err return err

View File

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

View File

@ -3,6 +3,7 @@ package schedules
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@ -18,6 +19,7 @@ type taskContainer struct {
Status string `json:"Status"` Status string `json:"Status"`
Created float64 `json:"Created"` Created float64 `json:"Created"`
Labels map[string]string `json:"Labels"` Labels map[string]string `json:"Labels"`
Edge bool `json:"Edge"`
} }
// GET request on /api/schedules/:id/tasks // GET request on /api/schedules/:id/tasks
@ -64,6 +66,22 @@ func (handler *Handler) scheduleTasks(w http.ResponseWriter, r *http.Request) *h
tasks = append(tasks, endpointTasks...) tasks = append(tasks, endpointTasks...)
} }
if schedule.EdgeSchedule != nil {
for _, endpointID := range schedule.EdgeSchedule.Endpoints {
cronTask := taskContainer{
ID: fmt.Sprintf("schedule_%d", schedule.EdgeSchedule.ID),
EndpointID: endpointID,
Edge: true,
Status: "",
Created: 0,
Labels: map[string]string{},
}
tasks = append(tasks, cronTask)
}
}
return response.JSON(w, tasks) return response.JSON(w, tasks)
} }
@ -87,6 +105,7 @@ func extractTasksFromContainerSnasphot(endpoint *portainer.Endpoint, scheduleID
for _, container := range containers { for _, container := range containers {
if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) { if container.Labels["io.portainer.schedule.id"] == strconv.Itoa(int(scheduleID)) {
container.EndpointID = endpoint.ID container.EndpointID = endpoint.ID
container.Edge = false
endpointTasks = append(endpointTasks, container) endpointTasks = append(endpointTasks, container)
} }
} }

View File

@ -1,6 +1,7 @@
package schedules package schedules
import ( import (
"encoding/base64"
"errors" "errors"
"net/http" "net/http"
"strconv" "strconv"
@ -58,7 +59,15 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a schedule with the specified identifier inside the database", err}
} }
updateJobSchedule := updateSchedule(schedule, &payload) updateJobSchedule := false
if schedule.EdgeSchedule != nil {
err := handler.updateEdgeSchedule(schedule, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update Edge schedule", err}
}
} else {
updateJobSchedule = updateSchedule(schedule, &payload)
}
if payload.FileContent != nil { if payload.FileContent != nil {
_, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent)) _, err := handler.FileService.StoreScheduledJobFileFromBytes(strconv.Itoa(scheduleID), []byte(*payload.FileContent))
@ -85,6 +94,46 @@ func (handler *Handler) scheduleUpdate(w http.ResponseWriter, r *http.Request) *
return response.JSON(w, schedule) return response.JSON(w, schedule)
} }
func (handler *Handler) updateEdgeSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) error {
if payload.Name != nil {
schedule.Name = *payload.Name
}
if payload.Endpoints != nil {
edgeEndpointIDs := make([]portainer.EndpointID, 0)
for _, ID := range payload.Endpoints {
endpoint, err := handler.EndpointService.Endpoint(ID)
if err != nil {
return err
}
if endpoint.Type == portainer.EdgeAgentEnvironment {
edgeEndpointIDs = append(edgeEndpointIDs, endpoint.ID)
}
}
schedule.EdgeSchedule.Endpoints = edgeEndpointIDs
}
if payload.CronExpression != nil {
schedule.EdgeSchedule.CronExpression = *payload.CronExpression
schedule.EdgeSchedule.Version++
}
if payload.FileContent != nil {
schedule.EdgeSchedule.Script = base64.RawStdEncoding.EncodeToString([]byte(*payload.FileContent))
schedule.EdgeSchedule.Version++
}
for _, endpointID := range schedule.EdgeSchedule.Endpoints {
handler.ReverseTunnelService.AddSchedule(endpointID, schedule.EdgeSchedule)
}
return nil
}
func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool { func updateSchedule(schedule *portainer.Schedule, payload *scheduleUpdatePayload) bool {
updateJobSchedule := false updateJobSchedule := false

View File

@ -22,6 +22,7 @@ type settingsUpdatePayload struct {
EnableHostManagementFeatures *bool EnableHostManagementFeatures *bool
SnapshotInterval *string SnapshotInterval *string
TemplatesURL *string TemplatesURL *string
EdgeAgentCheckinInterval *int
} }
func (payload *settingsUpdatePayload) Validate(r *http.Request) error { func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
@ -103,6 +104,10 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
} }
} }
if payload.EdgeAgentCheckinInterval != nil {
settings.EdgeAgentCheckinInterval = *payload.EdgeAgentCheckinInterval
}
tlsError := handler.updateTLS(settings) tlsError := handler.updateTLS(settings)
if tlsError != nil { if tlsError != nil {
return tlsError return tlsError

View File

@ -62,8 +62,10 @@ func (handler *Handler) handleAttachRequest(w http.ResponseWriter, r *http.Reque
r.Header.Del("Origin") r.Header.Del("Origin")
if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment { if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
return handler.proxyWebsocketRequest(w, r, params) return handler.proxyAgentWebsocketRequest(w, r, params)
} else if params.endpoint.Type == portainer.EdgeAgentEnvironment {
return handler.proxyEdgeAgentWebsocketRequest(w, r, params)
} }
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)

View File

@ -68,8 +68,10 @@ func (handler *Handler) websocketExec(w http.ResponseWriter, r *http.Request) *h
func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { func (handler *Handler) handleExecRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
r.Header.Del("Origin") r.Header.Del("Origin")
if params.nodeName != "" || params.endpoint.Type == portainer.AgentOnDockerEnvironment { if params.endpoint.Type == portainer.AgentOnDockerEnvironment {
return handler.proxyWebsocketRequest(w, r, params) return handler.proxyAgentWebsocketRequest(w, r, params)
} else if params.endpoint.Type == portainer.EdgeAgentEnvironment {
return handler.proxyEdgeAgentWebsocketRequest(w, r, params)
} }
websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil) websocketConn, err := handler.connectionUpgrader.Upgrade(w, r, nil)

View File

@ -13,6 +13,7 @@ type Handler struct {
*mux.Router *mux.Router
EndpointService portainer.EndpointService EndpointService portainer.EndpointService
SignatureService portainer.DigitalSignatureService SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
requestBouncer *security.RequestBouncer requestBouncer *security.RequestBouncer
connectionUpgrader websocket.Upgrader connectionUpgrader websocket.Upgrader
} }

View File

@ -2,14 +2,37 @@ package websocket
import ( import (
"crypto/tls" "crypto/tls"
"fmt"
"net/http"
"net/url"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/koding/websocketproxy" "github.com/koding/websocketproxy"
"github.com/portainer/portainer/api" "github.com/portainer/portainer/api"
"net/http"
"net/url"
) )
func (handler *Handler) proxyWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error { func (handler *Handler) proxyEdgeAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
tunnel := handler.ReverseTunnelService.GetTunnelDetails(params.endpoint.ID)
endpointURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", tunnel.Port))
if err != nil {
return err
}
endpointURL.Scheme = "ws"
proxy := websocketproxy.NewProxy(endpointURL)
proxy.Director = func(incoming *http.Request, out http.Header) {
out.Set(portainer.PortainerAgentTargetHeader, params.nodeName)
}
handler.ReverseTunnelService.SetTunnelStatusToActive(params.endpoint.ID)
proxy.ServeHTTP(w, r)
return nil
}
func (handler *Handler) proxyAgentWebsocketRequest(w http.ResponseWriter, r *http.Request, params *webSocketRequestParams) error {
agentURL, err := url.Parse(params.endpoint.URL) agentURL, err := url.Parse(params.endpoint.URL)
if err != nil { if err != nil {
return err return err

View File

@ -24,7 +24,9 @@ type (
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
SettingsService portainer.SettingsService SettingsService portainer.SettingsService
SignatureService portainer.DigitalSignatureService SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
endpointIdentifier portainer.EndpointID endpointIdentifier portainer.EndpointID
endpointType portainer.EndpointType
} }
restrictedDockerOperationContext struct { restrictedDockerOperationContext struct {
isAdmin bool isAdmin bool
@ -58,7 +60,19 @@ func (p *proxyTransport) RoundTrip(request *http.Request) (*http.Response, error
} }
func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) { func (p *proxyTransport) executeDockerRequest(request *http.Request) (*http.Response, error) {
return p.dockerTransport.RoundTrip(request) response, err := p.dockerTransport.RoundTrip(request)
if p.endpointType != portainer.EdgeAgentEnvironment {
return response, err
}
if err == nil {
p.ReverseTunnelService.SetTunnelStatusToActive(p.endpointIdentifier)
} else {
p.ReverseTunnelService.SetTunnelStatusToIdle(p.endpointIdentifier)
}
return response, err
} }
func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) { func (p *proxyTransport) proxyDockerRequest(request *http.Request) (*http.Response, error) {

View File

@ -21,6 +21,7 @@ type proxyFactory struct {
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
} }
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler { func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
@ -29,21 +30,21 @@ func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
} }
func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) { func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) {
url, err := url.Parse(AzureAPIBaseURL) remoteURL, err := url.Parse(AzureAPIBaseURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
proxy := newSingleHostReverseProxyWithHostHeader(url) proxy := newSingleHostReverseProxyWithHostHeader(remoteURL)
proxy.Transport = NewAzureTransport(credentials) proxy.Transport = NewAzureTransport(credentials)
return proxy, nil return proxy, nil
} }
func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, enableSignature bool, endpointID portainer.EndpointID) (http.Handler, error) { func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portainer.TLSConfiguration, endpoint *portainer.Endpoint) (http.Handler, error) {
u.Scheme = "https" u.Scheme = "https"
proxy := factory.createDockerReverseProxy(u, enableSignature, endpointID) proxy := factory.createDockerReverseProxy(u, endpoint)
config, err := crypto.CreateTLSConfigurationFromDisk(tlsConfig.TLSCACertPath, tlsConfig.TLSCertPath, tlsConfig.TLSKeyPath, tlsConfig.TLSSkipVerify) config, err := crypto.CreateTLSConfigurationFromDisk(tlsConfig.TLSCACertPath, tlsConfig.TLSCertPath, tlsConfig.TLSKeyPath, tlsConfig.TLSSkipVerify)
if err != nil { if err != nil {
return nil, err return nil, err
@ -53,13 +54,19 @@ func (factory *proxyFactory) newDockerHTTPSProxy(u *url.URL, tlsConfig *portaine
return proxy, nil return proxy, nil
} }
func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, enableSignature bool, endpointID portainer.EndpointID) http.Handler { func (factory *proxyFactory) newDockerHTTPProxy(u *url.URL, endpoint *portainer.Endpoint) http.Handler {
u.Scheme = "http" u.Scheme = "http"
return factory.createDockerReverseProxy(u, enableSignature, endpointID) return factory.createDockerReverseProxy(u, endpoint)
} }
func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignature bool, endpointID portainer.EndpointID) *httputil.ReverseProxy { func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, endpoint *portainer.Endpoint) *httputil.ReverseProxy {
proxy := newSingleHostReverseProxyWithHostHeader(u) proxy := newSingleHostReverseProxyWithHostHeader(u)
enableSignature := false
if endpoint.Type == portainer.AgentOnDockerEnvironment {
enableSignature = true
}
transport := &proxyTransport{ transport := &proxyTransport{
enableSignature: enableSignature, enableSignature: enableSignature,
ResourceControlService: factory.ResourceControlService, ResourceControlService: factory.ResourceControlService,
@ -67,8 +74,10 @@ func (factory *proxyFactory) createDockerReverseProxy(u *url.URL, enableSignatur
SettingsService: factory.SettingsService, SettingsService: factory.SettingsService,
RegistryService: factory.RegistryService, RegistryService: factory.RegistryService,
DockerHubService: factory.DockerHubService, DockerHubService: factory.DockerHubService,
ReverseTunnelService: factory.ReverseTunnelService,
dockerTransport: &http.Transport{}, dockerTransport: &http.Transport{},
endpointIdentifier: endpointID, endpointIdentifier: endpoint.ID,
endpointType: endpoint.Type,
} }
if enableSignature { if enableSignature {

View File

@ -8,7 +8,7 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.EndpointID) http.Handler { func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endpoint) http.Handler {
proxy := &localProxy{} proxy := &localProxy{}
transport := &proxyTransport{ transport := &proxyTransport{
enableSignature: false, enableSignature: false,
@ -18,7 +18,9 @@ func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.End
RegistryService: factory.RegistryService, RegistryService: factory.RegistryService,
DockerHubService: factory.DockerHubService, DockerHubService: factory.DockerHubService,
dockerTransport: newSocketTransport(path), dockerTransport: newSocketTransport(path),
endpointIdentifier: endpointID, ReverseTunnelService: factory.ReverseTunnelService,
endpointIdentifier: endpoint.ID,
endpointType: endpoint.Type,
} }
proxy.Transport = transport proxy.Transport = transport
return proxy return proxy

View File

@ -5,12 +5,11 @@ package proxy
import ( import (
"net" "net"
"net/http" "net/http"
"github.com/Microsoft/go-winio"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
) )
func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.EndpointID) http.Handler { func (factory *proxyFactory) newLocalProxy(path string, endpoint *portainer.Endpoint) http.Handler {
proxy := &localProxy{} proxy := &localProxy{}
transport := &proxyTransport{ transport := &proxyTransport{
enableSignature: false, enableSignature: false,
@ -19,8 +18,10 @@ func (factory *proxyFactory) newLocalProxy(path string, endpointID portainer.End
SettingsService: factory.SettingsService, SettingsService: factory.SettingsService,
RegistryService: factory.RegistryService, RegistryService: factory.RegistryService,
DockerHubService: factory.DockerHubService, DockerHubService: factory.DockerHubService,
ReverseTunnelService: factory.ReverseTunnelService,
dockerTransport: newNamedPipeTransport(path), dockerTransport: newNamedPipeTransport(path),
endpointIdentifier: endpointID, endpointIdentifier: endpoint.ID,
endpointType: endpoint.Type,
} }
proxy.Transport = transport proxy.Transport = transport
return proxy return proxy

View File

@ -1,6 +1,7 @@
package proxy package proxy
import ( import (
"fmt"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@ -21,6 +22,7 @@ type (
// Manager represents a service used to manage Docker proxies. // Manager represents a service used to manage Docker proxies.
Manager struct { Manager struct {
proxyFactory *proxyFactory proxyFactory *proxyFactory
reverseTunnelService portainer.ReverseTunnelService
proxies cmap.ConcurrentMap proxies cmap.ConcurrentMap
extensionProxies cmap.ConcurrentMap extensionProxies cmap.ConcurrentMap
legacyExtensionProxies cmap.ConcurrentMap legacyExtensionProxies cmap.ConcurrentMap
@ -34,6 +36,7 @@ type (
RegistryService portainer.RegistryService RegistryService portainer.RegistryService
DockerHubService portainer.DockerHubService DockerHubService portainer.DockerHubService
SignatureService portainer.DigitalSignatureService SignatureService portainer.DigitalSignatureService
ReverseTunnelService portainer.ReverseTunnelService
} }
) )
@ -50,13 +53,15 @@ func NewManager(parameters *ManagerParams) *Manager {
RegistryService: parameters.RegistryService, RegistryService: parameters.RegistryService,
DockerHubService: parameters.DockerHubService, DockerHubService: parameters.DockerHubService,
SignatureService: parameters.SignatureService, SignatureService: parameters.SignatureService,
ReverseTunnelService: parameters.ReverseTunnelService,
}, },
reverseTunnelService: parameters.ReverseTunnelService,
} }
} }
// GetProxy returns the proxy associated to a key // GetProxy returns the proxy associated to a key
func (manager *Manager) GetProxy(key string) http.Handler { func (manager *Manager) GetProxy(endpoint *portainer.Endpoint) http.Handler {
proxy, ok := manager.proxies.Get(key) proxy, ok := manager.proxies.Get(string(endpoint.ID))
if !ok { if !ok {
return nil return nil
} }
@ -76,8 +81,8 @@ func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (ht
} }
// DeleteProxy deletes the proxy associated to a key // DeleteProxy deletes the proxy associated to a key
func (manager *Manager) DeleteProxy(key string) { func (manager *Manager) DeleteProxy(endpoint *portainer.Endpoint) {
manager.proxies.Remove(key) manager.proxies.Remove(string(endpoint.ID))
} }
// GetExtensionProxy returns an extension proxy associated to an extension identifier // GetExtensionProxy returns an extension proxy associated to an extension identifier
@ -92,7 +97,7 @@ func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) htt
// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and // CreateExtensionProxy creates a new HTTP reverse proxy for an extension and
// registers it in the extension map associated to the specified extension identifier // registers it in the extension map associated to the specified extension identifier
func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) { func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) {
address := "http://localhost:" + extensionPorts[extensionID] address := "http://" + portainer.ExtensionServer + ":" + extensionPorts[extensionID]
extensionURL, err := url.Parse(address) extensionURL, err := url.Parse(address)
if err != nil { if err != nil {
@ -136,28 +141,40 @@ func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string)
return proxy, nil return proxy, nil
} }
func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration, endpointID portainer.EndpointID) (http.Handler, error) { func (manager *Manager) createDockerProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if endpointURL.Scheme == "tcp" { baseURL := endpoint.URL
if tlsConfig.TLS || tlsConfig.TLSSkipVerify { if endpoint.Type == portainer.EdgeAgentEnvironment {
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, tlsConfig, false, endpointID) tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID)
} baseURL = fmt.Sprintf("http://localhost:%d", tunnel.Port)
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, false, endpointID), nil
}
return manager.proxyFactory.newLocalProxy(endpointURL.Path, endpointID), nil
} }
func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) { endpointURL, err := url.Parse(baseURL)
endpointURL, err := url.Parse(endpoint.URL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
switch endpoint.Type { switch endpoint.Type {
case portainer.AgentOnDockerEnvironment: case portainer.AgentOnDockerEnvironment:
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, true, endpoint.ID) return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, endpoint)
case portainer.AzureEnvironment: case portainer.EdgeAgentEnvironment:
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, endpoint), nil
}
if endpointURL.Scheme == "tcp" {
if endpoint.TLSConfig.TLS || endpoint.TLSConfig.TLSSkipVerify {
return manager.proxyFactory.newDockerHTTPSProxy(endpointURL, &endpoint.TLSConfig, endpoint)
}
return manager.proxyFactory.newDockerHTTPProxy(endpointURL, endpoint), nil
}
return manager.proxyFactory.newLocalProxy(endpointURL.Path, endpoint), nil
}
func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
if endpoint.Type == portainer.AzureEnvironment {
return newAzureProxy(&endpoint.AzureCredentials) return newAzureProxy(&endpoint.AzureCredentials)
default:
return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig, endpoint.ID)
} }
return manager.createDockerProxy(endpoint)
} }

View File

@ -44,6 +44,7 @@ type Server struct {
AuthDisabled bool AuthDisabled bool
EndpointManagement bool EndpointManagement bool
Status *portainer.Status Status *portainer.Status
ReverseTunnelService portainer.ReverseTunnelService
ExtensionManager portainer.ExtensionManager ExtensionManager portainer.ExtensionManager
ComposeStackManager portainer.ComposeStackManager ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService CryptoService portainer.CryptoService
@ -88,6 +89,7 @@ func (server *Server) Start() error {
RegistryService: server.RegistryService, RegistryService: server.RegistryService,
DockerHubService: server.DockerHubService, DockerHubService: server.DockerHubService,
SignatureService: server.SignatureService, SignatureService: server.SignatureService,
ReverseTunnelService: server.ReverseTunnelService,
} }
proxyManager := proxy.NewManager(proxyManagerParameters) proxyManager := proxy.NewManager(proxyManagerParameters)
@ -132,6 +134,8 @@ func (server *Server) Start() error {
endpointHandler.ProxyManager = proxyManager endpointHandler.ProxyManager = proxyManager
endpointHandler.Snapshotter = server.Snapshotter endpointHandler.Snapshotter = server.Snapshotter
endpointHandler.JobService = server.JobService endpointHandler.JobService = server.JobService
endpointHandler.ReverseTunnelService = server.ReverseTunnelService
endpointHandler.SettingsService = server.SettingsService
var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer) var endpointGroupHandler = endpointgroups.NewHandler(requestBouncer)
endpointGroupHandler.EndpointGroupService = server.EndpointGroupService endpointGroupHandler.EndpointGroupService = server.EndpointGroupService
@ -140,6 +144,8 @@ func (server *Server) Start() error {
var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer) var endpointProxyHandler = endpointproxy.NewHandler(requestBouncer)
endpointProxyHandler.EndpointService = server.EndpointService endpointProxyHandler.EndpointService = server.EndpointService
endpointProxyHandler.ProxyManager = proxyManager endpointProxyHandler.ProxyManager = proxyManager
endpointProxyHandler.SettingsService = server.SettingsService
endpointProxyHandler.ReverseTunnelService = server.ReverseTunnelService
var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public")) var fileHandler = file.NewHandler(filepath.Join(server.AssetsPath, "public"))
@ -168,6 +174,7 @@ func (server *Server) Start() error {
schedulesHandler.JobService = server.JobService schedulesHandler.JobService = server.JobService
schedulesHandler.JobScheduler = server.JobScheduler schedulesHandler.JobScheduler = server.JobScheduler
schedulesHandler.SettingsService = server.SettingsService schedulesHandler.SettingsService = server.SettingsService
schedulesHandler.ReverseTunnelService = server.ReverseTunnelService
var settingsHandler = settings.NewHandler(requestBouncer) var settingsHandler = settings.NewHandler(requestBouncer)
settingsHandler.SettingsService = server.SettingsService settingsHandler.SettingsService = server.SettingsService
@ -216,6 +223,7 @@ func (server *Server) Start() error {
var websocketHandler = websocket.NewHandler(requestBouncer) var websocketHandler = websocket.NewHandler(requestBouncer)
websocketHandler.EndpointService = server.EndpointService websocketHandler.EndpointService = server.EndpointService
websocketHandler.SignatureService = server.SignatureService websocketHandler.SignatureService = server.SignatureService
websocketHandler.ReverseTunnelService = server.ReverseTunnelService
var webhookHandler = webhooks.NewHandler(requestBouncer) var webhookHandler = webhooks.NewHandler(requestBouncer)
webhookHandler.WebhookService = server.WebhookService webhookHandler.WebhookService = server.WebhookService

View File

@ -2,6 +2,7 @@ package libcompose
import ( import (
"context" "context"
"fmt"
"path" "path"
"path/filepath" "path/filepath"
@ -18,18 +19,27 @@ import (
// ComposeStackManager represents a service for managing compose stacks. // ComposeStackManager represents a service for managing compose stacks.
type ComposeStackManager struct { type ComposeStackManager struct {
dataPath string dataPath string
reverseTunnelService portainer.ReverseTunnelService
} }
// NewComposeStackManager initializes a new ComposeStackManager service. // NewComposeStackManager initializes a new ComposeStackManager service.
func NewComposeStackManager(dataPath string) *ComposeStackManager { func NewComposeStackManager(dataPath string, reverseTunnelService portainer.ReverseTunnelService) *ComposeStackManager {
return &ComposeStackManager{ return &ComposeStackManager{
dataPath: dataPath, dataPath: dataPath,
reverseTunnelService: reverseTunnelService,
} }
} }
func createClient(endpoint *portainer.Endpoint) (client.Factory, error) { func (manager *ComposeStackManager) createClient(endpoint *portainer.Endpoint) (client.Factory, error) {
endpointURL := endpoint.URL
if endpoint.Type == portainer.EdgeAgentEnvironment {
tunnel := manager.reverseTunnelService.GetTunnelDetails(endpoint.ID)
endpointURL = fmt.Sprintf("tcp://localhost:%d", tunnel.Port)
}
clientOpts := client.Options{ clientOpts := client.Options{
Host: endpoint.URL, Host: endpointURL,
APIVersion: portainer.SupportedDockerAPIVersion, APIVersion: portainer.SupportedDockerAPIVersion,
} }
@ -47,7 +57,7 @@ func createClient(endpoint *portainer.Endpoint) (client.Factory, error) {
// Up will deploy a compose stack (equivalent of docker-compose up) // Up will deploy a compose stack (equivalent of docker-compose up)
func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
clientFactory, err := createClient(endpoint) clientFactory, err := manager.createClient(endpoint)
if err != nil { if err != nil {
return err return err
} }
@ -85,7 +95,7 @@ func (manager *ComposeStackManager) Up(stack *portainer.Stack, endpoint *portain
// Down will shutdown a compose stack (equivalent of docker-compose down) // Down will shutdown a compose stack (equivalent of docker-compose down)
func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error { func (manager *ComposeStackManager) Down(stack *portainer.Stack, endpoint *portainer.Endpoint) error {
clientFactory, err := createClient(endpoint) clientFactory, err := manager.createClient(endpoint)
if err != nil { if err != nil {
return err return err
} }

View File

@ -1,5 +1,7 @@
package portainer package portainer
import "time"
type ( type (
// Pair defines a key/value string pair // Pair defines a key/value string pair
Pair struct { Pair struct {
@ -10,6 +12,8 @@ type (
// CLIFlags represents the available flags on the CLI // CLIFlags represents the available flags on the CLI
CLIFlags struct { CLIFlags struct {
Addr *string Addr *string
TunnelAddr *string
TunnelPort *string
AdminPassword *string AdminPassword *string
AdminPasswordFile *string AdminPasswordFile *string
Assets *string Assets *string
@ -105,6 +109,7 @@ type (
SnapshotInterval string `json:"SnapshotInterval"` SnapshotInterval string `json:"SnapshotInterval"`
TemplatesURL string `json:"TemplatesURL"` TemplatesURL string `json:"TemplatesURL"`
EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"` EnableHostManagementFeatures bool `json:"EnableHostManagementFeatures"`
EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval"`
// Deprecated fields // Deprecated fields
DisplayDonationHeader bool DisplayDonationHeader bool
@ -250,7 +255,8 @@ type (
Snapshots []Snapshot `json:"Snapshots"` Snapshots []Snapshot `json:"Snapshots"`
UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"`
TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"`
EdgeID string `json:"EdgeID,omitempty"`
EdgeKey string `json:"EdgeKey"`
// Deprecated fields // Deprecated fields
// Deprecated in DBVersion == 4 // Deprecated in DBVersion == 4
TLS bool `json:"TLS,omitempty"` TLS bool `json:"TLS,omitempty"`
@ -333,11 +339,21 @@ type (
Recurring bool Recurring bool
Created int64 Created int64
JobType JobType JobType JobType
EdgeSchedule *EdgeSchedule
ScriptExecutionJob *ScriptExecutionJob ScriptExecutionJob *ScriptExecutionJob
SnapshotJob *SnapshotJob SnapshotJob *SnapshotJob
EndpointSyncJob *EndpointSyncJob EndpointSyncJob *EndpointSyncJob
} }
// EdgeSchedule represents a scheduled job that can run on Edge environments.
EdgeSchedule struct {
ID ScheduleID `json:"Id"`
CronExpression string `json:"CronExpression"`
Script string `json:"Script"`
Version int `json:"Version"`
Endpoints []EndpointID `json:"Endpoints"`
}
// WebhookID represents a webhook identifier. // WebhookID represents a webhook identifier.
WebhookID int WebhookID int
@ -575,6 +591,20 @@ type (
Valid bool `json:"Valid,omitempty"` Valid bool `json:"Valid,omitempty"`
} }
// TunnelDetails represents information associated to a tunnel
TunnelDetails struct {
Status string
LastActivity time.Time
Port int
Schedules []EdgeSchedule
Credentials string
}
// TunnelServerInfo represents information associated to the tunnel server
TunnelServerInfo struct {
PrivateKeySeed string `json:"PrivateKeySeed"`
}
// CLIService represents a service for managing CLI // CLIService represents a service for managing CLI
CLIService interface { CLIService interface {
ParseFlags(version string) (*CLIFlags, error) ParseFlags(version string) (*CLIFlags, error)
@ -692,6 +722,12 @@ type (
StoreDBVersion(version int) error StoreDBVersion(version int) error
} }
// TunnelServerService represents a service for managing data associated to the tunnel server
TunnelServerService interface {
Info() (*TunnelServerInfo, error)
UpdateInfo(info *TunnelServerInfo) error
}
// WebhookService represents a service for managing webhook data. // WebhookService represents a service for managing webhook data.
WebhookService interface { WebhookService interface {
Webhooks() ([]Webhook, error) Webhooks() ([]Webhook, error)
@ -850,21 +886,35 @@ type (
DisableExtension(extension *Extension) error DisableExtension(extension *Extension) error
UpdateExtension(extension *Extension, version string) error UpdateExtension(extension *Extension, version string) error
} }
// ReverseTunnelService represensts a service used to manage reverse tunnel connections.
ReverseTunnelService interface {
StartTunnelServer(addr, port string, snapshotter Snapshotter) error
GenerateEdgeKey(url, host string, endpointIdentifier int) string
SetTunnelStatusToActive(endpointID EndpointID)
SetTunnelStatusToRequired(endpointID EndpointID) error
SetTunnelStatusToIdle(endpointID EndpointID)
GetTunnelDetails(endpointID EndpointID) *TunnelDetails
AddSchedule(endpointID EndpointID, schedule *EdgeSchedule)
RemoveSchedule(scheduleID ScheduleID)
}
) )
const ( const (
// APIVersion is the version number of the Portainer API // APIVersion is the version number of the Portainer API
APIVersion = "1.21.0" APIVersion = "1.22.0"
// DBVersion is the version number of the Portainer database // DBVersion is the version number of the Portainer database
DBVersion = 18 DBVersion = 19
// AssetsServerURL represents the URL of the Portainer asset server // AssetsServerURL represents the URL of the Portainer asset server
AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com"
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
MessageOfTheDayURL = AssetsServerURL + "/motd.json" MessageOfTheDayURL = AssetsServerURL + "/motd.json"
// ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved
ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.21.0.json" ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.22.0.json"
// PortainerAgentHeader represents the name of the header available in any agent response // PortainerAgentHeader represents the name of the header available in any agent response
PortainerAgentHeader = "Portainer-Agent" PortainerAgentHeader = "Portainer-Agent"
// PortainerAgentEdgeIDHeader represent the name of the header containing the Edge ID associated to an agent/agent cluster
PortainerAgentEdgeIDHeader = "X-PortainerAgent-EdgeID"
// PortainerAgentTargetHeader represent the name of the header containing the target node name // PortainerAgentTargetHeader represent the name of the header containing the target node name
PortainerAgentTargetHeader = "X-PortainerAgent-Target" PortainerAgentTargetHeader = "X-PortainerAgent-Target"
// PortainerAgentSignatureHeader represent the name of the header containing the digital signature // PortainerAgentSignatureHeader represent the name of the header containing the digital signature
@ -876,6 +926,10 @@ const (
PortainerAgentSignatureMessage = "Portainer-App" PortainerAgentSignatureMessage = "Portainer-App"
// SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer // SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer
SupportedDockerAPIVersion = "1.24" SupportedDockerAPIVersion = "1.24"
// ExtensionServer represents the server used by Portainer to communicate with extensions
ExtensionServer = "localhost"
// DefaultEdgeAgentCheckinIntervalInSeconds represents the default interval (in seconds) used by Edge agents to checkin with the Portainer instance
DefaultEdgeAgentCheckinIntervalInSeconds = 5
) )
const ( const (
@ -951,6 +1005,8 @@ const (
AgentOnDockerEnvironment AgentOnDockerEnvironment
// AzureEnvironment represents an endpoint connected to an Azure environment // AzureEnvironment represents an endpoint connected to an Azure environment
AzureEnvironment AzureEnvironment
// EdgeAgentEnvironment represents an endpoint connected to an Edge agent
EdgeAgentEnvironment
) )
const ( const (
@ -1017,6 +1073,15 @@ const (
CustomRegistry CustomRegistry
) )
const (
// EdgeAgentIdle represents an idle state for a tunnel connected to an Edge endpoint.
EdgeAgentIdle string = "IDLE"
// EdgeAgentManagementRequired represents a required state for a tunnel connected to an Edge endpoint
EdgeAgentManagementRequired string = "REQUIRED"
// EdgeAgentActive represents an active state for a tunnel connected to an Edge endpoint
EdgeAgentActive string = "ACTIVE"
)
const ( const (
OperationDockerContainerArchiveInfo Authorization = "DockerContainerArchiveInfo" OperationDockerContainerArchiveInfo Authorization = "DockerContainerArchiveInfo"
OperationDockerContainerList Authorization = "DockerContainerList" OperationDockerContainerList Authorization = "DockerContainerList"

View File

@ -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). **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.21.0" version: "1.22.0"
title: "Portainer API" title: "Portainer API"
contact: contact:
email: "info@portainer.io" email: "info@portainer.io"
@ -69,10 +69,14 @@ tags:
description: "Manage Docker environments" description: "Manage Docker environments"
- name: "endpoint_groups" - name: "endpoint_groups"
description: "Manage endpoint groups" description: "Manage endpoint groups"
- name: "extensions"
description: "Manage extensions"
- name: "registries" - name: "registries"
description: "Manage Docker registries" description: "Manage Docker registries"
- name: "resource_controls" - name: "resource_controls"
description: "Manage access control on Docker resources" description: "Manage access control on Docker resources"
- name: "roles"
description: "Manage roles"
- name: "settings" - name: "settings"
description: "Manage Portainer settings" description: "Manage Portainer settings"
- name: "status" - name: "status"
@ -741,6 +745,285 @@ paths:
examples: examples:
application/json: application/json:
err: "EndpointGroup management is disabled" err: "EndpointGroup management is disabled"
/endpoint_groups/{id}/endpoints/{endpointId}:
put:
tags:
- "endpoint_groups"
summary: "Add an endpoint to an endpoint group"
description: |
Add an endpoint to an endpoint group
**Access policy**: administrator
operationId: "EndpointGroupAddEndpoint"
consumes:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "EndpointGroup identifier"
required: true
type: "integer"
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
404:
description: "EndpointGroup not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "EndpointGroup not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
delete:
tags:
- "endpoint_groups"
summary: "Remove an endpoint group"
description: |
Remove an endpoint group.
**Access policy**: administrator
operationId: "EndpointGroupDeleteEndpoint"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "EndpointGroup identifier"
required: true
type: "integer"
- name: "endpointId"
in: "path"
description: "Endpoint identifier"
required: true
type: "integer"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
404:
description: "EndpointGroup not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "EndpointGroup not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/extensions:
get:
tags:
- "extensions"
summary: "List extensions"
description: |
List all extensions registered inside Portainer. If the store parameter is set to true,
will retrieve extensions details from the online repository.
**Access policy**: administrator
operationId: "ExtensionList"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "store"
in: "query"
description: "Retrieve online information about extensions. Possible values: true or false."
required: false
type: "boolean"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/ExtensionListResponse"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
post:
tags:
- "extensions"
summary: "Enable an extension"
description: |
Enable an extension.
**Access policy**: administrator
operationId: "ExtensionCreate"
consumes:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- in: "body"
name: "body"
description: "Extension details"
required: true
schema:
$ref: "#/definitions/ExtensionCreateRequest"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/extensions/{id}:
get:
tags:
- "extensions"
summary: "Inspect an extension"
description: |
Retrieve details abount an extension.
**Access policy**: administrator
operationId: "ExtensionInspect"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "extension identifier"
required: true
type: "integer"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/Extension"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
404:
description: "Extension not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Extension not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
put:
tags:
- "extensions"
summary: "Update an extension"
description: |
Update an extension to a specific version of the extension.
**Access policy**: administrator
operationId: "ExtensionUpdate"
consumes:
- "application/json"
produces:
- "application/json"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "Extension identifier"
required: true
type: "integer"
- in: "body"
name: "body"
description: "Extension details"
required: true
schema:
$ref: "#/definitions/ExtensionUpdateRequest"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
404:
description: "Extension not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Extension not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
delete:
tags:
- "extensions"
summary: "Disable an extension"
description: |
Disable an extension.
**Access policy**: administrator
operationId: "ExtensionDelete"
security:
- jwt: []
parameters:
- name: "id"
in: "path"
description: "Extension identifier"
required: true
type: "integer"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
404:
description: "Extension not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Extension not found"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/registries: /registries:
get: get:
tags: tags:
@ -1098,6 +1381,29 @@ paths:
description: "Server error" description: "Server error"
schema: schema:
$ref: "#/definitions/GenericError" $ref: "#/definitions/GenericError"
/roles:
get:
tags:
- "roles"
summary: "List roles"
description: |
List all roles available for use with the RBAC extension.
**Access policy**: administrator
operationId: "RoleList"
produces:
- "application/json"
security:
- jwt: []
parameters: []
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/RoleListResponse"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/settings: /settings:
get: get:
tags: tags:
@ -2868,7 +3174,7 @@ definitions:
description: "Is analytics enabled" description: "Is analytics enabled"
Version: Version:
type: "string" type: "string"
example: "1.21.0" example: "1.22.0"
description: "Portainer API version" description: "Portainer API version"
PublicSettingsInspectResponse: PublicSettingsInspectResponse:
type: "object" type: "object"
@ -3649,17 +3955,13 @@ definitions:
type: "string" type: "string"
example: "Endpoint group description" example: "Endpoint group description"
description: "Endpoint group description" description: "Endpoint group description"
Labels: Tags:
type: "array" type: "array"
description: "List of tags associated to the endpoint group"
items: items:
$ref: "#/definitions/Pair" type: "string"
AssociatedEndpoints: example: "zone/east-coast"
type: "array" description: "Tag"
description: "List of endpoint identifiers that will be part of this group"
items:
type: "integer"
example: 1
description: "Endpoint identifier"
UserAccessPolicies: UserAccessPolicies:
$ref: "#/definitions/UserAccessPolicies" $ref: "#/definitions/UserAccessPolicies"
TeamAccessPolicies: TeamAccessPolicies:
@ -4335,3 +4637,138 @@ definitions:
type: "string" type: "string"
example: "version: 3\n services:\n web:\n image:nginx" example: "version: 3\n services:\n web:\n image:nginx"
description: "Content of the Stack file." description: "Content of the Stack file."
LicenseInformation:
type: "object"
properties:
LicenseKey:
type: "string"
description: "License key"
example: "1-uKmVwboSWVIZv5URmE0VRkpbPX0rrCVeDxJl97LZ0piltw2SU28DSrNwPZAHCEAwB2SeKm6BCFcVwzGMBEixKQ"
Company:
type: "string"
description: "Company associated to the license"
example: "Portainer.io"
Expiration:
type: "string"
description: "License expiry date"
example: "2077-07-07"
Valid:
type: "boolean"
description: "Is the license valid"
example: "true"
Extension:
type: "object"
properties:
Id:
type: "integer"
example: 1
description: "Extension identifier"
Name:
type: "string"
example: "Registry Manager"
description: "Extension name"
Enabled:
type: "boolean"
example: "true"
description: "Is the extension enabled"
ShortDescription:
type: "string"
description: "Short description about the extension"
example: "Enable in-app registry management"
DescriptionURL:
type: "string"
description: "URL to the file containing the extension description"
example: https://portainer-io-assets.sfo2.digitaloceanspaces.com/description_registry_manager.html"
Available:
type: "boolean"
description: "Is the extension available for download and activation"
example: "true"
Images:
type: "array"
description: "List of screenshot URLs"
items:
type: "string"
example: "https://portainer-io-assets.sfo2.digitaloceanspaces.com/screenshots/rm01.png"
description: "Screenshot URL"
Logo:
type: "string"
description: "Icon associated to the extension"
example: "fa-database"
Price:
type: "string"
description: "Extension price"
example: "US$9.95"
PriceDescription:
type: "string"
description: "Details about extension pricing"
example: "Price per instance per year"
ShopURL:
type: "string"
description: "URL used to buy the extension"
example: "https://portainer.io/checkout/?add-to-cart=1164"
UpdateAvailable:
type: "boolean"
description: "Is an update available for this extension"
example: "true"
Version:
type: "string"
description: "Extension version"
example: "1.0.0"
License:
$ref: "#/definitions/LicenseInformation"
ExtensionListResponse:
type: "array"
items:
$ref: "#/definitions/Extension"
ExtensionCreateRequest:
type: "object"
required:
- "License"
properties:
License:
type: "string"
example: "1-uKmVwboSWVIZv5URmE0VRkpbPX0rrCVeDxJl97LZ0piltw2SU28DSrNwPZAHCEAwB2SeKm6BCFcVwzGMBEixKQ"
description: "License key"
ExtensionUpdateRequest:
type: "object"
required:
- "Version"
properties:
Version:
type: "string"
example: "1.1.0"
description: "New version of the extension"
RoleListResponse:
type: "array"
items:
$ref: "#/definitions/Role"
Role:
type: "object"
properties:
Id:
type: "integer"
description: "Role identifier"
example: 2
Name:
type: "string"
description: "Role name"
example: "HelpDesk"
Description:
type: "string"
description: "Role description"
example: "Read-only access of all resources in an endpoint"
Authorizations:
$ref: "#/definitions/Authorizations"
Authorizations:
type: "object"
description: "Authorizations associated to a role"
additionalProperties:
type: "object"
properties:
authorization:
type: "string"
value:
type: "boolean"
example:
"DockerContainerList": true
"DockerVolumeList": true

View File

@ -1,5 +1,5 @@
{ {
"packageName": "portainer", "packageName": "portainer",
"packageVersion": "1.21.0", "packageVersion": "1.22.0",
"projectName": "portainer" "projectName": "portainer"
} }

View File

@ -4,7 +4,6 @@ import angular from 'angular';
import './agent/_module'; import './agent/_module';
import './azure/_module'; import './azure/_module';
import './docker/__module'; import './docker/__module';
import './extensions/storidge/__module';
import './portainer/__module'; import './portainer/__module';
angular.module('portainer', [ angular.module('portainer', [
@ -31,7 +30,7 @@ angular.module('portainer', [
'portainer.azure', 'portainer.azure',
'portainer.docker', 'portainer.docker',
'portainer.extensions', 'portainer.extensions',
'extension.storidge', 'portainer.integrations',
'rzModule', 'rzModule',
'moment-picker' 'moment-picker'
]); ]);

View File

@ -42,7 +42,7 @@ function (HttpRequestHelper, VolumeBrowserService, FileSaver, Blob, ModalService
var filePath = this.state.path === '/' ? file : this.state.path + '/' + file; var filePath = this.state.path === '/' ? file : this.state.path + '/' + file;
VolumeBrowserService.get(this.volumeId, filePath) VolumeBrowserService.get(this.volumeId, filePath)
.then(function success(data) { .then(function success(data) {
var downloadData = new Blob([data.file], { type: 'text/plain;charset=utf-8' }); var downloadData = new Blob([data.file]);
FileSaver.saveAs(downloadData, file); FileSaver.saveAs(downloadData, file);
}) })
.catch(function error(err) { .catch(function error(err) {

View File

@ -14,7 +14,8 @@ angular.module('portainer.agent')
}, },
get: { get: {
method: 'GET', params: { action: 'get' }, method: 'GET', params: { action: 'get' },
transformResponse: browseGetResponse transformResponse: browseGetResponse,
responseType: 'arraybuffer'
}, },
delete: { delete: {
method: 'DELETE', params: { action: 'delete' } method: 'DELETE', params: { action: 'delete' }

View File

@ -12,7 +12,8 @@ angular.module('portainer.agent')
}, },
get: { get: {
method: 'GET', params: { action: 'get' }, method: 'GET', params: { action: 'get' },
transformResponse: browseGetResponse transformResponse: browseGetResponse,
responseType: 'arraybuffer'
}, },
delete: { delete: {
method: 'DELETE', params: { action: 'delete' } method: 'DELETE', params: { action: 'delete' }

View File

@ -1,8 +1,8 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
angular.module('portainer') angular.module('portainer')
.run(['$rootScope', '$state', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', .run(['$rootScope', '$state', '$interval', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper',
function ($rootScope, $state, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, cfpLoadingBar, $transitions, HttpRequestHelper) { function ($rootScope, $state, $interval, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) {
'use strict'; 'use strict';
EndpointProvider.initialize(); EndpointProvider.initialize();
@ -31,11 +31,23 @@ function ($rootScope, $state, Authentication, authManager, StateManager, Endpoin
} }
}; };
$transitions.onBefore({ to: 'docker.**' }, function() { $transitions.onBefore({}, function() {
HttpRequestHelper.resetAgentHeaders(); HttpRequestHelper.resetAgentHeaders();
}); });
// Keep-alive Edge endpoints by sending a ping request every minute
$interval(function() {
ping(EndpointProvider, SystemService);
}, 60 * 1000)
}]); }]);
function ping(EndpointProvider, SystemService) {
let endpoint = EndpointProvider.currentEndpoint();
if (endpoint !== undefined && endpoint.Type === 4) {
SystemService.ping(endpoint.Id);
}
}
function initAuthentication(authManager, Authentication, $rootScope, $state) { function initAuthentication(authManager, Authentication, $rootScope, $state) {
authManager.checkAuthOnRefresh(); authManager.checkAuthOnRefresh();

View File

@ -50,7 +50,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox"> <span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="azure.containerinstances.container({ id: item.Id })">{{ item.Name | truncate:50 }}</a> <a ui-sref="azure.containerinstances.container({ id: item.Id })">{{ item.Name | truncate:50 }}</a>

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<div class="actionBar" authorization="DockerConfigDelete, DockerConfigCreate"> <div class="actionBar" authorization="DockerConfigDelete, DockerConfigCreate">
<button type="button" class="btn btn-sm btn-danger" authorization="DockerConfigDelete" <button type="button" class="btn btn-sm btn-danger" authorization="DockerConfigDelete"
@ -54,7 +92,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox" authorization="DockerConfigDelete, DockerConfigCreate"> <span class="md-checkbox" authorization="DockerConfigDelete, DockerConfigCreate">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.configs.config({id: item.Id})">{{ item.Name }}</a> <a ui-sref="docker.configs.config({id: item.Id})">{{ item.Name }}</a>

View File

@ -9,6 +9,7 @@ angular.module('portainer.docker').component('configsDatatable', {
orderBy: '@', orderBy: '@',
reverseOrder: '<', reverseOrder: '<',
showOwnershipColumn: '<', showOwnershipColumn: '<',
removeAction: '<' removeAction: '<',
refreshCallback: '<'
} }
}); });

View File

@ -69,6 +69,27 @@
<input id="setting_container_trunc" type="checkbox" ng-model="$ctrl.settings.truncateContainerName" ng-change="$ctrl.onSettingsContainerNameTruncateChange()"/> <input id="setting_container_trunc" type="checkbox" ng-model="$ctrl.settings.truncateContainerName" ng-change="$ctrl.onSettingsContainerNameTruncateChange()"/>
<label for="setting_container_trunc">Truncate container name</label> <label for="setting_container_trunc">Truncate container name</label>
</div> </div>
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div> </div>
<div authorization="DockerContainerStats, DockerContainerLogs, DockerExecStart, DockerContainerInspect, DockerTaskInspect, DockerTaskLogs, DockerContainerAttach"> <div authorization="DockerContainerStats, DockerContainerLogs, DockerExecStart, DockerContainerInspect, DockerTaskInspect, DockerTaskLogs, DockerContainerAttach">
<div class="menuHeader"> <div class="menuHeader">
@ -217,7 +238,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="DockerContainerStart, DockerContainerStop, DockerContainerKill, DockerContainerRestart, DockerContainerPause, DockerContainerUnpause, DockerContainerDelete, DockerContainerCreate"> <span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="DockerContainerStart, DockerContainerStop, DockerContainerKill, DockerContainerRestart, DockerContainerPause, DockerContainerUnpause, DockerContainerDelete, DockerContainerCreate">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })" title="{{ item | containername }}">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a> <a ng-if="!$ctrl.offlineMode" ui-sref="docker.containers.container({ id: item.Id, nodeName: item.NodeName })" title="{{ item | containername }}">{{ item | containername | truncate: $ctrl.settings.containerNameTruncateSize }}</a>

View File

@ -11,6 +11,7 @@ angular.module('portainer.docker').component('containersDatatable', {
showOwnershipColumn: '<', showOwnershipColumn: '<',
showHostColumn: '<', showHostColumn: '<',
showAddAction: '<', showAddAction: '<',
offlineMode: '<' offlineMode: '<',
refreshCallback: '<'
} }
}); });

View File

@ -1,25 +1,21 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ContainersDatatableController', ['PaginationService', 'DatatableService', 'EndpointProvider', .controller('ContainersDatatableController', ['$scope', '$controller', 'DatatableService', 'EndpointProvider',
function (PaginationService, DatatableService, EndpointProvider) { function ($scope, $controller, DatatableService, EndpointProvider) {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
var ctrl = this; var ctrl = this;
this.state = { this.state = Object.assign(this.state, {
selectAll: false,
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
displayTextFilter: false,
selectedItemCount: 0,
selectedItems: [],
noStoppedItemsSelected: true, noStoppedItemsSelected: true,
noRunningItemsSelected: true, noRunningItemsSelected: true,
noPausedItemsSelected: true, noPausedItemsSelected: true,
publicURL: EndpointProvider.endpointPublicURL() publicURL: EndpointProvider.endpointPublicURL()
}; });
this.settings = { this.settings = Object.assign(this.settings, {
open: false,
truncateContainerName: true, truncateContainerName: true,
containerNameTruncateSize: 32, containerNameTruncateSize: 32,
showQuickActionStats: true, showQuickActionStats: true,
@ -27,7 +23,7 @@ function (PaginationService, DatatableService, EndpointProvider) {
showQuickActionExec: true, showQuickActionExec: true,
showQuickActionInspect: true, showQuickActionInspect: true,
showQuickActionAttach: false showQuickActionAttach: false
}; });
this.filters = { this.filters = {
state: { state: {
@ -81,45 +77,13 @@ function (PaginationService, DatatableService, EndpointProvider) {
} }
}; };
this.onTextFilterChange = function() {
DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter);
};
this.onColumnVisibilityChange = function() { this.onColumnVisibilityChange = function() {
DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility); DatatableService.setColumnVisibilitySettings(this.tableKey, this.columnVisibility);
}; };
this.changeOrderBy = function(orderField) { this.onSelectionChanged = function() {
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
this.state.orderBy = orderField;
DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder);
};
this.toggleItemSelection = 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.selectItem = function(item) {
this.toggleItemSelection(item);
this.updateSelectionState(); this.updateSelectionState();
};
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.toggleItemSelection(item);
} }
}
this.updateSelectionState();
};
this.updateSelectionState = function() { this.updateSelectionState = function() {
this.state.noStoppedItemsSelected = true; this.state.noStoppedItemsSelected = true;
@ -144,10 +108,6 @@ function (PaginationService, DatatableService, EndpointProvider) {
} }
}; };
this.changePaginationLimit = function() {
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
};
this.applyFilters = function(value) { this.applyFilters = function(value) {
var container = value; var container = value;
var filters = ctrl.filters; var filters = ctrl.filters;
@ -209,40 +169,40 @@ function (PaginationService, DatatableService, EndpointProvider) {
}; };
this.$onInit = function() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset(); this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) { if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse; this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy; this.state.orderBy = storedOrder.orderBy;
} }
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey); var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) { if (storedFilters !== null) {
this.filters = storedFilters;
this.filters.state.open = false;
this.updateStoredFilters(storedFilters.state.values); this.updateStoredFilters(storedFilters.state.values);
} }
this.filters.state.open = false;
var storedSettings = DatatableService.getDataTableSettings(this.tableKey); var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) { if (storedSettings !== null) {
this.settings = storedSettings; this.settings = storedSettings;
}
this.settings.open = false; this.settings.open = false;
}
this.onSettingsRepeaterChange();
var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey); var storedColumnVisibility = DatatableService.getColumnVisibilitySettings(this.tableKey);
if (storedColumnVisibility !== null) { if (storedColumnVisibility !== null) {
this.columnVisibility = storedColumnVisibility; this.columnVisibility = storedColumnVisibility;
}
this.columnVisibility.state.open = false; this.columnVisibility.state.open = false;
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
} }
this.state.orderBy = this.orderBy;
}; };
function setDefaults(ctrl) {
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
}
}]); }]);

View File

@ -1,15 +1,12 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('JobsDatatableController', ['$q', '$state', 'PaginationService', 'DatatableService', 'ContainerService', 'ModalService', 'Notifications', .controller('JobsDatatableController', ['$scope', '$controller', '$q', '$state', 'PaginationService', 'DatatableService', 'ContainerService', 'ModalService', 'Notifications',
function ($q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) { function ($scope, $controller, $q, $state, PaginationService, DatatableService, ContainerService, ModalService, Notifications) {
var ctrl = this;
this.state = { angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey), var ctrl = this;
displayTextFilter: false
};
this.filters = { this.filters = {
state: { state: {
@ -19,20 +16,6 @@ angular.module('portainer.docker')
} }
}; };
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) { this.applyFilters = function (value) {
var container = value; var container = value;
var filters = ctrl.filters; var filters = ctrl.filters;
@ -122,7 +105,7 @@ angular.module('portainer.docker')
}; };
this.$onInit = function() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset(); this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
@ -131,21 +114,28 @@ angular.module('portainer.docker')
this.state.orderBy = storedOrder.orderBy; 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); var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) { if (textFilter !== null) {
this.state.textFilter = textFilter; this.state.textFilter = textFilter;
this.onTextFilterChange();
} }
};
function setDefaults(ctrl) { var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; if (storedFilters !== null) {
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; this.filters = storedFilters;
this.updateStoredFilters(storedFilters.state.values);
} }
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
};
} }
]); ]);

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerImageDelete, DockerImageBuild, DockerImageLoad, DockerImageGet"> <div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerImageDelete, DockerImageBuild, DockerImageLoad, DockerImageGet">
<div class="btn-group" authorization="DockerImageDelete"> <div class="btn-group" authorization="DockerImageDelete">
@ -43,7 +81,7 @@
<table class="table table-hover table-filters nowrap-cells"> <table class="table table-hover table-filters nowrap-cells">
<thead> <thead>
<tr> <tr>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" popover-placement="bottom-left" is-open="$ctrl.filters.usage.open"> <th uib-dropdown dropdown-append-to-body auto-close="disabled" popover-placement="bottom-left" is-open="$ctrl.filters.state.open">
<span class="md-checkbox" ng-if="!$ctrl.offlineMode"> <span class="md-checkbox" ng-if="!$ctrl.offlineMode">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" /> <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label> <label for="select_all"></label>
@ -54,8 +92,8 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
</a> </a>
<div> <div>
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.usage.enabled">Filter <i class="fa fa-filter" aria-hidden="true"></i></span> <span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled">Filter <i class="fa fa-filter" aria-hidden="true"></i></span>
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.usage.enabled">Filter <i class="fa fa-check" aria-hidden="true"></i></span> <span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled">Filter <i class="fa fa-check" aria-hidden="true"></i></span>
</div> </div>
<div class="dropdown-menu" uib-dropdown-menu> <div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu"> <div class="tableMenu">
@ -64,16 +102,16 @@
</div> </div>
<div class="menuContent"> <div class="menuContent">
<div class="md-checkbox"> <div class="md-checkbox">
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.usage.showUsedImages" ng-change="$ctrl.onUsageFilterChange()"/> <input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedImages" ng-change="$ctrl.onUsageFilterChange()"/>
<label for="filter_usage_usedImages">Used images</label> <label for="filter_usage_usedImages">Used images</label>
</div> </div>
<div class="md-checkbox"> <div class="md-checkbox">
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.usage.showUnusedImages" ng-change="$ctrl.onUsageFilterChange()"/> <input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedImages" ng-change="$ctrl.onUsageFilterChange()"/>
<label for="filter_usage_unusedImages">Unused images</label> <label for="filter_usage_unusedImages">Unused images</label>
</div> </div>
</div> </div>
<div> <div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.usage.open = false;">Close</a> <a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div> </div>
</div> </div>
</div> </div>
@ -112,7 +150,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox" ng-if="!$ctrl.offlineMode"> <span class="md-checkbox" ng-if="!$ctrl.offlineMode">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a> <a ng-if="!$ctrl.offlineMode" ui-sref="docker.images.image({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>

View File

@ -13,6 +13,7 @@ angular.module('portainer.docker').component('imagesDatatable', {
downloadAction: '<', downloadAction: '<',
forceRemoveAction: '<', forceRemoveAction: '<',
exportInProgress: '<', exportInProgress: '<',
offlineMode: '<' offlineMode: '<',
refreshCallback: '<'
} }
}); });

View File

@ -1,20 +1,13 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ImagesDatatableController', ['PaginationService', 'DatatableService', .controller('ImagesDatatableController', ['$scope', '$controller', 'DatatableService',
function (PaginationService, DatatableService) { function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
var ctrl = this; var ctrl = this;
this.state = {
selectAll: false,
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
displayTextFilter: false,
selectedItemCount: 0,
selectedItems: []
};
this.filters = { this.filters = {
usage: { state: {
open: false, open: false,
enabled: false, enabled: false,
showUsedImages: true, showUsedImages: true,
@ -22,62 +15,29 @@ 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;
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.applyFilters = function(value) { this.applyFilters = function(value) {
var image = value; var image = value;
var filters = ctrl.filters; var filters = ctrl.filters;
if ((image.ContainerCount === 0 && filters.usage.showUnusedImages) if ((image.ContainerCount === 0 && filters.state.showUnusedImages)
|| (image.ContainerCount !== 0 && filters.usage.showUsedImages)) { || (image.ContainerCount !== 0 && filters.state.showUsedImages)) {
return true; return true;
} }
return false; return false;
}; };
this.onUsageFilterChange = function() { this.onstateFilterChange = function() {
var filters = this.filters.usage; var filters = this.filters.state;
var filtered = false; var filtered = false;
if (!filters.showUsedImages || !filters.showUnusedImages) { if (!filters.showUsedImages || !filters.showUnusedImages) {
filtered = true; filtered = true;
} }
this.filters.usage.enabled = filtered; this.filters.state.enabled = filtered;
DatatableService.setDataTableFilters(this.tableKey, this.filters); DatatableService.setDataTableFilters(this.tableKey, this.filters);
}; };
this.$onInit = function() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) { if (storedOrder !== null) {
@ -85,20 +45,27 @@ function (PaginationService, DatatableService) {
this.state.orderBy = storedOrder.orderBy; this.state.orderBy = storedOrder.orderBy;
} }
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey); var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) { if (storedFilters !== null) {
this.filters = storedFilters; this.filters = storedFilters;
} }
this.filters.usage.open = false; if (this.filters && this.filters.state) {
this.filters.state.open = false;
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
} }
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
}; };
function setDefaults(ctrl) {
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
}
}]); }]);

View File

@ -60,7 +60,7 @@
ng-class="{active: item.Checked}"> ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox"> <span class="md-checkbox">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" /> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" />
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.nodes.node({id: item.Id})" ng-if="$ctrl.accessToNodeDetails">{{ item.Hostname }}</a> <a ui-sref="docker.nodes.node({id: item.Id})" ng-if="$ctrl.accessToNodeDetails">{{ item.Hostname }}</a>

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerNetworkDelete, DockerNetworkCreate"> <div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerNetworkDelete, DockerNetworkCreate">
<button type="button" class="btn btn-sm btn-danger" authorization="DockerNetworkDelete" <button type="button" class="btn btn-sm btn-danger" authorization="DockerNetworkDelete"
@ -110,7 +148,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox" ng-if="!$ctrl.offlineMode"> <span class="md-checkbox" ng-if="!$ctrl.offlineMode">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" ng-disabled="$ctrl.disableRemove(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)" ng-disabled="$ctrl.disableRemove(item)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.networks.network({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a> <a ng-if="!$ctrl.offlineMode" ui-sref="docker.networks.network({ id: item.Id, nodeName: item.NodeName })" title="{{ item.Name }}">{{ item.Name | truncate:40 }}</a>

View File

@ -11,6 +11,7 @@ angular.module('portainer.docker').component('networksDatatable', {
showOwnershipColumn: '<', showOwnershipColumn: '<',
showHostColumn: '<', showHostColumn: '<',
removeAction: '<', removeAction: '<',
offlineMode: '<' offlineMode: '<',
refreshCallback: '<'
} }
}); });

View File

@ -1,20 +1,51 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('NetworksDatatableController', ['$scope', '$controller', 'PREDEFINED_NETWORKS', .controller('NetworksDatatableController', ['$scope', '$controller', 'PREDEFINED_NETWORKS', 'DatatableService',
function ($scope, $controller, PREDEFINED_NETWORKS) { function ($scope, $controller, PREDEFINED_NETWORKS, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope})); angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
this.disableRemove = function(item) { this.disableRemove = function(item) {
return PREDEFINED_NETWORKS.includes(item.Name); return PREDEFINED_NETWORKS.includes(item.Name);
}; };
this.selectAll = function() { /**
for (var i = 0; i < this.state.filteredDataSet.length; i++) { * Do not allow PREDEFINED_NETWORKS to be selected
var item = this.state.filteredDataSet[i]; */
if (!this.disableRemove(item) && item.Checked !== this.state.selectAll) { this.allowSelection = function(item) {
item.Checked = this.state.selectAll; return !this.disableRemove(item);
this.selectItem(item);
} }
this.$onInit = function() {
this.setDefaults();
this.prepareTableFromDataset();
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;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
}; };
} }
]); ]);

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<div class="searchBar"> <div class="searchBar">
<i class="fa fa-search searchIcon" aria-hidden="true"></i> <i class="fa fa-search searchIcon" aria-hidden="true"></i>

View File

@ -9,6 +9,7 @@ angular.module('portainer.docker').component('nodesDatatable', {
orderBy: '@', orderBy: '@',
reverseOrder: '<', reverseOrder: '<',
showIpAddressColumn: '<', showIpAddressColumn: '<',
accessToNodeDetails: '<' accessToNodeDetails: '<',
refreshCallback: '<'
} }
}); });

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<div class="actionBar" authorization="DockerSecretDelete, DockerSecretCreate"> <div class="actionBar" authorization="DockerSecretDelete, DockerSecretCreate">
<button type="button" class="btn btn-sm btn-danger" authorization="DockerSecretDelete" <button type="button" class="btn btn-sm btn-danger" authorization="DockerSecretDelete"
@ -54,7 +92,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox" authorization="DockerSecretDelete, DockerSecretCreate"> <span class="md-checkbox" authorization="DockerSecretDelete, DockerSecretCreate">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ui-sref="docker.secrets.secret({id: item.Id})">{{ item.Name }}</a> <a ui-sref="docker.secrets.secret({id: item.Id})">{{ item.Name }}</a>

View File

@ -9,6 +9,7 @@ angular.module('portainer.docker').component('secretsDatatable', {
orderBy: '@', orderBy: '@',
reverseOrder: '<', reverseOrder: '<',
showOwnershipColumn: '<', showOwnershipColumn: '<',
removeAction: '<' removeAction: '<',
refreshCallback: '<'
} }
}); });

View File

@ -1,18 +1,21 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ServiceTasksDatatableController', ['DatatableService', .controller('ServiceTasksDatatableController', ['$scope', '$controller', 'DatatableService',
function (DatatableService) { function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
var ctrl = this; var ctrl = this;
this.state = { this.state = Object.assign(this.state, {
orderBy: this.orderBy,
showQuickActionStats: true, showQuickActionStats: true,
showQuickActionLogs: true, showQuickActionLogs: true,
showQuickActionExec: true, showQuickActionConsole: true,
showQuickActionInspect: true, showQuickActionInspect: true,
showQuickActionExec: true,
showQuickActionAttach: false showQuickActionAttach: false
}; });
this.filters = { this.filters = {
state: { state: {
@ -45,12 +48,6 @@ function (DatatableService) {
this.filters.state.enabled = filtered; this.filters.state.enabled = filtered;
}; };
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.prepareTableFromDataset = function() { this.prepareTableFromDataset = function() {
var availableStateFilters = []; var availableStateFilters = [];
for (var i = 0; i < this.dataset.length; i++) { for (var i = 0; i < this.dataset.length; i++) {
@ -61,7 +58,7 @@ function (DatatableService) {
}; };
this.$onInit = function() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset(); this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
@ -69,9 +66,28 @@ function (DatatableService) {
this.state.reverseOrder = storedOrder.reverse; this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy; this.state.orderBy = storedOrder.orderBy;
} }
};
function setDefaults(ctrl) { var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
} }
}]);
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
};
}
]);

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<services-datatable-actions <services-datatable-actions
selected-items="$ctrl.state.selectedItems" selected-items="$ctrl.state.selectedItems"
@ -84,7 +122,7 @@
<tr ng-click="$ctrl.expandItem(item, !item.Expanded)" dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}" class="interactive"> <tr ng-click="$ctrl.expandItem(item, !item.Expanded)" dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}" class="interactive">
<td> <td>
<span class="md-checkbox" authorization="DockerServiceUpdate, DockerServiceDelete, DockerServiceCreate"> <span class="md-checkbox" authorization="DockerServiceUpdate, DockerServiceDelete, DockerServiceCreate">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a><i ng-class="{ 'fas fa-angle-down': item.Expanded, 'fas fa-angle-right': !item.Expanded }" class="space-right" aria-hidden="true"></i></a> <a><i ng-class="{ 'fas fa-angle-down': item.Expanded, 'fas fa-angle-right': !item.Expanded }" class="space-right" aria-hidden="true"></i></a>

View File

@ -14,6 +14,7 @@ angular.module('portainer.docker').component('servicesDatatable', {
showUpdateAction: '<', showUpdateAction: '<',
showAddAction: '<', showAddAction: '<',
showStackColumn: '<', showStackColumn: '<',
showTaskLogsButton: '<' showTaskLogsButton: '<',
refreshCallback: '<'
} }
}); });

View File

@ -1,52 +1,18 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('ServicesDatatableController', ['PaginationService', 'DatatableService', 'EndpointProvider', .controller('ServicesDatatableController', ['$scope', '$controller', 'DatatableService', 'EndpointProvider',
function (PaginationService, DatatableService, EndpointProvider) { function ($scope, $controller, DatatableService, EndpointProvider) {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
var ctrl = this; var ctrl = this;
this.state = { this.state = Object.assign(this.state,{
selectAll: false,
expandAll: false, expandAll: false,
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
displayTextFilter: false,
selectedItemCount: 0,
selectedItems: [],
expandedItems: [], expandedItems: [],
publicURL: EndpointProvider.endpointPublicURL() 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;
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.expandAll = function() { this.expandAll = function() {
this.state.expandAll = !this.state.expandAll; this.state.expandAll = !this.state.expandAll;
@ -56,10 +22,6 @@ function (PaginationService, DatatableService, EndpointProvider) {
} }
}; };
this.changePaginationLimit = function() {
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
};
this.expandItem = function(item, expanded) { this.expandItem = function(item, expanded) {
item.Expanded = expanded; item.Expanded = expanded;
if (item.Expanded) { if (item.Expanded) {
@ -103,7 +65,8 @@ function (PaginationService, DatatableService, EndpointProvider) {
}; };
this.$onInit = function() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) { if (storedOrder !== null) {
@ -111,19 +74,31 @@ function (PaginationService, DatatableService, EndpointProvider) {
this.state.orderBy = storedOrder.orderBy; this.state.orderBy = storedOrder.orderBy;
} }
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) {
this.filters = storedFilters;
}
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey); var storedExpandedItems = DatatableService.getDataTableExpandedItems(this.tableKey);
if (storedExpandedItems !== null) { if (storedExpandedItems !== null) {
this.expandItems(storedExpandedItems); this.expandItems(storedExpandedItems);
} }
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (textFilter !== null) { if (storedSettings !== null) {
this.state.textFilter = textFilter; this.settings = storedSettings;
this.settings.open = false;
} }
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
}; };
function setDefaults(ctrl) {
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
}
}]); }]);

View File

@ -1,56 +1,20 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('TasksDatatableController', ['PaginationService', 'DatatableService', .controller('TasksDatatableController', ['$scope', '$controller', 'DatatableService',
function (PaginationService, DatatableService) { function ($scope, $controller, DatatableService) {
this.state = {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
this.state = Object.assign(this.state, {
showQuickActionStats: true, showQuickActionStats: true,
showQuickActionLogs: true, showQuickActionLogs: true,
showQuickActionExec: true, showQuickActionExec: true,
showQuickActionInspect: true, showQuickActionInspect: true,
showQuickActionAttach: false, showQuickActionAttach: false
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() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) { if (storedOrder !== null) {
@ -61,11 +25,23 @@ function (PaginationService, DatatableService) {
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey); var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) { if (textFilter !== null) {
this.state.textFilter = textFilter; this.state.textFilter = textFilter;
this.onTextFilterChange();
} }
};
function setDefaults(ctrl) { var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false; if (storedFilters !== null) {
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false; this.filters = storedFilters;
} }
if (this.filters && this.filters.state) {
this.filters.state.open = false;
}
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
};
}]); }]);

View File

@ -5,6 +5,44 @@
<div class="toolBarTitle"> <div class="toolBarTitle">
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
</div> </div>
<div class="settings">
<span class="setting" ng-class="{ 'setting-active': $ctrl.settings.open }" uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.settings.open">
<span uib-dropdown-toggle><i class="fa fa-cog" aria-hidden="true"></i> Settings</span>
<div class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
<div class="tableMenu">
<div class="menuHeader">
Table settings
</div>
<div class="menuContent">
<div>
<div class="md-checkbox">
<input id="setting_auto_refresh" type="checkbox" ng-model="$ctrl.settings.repeater.autoRefresh" ng-change="$ctrl.onSettingsRepeaterChange()"/>
<label for="setting_auto_refresh">Auto refresh</label>
</div>
<div ng-if="$ctrl.settings.repeater.autoRefresh">
<label for="settings_refresh_rate">
Refresh rate
</label>
<select id="settings_refresh_rate" ng-model="$ctrl.settings.repeater.refreshRate" ng-change="$ctrl.onSettingsRepeaterChange()" class="small-select">
<option value="10">10s</option>
<option value="30">30s</option>
<option value="60">1min</option>
<option value="120">2min</option>
<option value="300">5min</option>
</select>
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-top: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.settings.open = false;">Close</a>
</div>
</div>
</div>
</span>
</div>
</div> </div>
<div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate"> <div class="actionBar" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate">
<button type="button" class="btn btn-sm btn-danger" authorization="DockerVolumeDelete" <button type="button" class="btn btn-sm btn-danger" authorization="DockerVolumeDelete"
@ -23,7 +61,7 @@
<table class="table table-hover table-filters nowrap-cells"> <table class="table table-hover table-filters nowrap-cells">
<thead> <thead>
<tr> <tr>
<th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.usage.open"> <th uib-dropdown dropdown-append-to-body auto-close="disabled" is-open="$ctrl.filters.state.open">
<span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate"> <span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate">
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" /> <input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
<label for="select_all"></label> <label for="select_all"></label>
@ -34,8 +72,8 @@
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i> <i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Id' && $ctrl.state.reverseOrder"></i>
</a> </a>
<div> <div>
<span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.usage.enabled">Filter <i class="fa fa-filter" aria-hidden="true"></i></span> <span uib-dropdown-toggle class="table-filter" ng-if="!$ctrl.filters.state.enabled">Filter <i class="fa fa-filter" aria-hidden="true"></i></span>
<span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.usage.enabled">Filter <i class="fa fa-check" aria-hidden="true"></i></span> <span uib-dropdown-toggle class="table-filter filter-active" ng-if="$ctrl.filters.state.enabled">Filter <i class="fa fa-check" aria-hidden="true"></i></span>
</div> </div>
<div class="dropdown-menu" uib-dropdown-menu> <div class="dropdown-menu" uib-dropdown-menu>
<div class="tableMenu"> <div class="tableMenu">
@ -44,16 +82,16 @@
</div> </div>
<div class="menuContent"> <div class="menuContent">
<div class="md-checkbox"> <div class="md-checkbox">
<input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.usage.showUsedVolumes" ng-change="$ctrl.onUsageFilterChange()"/> <input id="filter_usage_usedImages" type="checkbox" ng-model="$ctrl.filters.state.showUsedVolumes" ng-change="$ctrl.onUsageFilterChange()"/>
<label for="filter_usage_usedImages">Used volumes</label> <label for="filter_usage_usedImages">Used volumes</label>
</div> </div>
<div class="md-checkbox"> <div class="md-checkbox">
<input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.usage.showUnusedVolumes" ng-change="$ctrl.onUsageFilterChange()"/> <input id="filter_usage_unusedImages" type="checkbox" ng-model="$ctrl.filters.state.showUnusedVolumes" ng-change="$ctrl.onUsageFilterChange()"/>
<label for="filter_usage_unusedImages">Unused volumes</label> <label for="filter_usage_unusedImages">Unused volumes</label>
</div> </div>
</div> </div>
<div> <div>
<a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.usage.open = false;">Close</a> <a type="button" class="btn btn-default btn-sm" ng-click="$ctrl.filters.state.open = false;">Close</a>
</div> </div>
</div> </div>
</div> </div>
@ -106,7 +144,7 @@
<tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}"> <tr dir-paginate="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter: $ctrl.applyFilters | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td> <td>
<span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate"> <span class="md-checkbox" ng-if="!$ctrl.offlineMode" authorization="DockerVolumeDelete, DockerVolumeCreate">
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)"/> <input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-click="$ctrl.selectItem(item, $event)"/>
<label for="select_{{ $index }}"></label> <label for="select_{{ $index }}"></label>
</span> </span>
<a ng-if="!$ctrl.offlineMode" ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a> <a ng-if="!$ctrl.offlineMode" ui-sref="docker.volumes.volume({ id: item.Id, nodeName: item.NodeName })" class="monospaced" title="{{ item.Id }}">{{ item.Id | truncate:40 }}</a>

View File

@ -12,6 +12,7 @@ angular.module('portainer.docker').component('volumesDatatable', {
showHostColumn: '<', showHostColumn: '<',
removeAction: '<', removeAction: '<',
showBrowseAction: '<', showBrowseAction: '<',
offlineMode: '<' offlineMode: '<',
refreshCallback: '<'
} }
}); });

View File

@ -1,20 +1,13 @@
angular.module('portainer.docker') angular.module('portainer.docker')
.controller('VolumesDatatableController', ['PaginationService', 'DatatableService', .controller('VolumesDatatableController', ['$scope', '$controller', 'DatatableService',
function (PaginationService, DatatableService) { function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', {$scope: $scope}));
var ctrl = this; var ctrl = this;
this.state = {
selectAll: false,
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
displayTextFilter: false,
selectedItemCount: 0,
selectedItems: []
};
this.filters = { this.filters = {
usage: { state: {
open: false, open: false,
enabled: false, enabled: false,
showUsedVolumes: true, showUsedVolumes: true,
@ -22,62 +15,29 @@ 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;
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.applyFilters = function(value) { this.applyFilters = function(value) {
var volume = value; var volume = value;
var filters = ctrl.filters; var filters = ctrl.filters;
if ((volume.dangling && filters.usage.showUnusedVolumes) if ((volume.dangling && filters.state.showUnusedVolumes)
|| (!volume.dangling && filters.usage.showUsedVolumes)) { || (!volume.dangling && filters.state.showUsedVolumes)) {
return true; return true;
} }
return false; return false;
}; };
this.onUsageFilterChange = function() { this.onstateFilterChange = function() {
var filters = this.filters.usage; var filters = this.filters.state;
var filtered = false; var filtered = false;
if (!filters.showUsedVolumes || !filters.showUnusedVolumes) { if (!filters.showUsedVolumes || !filters.showUnusedVolumes) {
filtered = true; filtered = true;
} }
this.filters.usage.enabled = filtered; this.filters.state.enabled = filtered;
DatatableService.setDataTableFilters(this.tableKey, this.filters); DatatableService.setDataTableFilters(this.tableKey, this.filters);
}; };
this.$onInit = function() { this.$onInit = function() {
setDefaults(this); this.setDefaults();
this.prepareTableFromDataset();
var storedOrder = DatatableService.getDataTableOrder(this.tableKey); var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) { if (storedOrder !== null) {
@ -85,20 +45,26 @@ function (PaginationService, DatatableService) {
this.state.orderBy = storedOrder.orderBy; this.state.orderBy = storedOrder.orderBy;
} }
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
this.onTextFilterChange();
}
var storedFilters = DatatableService.getDataTableFilters(this.tableKey); var storedFilters = DatatableService.getDataTableFilters(this.tableKey);
if (storedFilters !== null) { if (storedFilters !== null) {
this.filters = storedFilters; this.filters = storedFilters;
} }
this.filters.usage.open = false; if (this.filters && this.filters.state) {
this.filters.state.open = false;
var textFilter = DatatableService.getDataTableTextFilters(this.tableKey);
if (textFilter !== null) {
this.state.textFilter = textFilter;
} }
var storedSettings = DatatableService.getDataTableSettings(this.tableKey);
if (storedSettings !== null) {
this.settings = storedSettings;
this.settings.open = false;
}
this.onSettingsRepeaterChange();
this.state.orderBy = this.orderBy;
}; };
function setDefaults(ctrl) {
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
}
}]); }]);

View File

@ -29,6 +29,10 @@
<td>Network Plugins</td> <td>Network Plugins</td>
<td>{{ $ctrl.engine.networkPlugins | arraytostr: ', ' }}</td> <td>{{ $ctrl.engine.networkPlugins | arraytostr: ', ' }}</td>
</tr> </tr>
<tr ng-if="$ctrl.engine.engineLabels.length">
<td>Engine Labels</td>
<td>{{ $ctrl.engine.engineLabels | labelsToStr:', ' }}</td>
</tr>
</tbody> </tbody>
</table> </table>
</rd-widget-body> </rd-widget-body>

View File

@ -26,10 +26,6 @@
<td><span class="label label-{{ $ctrl.details.status | nodestatusbadge }}">{{ <td><span class="label label-{{ $ctrl.details.status | nodestatusbadge }}">{{
$ctrl.details.status }}</span></td> $ctrl.details.status }}</span></td>
</tr> </tr>
<tr ng-if=" $ctrl.details.engineLabels.length">
<td>Engine Labels</td>
<td>{{ $ctrl.details.engineLabels | labelsToStr:', ' }}</td>
</tr>
<tr> <tr>
<td> <td>
<div class="nopadding"> <div class="nopadding">

View File

@ -13,7 +13,7 @@ angular.module('portainer.docker')
agentProxy: false agentProxy: false
}; };
if (type === 2) { if (type === 2 || type === 4) {
mode.agentProxy = true; mode.agentProxy = true;
} }

View File

@ -1,4 +1,4 @@
import { logsHandler, genericHandler } from "./response/handlers"; import {genericHandler, logsHandler} from './response/handlers';
angular.module('portainer.docker') angular.module('portainer.docker')
.factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'ContainersInterceptor', .factory('Container', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', 'ContainersInterceptor',
@ -11,7 +11,7 @@ function ContainerFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, C
{ {
query: { query: {
method: 'GET', params: { all: 0, action: 'json', filters: '@filters' }, method: 'GET', params: { all: 0, action: 'json', filters: '@filters' },
isArray: true, interceptor: ContainersInterceptor, timeout: 10000 isArray: true, interceptor: ContainersInterceptor, timeout: 15000
}, },
get: { get: {
method: 'GET', params: { action: 'json' } method: 'GET', params: { action: 'json' }

View File

@ -1,4 +1,4 @@
import { jsonObjectsToArrayHandler, deleteImageHandler } from './response/handlers'; import {deleteImageHandler, jsonObjectsToArrayHandler} from './response/handlers';
import {imageGetResponse} from './response/image'; import {imageGetResponse} from './response/image';
angular.module('portainer.docker') angular.module('portainer.docker')
@ -10,7 +10,7 @@ function ImageFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, HttpR
endpointId: EndpointProvider.endpointID endpointId: EndpointProvider.endpointID
}, },
{ {
query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true, interceptor: ImagesInterceptor, timeout: 10000}, query: {method: 'GET', params: {all: 0, action: 'json'}, isArray: true, interceptor: ImagesInterceptor, timeout: 15000},
get: {method: 'GET', params: {action: 'json'}}, get: {method: 'GET', params: {action: 'json'}},
search: {method: 'GET', params: {action: 'search'}}, search: {method: 'GET', params: {action: 'search'}},
history: {method: 'GET', params: {action: 'history'}, isArray: true}, history: {method: 'GET', params: {action: 'history'}, isArray: true},

View File

@ -10,7 +10,7 @@ function NetworkFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider, Net
}, },
{ {
query: { query: {
method: 'GET', isArray: true, interceptor: NetworksInterceptor, timeout: 10000 method: 'GET', isArray: true, interceptor: NetworksInterceptor, timeout: 15000
}, },
get: { get: {
method: 'GET' method: 'GET'

View File

@ -10,7 +10,7 @@ angular.module('portainer.docker')
}, },
{ {
info: { info: {
method: 'GET', params: { action: 'info' }, timeout: 10000, interceptor: InfoInterceptor method: 'GET', params: { action: 'info' }, timeout: 15000, interceptor: InfoInterceptor
}, },
version: { method: 'GET', params: { action: 'version' }, timeout: 4500, interceptor: VersionInterceptor }, version: { method: 'GET', params: { action: 'version' }, timeout: 4500, interceptor: VersionInterceptor },
events: { events: {

View File

@ -7,7 +7,7 @@ angular.module('portainer.docker')
}, },
{ {
ping: { ping: {
method: 'GET', params: { action: '_ping', endpointId: '@endpointId' }, timeout: 10000 method: 'GET', params: { action: '_ping', endpointId: '@endpointId' }
} }
}); });
}]); }]);

View File

@ -9,7 +9,7 @@ angular.module('portainer.docker')
endpointId: EndpointProvider.endpointID endpointId: EndpointProvider.endpointID
}, },
{ {
query: { method: 'GET', interceptor: VolumesInterceptor, timeout: 10000}, query: { method: 'GET', interceptor: VolumesInterceptor, timeout: 15000},
get: { method: 'GET', params: {id: '@id'} }, get: { method: 'GET', params: {id: '@id'} },
create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true}, create: {method: 'POST', params: {action: 'create'}, transformResponse: genericHandler, ignoreLoadingBar: true},
remove: { remove: {

View File

@ -15,6 +15,7 @@
order-by="Name" order-by="Name"
show-ownership-column="applicationState.application.authentication" show-ownership-column="applicationState.application.authentication"
remove-action="ctrl.removeAction" remove-action="ctrl.removeAction"
refresh-callback="ctrl.getConfigs"
></configs-datatable> ></configs-datatable>
</div> </div>
</div> </div>

View File

@ -3,16 +3,23 @@ import angular from 'angular';
class ConfigsController { class ConfigsController {
/* @ngInject */ /* @ngInject */
constructor($state, ConfigService, Notifications) { constructor($state, ConfigService, Notifications, $async) {
this.$state = $state; this.$state = $state;
this.ConfigService = ConfigService; this.ConfigService = ConfigService;
this.Notifications = Notifications; this.Notifications = Notifications;
this.$async = $async;
this.removeAction = this.removeAction.bind(this); this.removeAction = this.removeAction.bind(this);
this.removeActionAsync = this.removeActionAsync.bind(this);
this.getConfigs = this.getConfigs.bind(this);
this.getConfigsAsync = this.getConfigsAsync.bind(this);
} }
async $onInit() { getConfigs() {
this.configs = []; return this.$async(this.getConfigsAsync);
}
async getConfigsAsync() {
try { try {
this.configs = await this.ConfigService.configs(); this.configs = await this.ConfigService.configs();
} catch (err) { } catch (err) {
@ -20,7 +27,16 @@ class ConfigsController {
} }
} }
async removeAction(selectedItems) { async $onInit() {
this.configs = [];
this.getConfigs();
}
removeAction(selectedItems) {
return this.$async(this.removeActionAsync, selectedItems);
}
async removeActionAsync(selectedItems) {
let actionCount = selectedItems.length; let actionCount = selectedItems.length;
for (const config of selectedItems) { for (const config of selectedItems) {
try { try {

View File

@ -5,7 +5,7 @@ import angular from "angular";
class CreateConfigController { class CreateConfigController {
/* @ngInject */ /* @ngInject */
constructor($state, $transition$, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) { constructor($async, $state, $transition$, Notifications, ConfigService, Authentication, FormValidator, ResourceControlService) {
this.$state = $state; this.$state = $state;
this.$transition$ = $transition$; this.$transition$ = $transition$;
this.Notifications = Notifications; this.Notifications = Notifications;
@ -13,6 +13,7 @@ class CreateConfigController {
this.Authentication = Authentication; this.Authentication = Authentication;
this.FormValidator = FormValidator; this.FormValidator = FormValidator;
this.ResourceControlService = ResourceControlService; this.ResourceControlService = ResourceControlService;
this.$async = $async;
this.formValues = { this.formValues = {
Name: "", Name: "",
@ -26,6 +27,30 @@ class CreateConfigController {
}; };
this.editorUpdate = this.editorUpdate.bind(this); this.editorUpdate = this.editorUpdate.bind(this);
this.createAsync = this.createAsync.bind(this);
}
async $onInit() {
if (!this.$transition$.params().id) {
this.formValues.displayCodeEditor = true;
return;
}
try {
let data = await this.ConfigService.config(this.$transition$.params().id);
this.formValues.Name = data.Name + "_copy";
this.formValues.Data = data.Data;
let labels = _.keys(data.Labels);
for (let i = 0; i < labels.length; i++) {
let labelName = labels[i];
let labelValue = data.Labels[labelName];
this.formValues.Labels.push({ name: labelName, value: labelValue });
}
this.formValues.displayCodeEditor = true;
} catch (err) {
this.formValues.displayCodeEditor = true;
this.Notifications.error("Failure", err, "Unable to clone config");
}
} }
addLabel() { addLabel() {
@ -74,7 +99,11 @@ class CreateConfigController {
return true; return true;
} }
async create() { create() {
return this.$async(this.createAsync);
}
async createAsync() {
let accessControlData = this.formValues.AccessControlData; let accessControlData = this.formValues.AccessControlData;
let userDetails = this.Authentication.getUserDetails(); let userDetails = this.Authentication.getUserDetails();
let isAdmin = this.Authentication.isAdmin(); let isAdmin = this.Authentication.isAdmin();
@ -111,29 +140,6 @@ class CreateConfigController {
editorUpdate(cm) { editorUpdate(cm) {
this.formValues.ConfigContent = cm.getValue(); this.formValues.ConfigContent = cm.getValue();
} }
async $onInit() {
if (!this.$transition$.params().id) {
this.formValues.displayCodeEditor = true;
return;
}
try {
let data = await this.ConfigService.config(this.$transition$.params().id);
this.formValues.Name = data.Name + "_copy";
this.formValues.Data = data.Data;
let labels = _.keys(data.Labels);
for (let i = 0; i < labels.length; i++) {
let labelName = labels[i];
let labelValue = data.Labels[labelName];
this.formValues.Labels.push({ name: labelName, value: labelValue });
}
this.formValues.displayCodeEditor = true;
} catch (err) {
this.formValues.displayCodeEditor = true;
this.Notifications.error("Failure", err, "Unable to clone config");
}
}
} }
export default CreateConfigController; export default CreateConfigController;

View File

@ -17,6 +17,7 @@
show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'" show-host-column="applicationState.endpoint.mode.agentProxy && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE'"
show-add-action="true" show-add-action="true"
offline-mode="offlineMode" offline-mode="offlineMode"
refresh-callback="getContainers"
></containers-datatable> ></containers-datatable>
</div> </div>
</div> </div>

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