mirror of https://github.com/portainer/portainer
feat(extensions): introduce extension support (#2527)
* wip * wip: missing repository & tags removal * feat(registry): private registry management * style(plugin-details): update view * wip * wip * wip * feat(plugins): add license info * feat(plugins): browse feature preview * feat(registry-configure): add the ability to configure registry management * style(app): update text in app * feat(plugins): add plugin version number * feat(plugins): wip plugin upgrade process * feat(plugins): wip plugin upgrade * feat(plugins): add the ability to update a plugin * feat(plugins): init plugins at startup time * feat(plugins): add the ability to remove a plugin * feat(plugins): update to latest plugin definitions * feat(plugins): introduce plugin-tooltip component * refactor(app): relocate plugin files to app/plugins * feat(plugins): introduce PluginDefinitionsURL constant * feat(plugins): update the flags used by the plugins * feat(plugins): wip * feat(plugins): display a label when a plugin has expired * wip * feat(registry-creation): update registry creation logic * refactor(registry-creation): change name/ids for inputs * feat(api): pass registry type to management configuration * feat(api): unstrip /v2 in regsitry proxy * docs(api): add TODO * feat(store): mockup-1 * feat(store): mockup 2 * feat(store): mockup 2 * feat(store): update mockup-2 * feat(app): add unauthenticated event check * update gruntfile * style(support): update support views * style(support): update product views * refactor(extensions): refactor plugins to extensions * feat(extensions): add a deal property * feat(extensions): introduce ExtensionManager * style(extensions): update extension details style * feat(extensions): display license/company when enabling extension * feat(extensions): update extensions views * feat(extensions): use ProductId defined in extension schema * style(app): remove padding left for form section title elements * style(support): use per host model * refactor(extensions): multiple refactors related to extensions mecanism * feat(extensions): update tls file path for registry extension * feat(extensions): update registry management configuration * feat(extensions): send license in header to extension proxy * fix(proxy): fix invalid default loopback address * feat(extensions): add header X-RegistryManagement-ForceNew for specific operations * feat(extensions): add the ability to display screenshots * feat(extensions): center screenshots * style(extensions): tune style * feat(extensions-details): open full screen image on click (#2517) * feat(extension-details): show magnifying glass on images * feat(extensions): support extension logo * feat(extensions): update support logos * refactor(lint): fix lint issuespull/2531/head
parent
f5dc663879
commit
6fd5ddc802
|
@ -0,0 +1,48 @@
|
|||
package archive
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// UnzipArchive will unzip an archive from bytes into the dest destination folder on disk
|
||||
func UnzipArchive(archiveData []byte, dest string) error {
|
||||
zipReader, err := zip.NewReader(bytes.NewReader(archiveData), int64(len(archiveData)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, zipFile := range zipReader.File {
|
||||
|
||||
f, err := zipFile.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fpath := filepath.Join(dest, zipFile.Name)
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, zipFile.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/portainer/portainer/bolt/dockerhub"
|
||||
"github.com/portainer/portainer/bolt/endpoint"
|
||||
"github.com/portainer/portainer/bolt/endpointgroup"
|
||||
"github.com/portainer/portainer/bolt/extension"
|
||||
"github.com/portainer/portainer/bolt/migrator"
|
||||
"github.com/portainer/portainer/bolt/registry"
|
||||
"github.com/portainer/portainer/bolt/resourcecontrol"
|
||||
|
@ -39,6 +40,7 @@ type Store struct {
|
|||
DockerHubService *dockerhub.Service
|
||||
EndpointGroupService *endpointgroup.Service
|
||||
EndpointService *endpoint.Service
|
||||
ExtensionService *extension.Service
|
||||
RegistryService *registry.Service
|
||||
ResourceControlService *resourcecontrol.Service
|
||||
SettingsService *settings.Service
|
||||
|
@ -176,6 +178,12 @@ func (store *Store) initServices() error {
|
|||
}
|
||||
store.EndpointService = endpointService
|
||||
|
||||
extensionService, err := extension.NewService(store.db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.ExtensionService = extensionService
|
||||
|
||||
registryService, err := registry.NewService(store.db)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
package extension
|
||||
|
||||
import (
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/bolt/internal"
|
||||
|
||||
"github.com/boltdb/bolt"
|
||||
)
|
||||
|
||||
const (
|
||||
// BucketName represents the name of the bucket where this service stores data.
|
||||
BucketName = "extension"
|
||||
)
|
||||
|
||||
// Service represents a service for managing endpoint data.
|
||||
type Service struct {
|
||||
db *bolt.DB
|
||||
}
|
||||
|
||||
// NewService creates a new instance of a service.
|
||||
func NewService(db *bolt.DB) (*Service, error) {
|
||||
err := internal.CreateBucket(db, BucketName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Service{
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Extension returns a extension by ID
|
||||
func (service *Service) Extension(ID portainer.ExtensionID) (*portainer.Extension, error) {
|
||||
var extension portainer.Extension
|
||||
identifier := internal.Itob(int(ID))
|
||||
|
||||
err := internal.GetObject(service.db, BucketName, identifier, &extension)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &extension, nil
|
||||
}
|
||||
|
||||
// Extensions return an array containing all the extensions.
|
||||
func (service *Service) Extensions() ([]portainer.Extension, error) {
|
||||
var extensions = make([]portainer.Extension, 0)
|
||||
|
||||
err := service.db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
var extension portainer.Extension
|
||||
err := internal.UnmarshalObject(v, &extension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extensions = append(extensions, extension)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return extensions, err
|
||||
}
|
||||
|
||||
// Persist persists a extension inside the database.
|
||||
func (service *Service) Persist(extension *portainer.Extension) error {
|
||||
return service.db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(BucketName))
|
||||
|
||||
data, err := internal.MarshalObject(extension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return bucket.Put(internal.Itob(int(extension.ID)), data)
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteExtension deletes a Extension.
|
||||
func (service *Service) DeleteExtension(ID portainer.ExtensionID) error {
|
||||
identifier := internal.Itob(int(ID))
|
||||
return internal.DeleteObject(service.db, BucketName, identifier)
|
||||
}
|
|
@ -471,6 +471,24 @@ func initJobService(dockerClientFactory *docker.ClientFactory) portainer.JobServ
|
|||
return docker.NewJobService(dockerClientFactory)
|
||||
}
|
||||
|
||||
func initExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) (portainer.ExtensionManager, error) {
|
||||
extensionManager := exec.NewExtensionManager(fileService, extensionService)
|
||||
|
||||
extensions, err := extensionService.Extensions()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, extension := range extensions {
|
||||
err := extensionManager.EnableExtension(&extension, extension.License.LicenseKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return extensionManager, nil
|
||||
}
|
||||
|
||||
func terminateIfNoAdminCreated(userService portainer.UserService) {
|
||||
timer1 := time.NewTimer(5 * time.Minute)
|
||||
<-timer1.C
|
||||
|
@ -509,6 +527,11 @@ func main() {
|
|||
log.Fatal(err)
|
||||
}
|
||||
|
||||
extensionManager, err := initExtensionManager(fileService, store.ExtensionService)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
clientFactory := initClientFactory(digitalSignatureService)
|
||||
|
||||
jobService := initJobService(clientFactory)
|
||||
|
@ -619,6 +642,7 @@ func main() {
|
|||
TeamMembershipService: store.TeamMembershipService,
|
||||
EndpointService: store.EndpointService,
|
||||
EndpointGroupService: store.EndpointGroupService,
|
||||
ExtensionService: store.ExtensionService,
|
||||
ResourceControlService: store.ResourceControlService,
|
||||
SettingsService: store.SettingsService,
|
||||
RegistryService: store.RegistryService,
|
||||
|
@ -630,6 +654,7 @@ func main() {
|
|||
WebhookService: store.WebhookService,
|
||||
SwarmStackManager: swarmStackManager,
|
||||
ComposeStackManager: composeStackManager,
|
||||
ExtensionManager: extensionManager,
|
||||
CryptoService: cryptoService,
|
||||
JWTService: jwtService,
|
||||
FileService: fileService,
|
||||
|
|
|
@ -88,6 +88,11 @@ const (
|
|||
ErrUndefinedTLSFileType = Error("Undefined TLS file type")
|
||||
)
|
||||
|
||||
// Extension errors.
|
||||
const (
|
||||
ErrExtensionAlreadyEnabled = Error("This extension is already enabled")
|
||||
)
|
||||
|
||||
// Docker errors.
|
||||
const (
|
||||
ErrUnableToPingEndpoint = Error("Unable to communicate with the endpoint")
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
package exec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/client"
|
||||
)
|
||||
|
||||
var extensionDownloadBaseURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions/"
|
||||
|
||||
var extensionBinaryMap = map[portainer.ExtensionID]string{
|
||||
portainer.RegistryManagementExtension: "extension-registry-management",
|
||||
}
|
||||
|
||||
// ExtensionManager represents a service used to
|
||||
// manage extension processes.
|
||||
type ExtensionManager struct {
|
||||
processes cmap.ConcurrentMap
|
||||
fileService portainer.FileService
|
||||
extensionService portainer.ExtensionService
|
||||
}
|
||||
|
||||
// NewExtensionManager returns a pointer to an ExtensionManager
|
||||
func NewExtensionManager(fileService portainer.FileService, extensionService portainer.ExtensionService) *ExtensionManager {
|
||||
return &ExtensionManager{
|
||||
processes: cmap.New(),
|
||||
fileService: fileService,
|
||||
extensionService: extensionService,
|
||||
}
|
||||
}
|
||||
|
||||
func processKey(ID portainer.ExtensionID) string {
|
||||
return strconv.Itoa(int(ID))
|
||||
}
|
||||
|
||||
func buildExtensionURL(extension *portainer.Extension) string {
|
||||
extensionURL := extensionDownloadBaseURL
|
||||
extensionURL += extensionBinaryMap[extension.ID]
|
||||
extensionURL += "-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||
extensionURL += "-" + extension.Version
|
||||
extensionURL += ".zip"
|
||||
return extensionURL
|
||||
}
|
||||
|
||||
func buildExtensionPath(binaryPath string, extension *portainer.Extension) string {
|
||||
|
||||
extensionFilename := extensionBinaryMap[extension.ID]
|
||||
extensionFilename += "-" + runtime.GOOS + "-" + runtime.GOARCH
|
||||
extensionFilename += "-" + extension.Version
|
||||
|
||||
extensionPath := path.Join(
|
||||
binaryPath,
|
||||
extensionFilename)
|
||||
|
||||
return extensionPath
|
||||
}
|
||||
|
||||
// FetchExtensionDefinitions will fetch the list of available
|
||||
// extension definitions from the official Portainer assets server
|
||||
func (manager *ExtensionManager) FetchExtensionDefinitions() ([]portainer.Extension, error) {
|
||||
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var extensions []portainer.Extension
|
||||
err = json.Unmarshal(extensionData, &extensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return extensions, nil
|
||||
}
|
||||
|
||||
// EnableExtension will check for the existence of the extension binary on the filesystem
|
||||
// first. If it does not exist, it will download it from the official Portainer assets server.
|
||||
// After installing the binary on the filesystem, it will execute the binary in license check
|
||||
// mode to validate the extension license. If the license is valid, it will then start
|
||||
// the extension process and register it in the processes map.
|
||||
func (manager *ExtensionManager) EnableExtension(extension *portainer.Extension, licenseKey string) error {
|
||||
extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension)
|
||||
extensionBinaryExists, err := manager.fileService.FileExists(extensionBinaryPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !extensionBinaryExists {
|
||||
err := manager.downloadExtension(extension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
licenseDetails, err := validateLicense(extensionBinaryPath, licenseKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extension.License = portainer.LicenseInformation{
|
||||
LicenseKey: licenseKey,
|
||||
Company: licenseDetails[0],
|
||||
Expiration: licenseDetails[1],
|
||||
}
|
||||
extension.Version = licenseDetails[2]
|
||||
|
||||
return manager.startExtensionProcess(extension, extensionBinaryPath)
|
||||
}
|
||||
|
||||
// DisableExtension will retrieve the process associated to the extension
|
||||
// from the processes map and kill the process. It will then remove the process
|
||||
// from the processes map and remove the binary associated to the extension
|
||||
// from the filesystem
|
||||
func (manager *ExtensionManager) DisableExtension(extension *portainer.Extension) error {
|
||||
process, ok := manager.processes.Get(processKey(extension.ID))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := process.(*exec.Cmd).Process.Kill()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.processes.Remove(processKey(extension.ID))
|
||||
|
||||
extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension)
|
||||
return manager.fileService.RemoveDirectory(extensionBinaryPath)
|
||||
}
|
||||
|
||||
// UpdateExtension will download the new extension binary from the official Portainer assets
|
||||
// server, disable the previous extension via DisableExtension, trigger a license check
|
||||
// and then start the extension process and add it to the processes map
|
||||
func (manager *ExtensionManager) UpdateExtension(extension *portainer.Extension, version string) error {
|
||||
oldVersion := extension.Version
|
||||
|
||||
extension.Version = version
|
||||
err := manager.downloadExtension(extension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extension.Version = oldVersion
|
||||
err = manager.DisableExtension(extension)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extension.Version = version
|
||||
extensionBinaryPath := buildExtensionPath(manager.fileService.GetBinaryFolder(), extension)
|
||||
|
||||
licenseDetails, err := validateLicense(extensionBinaryPath, extension.License.LicenseKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extension.Version = licenseDetails[2]
|
||||
|
||||
return manager.startExtensionProcess(extension, extensionBinaryPath)
|
||||
}
|
||||
|
||||
func (manager *ExtensionManager) downloadExtension(extension *portainer.Extension) error {
|
||||
extensionURL := buildExtensionURL(extension)
|
||||
|
||||
data, err := client.Get(extensionURL, 30)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return manager.fileService.ExtractExtensionArchive(data)
|
||||
}
|
||||
|
||||
func validateLicense(binaryPath, licenseKey string) ([]string, error) {
|
||||
licenseCheckProcess := exec.Command(binaryPath, "-license", licenseKey, "-check")
|
||||
cmdOutput := &bytes.Buffer{}
|
||||
licenseCheckProcess.Stdout = cmdOutput
|
||||
|
||||
err := licenseCheckProcess.Run()
|
||||
if err != nil {
|
||||
return nil, errors.New("Invalid extension license key")
|
||||
}
|
||||
|
||||
output := string(cmdOutput.Bytes())
|
||||
|
||||
return strings.Split(output, "|"), nil
|
||||
}
|
||||
|
||||
func (manager *ExtensionManager) startExtensionProcess(extension *portainer.Extension, binaryPath string) error {
|
||||
extensionProcess := exec.Command(binaryPath, "-license", extension.License.LicenseKey)
|
||||
err := extensionProcess.Start()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
manager.processes.Set(processKey(extension.ID), extensionProcess)
|
||||
return nil
|
||||
}
|
|
@ -7,6 +7,7 @@ import (
|
|||
"io/ioutil"
|
||||
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/archive"
|
||||
|
||||
"io"
|
||||
"os"
|
||||
|
@ -32,8 +33,13 @@ const (
|
|||
PrivateKeyFile = "portainer.key"
|
||||
// PublicKeyFile represents the name on disk of the file containing the public key.
|
||||
PublicKeyFile = "portainer.pub"
|
||||
// BinaryStorePath represents the subfolder where binaries are stored in the file store folder.
|
||||
BinaryStorePath = "bin"
|
||||
// ScheduleStorePath represents the subfolder where schedule files are stored.
|
||||
ScheduleStorePath = "schedules"
|
||||
// ExtensionRegistryManagementStorePath represents the subfolder where files related to the
|
||||
// registry management extension are stored.
|
||||
ExtensionRegistryManagementStorePath = "extensions"
|
||||
)
|
||||
|
||||
// Service represents a service for managing files and directories.
|
||||
|
@ -65,9 +71,30 @@ func NewService(dataStorePath, fileStorePath string) (*Service, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
err = service.createDirectoryInStore(BinaryStorePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetBinaryFolder returns the full path to the binary store on the filesystem
|
||||
func (service *Service) GetBinaryFolder() string {
|
||||
return path.Join(service.fileStorePath, BinaryStorePath)
|
||||
}
|
||||
|
||||
// ExtractExtensionArchive extracts the content of an extension archive
|
||||
// specified as raw data into the binary store on the filesystem
|
||||
func (service *Service) ExtractExtensionArchive(data []byte) error {
|
||||
err := archive.UnzipArchive(data, path.Join(service.fileStorePath, BinaryStorePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveDirectory removes a directory on the filesystem.
|
||||
func (service *Service) RemoveDirectory(directoryPath string) error {
|
||||
return os.RemoveAll(directoryPath)
|
||||
|
@ -99,6 +126,27 @@ func (service *Service) StoreStackFileFromBytes(stackIdentifier, fileName string
|
|||
return path.Join(service.fileStorePath, stackStorePath), nil
|
||||
}
|
||||
|
||||
// StoreRegistryManagementFileFromBytes creates a subfolder in the
|
||||
// ExtensionRegistryManagementStorePath and stores a new file from bytes.
|
||||
// It returns the path to the folder where the file is stored.
|
||||
func (service *Service) StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error) {
|
||||
extensionStorePath := path.Join(ExtensionRegistryManagementStorePath, folder)
|
||||
err := service.createDirectoryInStore(extensionStorePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
file := path.Join(extensionStorePath, fileName)
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
err = service.createFileInStore(file, r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path.Join(service.fileStorePath, file), nil
|
||||
}
|
||||
|
||||
// StoreTLSFileFromBytes creates a folder in the TLSStorePath and stores a new file from bytes.
|
||||
// It returns the path to the newly created file.
|
||||
func (service *Service) StoreTLSFileFromBytes(folder string, fileType portainer.TLSFileType, data []byte) (string, error) {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package endpointproxy
|
||||
|
||||
// TODO: legacy extension management
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
|
@ -42,9 +44,9 @@ func (handler *Handler) proxyRequestsToStoridgeAPI(w http.ResponseWriter, r *htt
|
|||
proxyExtensionKey := string(endpoint.ID) + "_" + string(portainer.StoridgeEndpointExtension)
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetExtensionProxy(proxyExtensionKey)
|
||||
proxy = handler.ProxyManager.GetLegacyExtensionProxy(proxyExtensionKey)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateAndRegisterExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
|
||||
proxy, err = handler.ProxyManager.CreateLegacyExtensionProxy(proxyExtensionKey, storidgeExtension.URL)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy", err}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,6 @@ func (handler *Handler) endpointDelete(w http.ResponseWriter, r *http.Request) *
|
|||
}
|
||||
|
||||
handler.ProxyManager.DeleteProxy(string(endpointID))
|
||||
handler.ProxyManager.DeleteExtensionProxies(string(endpointID))
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package endpoints
|
||||
|
||||
// TODO: legacy extension management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package endpoints
|
||||
|
||||
// TODO: legacy extension management
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
type extensionCreatePayload struct {
|
||||
License string
|
||||
}
|
||||
|
||||
func (payload *extensionCreatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.License) {
|
||||
return portainer.Error("Invalid license")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) extensionCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
var payload extensionCreatePayload
|
||||
err := request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
extensionIdentifier, err := strconv.Atoi(string(payload.License[0]))
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid license format", err}
|
||||
}
|
||||
extensionID := portainer.ExtensionID(extensionIdentifier)
|
||||
|
||||
extensions, err := handler.ExtensionService.Extensions()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions status from the database", err}
|
||||
}
|
||||
|
||||
for _, existingExtension := range extensions {
|
||||
if existingExtension.ID == extensionID {
|
||||
return &httperror.HandlerError{http.StatusConflict, "Unable to enable extension", portainer.ErrExtensionAlreadyEnabled}
|
||||
}
|
||||
}
|
||||
|
||||
extension := &portainer.Extension{
|
||||
ID: extensionID,
|
||||
}
|
||||
|
||||
extensionDefinitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
|
||||
}
|
||||
|
||||
for _, def := range extensionDefinitions {
|
||||
if def.ID == extension.ID {
|
||||
extension.Version = def.Version
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.ExtensionManager.EnableExtension(extension, payload.License)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to enable extension", err}
|
||||
}
|
||||
|
||||
extension.Enabled = true
|
||||
|
||||
err = handler.ExtensionService.Persist(extension)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// DELETE request on /api/extensions/:id
|
||||
func (handler *Handler) extensionDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
|
||||
}
|
||||
extensionID := portainer.ExtensionID(extensionIdentifier)
|
||||
|
||||
extension, err := handler.ExtensionService.Extension(extensionID)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.ExtensionManager.DisableExtension(extension)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete extension", err}
|
||||
}
|
||||
|
||||
err = handler.ExtensionService.DeleteExtension(extensionID)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to delete the extension from the database", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/client"
|
||||
)
|
||||
|
||||
// GET request on /api/extensions/:id
|
||||
func (handler *Handler) extensionInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
|
||||
}
|
||||
extensionID := portainer.ExtensionID(extensionIdentifier)
|
||||
|
||||
extensionData, err := client.Get(portainer.ExtensionDefinitionsURL, 30)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
|
||||
}
|
||||
|
||||
var extensions []portainer.Extension
|
||||
err = json.Unmarshal(extensionData, &extensions)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to parse external extension definitions", err}
|
||||
}
|
||||
|
||||
var extension portainer.Extension
|
||||
for _, p := range extensions {
|
||||
if p.ID == extensionID {
|
||||
extension = p
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
storedExtension, err := handler.ExtensionService.Extension(extensionID)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return response.JSON(w, extension)
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
extension.Enabled = storedExtension.Enabled
|
||||
|
||||
extensionVer := semver.New(extension.Version)
|
||||
pVer := semver.New(storedExtension.Version)
|
||||
|
||||
if pVer.LessThan(*extensionVer) {
|
||||
extension.UpdateAvailable = true
|
||||
}
|
||||
|
||||
return response.JSON(w, extension)
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coreos/go-semver/semver"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// GET request on /api/extensions?store=<store>
|
||||
func (handler *Handler) extensionList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
storeDetails, _ := request.RetrieveBooleanQueryParameter(r, "store", true)
|
||||
|
||||
extensions, err := handler.ExtensionService.Extensions()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extensions from the database", err}
|
||||
}
|
||||
|
||||
if storeDetails {
|
||||
definitions, err := handler.ExtensionManager.FetchExtensionDefinitions()
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve extension definitions", err}
|
||||
}
|
||||
|
||||
for idx := range definitions {
|
||||
associateExtensionData(&definitions[idx], extensions)
|
||||
}
|
||||
|
||||
extensions = definitions
|
||||
}
|
||||
|
||||
return response.JSON(w, extensions)
|
||||
}
|
||||
|
||||
func associateExtensionData(definition *portainer.Extension, extensions []portainer.Extension) {
|
||||
for _, extension := range extensions {
|
||||
if extension.ID == definition.ID {
|
||||
|
||||
definition.Enabled = extension.Enabled
|
||||
definition.License.Company = extension.License.Company
|
||||
definition.License.Expiration = extension.License.Expiration
|
||||
|
||||
definitionVersion := semver.New(definition.Version)
|
||||
extensionVersion := semver.New(extension.Version)
|
||||
if extensionVersion.LessThan(*definitionVersion) {
|
||||
definition.UpdateAvailable = true
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/asaskevich/govalidator"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
type extensionUpdatePayload struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
func (payload *extensionUpdatePayload) Validate(r *http.Request) error {
|
||||
if govalidator.IsNull(payload.Version) {
|
||||
return portainer.Error("Invalid extension version")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (handler *Handler) extensionUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
extensionIdentifier, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid extension identifier route variable", err}
|
||||
}
|
||||
extensionID := portainer.ExtensionID(extensionIdentifier)
|
||||
|
||||
var payload extensionUpdatePayload
|
||||
err = request.DecodeAndValidateJSONPayload(r, &payload)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
extension, err := handler.ExtensionService.Extension(extensionID)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a extension with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
err = handler.ExtensionManager.UpdateExtension(extension, payload.Version)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to update extension", err}
|
||||
}
|
||||
|
||||
err = handler.ExtensionService.Persist(extension)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist extension status inside the database", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle extension operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
ExtensionService portainer.ExtensionService
|
||||
ExtensionManager portainer.ExtensionManager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage extension operations.
|
||||
func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
}
|
||||
|
||||
h.Handle("/extensions",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionList))).Methods(http.MethodGet)
|
||||
h.Handle("/extensions",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionCreate))).Methods(http.MethodPost)
|
||||
h.Handle("/extensions/{id}",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionInspect))).Methods(http.MethodGet)
|
||||
h.Handle("/extensions/{id}",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/extensions/{id}/update",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.extensionUpdate))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/portainer/portainer/http/handler/endpointgroups"
|
||||
"github.com/portainer/portainer/http/handler/endpointproxy"
|
||||
"github.com/portainer/portainer/http/handler/endpoints"
|
||||
"github.com/portainer/portainer/http/handler/extensions"
|
||||
"github.com/portainer/portainer/http/handler/file"
|
||||
"github.com/portainer/portainer/http/handler/motd"
|
||||
"github.com/portainer/portainer/http/handler/registries"
|
||||
|
@ -37,6 +38,7 @@ type Handler struct {
|
|||
EndpointProxyHandler *endpointproxy.Handler
|
||||
FileHandler *file.Handler
|
||||
MOTDHandler *motd.Handler
|
||||
ExtensionHandler *extensions.Handler
|
||||
RegistryHandler *registries.Handler
|
||||
ResourceControlHandler *resourcecontrols.Handler
|
||||
SettingsHandler *settings.Handler
|
||||
|
@ -75,6 +77,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
case strings.HasPrefix(r.URL.Path, "/api/motd"):
|
||||
http.StripPrefix("/api", h.MOTDHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/extensions"):
|
||||
http.StripPrefix("/api", h.ExtensionHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/registries"):
|
||||
http.StripPrefix("/api", h.RegistryHandler).ServeHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/api/resource_controls"):
|
||||
|
|
|
@ -1,23 +1,27 @@
|
|||
package registries
|
||||
|
||||
import (
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/portainer"
|
||||
"github.com/portainer/portainer/http/proxy"
|
||||
"github.com/portainer/portainer/http/security"
|
||||
)
|
||||
|
||||
func hideFields(registry *portainer.Registry) {
|
||||
registry.Password = ""
|
||||
registry.ManagementConfiguration = nil
|
||||
}
|
||||
|
||||
// Handler is the HTTP handler used to handle registry operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
RegistryService portainer.RegistryService
|
||||
RegistryService portainer.RegistryService
|
||||
ExtensionService portainer.ExtensionService
|
||||
FileService portainer.FileService
|
||||
ProxyManager *proxy.Manager
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage registry operations.
|
||||
|
@ -36,8 +40,12 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
|||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdate))).Methods(http.MethodPut)
|
||||
h.Handle("/registries/{id}/access",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryUpdateAccess))).Methods(http.MethodPut)
|
||||
h.Handle("/registries/{id}/configure",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryConfigure))).Methods(http.MethodPost)
|
||||
h.Handle("/registries/{id}",
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete)
|
||||
h.PathPrefix("/registries/{id}/v2").Handler(
|
||||
bouncer.AdministratorAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI)))
|
||||
|
||||
return h
|
||||
}
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
package registries
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// request on /api/registries/:id/v2
|
||||
func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
}
|
||||
|
||||
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension)
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
var proxy http.Handler
|
||||
proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension)
|
||||
if proxy == nil {
|
||||
proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register registry proxy", err}
|
||||
}
|
||||
}
|
||||
|
||||
managementConfiguration := registry.ManagementConfiguration
|
||||
if managementConfiguration == nil {
|
||||
managementConfiguration = createDefaultManagementConfiguration(registry)
|
||||
}
|
||||
|
||||
encodedConfiguration, err := json.Marshal(managementConfiguration)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err}
|
||||
}
|
||||
|
||||
id := strconv.Itoa(int(registryID))
|
||||
r.Header.Set("X-RegistryManagement-Key", id)
|
||||
r.Header.Set("X-RegistryManagement-URI", registry.URL)
|
||||
r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration))
|
||||
r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey)
|
||||
|
||||
http.StripPrefix("/registries/"+id, proxy).ServeHTTP(w, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDefaultManagementConfiguration(registry *portainer.Registry) *portainer.RegistryManagementConfiguration {
|
||||
config := &portainer.RegistryManagementConfiguration{
|
||||
Type: registry.Type,
|
||||
TLSConfig: portainer.TLSConfiguration{
|
||||
TLS: false,
|
||||
},
|
||||
}
|
||||
|
||||
if registry.Authentication {
|
||||
config.Authentication = true
|
||||
config.Username = registry.Username
|
||||
config.Password = registry.Password
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
|
@ -0,0 +1,137 @@
|
|||
package registries
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
httperror "github.com/portainer/libhttp/error"
|
||||
"github.com/portainer/libhttp/request"
|
||||
"github.com/portainer/libhttp/response"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
type registryConfigurePayload struct {
|
||||
Authentication bool
|
||||
Username string
|
||||
Password string
|
||||
TLS bool
|
||||
TLSSkipVerify bool
|
||||
TLSCertFile []byte
|
||||
TLSKeyFile []byte
|
||||
TLSCACertFile []byte
|
||||
}
|
||||
|
||||
func (payload *registryConfigurePayload) Validate(r *http.Request) error {
|
||||
useAuthentication, _ := request.RetrieveBooleanMultiPartFormValue(r, "Authentication", true)
|
||||
payload.Authentication = useAuthentication
|
||||
|
||||
if useAuthentication {
|
||||
username, err := request.RetrieveMultiPartFormValue(r, "Username", false)
|
||||
if err != nil {
|
||||
return portainer.Error("Invalid username")
|
||||
}
|
||||
payload.Username = username
|
||||
|
||||
password, _ := request.RetrieveMultiPartFormValue(r, "Password", true)
|
||||
payload.Password = password
|
||||
}
|
||||
|
||||
useTLS, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLS", true)
|
||||
payload.TLS = useTLS
|
||||
|
||||
skipTLSVerify, _ := request.RetrieveBooleanMultiPartFormValue(r, "TLSSkipVerify", true)
|
||||
payload.TLSSkipVerify = skipTLSVerify
|
||||
|
||||
if useTLS && !skipTLSVerify {
|
||||
cert, _, err := request.RetrieveMultiPartFormFile(r, "TLSCertFile")
|
||||
if err != nil {
|
||||
return portainer.Error("Invalid certificate file. Ensure that the file is uploaded correctly")
|
||||
}
|
||||
payload.TLSCertFile = cert
|
||||
|
||||
key, _, err := request.RetrieveMultiPartFormFile(r, "TLSKeyFile")
|
||||
if err != nil {
|
||||
return portainer.Error("Invalid key file. Ensure that the file is uploaded correctly")
|
||||
}
|
||||
payload.TLSKeyFile = key
|
||||
|
||||
ca, _, err := request.RetrieveMultiPartFormFile(r, "TLSCACertFile")
|
||||
if err != nil {
|
||||
return portainer.Error("Invalid CA certificate file. Ensure that the file is uploaded correctly")
|
||||
}
|
||||
payload.TLSCACertFile = ca
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// POST request on /api/registries/:id/configure
|
||||
func (handler *Handler) registryConfigure(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
registryID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err}
|
||||
}
|
||||
|
||||
payload := ®istryConfigurePayload{}
|
||||
err = payload.Validate(r)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||
}
|
||||
|
||||
registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID))
|
||||
if err == portainer.ErrObjectNotFound {
|
||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
} else if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err}
|
||||
}
|
||||
|
||||
registry.ManagementConfiguration = &portainer.RegistryManagementConfiguration{
|
||||
Type: registry.Type,
|
||||
}
|
||||
|
||||
if payload.Authentication {
|
||||
registry.ManagementConfiguration.Authentication = true
|
||||
registry.ManagementConfiguration.Username = payload.Username
|
||||
if payload.Username == registry.Username && payload.Password == "" {
|
||||
registry.ManagementConfiguration.Password = registry.Password
|
||||
} else {
|
||||
registry.ManagementConfiguration.Password = payload.Password
|
||||
}
|
||||
}
|
||||
|
||||
if payload.TLS {
|
||||
registry.ManagementConfiguration.TLSConfig = portainer.TLSConfiguration{
|
||||
TLS: true,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
}
|
||||
|
||||
if !payload.TLSSkipVerify {
|
||||
folder := strconv.Itoa(int(registry.ID))
|
||||
|
||||
certPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "cert.pem", payload.TLSCertFile)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS certificate file on disk", err}
|
||||
}
|
||||
registry.ManagementConfiguration.TLSConfig.TLSCertPath = certPath
|
||||
|
||||
keyPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "key.pem", payload.TLSKeyFile)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS key file on disk", err}
|
||||
}
|
||||
registry.ManagementConfiguration.TLSConfig.TLSKeyPath = keyPath
|
||||
|
||||
cacertPath, err := handler.FileService.StoreRegistryManagementFileFromBytes(folder, "ca.pem", payload.TLSCACertFile)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist TLS CA certificate file on disk", err}
|
||||
}
|
||||
registry.ManagementConfiguration.TLSConfig.TLSCACertPath = cacertPath
|
||||
}
|
||||
}
|
||||
|
||||
err = handler.RegistryService.UpdateRegistry(registry.ID, registry)
|
||||
if err != nil {
|
||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist registry changes inside the database", err}
|
||||
}
|
||||
|
||||
return response.Empty(w)
|
||||
}
|
|
@ -12,6 +12,7 @@ import (
|
|||
|
||||
type registryCreatePayload struct {
|
||||
Name string
|
||||
Type int
|
||||
URL string
|
||||
Authentication bool
|
||||
Username string
|
||||
|
@ -28,6 +29,9 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error {
|
|||
if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) {
|
||||
return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled")
|
||||
}
|
||||
if payload.Type != 1 && payload.Type != 2 && payload.Type != 3 {
|
||||
return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -49,6 +53,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) *
|
|||
}
|
||||
|
||||
registry := &portainer.Registry{
|
||||
Type: portainer.RegistryType(payload.Type),
|
||||
Name: payload.Name,
|
||||
URL: payload.URL,
|
||||
Authentication: payload.Authentication,
|
||||
|
|
|
@ -25,7 +25,7 @@ type proxyFactory struct {
|
|||
|
||||
func (factory *proxyFactory) newHTTPProxy(u *url.URL) http.Handler {
|
||||
u.Scheme = "http"
|
||||
return newSingleHostReverseProxyWithHostHeader(u)
|
||||
return httputil.NewSingleHostReverseProxy(u)
|
||||
}
|
||||
|
||||
func newAzureProxy(credentials *portainer.AzureCredentials) (http.Handler, error) {
|
||||
|
|
|
@ -3,18 +3,25 @@ package proxy
|
|||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"strconv"
|
||||
|
||||
"github.com/orcaman/concurrent-map"
|
||||
"github.com/portainer/portainer"
|
||||
)
|
||||
|
||||
// TODO: contain code related to legacy extension management
|
||||
|
||||
var extensionPorts = map[portainer.ExtensionID]string{
|
||||
portainer.RegistryManagementExtension: "7001",
|
||||
}
|
||||
|
||||
type (
|
||||
// Manager represents a service used to manage Docker proxies.
|
||||
Manager struct {
|
||||
proxyFactory *proxyFactory
|
||||
proxies cmap.ConcurrentMap
|
||||
extensionProxies cmap.ConcurrentMap
|
||||
proxyFactory *proxyFactory
|
||||
proxies cmap.ConcurrentMap
|
||||
extensionProxies cmap.ConcurrentMap
|
||||
legacyExtensionProxies cmap.ConcurrentMap
|
||||
}
|
||||
|
||||
// ManagerParams represents the required parameters to create a new Manager instance.
|
||||
|
@ -31,8 +38,9 @@ type (
|
|||
// NewManager initializes a new proxy Service
|
||||
func NewManager(parameters *ManagerParams) *Manager {
|
||||
return &Manager{
|
||||
proxies: cmap.New(),
|
||||
extensionProxies: cmap.New(),
|
||||
proxies: cmap.New(),
|
||||
extensionProxies: cmap.New(),
|
||||
legacyExtensionProxies: cmap.New(),
|
||||
proxyFactory: &proxyFactory{
|
||||
ResourceControlService: parameters.ResourceControlService,
|
||||
TeamMembershipService: parameters.TeamMembershipService,
|
||||
|
@ -44,6 +52,83 @@ func NewManager(parameters *ManagerParams) *Manager {
|
|||
}
|
||||
}
|
||||
|
||||
// GetProxy returns the proxy associated to a key
|
||||
func (manager *Manager) GetProxy(key string) http.Handler {
|
||||
proxy, ok := manager.proxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
proxy, err := manager.createProxy(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager.proxies.Set(string(endpoint.ID), proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// DeleteProxy deletes the proxy associated to a key
|
||||
func (manager *Manager) DeleteProxy(key string) {
|
||||
manager.proxies.Remove(key)
|
||||
}
|
||||
|
||||
// GetExtensionProxy returns an extension proxy associated to an extension identifier
|
||||
func (manager *Manager) GetExtensionProxy(extensionID portainer.ExtensionID) http.Handler {
|
||||
proxy, ok := manager.extensionProxies.Get(strconv.Itoa(int(extensionID)))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// CreateExtensionProxy creates a new HTTP reverse proxy for an extension and
|
||||
// registers it in the extension map associated to the specified extension identifier
|
||||
func (manager *Manager) CreateExtensionProxy(extensionID portainer.ExtensionID) (http.Handler, error) {
|
||||
address := "http://localhost:" + extensionPorts[extensionID]
|
||||
|
||||
extensionURL, err := url.Parse(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
|
||||
manager.extensionProxies.Set(strconv.Itoa(int(extensionID)), proxy)
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// DeleteExtensionProxy deletes the extension proxy associated to an extension identifier
|
||||
func (manager *Manager) DeleteExtensionProxy(extensionID portainer.ExtensionID) {
|
||||
manager.extensionProxies.Remove(strconv.Itoa(int(extensionID)))
|
||||
}
|
||||
|
||||
// GetLegacyExtensionProxy returns a legacy extension proxy associated to a key
|
||||
func (manager *Manager) GetLegacyExtensionProxy(key string) http.Handler {
|
||||
proxy, ok := manager.legacyExtensionProxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// CreateLegacyExtensionProxy creates a new HTTP reverse proxy for a legacy extension and adds it to the registered proxies.
|
||||
func (manager *Manager) CreateLegacyExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
|
||||
extensionURL, err := url.Parse(extensionAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
|
||||
manager.extensionProxies.Set(key, proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (manager *Manager) createDockerProxy(endpointURL *url.URL, tlsConfig *portainer.TLSConfiguration) (http.Handler, error) {
|
||||
if endpointURL.Scheme == "tcp" {
|
||||
if tlsConfig.TLS || tlsConfig.TLSSkipVerify {
|
||||
|
@ -69,59 +154,3 @@ func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler,
|
|||
return manager.createDockerProxy(endpointURL, &endpoint.TLSConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAndRegisterProxy creates a new HTTP reverse proxy based on endpoint properties and and adds it to the registered proxies.
|
||||
// It can also be used to create a new HTTP reverse proxy and replace an already registered proxy.
|
||||
func (manager *Manager) CreateAndRegisterProxy(endpoint *portainer.Endpoint) (http.Handler, error) {
|
||||
proxy, err := manager.createProxy(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
manager.proxies.Set(string(endpoint.ID), proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// GetProxy returns the proxy associated to a key
|
||||
func (manager *Manager) GetProxy(key string) http.Handler {
|
||||
proxy, ok := manager.proxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// DeleteProxy deletes the proxy associated to a key
|
||||
func (manager *Manager) DeleteProxy(key string) {
|
||||
manager.proxies.Remove(key)
|
||||
}
|
||||
|
||||
// CreateAndRegisterExtensionProxy creates a new HTTP reverse proxy for an extension and adds it to the registered proxies.
|
||||
func (manager *Manager) CreateAndRegisterExtensionProxy(key, extensionAPIURL string) (http.Handler, error) {
|
||||
extensionURL, err := url.Parse(extensionAPIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := manager.proxyFactory.newHTTPProxy(extensionURL)
|
||||
manager.extensionProxies.Set(key, proxy)
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// GetExtensionProxy returns the extension proxy associated to a key
|
||||
func (manager *Manager) GetExtensionProxy(key string) http.Handler {
|
||||
proxy, ok := manager.extensionProxies.Get(key)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return proxy.(http.Handler)
|
||||
}
|
||||
|
||||
// DeleteExtensionProxies deletes all the extension proxies associated to a key
|
||||
func (manager *Manager) DeleteExtensionProxies(key string) {
|
||||
for _, k := range manager.extensionProxies.Keys() {
|
||||
if strings.Contains(k, key+"_") {
|
||||
manager.extensionProxies.Remove(k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/portainer/portainer/http/handler/endpointgroups"
|
||||
"github.com/portainer/portainer/http/handler/endpointproxy"
|
||||
"github.com/portainer/portainer/http/handler/endpoints"
|
||||
"github.com/portainer/portainer/http/handler/extensions"
|
||||
"github.com/portainer/portainer/http/handler/file"
|
||||
"github.com/portainer/portainer/http/handler/motd"
|
||||
"github.com/portainer/portainer/http/handler/registries"
|
||||
|
@ -41,6 +42,7 @@ type Server struct {
|
|||
AuthDisabled bool
|
||||
EndpointManagement bool
|
||||
Status *portainer.Status
|
||||
ExtensionManager portainer.ExtensionManager
|
||||
ComposeStackManager portainer.ComposeStackManager
|
||||
CryptoService portainer.CryptoService
|
||||
SignatureService portainer.DigitalSignatureService
|
||||
|
@ -53,6 +55,7 @@ type Server struct {
|
|||
GitService portainer.GitService
|
||||
JWTService portainer.JWTService
|
||||
LDAPService portainer.LDAPService
|
||||
ExtensionService portainer.ExtensionService
|
||||
RegistryService portainer.RegistryService
|
||||
ResourceControlService portainer.ResourceControlService
|
||||
ScheduleService portainer.ScheduleService
|
||||
|
@ -128,8 +131,15 @@ func (server *Server) Start() error {
|
|||
|
||||
var motdHandler = motd.NewHandler(requestBouncer)
|
||||
|
||||
var extensionHandler = extensions.NewHandler(requestBouncer)
|
||||
extensionHandler.ExtensionService = server.ExtensionService
|
||||
extensionHandler.ExtensionManager = server.ExtensionManager
|
||||
|
||||
var registryHandler = registries.NewHandler(requestBouncer)
|
||||
registryHandler.RegistryService = server.RegistryService
|
||||
registryHandler.ExtensionService = server.ExtensionService
|
||||
registryHandler.FileService = server.FileService
|
||||
registryHandler.ProxyManager = proxyManager
|
||||
|
||||
var resourceControlHandler = resourcecontrols.NewHandler(requestBouncer)
|
||||
resourceControlHandler.ResourceControlService = server.ResourceControlService
|
||||
|
@ -203,6 +213,7 @@ func (server *Server) Start() error {
|
|||
EndpointProxyHandler: endpointProxyHandler,
|
||||
FileHandler: fileHandler,
|
||||
MOTDHandler: motdHandler,
|
||||
ExtensionHandler: extensionHandler,
|
||||
RegistryHandler: registryHandler,
|
||||
ResourceControlHandler: resourceControlHandler,
|
||||
SettingsHandler: settingsHandler,
|
||||
|
|
100
api/portainer.go
100
api/portainer.go
|
@ -165,17 +165,32 @@ type (
|
|||
// RegistryID represents a registry identifier
|
||||
RegistryID int
|
||||
|
||||
// RegistryType represents a type of registry
|
||||
RegistryType int
|
||||
|
||||
// Registry represents a Docker registry with all the info required
|
||||
// to connect to it
|
||||
Registry struct {
|
||||
ID RegistryID `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password,omitempty"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
ID RegistryID `json:"Id"`
|
||||
Type RegistryType `json:"Type"`
|
||||
Name string `json:"Name"`
|
||||
URL string `json:"URL"`
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password,omitempty"`
|
||||
AuthorizedUsers []UserID `json:"AuthorizedUsers"`
|
||||
AuthorizedTeams []TeamID `json:"AuthorizedTeams"`
|
||||
ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"`
|
||||
}
|
||||
|
||||
// RegistryManagementConfiguration represents a configuration that can be used to query
|
||||
// the registry API via the registry management extension.
|
||||
RegistryManagementConfiguration struct {
|
||||
Type RegistryType `json:"Type"`
|
||||
Authentication bool `json:"Authentication"`
|
||||
Username string `json:"Username"`
|
||||
Password string `json:"Password"`
|
||||
TLSConfig TLSConfiguration `json:"TLSConfig"`
|
||||
}
|
||||
|
||||
// DockerHub represents all the required information to connect and use the
|
||||
|
@ -323,7 +338,8 @@ type (
|
|||
Labels []Pair `json:"Labels"`
|
||||
}
|
||||
|
||||
// EndpointExtension represents a extension associated to an endpoint
|
||||
// EndpointExtension represents a deprecated form of Portainer extension
|
||||
// TODO: legacy extension management
|
||||
EndpointExtension struct {
|
||||
Type EndpointExtensionType `json:"Type"`
|
||||
URL string `json:"URL"`
|
||||
|
@ -459,6 +475,35 @@ type (
|
|||
// It can be either a TLS CA file, a TLS certificate file or a TLS key file
|
||||
TLSFileType int
|
||||
|
||||
// ExtensionID represents a extension identifier
|
||||
ExtensionID int
|
||||
|
||||
// Extension represents a Portainer extension
|
||||
Extension struct {
|
||||
ID ExtensionID `json:"Id"`
|
||||
Enabled bool `json:"Enabled"`
|
||||
Name string `json:"Name,omitempty"`
|
||||
ShortDescription string `json:"ShortDescription,omitempty"`
|
||||
Description string `json:"Description,omitempty"`
|
||||
Price string `json:"Price,omitempty"`
|
||||
PriceDescription string `json:"PriceDescription,omitempty"`
|
||||
Deal bool `json:"Deal,omitempty"`
|
||||
Available bool `json:"Available,omitempty"`
|
||||
License LicenseInformation `json:"License,omitempty"`
|
||||
Version string `json:"Version"`
|
||||
UpdateAvailable bool `json:"UpdateAvailable"`
|
||||
ProductID int `json:"ProductId,omitempty"`
|
||||
Images []string `json:"Images,omitempty"`
|
||||
Logo string `json:"Logo,omitempty"`
|
||||
}
|
||||
|
||||
// LicenseInformation represents information about an extension license
|
||||
LicenseInformation struct {
|
||||
LicenseKey string `json:"LicenseKey,omitempty"`
|
||||
Company string `json:"Company,omitempty"`
|
||||
Expiration string `json:"Expiration,omitempty"`
|
||||
}
|
||||
|
||||
// CLIService represents a service for managing CLI
|
||||
CLIService interface {
|
||||
ParseFlags(version string) (*CLIFlags, error)
|
||||
|
@ -617,6 +662,14 @@ type (
|
|||
DeleteTemplate(ID TemplateID) error
|
||||
}
|
||||
|
||||
// ExtensionService represents a service for managing extension data
|
||||
ExtensionService interface {
|
||||
Extension(ID ExtensionID) (*Extension, error)
|
||||
Extensions() ([]Extension, error)
|
||||
Persist(extension *Extension) error
|
||||
DeleteExtension(ID ExtensionID) error
|
||||
}
|
||||
|
||||
// CryptoService represents a service for encrypting/hashing data
|
||||
CryptoService interface {
|
||||
Hash(data string) (string, error)
|
||||
|
@ -649,6 +702,7 @@ type (
|
|||
DeleteTLSFiles(folder string) error
|
||||
GetStackProjectPath(stackIdentifier string) string
|
||||
StoreStackFileFromBytes(stackIdentifier, fileName string, data []byte) (string, error)
|
||||
StoreRegistryManagementFileFromBytes(folder, fileName string, data []byte) (string, error)
|
||||
KeyPairFilesExist() (bool, error)
|
||||
StoreKeyPair(private, public []byte, privatePEMHeader, publicPEMHeader string) error
|
||||
LoadKeyPair() ([]byte, []byte, error)
|
||||
|
@ -656,6 +710,8 @@ type (
|
|||
FileExists(path string) (bool, error)
|
||||
StoreScheduledJobFileFromBytes(identifier string, data []byte) (string, error)
|
||||
GetScheduleFolder(identifier string) string
|
||||
ExtractExtensionArchive(data []byte) error
|
||||
GetBinaryFolder() string
|
||||
}
|
||||
|
||||
// GitService represents a service for managing Git
|
||||
|
@ -709,6 +765,14 @@ type (
|
|||
JobService interface {
|
||||
ExecuteScript(endpoint *Endpoint, nodeName, image string, script []byte, schedule *Schedule) error
|
||||
}
|
||||
|
||||
// ExtensionManager represents a service used to manage extensions
|
||||
ExtensionManager interface {
|
||||
FetchExtensionDefinitions() ([]Extension, error)
|
||||
EnableExtension(extension *Extension, licenseKey string) error
|
||||
DisableExtension(extension *Extension) error
|
||||
UpdateExtension(extension *Extension, version string) error
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -718,6 +782,8 @@ const (
|
|||
DBVersion = 15
|
||||
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved
|
||||
MessageOfTheDayURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/motd.html"
|
||||
// ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved
|
||||
ExtensionDefinitionsURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com/extensions.json"
|
||||
// PortainerAgentHeader represents the name of the header available in any agent response
|
||||
PortainerAgentHeader = "Portainer-Agent"
|
||||
// PortainerAgentTargetHeader represent the name of the header containing the target node name
|
||||
|
@ -838,6 +904,12 @@ const (
|
|||
ServiceWebhook
|
||||
)
|
||||
|
||||
const (
|
||||
_ ExtensionID = iota
|
||||
// RegistryManagementExtension represents the registry management extension
|
||||
RegistryManagementExtension
|
||||
)
|
||||
|
||||
const (
|
||||
_ JobType = iota
|
||||
// ScriptExecutionJobType is a non-system job used to execute a script against a list of
|
||||
|
@ -849,3 +921,13 @@ const (
|
|||
// an external definition store
|
||||
EndpointSyncJobType
|
||||
)
|
||||
|
||||
const (
|
||||
_ RegistryType = iota
|
||||
// QuayRegistry represents a Quay.io registry
|
||||
QuayRegistry
|
||||
// AzureRegistry represents an ACR registry
|
||||
AzureRegistry
|
||||
// CustomRegistry represents a custom registry
|
||||
CustomRegistry
|
||||
)
|
||||
|
|
|
@ -22,6 +22,7 @@ angular.module('portainer', [
|
|||
'portainer.agent',
|
||||
'portainer.azure',
|
||||
'portainer.docker',
|
||||
'portainer.extensions',
|
||||
'extension.storidge',
|
||||
'rzModule',
|
||||
'moment-picker'
|
||||
|
|
|
@ -43,8 +43,10 @@ function initAuthentication(authManager, Authentication, $rootScope, $state) {
|
|||
// hitting a 401. We're using this instead of the usual combination of
|
||||
// authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector
|
||||
// to have more controls on which URL should trigger the unauthenticated state.
|
||||
$rootScope.$on('unauthenticated', function () {
|
||||
$state.go('portainer.auth', {error: 'Your session has expired'});
|
||||
$rootScope.$on('unauthenticated', function (event, data) {
|
||||
if (!_.includes(data.config.url, '/v2/')) {
|
||||
$state.go('portainer.auth', {error: 'Your session has expired'});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ angular.module('portainer')
|
|||
.constant('API_ENDPOINT_ENDPOINTS', 'api/endpoints')
|
||||
.constant('API_ENDPOINT_ENDPOINT_GROUPS', 'api/endpoint_groups')
|
||||
.constant('API_ENDPOINT_MOTD', 'api/motd')
|
||||
.constant('API_ENDPOINT_EXTENSIONS', 'api/extensions')
|
||||
.constant('API_ENDPOINT_REGISTRIES', 'api/registries')
|
||||
.constant('API_ENDPOINT_RESOURCE_CONTROLS', 'api/resource_controls')
|
||||
.constant('API_ENDPOINT_SCHEDULES', 'api/schedules')
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
angular.module('portainer.extensions', [
|
||||
'portainer.extensions.registrymanagement'
|
||||
]);
|
|
@ -0,0 +1,41 @@
|
|||
angular.module('portainer.extensions.registrymanagement', [])
|
||||
.config(['$stateRegistryProvider', function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
||||
var registryConfiguration = {
|
||||
name: 'portainer.registries.registry.configure',
|
||||
url: '/configure',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/extensions/registry-management/views/configure/configureregistry.html',
|
||||
controller: 'ConfigureRegistryController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var registryRepositories = {
|
||||
name: 'portainer.registries.registry.repositories',
|
||||
url: '/repositories',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/extensions/registry-management/views/repositories/registryRepositories.html',
|
||||
controller: 'RegistryRepositoriesController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var registryRepositoryTags = {
|
||||
name: 'portainer.registries.registry.repository',
|
||||
url: '/:repository',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/extensions/registry-management/views/repositories/edit/registryRepository.html',
|
||||
controller: 'RegistryRepositoryController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$stateRegistryProvider.register(registryConfiguration);
|
||||
$stateRegistryProvider.register(registryRepositories);
|
||||
$stateRegistryProvider.register(registryRepositoryTags);
|
||||
}]);
|
|
@ -0,0 +1,83 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-filters nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="md-checkbox">
|
||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||
<label for="select_all"></label>
|
||||
</span>
|
||||
<a ng-click="$ctrl.changeOrderBy('Name')">
|
||||
Repository
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('TagsCount')">
|
||||
Tags count
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'TagsCount' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<span class="md-checkbox">
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" />
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
<a ui-sref="portainer.registries.registry.repository({repository: item.Name})" class="monospaced"
|
||||
title="{{ item.Name }}">{{ item.Name }}</a>
|
||||
</td>
|
||||
<td>{{ item.TagsCount }}</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
<td colspan="5" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||
<td colspan="5" class="text-center text-muted">No repository available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" ng-if="$ctrl.dataset">
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
|
||||
{{ $ctrl.state.selectedItemCount }} item(s) selected
|
||||
</div>
|
||||
<div class="paginationControls">
|
||||
<form class="form-inline">
|
||||
<span class="limitSelector">
|
||||
<span style="margin-right: 5px;">
|
||||
Items per page
|
||||
</span>
|
||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</span>
|
||||
<dir-pagination-controls max-size="5"></dir-pagination-controls>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -0,0 +1,13 @@
|
|||
angular.module('portainer.extensions.registrymanagement').component('registryRepositoriesDatatable', {
|
||||
templateUrl: 'app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
removeAction: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,112 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle">
|
||||
<i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{
|
||||
$ctrl.titleText }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actionBar">
|
||||
<button type="button" class="btn btn-sm btn-danger" ng-disabled="$ctrl.state.selectedItemCount === 0" ng-click="$ctrl.removeAction($ctrl.state.selectedItems)">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Remove
|
||||
</button>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<i class="fa fa-search searchIcon" aria-hidden="true"></i>
|
||||
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter" placeholder="Search..." auto-focus>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover nowrap-cells">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<span class="md-checkbox">
|
||||
<input id="select_all" type="checkbox" ng-model="$ctrl.state.selectAll" ng-change="$ctrl.selectAll()" />
|
||||
<label for="select_all"></label>
|
||||
</span>
|
||||
<a ng-click="$ctrl.changeOrderBy('Name')">
|
||||
Name
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Name' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>Os/Architecture</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('ImageId')">
|
||||
Image ID
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'ImageId' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>
|
||||
<a ng-click="$ctrl.changeOrderBy('Size')">
|
||||
Size
|
||||
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && !$ctrl.state.reverseOrder"></i>
|
||||
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Size' && $ctrl.state.reverseOrder"></i>
|
||||
</a>
|
||||
</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<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>
|
||||
<span class="md-checkbox">
|
||||
<input id="select_{{ $index }}" type="checkbox" ng-model="item.Checked" ng-change="$ctrl.selectItem(item)" />
|
||||
<label for="select_{{ $index }}"></label>
|
||||
</span>
|
||||
{{ item.Name }}
|
||||
</td>
|
||||
<td>{{ item.Os }}/{{ item.Architecture }}</td>
|
||||
<td>{{ item.ImageId | truncate:40 }}</td>
|
||||
<td>{{ item.Size | humansize }}</td>
|
||||
<td>
|
||||
<span ng-if="!item.Modified">
|
||||
<a class="interactive" ng-click="item.Modified = true; item.NewName = item.Name; $event.stopPropagation();">
|
||||
<i class="fa fa-tag" aria-hidden="true"></i> Retag
|
||||
</a>
|
||||
</span>
|
||||
<span ng-if="item.Modified">
|
||||
<input class="input-sm" type="text" ng-model="item.NewName" on-enter-key="$ctrl.retagAction(item)"
|
||||
auto-focus ng-click="$event.stopPropagation();" />
|
||||
<a class="interactive" ng-click="item.Modified = false; $event.stopPropagation();"><i class="fa fa-times"></i></a>
|
||||
<a class="interactive" ng-click="$ctrl.retagAction(item); $event.stopPropagation();"><i class="fa fa-check-square"></i></a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
<td colspan="3" class="text-center text-muted">Loading...</td>
|
||||
</tr>
|
||||
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
|
||||
<td colspan="3" class="text-center text-muted">No tag available.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="footer" ng-if="$ctrl.dataset">
|
||||
<div class="infoBar" ng-if="$ctrl.state.selectedItemCount !== 0">
|
||||
{{ $ctrl.state.selectedItemCount }} item(s) selected
|
||||
</div>
|
||||
<div class="paginationControls">
|
||||
<form class="form-inline">
|
||||
<span class="limitSelector">
|
||||
<span style="margin-right: 5px;">
|
||||
Items per page
|
||||
</span>
|
||||
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
|
||||
<option value="0">All</option>
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</span>
|
||||
<dir-pagination-controls max-size="5"></dir-pagination-controls>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
angular.module('portainer.extensions.registrymanagement').component('registriesRepositoryTagsDatatable', {
|
||||
templateUrl: 'app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html',
|
||||
controller: 'GenericDatatableController',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
titleIcon: '@',
|
||||
dataset: '<',
|
||||
tableKey: '@',
|
||||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
removeAction: '<',
|
||||
retagAction: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,38 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.factory('RegistryV2Helper', [function RegistryV2HelperFactory() {
|
||||
'use strict';
|
||||
|
||||
var helper = {};
|
||||
|
||||
function historyRawToParsed(rawHistory) {
|
||||
var history = [];
|
||||
for (var i = 0; i < rawHistory.length; i++) {
|
||||
var item = rawHistory[i];
|
||||
history.push(angular.fromJson(item.v1Compatibility));
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
helper.manifestsToTag = function (manifests) {
|
||||
var v1 = manifests.v1;
|
||||
var v2 = manifests.v2;
|
||||
|
||||
var history = historyRawToParsed(v1.history);
|
||||
var imageId = history[0].id;
|
||||
var name = v1.tag;
|
||||
var os = history[0].os;
|
||||
var arch = v1.architecture;
|
||||
var size = v2.layers.reduce(function (a, b) {
|
||||
return {
|
||||
size: a.size + b.size
|
||||
};
|
||||
}).size;
|
||||
var digest = v2.digest;
|
||||
var repositoryName = v1.name;
|
||||
var fsLayers = v1.fsLayers;
|
||||
|
||||
return new RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, v2);
|
||||
};
|
||||
|
||||
return helper;
|
||||
}]);
|
|
@ -0,0 +1,4 @@
|
|||
function RegistryRepositoryViewModel(data) {
|
||||
this.Name = data.name;
|
||||
this.TagsCount = data.tags.length;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
function RepositoryTagViewModel(name, imageId, os, arch, size, digest, repositoryName, fsLayers, history, manifestv2) {
|
||||
this.Name = name;
|
||||
this.ImageId = imageId;
|
||||
this.Os = os;
|
||||
this.Architecture = arch;
|
||||
this.Size = size;
|
||||
this.Digest = digest;
|
||||
this.RepositoryName = repositoryName;
|
||||
this.FsLayers = fsLayers;
|
||||
this.History = history;
|
||||
this.ManifestV2 = manifestv2;
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.factory('RegistryCatalog', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryCatalogFactory($resource, API_ENDPOINT_REGISTRIES) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:action', {},
|
||||
{
|
||||
get: {
|
||||
method: 'GET',
|
||||
params: { id: '@id', action: '_catalog' }
|
||||
},
|
||||
ping: {
|
||||
method: 'GET',
|
||||
params: { id: '@id' }, timeout: 3500
|
||||
},
|
||||
pingWithForceNew: {
|
||||
method: 'GET',
|
||||
params: { id: '@id' }, timeout: 3500,
|
||||
headers: { 'X-RegistryManagement-ForceNew': '1' }
|
||||
}
|
||||
},
|
||||
{
|
||||
stripTrailingSlashes: false
|
||||
});
|
||||
}]);
|
|
@ -0,0 +1,61 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.factory('RegistryManifests', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryManifestsFactory($resource, API_ENDPOINT_REGISTRIES) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/manifests/:tag', {}, {
|
||||
get: {
|
||||
method: 'GET',
|
||||
params: {
|
||||
id: '@id',
|
||||
repository: '@repository',
|
||||
tag: '@tag'
|
||||
},
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
transformResponse: function (data, headers) {
|
||||
var response = angular.fromJson(data);
|
||||
response.digest = headers('docker-content-digest');
|
||||
return response;
|
||||
}
|
||||
},
|
||||
getV2: {
|
||||
method: 'GET',
|
||||
params: {
|
||||
id: '@id',
|
||||
repository: '@repository',
|
||||
tag: '@tag'
|
||||
},
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
transformResponse: function (data, headers) {
|
||||
var response = angular.fromJson(data);
|
||||
response.digest = headers('docker-content-digest');
|
||||
return response;
|
||||
}
|
||||
},
|
||||
put: {
|
||||
method: 'PUT',
|
||||
params: {
|
||||
id: '@id',
|
||||
repository: '@repository',
|
||||
tag: '@tag'
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/vnd.docker.distribution.manifest.v2+json'
|
||||
},
|
||||
transformRequest: function (data) {
|
||||
return angular.toJson(data, 3);
|
||||
}
|
||||
},
|
||||
delete: {
|
||||
method: 'DELETE',
|
||||
params: {
|
||||
id: '@id',
|
||||
repository: '@repository',
|
||||
tag: '@tag'
|
||||
}
|
||||
}
|
||||
});
|
||||
}]);
|
|
@ -0,0 +1,10 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.factory('RegistryTags', ['$resource', 'API_ENDPOINT_REGISTRIES', function RegistryTagsFactory($resource, API_ENDPOINT_REGISTRIES) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_REGISTRIES + '/:id/v2/:repository/tags/list', {}, {
|
||||
get: {
|
||||
method: 'GET',
|
||||
params: { id: '@id', repository: '@repository' }
|
||||
}
|
||||
});
|
||||
}]);
|
|
@ -0,0 +1,118 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.factory('RegistryV2Service', ['$q', 'RegistryCatalog', 'RegistryTags', 'RegistryManifests', 'RegistryV2Helper',
|
||||
function RegistryV2ServiceFactory($q, RegistryCatalog, RegistryTags, RegistryManifests, RegistryV2Helper) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.ping = function(id, forceNewConfig) {
|
||||
if (forceNewConfig) {
|
||||
return RegistryCatalog.pingWithForceNew({ id: id }).$promise;
|
||||
}
|
||||
return RegistryCatalog.ping({ id: id }).$promise;
|
||||
};
|
||||
|
||||
service.repositories = function (id) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
RegistryCatalog.get({
|
||||
id: id
|
||||
}).$promise
|
||||
.then(function success(data) {
|
||||
var promises = [];
|
||||
for (var i = 0; i < data.repositories.length; i++) {
|
||||
var repository = data.repositories[i];
|
||||
promises.push(RegistryTags.get({
|
||||
id: id,
|
||||
repository: repository
|
||||
}).$promise);
|
||||
}
|
||||
return $q.all(promises);
|
||||
})
|
||||
.then(function success(data) {
|
||||
var repositories = data.map(function (item) {
|
||||
if (!item.tags) {
|
||||
return;
|
||||
}
|
||||
return new RegistryRepositoryViewModel(item);
|
||||
});
|
||||
repositories = _.without(repositories, undefined);
|
||||
deferred.resolve(repositories);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({
|
||||
msg: 'Unable to retrieve repositories',
|
||||
err: err
|
||||
});
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.tags = function (id, repository) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
RegistryTags.get({
|
||||
id: id,
|
||||
repository: repository
|
||||
}).$promise
|
||||
.then(function succes(data) {
|
||||
deferred.resolve(data.tags);
|
||||
}).catch(function error(err) {
|
||||
deferred.reject({
|
||||
msg: 'Unable to retrieve tags',
|
||||
err: err
|
||||
});
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.tag = function (id, repository, tag) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
var promises = {
|
||||
v1: RegistryManifests.get({
|
||||
id: id,
|
||||
repository: repository,
|
||||
tag: tag
|
||||
}).$promise,
|
||||
v2: RegistryManifests.getV2({
|
||||
id: id,
|
||||
repository: repository,
|
||||
tag: tag
|
||||
}).$promise
|
||||
};
|
||||
$q.all(promises)
|
||||
.then(function success(data) {
|
||||
var tag = RegistryV2Helper.manifestsToTag(data);
|
||||
deferred.resolve(tag);
|
||||
}).catch(function error(err) {
|
||||
deferred.reject({
|
||||
msg: 'Unable to retrieve tag ' + tag,
|
||||
err: err
|
||||
});
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.addTag = function (id, repository, tag, manifest) {
|
||||
delete manifest.digest;
|
||||
return RegistryManifests.put({
|
||||
id: id,
|
||||
repository: repository,
|
||||
tag: tag
|
||||
}, manifest).$promise;
|
||||
};
|
||||
|
||||
service.deleteManifest = function (id, repository, digest) {
|
||||
return RegistryManifests.delete({
|
||||
id: id,
|
||||
repository: repository,
|
||||
tag: digest
|
||||
}).$promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
]);
|
|
@ -0,0 +1,66 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.controller('ConfigureRegistryController', ['$scope', '$state', '$transition$', 'RegistryService', 'RegistryV2Service', 'Notifications',
|
||||
function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Notifications) {
|
||||
|
||||
$scope.state = {
|
||||
testInProgress: false,
|
||||
updateInProgress: false,
|
||||
validConfiguration : false
|
||||
};
|
||||
|
||||
$scope.testConfiguration = testConfiguration;
|
||||
$scope.updateConfiguration = updateConfiguration;
|
||||
|
||||
function testConfiguration() {
|
||||
$scope.state.testInProgress = true;
|
||||
|
||||
RegistryService.configureRegistry($scope.registry.Id, $scope.model)
|
||||
.then(function success() {
|
||||
return RegistryV2Service.ping($scope.registry.Id, true);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Valid management configuration');
|
||||
$scope.state.validConfiguration = true;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Invalid management configuration');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.testInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateConfiguration() {
|
||||
$scope.state.updateInProgress = true;
|
||||
|
||||
RegistryService.configureRegistry($scope.registry.Id, $scope.model)
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Registry management configuration updated');
|
||||
$state.go('portainer.registries.registry.repositories', { id: $scope.registry.Id }, {reload: true});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to update registry management configuration');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.updateInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
var registryId = $transition$.params().id;
|
||||
|
||||
RegistryService.registry(registryId)
|
||||
.then(function success(data) {
|
||||
var registry = data;
|
||||
var model = new RegistryManagementConfigurationDefaultModel(registry);
|
||||
|
||||
$scope.registry = registry;
|
||||
$scope.model = model;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve registry details');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
|
@ -0,0 +1,161 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Configure registry"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="portainer.registries">Registries</a> > <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> > Management configuration
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Information
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
The following configuration will be used to access this <a href="https://docs.docker.com/registry/spec/api/" target="_blank">registry API</a> to provide Portainer management features.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Registry details
|
||||
</div>
|
||||
<!-- registry-url-input -->
|
||||
<div class="form-group">
|
||||
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Registry URL
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_url" ng-model="registry.URL" disabled>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !registry-url-input -->
|
||||
<!-- authentication-checkbox -->
|
||||
<div class="form-group" ng-if="registry.Type === 3">
|
||||
<div class="col-sm-12">
|
||||
<label for="registry_auth" class="control-label text-left">
|
||||
Authentication
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to this registry."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="model.Authentication"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !authentication-checkbox -->
|
||||
<!-- authentication-credentials -->
|
||||
<div ng-if="model.Authentication">
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="credentials_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="credentials_username" ng-model="model.Username">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="credentials_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="credentials_password" ng-model="model.Password" placeholder="*******">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
</div>
|
||||
<!-- !authentication-credentials -->
|
||||
<!-- tls -->
|
||||
<div ng-if="registry.Type === 3">
|
||||
<!-- tls-checkbox -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="tls" class="control-label text-left">
|
||||
TLS
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to connect to the registry API with TLS."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="model.TLS"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls-checkbox -->
|
||||
<!-- tls-skip-verify -->
|
||||
<div class="form-group" ng-if="model.TLS">
|
||||
<div class="col-sm-12">
|
||||
<label for="tls" class="control-label text-left">
|
||||
Skip certificate verification
|
||||
<portainer-tooltip position="bottom" message="Skip the verification of the server TLS certificate. Not recommended on unsecured networks."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="model.TLSSkipVerify"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls-skip-verify -->
|
||||
<div class="col-sm-12 form-section-title" ng-if="model.TLS && !model.TLSSkipVerify">
|
||||
Required TLS files
|
||||
</div>
|
||||
<!-- tls-file-upload -->
|
||||
<div ng-if="model.TLS && !model.TLSSkipVerify">
|
||||
<!-- tls-ca-file-cert -->
|
||||
<div class="form-group">
|
||||
<label for="tls_cert" class="col-sm-3 col-lg-2 control-label text-left">TLS CA certificate</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="model.TLSCACertFile">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ model.TLSCACertFile.name }}
|
||||
<i class="fa fa-check green-icon" ng-if="model.TLSCACertFile && model.TLSCACertFile === registry.ManagementConfiguration.TLSConfig.TLSCACertFile" aria-hidden="true"></i>
|
||||
<i class="fa fa-times red-icon" ng-if="!model.TLSCACertFile" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls-ca-file-cert -->
|
||||
<!-- tls-file-cert -->
|
||||
<div class="form-group">
|
||||
<label for="tls_cert" class="col-sm-3 col-lg-2 control-label text-left">TLS certificate</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="model.TLSCertFile">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ model.TLSCertFile.name }}
|
||||
<i class="fa fa-check green-icon" ng-if="model.TLSCertFile && model.TLSCertFile === registry.ManagementConfiguration.TLSConfig.TLSCertFile" aria-hidden="true"></i>
|
||||
<i class="fa fa-times red-icon" ng-if="!model.TLSCertFile" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls-file-cert -->
|
||||
<!-- tls-file-key -->
|
||||
<div class="form-group">
|
||||
<label class="col-sm-3 col-lg-2 control-label text-left">TLS key</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<button class="btn btn-sm btn-primary" ngf-select ng-model="model.TLSKeyFile">Select file</button>
|
||||
<span style="margin-left: 5px;">
|
||||
{{ model.TLSKeyFile.name }}
|
||||
<i class="fa fa-check green-icon" ng-if="model.TLSKeyFile && model.TLSKeyFile === registry.ManagementConfiguration.TLSConfig.TLSKeyFile" aria-hidden="true"></i>
|
||||
<i class="fa fa-times red-icon" ng-if="!model.TLSKeyFile" aria-hidden="true"></i>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls-file-key -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- !tls -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.testInProgress" ng-click="testConfiguration()" button-spinner="state.testInProgress">
|
||||
<span ng-hide="state.testInProgress">Test configuration</span>
|
||||
<span ng-show="state.testInProgress">Test in progress...</span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.updateInProgress || !state.validConfiguration" ng-click="updateConfiguration()" button-spinner="state.updateInProgress">
|
||||
<span ng-hide="state.updateInProgress">Update configuration</span>
|
||||
<span ng-show="state.updateInProgress">Updating configuration...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,88 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Repository">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.registries.registry.repository" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="portainer.registries">Registries</a> >
|
||||
<a ui-sref="portainer.registries.registry.repositories({id: registry.Id})">{{ registry.Name }}</a> >
|
||||
<a ui-sref="portainer.registries.registry.repository()">{{ repository.Name }} </a>
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-8">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-info" title-text="Repository information">
|
||||
</rd-widget-header>
|
||||
<rd-widget-body classes="no-padding">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Repository</td>
|
||||
<td>
|
||||
{{ repository.Name }}
|
||||
<button class="btn btn-xs btn-danger" ng-click="removeRepository()">
|
||||
<i class="fa fa-trash-alt space-right" aria-hidden="true"></i>Delete this repository
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tags count</td>
|
||||
<td>{{ repository.Tags.length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Images count</td>
|
||||
<td>{{ repository.Images.length }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
|
||||
<div class="col-sm-4">
|
||||
<rd-widget>
|
||||
<rd-widget-header icon="fa-plus" title-text="Add tag">
|
||||
</rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<form class="form-horizontal">
|
||||
<div class="form-group">
|
||||
<label for="tag" class="col-sm-3 col-lg-2 control-label text-left">Tag</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="tag" ng-model="formValues.Tag">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="image" class="col-sm-3 col-lg-2 control-label text-left">Image</label>
|
||||
<ui-select class="col-sm-9 col-lg-10" ng-model="formValues.SelectedImage" id="image">
|
||||
<ui-select-match placeholder="Select an image" allow-clear="true">
|
||||
<span>{{ $select.selected }}</span>
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="image in (repository.Images | filter: $select.search)">
|
||||
<span>{{ image }}</span>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Tag || !formValues.SelectedImage"
|
||||
ng-click="addTag()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Add tag</span>
|
||||
<span ng-show="state.actionInProgress">Adding tag...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<registries-repository-tags-datatable title-text="Tags" title-icon="fa-tags" dataset="tags" table-key="registryRepositoryTags"
|
||||
order-by="Name" remove-action="removeTags" retag-action="retagAction"></registries-repository-tags-datatable>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,150 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications',
|
||||
function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) {
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false
|
||||
};
|
||||
$scope.formValues = {
|
||||
Tag: ''
|
||||
};
|
||||
$scope.tags = [];
|
||||
$scope.repository = {
|
||||
Name: [],
|
||||
Tags: [],
|
||||
Images: []
|
||||
};
|
||||
|
||||
$scope.$watch('tags.length', function () {
|
||||
var images = $scope.tags.map(function (item) {
|
||||
return item.ImageId;
|
||||
});
|
||||
$scope.repository.Images = _.uniq(images);
|
||||
});
|
||||
|
||||
$scope.addTag = function () {
|
||||
var manifest = $scope.tags.find(function (item) {
|
||||
return item.ImageId === $scope.formValues.SelectedImage;
|
||||
}).ManifestV2;
|
||||
RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest)
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Tag successfully added');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to add tag');
|
||||
});
|
||||
};
|
||||
|
||||
$scope.retagAction = function (tag) {
|
||||
RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest)
|
||||
.then(function success() {
|
||||
var promises = [];
|
||||
var tagsToAdd = $scope.tags.filter(function (item) {
|
||||
return item.Digest === tag.Digest;
|
||||
});
|
||||
tagsToAdd.map(function (item) {
|
||||
var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name;
|
||||
promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2));
|
||||
});
|
||||
return $q.all(promises);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Tag successfully modified');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to modify tag');
|
||||
tag.Modified = false;
|
||||
tag.NewValue = tag.Value;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeTags = function (selectedItems) {
|
||||
ModalService.confirmDeletion(
|
||||
'Are you sure you want to remove the selected tags ?',
|
||||
function onConfirm(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
var promises = [];
|
||||
var uniqItems = _.uniqBy(selectedItems, 'Digest');
|
||||
uniqItems.map(function (item) {
|
||||
promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest));
|
||||
});
|
||||
$q.all(promises)
|
||||
.then(function success() {
|
||||
var promises = [];
|
||||
var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name');
|
||||
tagsToReupload.map(function (item) {
|
||||
promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2));
|
||||
});
|
||||
return $q.all(promises);
|
||||
})
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Tags successfully deleted');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to delete tags');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
$scope.removeRepository = function () {
|
||||
ModalService.confirmDeletion(
|
||||
'This action will only remove the manifests linked to this repository. You need to manually trigger a garbage collector pass on your registry to remove orphan layers and really remove the images content. THIS ACTION CAN NOT BE UNDONE',
|
||||
function onConfirm(confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
var promises = [];
|
||||
var uniqItems = _.uniqBy($scope.tags, 'Digest');
|
||||
uniqItems.map(function (item) {
|
||||
promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest));
|
||||
});
|
||||
$q.all(promises)
|
||||
.then(function success() {
|
||||
Notifications.success('Success', 'Repository sucessfully removed');
|
||||
$state.go('portainer.registries.registry.repositories', {
|
||||
id: $scope.registryId
|
||||
}, {
|
||||
reload: true
|
||||
});
|
||||
}).catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to delete repository');
|
||||
});
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function initView() {
|
||||
var registryId = $scope.registryId = $transition$.params().id;
|
||||
var repository = $scope.repository.Name = $transition$.params().repository;
|
||||
$q.all({
|
||||
registry: RegistryService.registry(registryId),
|
||||
tags: RegistryV2Service.tags(registryId, repository)
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.registry = data.registry;
|
||||
$scope.repository.Tags = data.tags;
|
||||
$scope.tags = [];
|
||||
for (var i = 0; i < data.tags.length; i++) {
|
||||
var tag = data.tags[i];
|
||||
RegistryV2Service.tag(registryId, repository, tag)
|
||||
.then(function success(data) {
|
||||
$scope.tags.push(data);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve tag information');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve repository information');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}
|
||||
]);
|
|
@ -0,0 +1,37 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Registry repositories">
|
||||
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.registries.registry.repositories" ui-sref-opts="{reload: true}">
|
||||
<i class="fa fa-sync" aria-hidden="true"></i>
|
||||
</a>
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="portainer.registries">Registries</a> > <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> > Repositories
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<information-panel ng-if="state.displayInvalidConfigurationMessage" title-text="Registry management configuration required">
|
||||
<span class="small text-muted">
|
||||
<p>
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Portainer was not able to use this registry management features. You might need to update the configuration used by Portainer to access this registry.
|
||||
</p>
|
||||
<p>Note: Portainer registry management features are only supported with registries exposing the <a href="https://docs.docker.com/registry/spec/api/" target="_blank">v2 registry API</a>.</p>
|
||||
<div style="margin-top: 7px;">
|
||||
<a ui-sref="portainer.registries.registry.configure({id: registry.Id})">
|
||||
<i class="fa fa-wrench" aria-hidden="true"></i> Configure this registry
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
</information-panel>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="repositories">
|
||||
<div class="col-sm-12">
|
||||
<registry-repositories-datatable
|
||||
title-text="Repositories" title-icon="fa-book"
|
||||
dataset="repositories" table-key="registryRepositories"
|
||||
order-by="Name">
|
||||
</registry-repositories-datatable>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,33 @@
|
|||
angular.module('portainer.extensions.registrymanagement')
|
||||
.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications',
|
||||
function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications) {
|
||||
|
||||
$scope.state = {
|
||||
displayInvalidConfigurationMessage: false
|
||||
};
|
||||
|
||||
function initView() {
|
||||
var registryId = $transition$.params().id;
|
||||
|
||||
RegistryService.registry(registryId)
|
||||
.then(function success(data) {
|
||||
$scope.registry = data;
|
||||
|
||||
RegistryV2Service.ping(registryId, false)
|
||||
.then(function success() {
|
||||
return RegistryV2Service.repositories(registryId);
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.repositories = data;
|
||||
})
|
||||
.catch(function error() {
|
||||
$scope.state.displayInvalidConfigurationMessage = true;
|
||||
});
|
||||
})
|
||||
.catch(function error(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve registry details');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
|
@ -1,3 +1,4 @@
|
|||
// TODO: legacy extension management
|
||||
angular.module('extension.storidge', [])
|
||||
.config(['$stateRegistryProvider', function ($stateRegistryProvider) {
|
||||
'use strict';
|
||||
|
|
|
@ -198,6 +198,28 @@ angular.module('portainer.app', [])
|
|||
}
|
||||
};
|
||||
|
||||
var extensions = {
|
||||
name: 'portainer.extensions',
|
||||
url: '/extensions',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/portainer/views/extensions/extensions.html',
|
||||
controller: 'ExtensionsController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var extension = {
|
||||
name: 'portainer.extensions.extension',
|
||||
url: '/extension/:id',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/portainer/views/extensions/inspect/extension.html',
|
||||
controller: 'ExtensionController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var registries = {
|
||||
name: 'portainer.registries',
|
||||
url: '/registries',
|
||||
|
@ -335,7 +357,22 @@ angular.module('portainer.app', [])
|
|||
url: '/support',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/portainer/views/support/support.html'
|
||||
templateUrl: 'app/portainer/views/support/support.html',
|
||||
controller: 'SupportController'
|
||||
}
|
||||
},
|
||||
params: {
|
||||
product: {}
|
||||
}
|
||||
};
|
||||
|
||||
var supportProduct = {
|
||||
name: 'portainer.support.product',
|
||||
url: '/product',
|
||||
views: {
|
||||
'content@': {
|
||||
templateUrl: 'app/portainer/views/support/product/product.html',
|
||||
controller: 'SupportProductController'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -457,6 +494,8 @@ angular.module('portainer.app', [])
|
|||
$stateRegistryProvider.register(init);
|
||||
$stateRegistryProvider.register(initEndpoint);
|
||||
$stateRegistryProvider.register(initAdmin);
|
||||
$stateRegistryProvider.register(extensions);
|
||||
$stateRegistryProvider.register(extension);
|
||||
$stateRegistryProvider.register(registries);
|
||||
$stateRegistryProvider.register(registry);
|
||||
$stateRegistryProvider.register(registryAccess);
|
||||
|
@ -470,6 +509,7 @@ angular.module('portainer.app', [])
|
|||
$stateRegistryProvider.register(stack);
|
||||
$stateRegistryProvider.register(stackCreation);
|
||||
$stateRegistryProvider.register(support);
|
||||
$stateRegistryProvider.register(supportProduct);
|
||||
$stateRegistryProvider.register(tags);
|
||||
$stateRegistryProvider.register(updatePassword);
|
||||
$stateRegistryProvider.register(users);
|
||||
|
|
|
@ -61,6 +61,12 @@
|
|||
<a ui-sref="portainer.registries.registry.access({id: item.Id})" ng-if="$ctrl.accessManagement">
|
||||
<i class="fa fa-users" aria-hidden="true"></i> Manage access
|
||||
</a>
|
||||
<a ui-sref="portainer.registries.registry.repositories({id: item.Id})" ng-if="$ctrl.registryManagement" class="space-left">
|
||||
<i class="fa fa-search" aria-hidden="true"></i> Browse
|
||||
</a>
|
||||
<a ui-sref="portainer.extensions.extension({id: 1})" ng-if="!$ctrl.registryManagement" class="space-left">
|
||||
<i class="fa fa-search" aria-hidden="true"></i> Browse ( <extension-tooltip></extension-tooltip> )
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr ng-if="!$ctrl.dataset">
|
||||
|
|
|
@ -9,6 +9,7 @@ angular.module('portainer.app').component('registriesDatatable', {
|
|||
orderBy: '@',
|
||||
reverseOrder: '<',
|
||||
accessManagement: '<',
|
||||
removeAction: '<'
|
||||
removeAction: '<',
|
||||
registryManagement: '<'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
angular.module('portainer.app').component('extensionItem', {
|
||||
templateUrl: 'app/portainer/components/extension-list/extension-item/extensionItem.html',
|
||||
controller: 'ExtensionItemController',
|
||||
bindings: {
|
||||
model: '<',
|
||||
currentDate: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,46 @@
|
|||
<!-- extension -->
|
||||
<div class="blocklist-item" ng-click="$ctrl.goToExtensionView()">
|
||||
<div class="blocklist-item-box">
|
||||
<!-- extension-image -->
|
||||
<span ng-if="$ctrl.model.Logo">
|
||||
<img class="blocklist-item-logo" ng-src="{{ $ctrl.model.Logo }}" />
|
||||
</span>
|
||||
<span class="blocklist-item-logo" ng-if="!$ctrl.model.Logo">
|
||||
<i class="fa fa-bolt fa-4x blue-icon" style="margin-left: 14px;" aria-hidden="true"></i>
|
||||
</span>
|
||||
<!-- !extension-image -->
|
||||
<!-- extension-details -->
|
||||
<span class="col-sm-12">
|
||||
<!-- blocklist-item-line1 -->
|
||||
<div class="blocklist-item-line">
|
||||
<span>
|
||||
<span class="blocklist-item-title">
|
||||
{{ $ctrl.model.Name }}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="label label-primary" ng-if="!$ctrl.model.Enabled && !$ctrl.model.Available">coming soon</span>
|
||||
<span class="label label-warning" ng-if="!$ctrl.model.Enabled && $ctrl.model.Deal">deal</span>
|
||||
<span class="label label-danger" ng-if="$ctrl.model.Enabled && $ctrl.model.Expired">expired</span>
|
||||
<span class="label label-success" ng-if="$ctrl.model.Enabled && !$ctrl.model.Expired">enabled</span>
|
||||
<span class="label label-primary" ng-if="$ctrl.model.Enabled && $ctrl.model.UpdateAvailable && !$ctrl.model.Expired">update available</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !blocklist-item-line1 -->
|
||||
<!-- blocklist-item-line2 -->
|
||||
<div class="blocklist-item-line">
|
||||
<span>
|
||||
<span class="blocklist-item-desc">
|
||||
{{ $ctrl.model.ShortDescription }}
|
||||
</span>
|
||||
</span>
|
||||
<span ng-if="$ctrl.model.License.Company">
|
||||
<span class="small text-muted">Licensed to {{ $ctrl.model.License.Company }} - Expires on {{ $ctrl.model.License.Expiration }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !blocklist-item-line2 -->
|
||||
</span>
|
||||
<!-- !extension-details -->
|
||||
</div>
|
||||
<!-- !extension -->
|
||||
</div>
|
|
@ -0,0 +1,18 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('ExtensionItemController', ['$state',
|
||||
function ($state) {
|
||||
|
||||
var ctrl = this;
|
||||
ctrl.$onInit = $onInit;
|
||||
ctrl.goToExtensionView = goToExtensionView;
|
||||
|
||||
function goToExtensionView() {
|
||||
$state.go('portainer.extensions.extension', { id: ctrl.model.Id });
|
||||
}
|
||||
|
||||
function $onInit() {
|
||||
if (ctrl.currentDate === ctrl.model.License.Expiration) {
|
||||
ctrl.model.Expired = true;
|
||||
}
|
||||
}
|
||||
}]);
|
|
@ -0,0 +1,7 @@
|
|||
angular.module('portainer.app').component('extensionList', {
|
||||
templateUrl: 'app/portainer/components/extension-list/extensionList.html',
|
||||
bindings: {
|
||||
extensions: '<',
|
||||
currentDate: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,20 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle">
|
||||
<i class="fa fa-bolt" aria-hidden="true" style="margin-right: 2px;"></i> Available extensions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blocklist">
|
||||
<extension-item ng-repeat="extension in $ctrl.extensions"
|
||||
model="extension"
|
||||
current-date="$ctrl.currentDate"
|
||||
></extension-item>
|
||||
</div>
|
||||
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -0,0 +1 @@
|
|||
<i class="fa fa-bolt orange-icon" aria-hidden="true" tooltip-append-to-body="true" tooltip-placement="bottom" tooltip-class="portainer-tooltip" uib-tooltip="Feature available via a plug-in"></i>
|
|
@ -0,0 +1,3 @@
|
|||
angular.module('portainer.app').component('extensionTooltip', {
|
||||
templateUrl: 'app/portainer/components/extension-tooltip/extension-tooltip.html'
|
||||
});
|
|
@ -0,0 +1,81 @@
|
|||
<form class="form-horizontal" name="registryFormAzure" ng-submit="$ctrl.formAction()">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Azure registry details
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="registry_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_name" name="registry_name" ng-model="$ctrl.model.Name" placeholder="my-azure-registry" required auto-focus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormAzure.registry_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormAzure.registry_name.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- url-input -->
|
||||
<div class="form-group" ng-if="$ctrl.model.Type === 2 || $ctrl.model.Type === 3">
|
||||
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Registry URL
|
||||
<portainer-tooltip position="bottom" message="URL of an Azure Container Registry. Any protocol will be stripped."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_url" name="registry_url" ng-model="$ctrl.model.URL" placeholder="myproject.azurecr.io" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormAzure.registry_url.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormAzure.registry_url.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- url-input -->
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="registry_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_username" name="registry_username" ng-model="$ctrl.model.Username" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormAzure.registry_username.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormAzure.registry_username.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="registry_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="registry_password" name="registry_password" ng-model="$ctrl.model.Password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormAzure.registry_password.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormAzure.registry_password.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm" ng-click="$ctrl.formAction()" ng-disabled="$ctrl.actionInProgress || !registryFormAzure.$valid" button-spinner="$ctrl.actionInProgress">
|
||||
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
|
||||
<span ng-show="$ctrl.actionInProgress">In progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
|
@ -0,0 +1,9 @@
|
|||
angular.module('portainer.app').component('registryFormAzure', {
|
||||
templateUrl: 'app/portainer/components/forms/registry-form-azure/registry-form-azure.html',
|
||||
bindings: {
|
||||
model: '=',
|
||||
formAction: '<',
|
||||
formActionLabel: '@',
|
||||
actionInProgress: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,105 @@
|
|||
<form class="form-horizontal" name="registryFormCustom" ng-submit="$ctrl.formAction()">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Important notice
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Docker requires you to connect to a <a href="https://docs.docker.com/registry/deploying/#running-a-domain-registry" target="_blank">secure registry</a>.
|
||||
You can find more information about how to connect to an insecure registry <a href="https://docs.docker.com/registry/insecure/" target="_blank">in the Docker documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Custom registry details
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group">
|
||||
<label for="registry_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_name" name="registry_name" ng-model="$ctrl.model.Name" placeholder="my-custom-registry" required auto-focus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormCustom.registry_name.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormCustom.registry_name.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- url-input -->
|
||||
<div class="form-group" ng-if="$ctrl.model.Type === 2 || $ctrl.model.Type === 3">
|
||||
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Registry URL
|
||||
<portainer-tooltip position="bottom" message="URL or IP address of a Docker registry. Any protocol will be stripped."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_url" name="registry_url" ng-model="$ctrl.model.URL" placeholder="10.0.0.10:5000 or myregistry.domain.tld" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormCustom.registry_url.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormCustom.registry_url.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- url-input -->
|
||||
<!-- authentication-checkbox -->
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<label for="registry_auth" class="control-label text-left">
|
||||
Authentication
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to this registry."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="$ctrl.model.Authentication"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !authentication-checkbox -->
|
||||
<div ng-if="$ctrl.model.Authentication">
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="registry_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_username" name="registry_username" ng-model="$ctrl.model.Username" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormCustom.registry_username.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormCustom.registry_username.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="registry_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="registry_password" name="registry_password" ng-model="$ctrl.model.Password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormCustom.registry_password.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormCustom.registry_password.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
</div>
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm" ng-click="$ctrl.formAction()" ng-disabled="$ctrl.actionInProgress || !registryFormCustom.$valid" button-spinner="$ctrl.actionInProgress">
|
||||
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
|
||||
<span ng-show="$ctrl.actionInProgress">In progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
|
@ -0,0 +1,9 @@
|
|||
angular.module('portainer.app').component('registryFormCustom', {
|
||||
templateUrl: 'app/portainer/components/forms/registry-form-custom/registry-form-custom.html',
|
||||
bindings: {
|
||||
model: '=',
|
||||
formAction: '<',
|
||||
formActionLabel: '@',
|
||||
actionInProgress: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
<form class="form-horizontal" name="registryFormQuay" ng-submit="$ctrl.formAction()">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Quay account details
|
||||
</div>
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="registry_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_username" name="registry_username" ng-model="$ctrl.model.Username" required auto-focus>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormQuay.registry_username.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormQuay.registry_username.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="registry_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="registry_password" name="registry_password" ng-model="$ctrl.model.Password" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-show="registryFormQuay.registry_password.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="registryFormQuay.registry_password.$error">
|
||||
<p ng-message="required"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> This field is required.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
<!-- actions -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="submit" class="btn btn-primary btn-sm" ng-click="$ctrl.formAction()" ng-disabled="$ctrl.actionInProgress || !registryFormQuay.$valid" button-spinner="$ctrl.actionInProgress">
|
||||
<span ng-hide="$ctrl.actionInProgress">{{ $ctrl.formActionLabel }}</span>
|
||||
<span ng-show="$ctrl.actionInProgress">In progress...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !actions -->
|
||||
</form>
|
|
@ -0,0 +1,9 @@
|
|||
angular.module('portainer.app').component('registryFormQuay', {
|
||||
templateUrl: 'app/portainer/components/forms/registry-form-quay/registry-form-quay.html',
|
||||
bindings: {
|
||||
model: '=',
|
||||
formAction: '<',
|
||||
formActionLabel: '@',
|
||||
actionInProgress: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,9 @@
|
|||
angular.module('portainer.app').component('productItem', {
|
||||
templateUrl: 'app/portainer/components/product-list/product-item/productItem.html',
|
||||
controller: 'ProductItemController',
|
||||
bindings: {
|
||||
model: '<',
|
||||
currentDate: '<',
|
||||
goTo: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,41 @@
|
|||
<!-- extension -->
|
||||
<div class="blocklist-item" ng-click="$ctrl.goTo($ctrl.model)">
|
||||
<div class="blocklist-item-box">
|
||||
<!-- extension-image -->
|
||||
<span class="blocklist-item-logo">
|
||||
<img class="blocklist-item-logo" src="images/support_{{ $ctrl.model.Id }}.png" />
|
||||
</span>
|
||||
<!-- !extension-image -->
|
||||
<!-- extension-details -->
|
||||
<span class="col-sm-12">
|
||||
<!-- blocklist-item-line1 -->
|
||||
<div class="blocklist-item-line">
|
||||
<span>
|
||||
<span class="blocklist-item-title">
|
||||
{{ $ctrl.model.Name }}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
<span class="label label-danger" ng-if="$ctrl.model.Enabled && $ctrl.model.Expired">expired</span>
|
||||
<span class="label label-success" ng-if="$ctrl.model.Enabled && !$ctrl.model.Expired">enabled</span>
|
||||
<span class="label label-primary" ng-if="$ctrl.model.Enabled && $ctrl.model.UpdateAvailable && !$ctrl.model.Expired">update available</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !blocklist-item-line1 -->
|
||||
<!-- blocklist-item-line2 -->
|
||||
<div class="blocklist-item-line">
|
||||
<span>
|
||||
<span class="blocklist-item-desc">
|
||||
{{ $ctrl.model.ShortDescription }}
|
||||
</span>
|
||||
</span>
|
||||
<span ng-if="$ctrl.model.License.Company">
|
||||
<span class="small text-muted">Licensed to {{ $ctrl.model.License.Company }} - Expires on {{ $ctrl.model.License.Expiration }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- !blocklist-item-line2 -->
|
||||
</span>
|
||||
<!-- !extension-details -->
|
||||
</div>
|
||||
<!-- !extension -->
|
||||
</div>
|
|
@ -0,0 +1,18 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('ProductItemController', ['$state',
|
||||
function ($state) {
|
||||
|
||||
var ctrl = this;
|
||||
ctrl.$onInit = $onInit;
|
||||
ctrl.goToExtensionView = goToExtensionView;
|
||||
|
||||
function goToExtensionView() {
|
||||
$state.go('portainer.extensions.extension', { id: ctrl.model.Id });
|
||||
}
|
||||
|
||||
function $onInit() {
|
||||
if (ctrl.currentDate === ctrl.model.License.Expiration) {
|
||||
ctrl.model.Expired = true;
|
||||
}
|
||||
}
|
||||
}]);
|
|
@ -0,0 +1,10 @@
|
|||
angular.module('portainer.app').component('productList', {
|
||||
templateUrl: 'app/portainer/components/product-list/productList.html',
|
||||
bindings: {
|
||||
titleText: '@',
|
||||
products: '<',
|
||||
goTo: '<'
|
||||
// extensions: '<',
|
||||
// currentDate: '<'
|
||||
}
|
||||
});
|
|
@ -0,0 +1,21 @@
|
|||
<div class="datatable">
|
||||
<rd-widget>
|
||||
<rd-widget-body classes="no-padding">
|
||||
|
||||
<div class="toolBar">
|
||||
<div class="toolBarTitle">
|
||||
<i class="fa fa-bolt" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="blocklist">
|
||||
<product-item ng-repeat="product in $ctrl.products"
|
||||
model="product"
|
||||
current-date="$ctrl.currentDate"
|
||||
go-to="$ctrl.goTo"
|
||||
></product-item>
|
||||
</div>
|
||||
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
|
@ -0,0 +1,17 @@
|
|||
function ExtensionViewModel(data) {
|
||||
this.Id = data.Id;
|
||||
this.Name = data.Name;
|
||||
this.Enabled = data.Enabled;
|
||||
this.Description = data.Description;
|
||||
this.Price = data.Price;
|
||||
this.PriceDescription = data.PriceDescription;
|
||||
this.Available = data.Available;
|
||||
this.Deal = data.Deal;
|
||||
this.ShortDescription = data.ShortDescription;
|
||||
this.License = data.License;
|
||||
this.Version = data.Version;
|
||||
this.UpdateAvailable = data.UpdateAvailable;
|
||||
this.ProductId = data.ProductId;
|
||||
this.Images = data.Images;
|
||||
this.Logo = data.Logo;
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
function RegistryViewModel(data) {
|
||||
this.Id = data.Id;
|
||||
this.Type = data.Type;
|
||||
this.Name = data.Name;
|
||||
this.URL = data.URL;
|
||||
this.Authentication = data.Authentication;
|
||||
|
@ -9,3 +10,44 @@ function RegistryViewModel(data) {
|
|||
this.AuthorizedTeams = data.AuthorizedTeams;
|
||||
this.Checked = false;
|
||||
}
|
||||
|
||||
function RegistryManagementConfigurationDefaultModel(registry) {
|
||||
this.Authentication = false;
|
||||
this.Password = '';
|
||||
this.TLS = false;
|
||||
this.TLSSkipVerify = false;
|
||||
this.TLSCACertFile = null;
|
||||
this.TLSCertFile = null;
|
||||
this.TLSKeyFile = null;
|
||||
|
||||
if (registry.Type === 1 || registry.Type === 2 ) {
|
||||
this.Authentication = true;
|
||||
this.Username = registry.Username;
|
||||
this.TLS = true;
|
||||
}
|
||||
|
||||
if (registry.Type === 3 && registry.Authentication) {
|
||||
this.Authentication = true;
|
||||
this.Username = registry.Username;
|
||||
}
|
||||
}
|
||||
|
||||
function RegistryDefaultModel() {
|
||||
this.Type = 3;
|
||||
this.URL = '';
|
||||
this.Name = '';
|
||||
this.Authentication = false;
|
||||
this.Username = '';
|
||||
this.Password = '';
|
||||
}
|
||||
|
||||
function RegistryCreateRequest(model) {
|
||||
this.Name = model.Name;
|
||||
this.Type = model.Type;
|
||||
this.URL = model.URL;
|
||||
this.Authentication = model.Authentication;
|
||||
if (model.Authentication) {
|
||||
this.Username = model.Username;
|
||||
this.Password = model.Password;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,4 +4,5 @@ function StatusViewModel(data) {
|
|||
this.EndpointManagement = data.EndpointManagement;
|
||||
this.Analytics = data.Analytics;
|
||||
this.Version = data.Version;
|
||||
this.EnabledExtensions = data.EnabledExtensions;
|
||||
}
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('Extensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function Extensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) {
|
||||
.factory('Extension', ['$resource', 'API_ENDPOINT_EXTENSIONS',
|
||||
function ExtensionFactory($resource, API_ENDPOINT_EXTENSIONS) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/:type', {
|
||||
endpointId: EndpointProvider.endpointID
|
||||
},
|
||||
{
|
||||
register: { method: 'POST' },
|
||||
deregister: { method: 'DELETE', params: { type: '@type' } }
|
||||
return $resource(API_ENDPOINT_EXTENSIONS + '/:id/:action', {}, {
|
||||
create: { method: 'POST' },
|
||||
query: { method: 'GET', isArray: true },
|
||||
get: { method: 'GET', params: { id: '@id' } },
|
||||
delete: { method: 'DELETE', params: { id: '@id' } },
|
||||
update: { method: 'POST', params: { id: '@id', action: 'update' } }
|
||||
});
|
||||
}]);
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
// TODO: legacy extension management
|
||||
angular.module('portainer.app')
|
||||
.factory('LegacyExtensions', ['$resource', 'EndpointProvider', 'API_ENDPOINT_ENDPOINTS', function LegacyExtensions($resource, EndpointProvider, API_ENDPOINT_ENDPOINTS) {
|
||||
'use strict';
|
||||
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/extensions/:type', {
|
||||
endpointId: EndpointProvider.endpointID
|
||||
},
|
||||
{
|
||||
register: { method: 'POST' },
|
||||
deregister: { method: 'DELETE', params: { type: '@type' } }
|
||||
});
|
||||
}]);
|
|
@ -7,6 +7,7 @@ angular.module('portainer.app')
|
|||
get: { method: 'GET', params: { id: '@id' } },
|
||||
update: { method: 'PUT', params: { id: '@id' } },
|
||||
updateAccess: { method: 'PUT', params: { id: '@id', action: 'access' } },
|
||||
remove: { method: 'DELETE', params: { id: '@id'} }
|
||||
remove: { method: 'DELETE', params: { id: '@id'} },
|
||||
configure: { method: 'POST', params: { id: '@id', action: 'configure' } }
|
||||
});
|
||||
}]);
|
||||
|
|
|
@ -1,19 +1,65 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('ExtensionService', ['Extensions', function ExtensionServiceFactory(Extensions) {
|
||||
.factory('ExtensionService', ['$q', 'Extension', function ExtensionServiceFactory($q, Extension) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.registerStoridgeExtension = function(url) {
|
||||
var payload = {
|
||||
Type: 1,
|
||||
URL: url
|
||||
};
|
||||
|
||||
return Extensions.register(payload).$promise;
|
||||
service.enable = function(license) {
|
||||
return Extension.create({ license: license }).$promise;
|
||||
};
|
||||
|
||||
service.deregisterStoridgeExtension = function() {
|
||||
return Extensions.deregister({ type: 1 }).$promise;
|
||||
service.update = function(id, version) {
|
||||
return Extension.update({ id: id, version: version }).$promise;
|
||||
};
|
||||
|
||||
service.delete = function(id) {
|
||||
return Extension.delete({ id: id }).$promise;
|
||||
};
|
||||
|
||||
service.extensions = function(store) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Extension.query({ store: store }).$promise
|
||||
.then(function success(data) {
|
||||
var extensions = data.map(function (item) {
|
||||
return new ExtensionViewModel(item);
|
||||
});
|
||||
deferred.resolve(extensions);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({msg: 'Unable to retrieve extensions', err: err});
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.extension = function(id) {
|
||||
var deferred = $q.defer();
|
||||
|
||||
Extension.get({ id: id }).$promise
|
||||
.then(function success(data) {
|
||||
var extension = new ExtensionViewModel(data);
|
||||
deferred.resolve(extension);
|
||||
})
|
||||
.catch(function error(err) {
|
||||
deferred.reject({msg: 'Unable to retrieve extension details', err: err});
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
service.registryManagementEnabled = function() {
|
||||
var deferred = $q.defer();
|
||||
|
||||
service.extensions(false)
|
||||
.then(function onSuccess(extensions) {
|
||||
var extensionAvailable = _.find(extensions, { Id: 1, Enabled: true }) ? true : false;
|
||||
deferred.resolve(extensionAvailable);
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
deferred.reject(err);
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
// TODO: legacy extension management
|
||||
angular.module('portainer.app')
|
||||
.factory('LegacyExtensionService', ['LegacyExtensions', function LegacyExtensionServiceFactory(LegacyExtensions) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
service.registerStoridgeExtension = function(url) {
|
||||
var payload = {
|
||||
Type: 1,
|
||||
URL: url
|
||||
};
|
||||
|
||||
return LegacyExtensions.register(payload).$promise;
|
||||
};
|
||||
|
||||
service.deregisterStoridgeExtension = function() {
|
||||
return LegacyExtensions.deregister({ type: 1 }).$promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
}]);
|
|
@ -1,5 +1,5 @@
|
|||
angular.module('portainer.app')
|
||||
.factory('RegistryService', ['$q', 'Registries', 'DockerHubService', 'RegistryHelper', 'ImageHelper', function RegistryServiceFactory($q, Registries, DockerHubService, RegistryHelper, ImageHelper) {
|
||||
.factory('RegistryService', ['$q', 'Registries', 'DockerHubService', 'RegistryHelper', 'ImageHelper', 'FileUploadService', function RegistryServiceFactory($q, Registries, DockerHubService, RegistryHelper, ImageHelper, FileUploadService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
|
@ -54,17 +54,13 @@ angular.module('portainer.app')
|
|||
return Registries.update({ id: registry.Id }, registry).$promise;
|
||||
};
|
||||
|
||||
service.createRegistry = function(name, URL, authentication, username, password) {
|
||||
var payload = {
|
||||
Name: name,
|
||||
URL: URL,
|
||||
Authentication: authentication
|
||||
};
|
||||
if (authentication) {
|
||||
payload.Username = username;
|
||||
payload.Password = password;
|
||||
}
|
||||
return Registries.create({}, payload).$promise;
|
||||
service.configureRegistry = function(id, registryManagementConfigurationModel) {
|
||||
return FileUploadService.configureRegistry(id, registryManagementConfigurationModel);
|
||||
};
|
||||
|
||||
service.createRegistry = function(model) {
|
||||
var payload = new RegistryCreateRequest(model);
|
||||
return Registries.create(payload).$promise;
|
||||
};
|
||||
|
||||
service.retrieveRegistryFromRepository = function(repository) {
|
||||
|
|
|
@ -80,6 +80,13 @@ angular.module('portainer.app')
|
|||
});
|
||||
};
|
||||
|
||||
service.configureRegistry = function(registryId, registryManagementConfigurationModel) {
|
||||
return Upload.upload({
|
||||
url: 'api/registries/' + registryId + '/configure',
|
||||
data: registryManagementConfigurationModel
|
||||
});
|
||||
};
|
||||
|
||||
service.executeEndpointJob = function (imageName, file, endpointId, nodeName) {
|
||||
return Upload.upload({
|
||||
url: 'api/endpoints/' + endpointId + '/job?method=file&nodeName=' + nodeName,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// TODO: legacy extension management
|
||||
angular.module('portainer.app')
|
||||
.factory('ExtensionManager', ['$q', 'PluginService', 'SystemService', 'ExtensionService',
|
||||
function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionService) {
|
||||
.factory('LegacyExtensionManager', ['$q', 'PluginService', 'SystemService', 'LegacyExtensionService',
|
||||
function ExtensionManagerFactory($q, PluginService, SystemService, LegacyExtensionService) {
|
||||
'use strict';
|
||||
var service = {};
|
||||
|
||||
|
@ -60,7 +61,7 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ
|
|||
.then(function success(data) {
|
||||
var managerIP = data.Swarm.NodeAddr;
|
||||
var storidgeAPIURL = 'tcp://' + managerIP + ':8282';
|
||||
return ExtensionService.registerStoridgeExtension(storidgeAPIURL);
|
||||
return LegacyExtensionService.registerStoridgeExtension(storidgeAPIURL);
|
||||
})
|
||||
.then(function success(data) {
|
||||
deferred.resolve(data);
|
||||
|
@ -73,7 +74,7 @@ function ExtensionManagerFactory($q, PluginService, SystemService, ExtensionServ
|
|||
}
|
||||
|
||||
function deregisterStoridgeExtension() {
|
||||
return ExtensionService.deregisterStoridgeExtension();
|
||||
return LegacyExtensionService.deregisterStoridgeExtension();
|
||||
}
|
||||
|
||||
return service;
|
|
@ -25,6 +25,14 @@ angular.module('portainer.app')
|
|||
return buttons;
|
||||
};
|
||||
|
||||
service.enlargeImage = function(image) {
|
||||
bootbox.dialog({
|
||||
message: '<img src="' + image + '" style="width:100%" />',
|
||||
className: 'image-zoom-modal',
|
||||
onEscape: true
|
||||
});
|
||||
};
|
||||
|
||||
service.confirm = function(options){
|
||||
var box = bootbox.confirm({
|
||||
title: options.title,
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Extensions"></rd-header-title>
|
||||
<rd-header-content>Portainer extensions</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<information-panel title-text="Information">
|
||||
<span class="small text-muted">
|
||||
<p>
|
||||
Content to be defined
|
||||
</p>
|
||||
</span>
|
||||
</information-panel>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
|
||||
<form class="form-horizontal" name="extensionEnableForm">
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Enable extension
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<span class="small text-muted">
|
||||
Ensure that you have a valid license.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="extension_license" class="col-sm-2 control-label text-left">License</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" name="extension_license" class="form-control" ng-model="formValues.License" ng-change="isValidLicenseFormat(extensionEnableForm)" required placeholder="Enter a license key here">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-show="extensionEnableForm.extension_license.$invalid">
|
||||
<div class="col-sm-12 small text-warning">
|
||||
<div ng-messages="extensionEnableForm.extension_license.$error">
|
||||
<p ng-message="invalidLicense"><i class="fa fa-exclamation-triangle" aria-hidden="true"></i> Invalid license format.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-click="enableExtension()" ng-disabled="state.actionInProgress || !extensionEnableForm.$valid" button-spinner="state.actionInProgress" style="margin-left: 0px;">
|
||||
<span ng-hide="state.actionInProgress">Enable extension</span>
|
||||
<span ng-show="state.actionInProgress">Enabling extension...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="extensions">
|
||||
<div class="col-sm-12">
|
||||
<extension-list
|
||||
current-date="state.currentDate"
|
||||
extensions="extensions"
|
||||
></extension-list>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,58 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('ExtensionsController', ['$scope', '$state', 'ExtensionService', 'Notifications',
|
||||
function ($scope, $state, ExtensionService, Notifications) {
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false,
|
||||
currentDate: moment().format('YYYY-MM-dd')
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
License: ''
|
||||
};
|
||||
|
||||
function initView() {
|
||||
ExtensionService.extensions(true)
|
||||
.then(function onSuccess(data) {
|
||||
$scope.extensions = data;
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to access extension store');
|
||||
});
|
||||
}
|
||||
|
||||
$scope.enableExtension = function() {
|
||||
var license = $scope.formValues.License;
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
ExtensionService.enable(license)
|
||||
.then(function onSuccess() {
|
||||
Notifications.success('Extension successfully enabled');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to enable extension');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
$scope.isValidLicenseFormat = function(form) {
|
||||
var valid = true;
|
||||
|
||||
if (!$scope.formValues.License) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNaN($scope.formValues.License[0])) {
|
||||
valid = false;
|
||||
}
|
||||
|
||||
form.extension_license.$setValidity('invalidLicense', valid);
|
||||
};
|
||||
|
||||
|
||||
initView();
|
||||
}]);
|
|
@ -0,0 +1,122 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Extension details"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="portainer.extensions">Portainer extensions</a> > {{ extension.Name }}
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row" ng-if="extension">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
|
||||
<div style="display: flex;">
|
||||
|
||||
<div style="flex-grow: 4; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
|
||||
<div class="form-group">
|
||||
<div class="text-muted" style="font-size: 150%;">
|
||||
{{ extension.Name }} extension
|
||||
</div>
|
||||
|
||||
<div class="small text-muted" style="margin-top: 5px;">
|
||||
By <a href="https://portainer.io" href="_blank">Portainer.io</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="text-muted">
|
||||
{{ extension.ShortDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 1; border-left: 1px solid #777;">
|
||||
|
||||
<div class="form-group" style="margin-left: 40px;">
|
||||
|
||||
<div style="font-size: 125%; border-bottom: 2px solid #2d3e63; padding-bottom: 5px;">
|
||||
{{ extension.Enabled ? 'Enabled' : extension.Price }}
|
||||
</div>
|
||||
|
||||
<div class="small text-muted col-sm-12" style="margin: 15px 0 15px 0;" ng-if="!extension.Enabled">
|
||||
{{ extension.PriceDescription }}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; margin-bottom: 95px;" ng-if="!extension.Enabled && extension.Available">
|
||||
<label for="instances_qty" class="col-sm-7 control-label text-left" style="margin-top: 7px;">Instances</label>
|
||||
<div class="col-sm-5">
|
||||
<input type="number" class="form-control" ng-model="formValues.instances" id="instances_qty" placeholder="1" min="1">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px;" ng-if="!extension.Enabled && extension.Available">
|
||||
<a href="https://2-portainer.pi.bypronto.com/checkout/?add-to-cart={{ extension.ProductId }}&quantity={{ formValues.instances }}" target="_blank" class="btn btn-primary btn-sm" style="width: 100%; margin-left: 0;">
|
||||
Buy
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px;" ng-if="!extension.Enabled && !extension.Available">
|
||||
<btn class="btn btn-primary btn-sm" style="width: 100%; margin-left: 0;" disabled>Coming soon</btn>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px;" ng-if="extension.Enabled && extension.UpdateAvailable">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-click="updateExtension(extension)" ng-disabled="state.updateInProgress" button-spinner="state.updateInProgress" style="width: 100%; margin-left: 0;">
|
||||
<span ng-hide="state.updateInProgress">Update</span>
|
||||
<span ng-show="state.updateInProgress">Updating extension...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 5px;" ng-if="extension.Enabled">
|
||||
<button type="button" class="btn btn-danger btn-sm" ng-click="deleteExtension(extension)" ng-disabled="state.deleteInProgress" button-spinner="state.deleteInProgress" style="width: 100%; margin-left: 0;">
|
||||
<span ng-hide="state.deleteInProgress">Delete</span>
|
||||
<span ng-show="state.deleteInProgress">Deleting extension...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="extension">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
<span>
|
||||
Description
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="small text-muted">
|
||||
{{ extension.Description }}
|
||||
</span>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="extension">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
<span>
|
||||
Screenshots
|
||||
</span>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div ng-repeat="image in extension.Images" style="margin-top: 25px; cursor: zoom-in;">
|
||||
<img ng-src="{{image}}" ng-click="enlargeImage(image)"/>
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,63 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('ExtensionController', ['$q', '$scope', '$transition$', '$state', 'ExtensionService', 'Notifications', 'ModalService',
|
||||
function ($q, $scope, $transition$, $state, ExtensionService, Notifications, ModalService) {
|
||||
|
||||
$scope.state = {
|
||||
updateInProgress: false,
|
||||
deleteInProgress: false
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
instances: 1
|
||||
};
|
||||
|
||||
$scope.updateExtension = updateExtension;
|
||||
$scope.deleteExtension = deleteExtension;
|
||||
$scope.enlargeImage = enlargeImage;
|
||||
|
||||
function enlargeImage(image) {
|
||||
ModalService.enlargeImage(image);
|
||||
}
|
||||
|
||||
function deleteExtension(extension) {
|
||||
$scope.state.deleteInProgress = true;
|
||||
ExtensionService.delete(extension.Id)
|
||||
.then(function onSuccess() {
|
||||
Notifications.success('Extension successfully deleted');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to delete extension');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.deleteInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function updateExtension(extension) {
|
||||
$scope.state.updateInProgress = true;
|
||||
ExtensionService.update(extension.Id, extension.Version)
|
||||
.then(function onSuccess() {
|
||||
Notifications.success('Extension successfully updated');
|
||||
$state.reload();
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to update extension');
|
||||
})
|
||||
.finally(function final() {
|
||||
$scope.state.updateInProgress = false;
|
||||
});
|
||||
}
|
||||
|
||||
function initView() {
|
||||
ExtensionService.extension($transition$.params().id)
|
||||
.then(function onSuccess(extension) {
|
||||
$scope.extension = extension;
|
||||
})
|
||||
.catch(function onError(err) {
|
||||
Notifications.error('Failure', err, 'Unable to retrieve extension information');
|
||||
});
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
|
@ -17,22 +17,13 @@
|
|||
</information-panel>
|
||||
|
||||
<information-panel
|
||||
ng-if="!applicationState.UI.dismissedInfoPanels['home-info-01']"
|
||||
title-text="Information"
|
||||
dismiss-action="dismissInformationPanel('home-info-01')">
|
||||
ng-if="!isAdmin && endpoints.length === 0"
|
||||
title-text="Information">
|
||||
<span class="small text-muted">
|
||||
<p ng-if="endpoints.length > 0">
|
||||
Welcome to Portainer ! Click on any endpoint in the list below to access management features.
|
||||
</p>
|
||||
<p ng-if="!isAdmin && endpoints.length === 0">
|
||||
<p>
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
You do not have access to any environment. Please contact your administrator.
|
||||
</p>
|
||||
|
||||
<p ng-if="isAdmin && !applicationState.application.snapshot">
|
||||
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Endpoint snapshot is disabled.
|
||||
</p>
|
||||
</span>
|
||||
</information-panel>
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager', 'ModalService', 'MotdService', 'SystemService',
|
||||
function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager, ModalService, MotdService, SystemService) {
|
||||
.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'LegacyExtensionManager', 'ModalService', 'MotdService', 'SystemService',
|
||||
function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, LegacyExtensionManager, ModalService, MotdService, SystemService) {
|
||||
|
||||
$scope.goToEdit = function(id) {
|
||||
$state.go('portainer.endpoints.endpoint', { id: id });
|
||||
|
@ -87,7 +87,7 @@ function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, G
|
|||
EndpointProvider.setEndpointID(endpoint.Id);
|
||||
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
|
||||
EndpointProvider.setOfflineModeFromStatus(endpoint.Status);
|
||||
ExtensionManager.initEndpointExtensions(endpoint)
|
||||
LegacyExtensionManager.initEndpointExtensions(endpoint)
|
||||
.then(function success(data) {
|
||||
var extensions = data;
|
||||
return StateManager.updateEndpointState(endpoint, extensions);
|
||||
|
|
|
@ -2,40 +2,38 @@ angular.module('portainer.app')
|
|||
.controller('CreateRegistryController', ['$scope', '$state', 'RegistryService', 'Notifications',
|
||||
function ($scope, $state, RegistryService, Notifications) {
|
||||
|
||||
$scope.selectQuayRegistry = selectQuayRegistry;
|
||||
$scope.selectAzureRegistry = selectAzureRegistry;
|
||||
$scope.selectCustomRegistry = selectCustomRegistry;
|
||||
$scope.create = createRegistry;
|
||||
|
||||
$scope.state = {
|
||||
RegistryType: 'quay',
|
||||
actionInProgress: false
|
||||
};
|
||||
|
||||
$scope.formValues = {
|
||||
Name: 'Quay',
|
||||
URL: 'quay.io',
|
||||
Authentication: true,
|
||||
Username: '',
|
||||
Password: ''
|
||||
};
|
||||
function selectQuayRegistry() {
|
||||
$scope.model.Name = 'Quay';
|
||||
$scope.model.URL = 'quay.io';
|
||||
$scope.model.Authentication = true;
|
||||
}
|
||||
|
||||
$scope.selectQuayRegistry = function() {
|
||||
$scope.formValues.Name = 'Quay';
|
||||
$scope.formValues.URL = 'quay.io';
|
||||
$scope.formValues.Authentication = true;
|
||||
};
|
||||
function selectAzureRegistry() {
|
||||
$scope.model.Name = '';
|
||||
$scope.model.URL = '';
|
||||
$scope.model.Authentication = true;
|
||||
}
|
||||
|
||||
$scope.selectCustomRegistry = function() {
|
||||
$scope.formValues.Name = '';
|
||||
$scope.formValues.URL = '';
|
||||
$scope.formValues.Authentication = false;
|
||||
};
|
||||
function selectCustomRegistry() {
|
||||
$scope.model.Name = '';
|
||||
$scope.model.URL = '';
|
||||
$scope.model.Authentication = false;
|
||||
}
|
||||
|
||||
$scope.addRegistry = function() {
|
||||
var registryName = $scope.formValues.Name;
|
||||
var registryURL = $scope.formValues.URL.replace(/^https?\:\/\//i, '');
|
||||
var authentication = $scope.formValues.Authentication;
|
||||
var username = $scope.formValues.Username;
|
||||
var password = $scope.formValues.Password;
|
||||
function createRegistry() {
|
||||
$scope.model.URL = $scope.model.URL.replace(/^https?\:\/\//i, '');
|
||||
|
||||
$scope.state.actionInProgress = true;
|
||||
RegistryService.createRegistry(registryName, registryURL, authentication, username, password)
|
||||
RegistryService.createRegistry($scope.model)
|
||||
.then(function success() {
|
||||
Notifications.success('Registry successfully created');
|
||||
$state.go('portainer.registries');
|
||||
|
@ -46,5 +44,11 @@ function ($scope, $state, RegistryService, Notifications) {
|
|||
.finally(function final() {
|
||||
$scope.state.actionInProgress = false;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function initView() {
|
||||
$scope.model = new RegistryDefaultModel();
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
||||
|
|
|
@ -13,11 +13,13 @@
|
|||
<div class="col-sm-12 form-section-title">
|
||||
Registry provider
|
||||
</div>
|
||||
|
||||
<div class="form-group"></div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 0">
|
||||
<div class="boxselector_wrapper">
|
||||
<div ng-click="selectQuayRegistry()">
|
||||
<input type="radio" id="registry_quay" ng-model="state.RegistryType" value="quay">
|
||||
<input type="radio" id="registry_quay" ng-model="model.Type" ng-value="1">
|
||||
<label for="registry_quay">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
|
@ -26,8 +28,18 @@
|
|||
<p>Quay container registry</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-click="selectAzureRegistry()">
|
||||
<input type="radio" id="registry_azure" ng-model="model.Type" ng-value="2">
|
||||
<label for="registry_azure">
|
||||
<div class="boxselector_header">
|
||||
<i class="fab fa-microsoft" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
Azure
|
||||
</div>
|
||||
<p>Azure container registry</p>
|
||||
</label>
|
||||
</div>
|
||||
<div ng-click="selectCustomRegistry()">
|
||||
<input type="radio" id="registry_custom" ng-model="state.RegistryType" value="custom">
|
||||
<input type="radio" id="registry_custom" ng-model="model.Type" ng-value="3">
|
||||
<label for="registry_custom">
|
||||
<div class="boxselector_header">
|
||||
<i class="fa fa-database" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||
|
@ -38,81 +50,28 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title" ng-if="state.RegistryType === 'custom'">
|
||||
Important notice
|
||||
</div>
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<span class="col-sm-12 text-muted small">
|
||||
Docker requires you to connect to a <a href="https://docs.docker.com/registry/deploying/#running-a-domain-registry" target="_blank">secure registry</a>.
|
||||
You can find more information about how to connect to an insecure registry <a href="https://docs.docker.com/registry/insecure/" target="_blank">in the Docker documentation</a>.
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Registry details
|
||||
</div>
|
||||
<!-- name-input -->
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<label for="registry_name" class="col-sm-3 col-lg-2 control-label text-left">Name</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_name" ng-model="formValues.Name" placeholder="e.g. my-registry">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !name-input -->
|
||||
<!-- registry-url-input -->
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<label for="registry_url" class="col-sm-3 col-lg-2 control-label text-left">
|
||||
Registry URL
|
||||
<portainer-tooltip position="bottom" message="URL or IP address of a Docker registry. Any protocol will be stripped."></portainer-tooltip>
|
||||
</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="registry_url" ng-model="formValues.URL" placeholder="e.g. 10.0.0.10:5000 or myregistry.domain.tld">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !registry-url-input -->
|
||||
<!-- authentication-checkbox -->
|
||||
<div class="form-group" ng-if="state.RegistryType === 'custom'">
|
||||
<div class="col-sm-12">
|
||||
<label for="registry_auth" class="control-label text-left">
|
||||
Authentication
|
||||
<portainer-tooltip position="bottom" message="Enable this option if you need to specify credentials to connect to this registry."></portainer-tooltip>
|
||||
</label>
|
||||
<label class="switch" style="margin-left: 20px;">
|
||||
<input type="checkbox" ng-model="formValues.Authentication"><i></i>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- !authentication-checkbox -->
|
||||
<!-- authentication-credentials -->
|
||||
<div ng-if="formValues.Authentication || state.RegistryType === 'quay'">
|
||||
<!-- credentials-user -->
|
||||
<div class="form-group">
|
||||
<label for="credentials_username" class="col-sm-3 col-lg-2 control-label text-left">Username</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="text" class="form-control" id="credentials_username" ng-model="formValues.Username">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-user -->
|
||||
<!-- credentials-password -->
|
||||
<div class="form-group">
|
||||
<label for="credentials_password" class="col-sm-3 col-lg-2 control-label text-left">Password</label>
|
||||
<div class="col-sm-9 col-lg-10">
|
||||
<input type="password" class="form-control" id="credentials_password" ng-model="formValues.Password">
|
||||
</div>
|
||||
</div>
|
||||
<!-- !credentials-password -->
|
||||
</div>
|
||||
<!-- !authentication-credentials -->
|
||||
<div class="col-sm-12 form-section-title">
|
||||
Actions
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-sm-12">
|
||||
<button type="button" class="btn btn-primary btn-sm" ng-disabled="state.actionInProgress || !formValues.Name || !formValues.URL || (formValues.Authentication && (!formValues.Username || !formValues.Password))" ng-click="addRegistry()" button-spinner="state.actionInProgress">
|
||||
<span ng-hide="state.actionInProgress">Add registry</span>
|
||||
<span ng-show="state.actionInProgress">Adding registry...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<registry-form-quay ng-if="model.Type === 1"
|
||||
model="model"
|
||||
form-action="create"
|
||||
form-action-label="Add registry"
|
||||
action-in-progress="state.actionInProgress"
|
||||
></registry-form-quay>
|
||||
|
||||
<registry-form-azure ng-if="model.Type === 2"
|
||||
model="model"
|
||||
form-action="create"
|
||||
form-action-label="Add registry"
|
||||
action-in-progress="state.actionInProgress"
|
||||
></registry-form-azure>
|
||||
|
||||
<registry-form-custom ng-if="model.Type === 3"
|
||||
model="model"
|
||||
form-action="create"
|
||||
form-action-label="Add registry"
|
||||
action-in-progress="state.actionInProgress"
|
||||
></registry-form-custom>
|
||||
|
||||
</form>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
|
|
|
@ -73,9 +73,10 @@
|
|||
<registries-datatable
|
||||
title-text="Registries" title-icon="fa-database"
|
||||
dataset="registries" table-key="registries"
|
||||
order-by="Name"
|
||||
order-by="Name"
|
||||
access-management="applicationState.application.authentication"
|
||||
remove-action="removeAction"
|
||||
registry-management="registryManagementAvailable"
|
||||
></registries-datatable>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications',
|
||||
function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications) {
|
||||
.controller('RegistriesController', ['$q', '$scope', '$state', 'RegistryService', 'DockerHubService', 'ModalService', 'Notifications', 'ExtensionService',
|
||||
function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, Notifications, ExtensionService) {
|
||||
|
||||
$scope.state = {
|
||||
actionInProgress: false
|
||||
|
@ -60,11 +60,13 @@ function ($q, $scope, $state, RegistryService, DockerHubService, ModalService, N
|
|||
function initView() {
|
||||
$q.all({
|
||||
registries: RegistryService.registries(),
|
||||
dockerhub: DockerHubService.dockerhub()
|
||||
dockerhub: DockerHubService.dockerhub(),
|
||||
registryManagement: ExtensionService.registryManagementEnabled()
|
||||
})
|
||||
.then(function success(data) {
|
||||
$scope.registries = data.registries;
|
||||
$scope.dockerhub = data.dockerhub;
|
||||
$scope.registryManagementAvailable = data.registryManagement;
|
||||
})
|
||||
.catch(function error(err) {
|
||||
$scope.registries = [];
|
||||
|
|
|
@ -45,6 +45,9 @@
|
|||
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
|
||||
<span>Settings</span>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="!applicationState.application.authentication || isAdmin">
|
||||
<a ui-sref="portainer.extensions" ui-sref-active="active">Extensions <span class="menu-icon fa fa-bolt fa-fw"></span></a>
|
||||
</li>
|
||||
<li class="sidebar-list" ng-if="applicationState.application.authentication && (isAdmin || isTeamLeader)">
|
||||
<a ui-sref="portainer.users" ui-sref-active="active">Users <span class="menu-icon fa fa-users fa-fw"></span></a>
|
||||
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'portainer.users' || $state.current.name === 'portainer.users.user' || $state.current.name === 'portainer.teams' || $state.current.name === 'portainer.teams.team')">
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Support option details"></rd-header-title>
|
||||
<rd-header-content>
|
||||
<a ui-sref="portainer.support">Portainer support</a> > {{ product.Name }}
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
|
||||
<div style="display: flex;">
|
||||
|
||||
<div style="flex-grow: 4; display: flex; flex-direction: column; justify-content: space-between;">
|
||||
|
||||
<div class="form-group">
|
||||
<div class="text-muted" style="font-size: 150%;">
|
||||
{{ product.Name }}
|
||||
</div>
|
||||
|
||||
<div class="small text-muted" style="margin-top: 5px;">
|
||||
By <a href="https://portainer.io" href="_blank">Portainer.io</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="text-muted">
|
||||
{{ product.ShortDescription }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div style="flex-grow: 1; border-left: 1px solid #777;">
|
||||
|
||||
<div class="form-group" style="margin-left: 40px;">
|
||||
|
||||
<div style="font-size: 125%; border-bottom: 2px solid #2d3e63; padding-bottom: 5px;">
|
||||
{{ product.Price }}
|
||||
</div>
|
||||
|
||||
<div class="small text-muted col-sm-12" style="margin: 15px 0 15px 0;">
|
||||
{{ product.PriceDescription }}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 10px; margin-bottom: 95px;">
|
||||
<label for="endpoint_count" class="col-sm-7 control-label text-left" style="margin-top: 7px;">Hosts</label>
|
||||
<div class="col-sm-5">
|
||||
<input type="number" class="form-control" ng-model="formValues.hostCount" id="endpoint_count" placeholder="10" min="10">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 15px;" ng-disabled="!formValues.hostCount">
|
||||
<a href="https://2-portainer.pi.bypronto.com/checkout/?add-to-cart={{ product.ProductId }}&quantity={{ formValues.hostCount }}" target="_blank" class="btn btn-primary btn-sm" style="width: 100%; margin-left: 0;">
|
||||
Buy
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-if="product">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-body>
|
||||
<div class="col-sm-12 form-section-title">
|
||||
<span>
|
||||
Description
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<span class="small text-muted" style="white-space: pre-line;">{{ product.Description }}</span>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
</div>
|
||||
</div>
|
|
@ -0,0 +1,14 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('SupportProductController', ['$scope', '$transition$',
|
||||
function($scope, $transition$) {
|
||||
|
||||
$scope.formValues = {
|
||||
hostCount: 10
|
||||
};
|
||||
|
||||
function initView() {
|
||||
$scope.product = $transition$.params().product;
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
|
@ -1,38 +1,34 @@
|
|||
<rd-header>
|
||||
<rd-header-title title-text="Support">
|
||||
<rd-header-title title-text="Portainer support">
|
||||
</rd-header-title>
|
||||
<rd-header-content>
|
||||
Portainer support
|
||||
Commercial support options
|
||||
</rd-header-content>
|
||||
</rd-header>
|
||||
|
||||
<information-panel title-text="Information">
|
||||
<span class="small text-muted">
|
||||
<p>
|
||||
Business support is a subscription service and is delivered by Portainer developers directly. Portainer Business Support is available in Standard and Critical levels, which offer a range of availability and response time options.
|
||||
</p>
|
||||
<p>
|
||||
Once acquired through an in-app purchase, a support subscription will enable private access to the Portainer Support Portal at the appropriate service level.
|
||||
</p>
|
||||
<p>
|
||||
Business support includes comprehensive assistance and issue resolution for Portainer software, as well as the ability to ask “how to” questions.
|
||||
</p>
|
||||
<p>
|
||||
Issues outside Portainer (such as those relating to third party software or hardware) will be diagnosed, verified and the client will be referred to the relevant supplier for support. Portainer support will investigate and resolve any bugs that are identified as part of the support case.
|
||||
</p>
|
||||
</span>
|
||||
</information-panel>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-12">
|
||||
<rd-widget>
|
||||
<rd-widget-header title-text="Portainer support options" icon="fa-life-ring"></rd-widget-header>
|
||||
<rd-widget-body>
|
||||
<div class="small" style="line-height:1.65;">
|
||||
<p>
|
||||
Portainer.io offers multiple commercial support options.
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-chevron-circle-right" aria-hidden="true"></i> <u>Per incident</u>
|
||||
<ul>
|
||||
<li>$USD 100</li>
|
||||
<li><a href="https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=YHXZJQNJQ36H6" target="_blank"><i class="fab fa-paypal" aria-hidden="true"></i> Buy it here</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-chevron-circle-right" aria-hidden="true"></i> <u>Per Portainer instance</u>
|
||||
<ul>
|
||||
<li>$USD 1200 per year</li>
|
||||
<li>Unlimited incidents</li>
|
||||
<li>4 named users</li>
|
||||
<li><a target="_blank" href="mailto:info@portainer.io"><i class="fa fa-envelope" aria-hidden="true"></i> Contact us</a></li>
|
||||
</ul>
|
||||
</p>
|
||||
</div>
|
||||
</rd-widget-body>
|
||||
</rd-widget>
|
||||
<product-list
|
||||
title-text="Available support options"
|
||||
products="products"
|
||||
go-to="goToProductView"
|
||||
></product-list>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
angular.module('portainer.app')
|
||||
.controller('SupportController', ['$scope', '$state',
|
||||
function($scope, $state) {
|
||||
|
||||
$scope.goToProductView = function(product) {
|
||||
$state.go('portainer.support.product', { product: product });
|
||||
};
|
||||
|
||||
function initView() {
|
||||
var supportProducts = [
|
||||
{
|
||||
Id: 1,
|
||||
Name: 'Business Support Standard',
|
||||
ShortDescription: '11x5 support with 4 hour response',
|
||||
Price: 'USD 120',
|
||||
PriceDescription: 'Price per month per host (minimum 10 hosts)',
|
||||
Description: 'Portainer Business Support Standard:\n\n* 7am – 6pm business days, local time.\n* 4 Hour response for issues, 4 named support contacts.\n\nPortainer support provides you with an easy way to interact directly with the Portainer development team; whether you have an issue with the product, think you have found a bug, or need help on how to use Portainer, we are here to help. Support is initiated from our web based ticketing system, and support is provided either by Slack messaging, Zoom remote support, or email.\n\nPrice is per Docker Host, with a 10 Host minimum, and is an annual support subscription.',
|
||||
ProductId: '1163'
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
Name: 'Business Support Critical',
|
||||
ShortDescription: '24x7 support with 1 hour response',
|
||||
Price: 'USD 240',
|
||||
PriceDescription: 'Price per month per host (minimum 10 hosts)',
|
||||
Description: 'Portainer Business Support Critical:\n\n* 24x7\n* 1 Hour response for issues, 4 named support contacts.\n\nPortainer support provides you with advanced support for critical requirements. Business Support Critical is an easy way to interact directly with the Portainer development team; whether you have an issue with the product, think you have found a bug, or need help on how to use Portainer, we are here to help. Support is initiated from our web based ticketing system, and support is provided either by Slack messaging, Zoom remote support, or email.\n\nPrice is per Docker Host, with a 10 Host minimum, and is an annual support subscription.',
|
||||
ProductId: '1162'
|
||||
}
|
||||
];
|
||||
|
||||
$scope.products = supportProducts;
|
||||
}
|
||||
|
||||
initView();
|
||||
}]);
|
|
@ -59,6 +59,7 @@ html, body, #page-wrapper, #content-wrapper, .page-content, #view {
|
|||
margin-top: 5px;
|
||||
margin-bottom: 15px;
|
||||
color: #777;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.form-horizontal .control-label.text-left{
|
||||
|
@ -798,6 +799,10 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active {
|
|||
.modal-open {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.image-zoom-modal .modal-dialog {
|
||||
width: 80%;
|
||||
}
|
||||
/*!bootbox override*/
|
||||
|
||||
/*angular-multi-select override*/
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 5.6 KiB |
Loading…
Reference in New Issue