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 issues
pull/2531/head
Anthony Lapenna 2018-12-09 16:49:27 +13:00 committed by GitHub
parent f5dc663879
commit 6fd5ddc802
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
100 changed files with 3519 additions and 268 deletions

48
api/archive/zip.go Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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)
}

View File

@ -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,

View File

@ -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")

205
api/exec/extension.go Normal file
View File

@ -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
}

View File

@ -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) {

View File

@ -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}
}

View File

@ -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)
}

View File

@ -1,5 +1,7 @@
package endpoints
// TODO: legacy extension management
import (
"net/http"

View File

@ -1,5 +1,7 @@
package endpoints
// TODO: legacy extension management
import (
"net/http"

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}
}
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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"):

View File

@ -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
}

View File

@ -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
}

View File

@ -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 := &registryConfigurePayload{}
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)
}

View File

@ -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,

View File

@ -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) {

View File

@ -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)
}
}
}

View File

@ -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,

View File

@ -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
)

View File

@ -22,6 +22,7 @@ angular.module('portainer', [
'portainer.agent',
'portainer.azure',
'portainer.docker',
'portainer.extensions',
'extension.storidge',
'rzModule',
'moment-picker'

View File

@ -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'});
}
});
}

View File

@ -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')

View File

@ -0,0 +1,3 @@
angular.module('portainer.extensions', [
'portainer.extensions.registrymanagement'
]);

View File

@ -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);
}]);

View File

@ -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>

View File

@ -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: '<'
}
});

View File

@ -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>

View File

@ -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: '<'
}
});

View File

@ -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;
}]);

View File

@ -0,0 +1,4 @@
function RegistryRepositoryViewModel(data) {
this.Name = data.name;
this.TagsCount = data.tags.length;
}

View File

@ -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;
}

View File

@ -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
});
}]);

View File

@ -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'
}
}
});
}]);

View File

@ -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' }
}
});
}]);

View File

@ -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;
}
]);

View File

@ -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();
}]);

View File

@ -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> &gt; <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> &gt; 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>

View File

@ -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> &gt;
<a ui-sref="portainer.registries.registry.repositories({id: registry.Id})">{{ registry.Name }}</a> &gt;
<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>

View File

@ -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();
}
]);

View File

@ -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> &gt; <a ui-sref="portainer.registries.registry({id: registry.Id})">{{ registry.Name }}</a> &gt; 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>

View File

@ -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();
}]);

View File

@ -1,3 +1,4 @@
// TODO: legacy extension management
angular.module('extension.storidge', [])
.config(['$stateRegistryProvider', function ($stateRegistryProvider) {
'use strict';

View File

@ -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);

View File

@ -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">

View File

@ -9,6 +9,7 @@ angular.module('portainer.app').component('registriesDatatable', {
orderBy: '@',
reverseOrder: '<',
accessManagement: '<',
removeAction: '<'
removeAction: '<',
registryManagement: '<'
}
});

View File

@ -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: '<'
}
});

View File

@ -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>

View File

@ -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;
}
}
}]);

View File

@ -0,0 +1,7 @@
angular.module('portainer.app').component('extensionList', {
templateUrl: 'app/portainer/components/extension-list/extensionList.html',
bindings: {
extensions: '<',
currentDate: '<'
}
});

View File

@ -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>

View File

@ -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>

View File

@ -0,0 +1,3 @@
angular.module('portainer.app').component('extensionTooltip', {
templateUrl: 'app/portainer/components/extension-tooltip/extension-tooltip.html'
});

View File

@ -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>

View File

@ -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: '<'
}
});

View File

@ -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>

View File

@ -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: '<'
}
});

View File

@ -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>

View File

@ -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: '<'
}
});

View File

@ -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: '<'
}
});

View File

@ -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>

View File

@ -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;
}
}
}]);

View File

@ -0,0 +1,10 @@
angular.module('portainer.app').component('productList', {
templateUrl: 'app/portainer/components/product-list/productList.html',
bindings: {
titleText: '@',
products: '<',
goTo: '<'
// extensions: '<',
// currentDate: '<'
}
});

View File

@ -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>

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -4,4 +4,5 @@ function StatusViewModel(data) {
this.EndpointManagement = data.EndpointManagement;
this.Analytics = data.Analytics;
this.Version = data.Version;
this.EnabledExtensions = data.EnabledExtensions;
}

View File

@ -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' } }
});
}]);

View File

@ -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' } }
});
}]);

View File

@ -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' } }
});
}]);

View File

@ -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;

View File

@ -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;
}]);

View File

@ -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) {

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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>

View File

@ -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();
}]);

View File

@ -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> &gt; {{ 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>

View File

@ -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();
}]);

View File

@ -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>

View File

@ -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);

View File

@ -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();
}]);

View File

@ -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>

View File

@ -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>

View File

@ -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 = [];

View File

@ -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')">

View File

@ -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> &gt; {{ 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>

View File

@ -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();
}]);

View File

@ -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>

View File

@ -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();
}]);

View File

@ -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*/

BIN
assets/images/support_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
assets/images/support_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB