feat(home): add a new home view (#2033)

pull/2034/head
Anthony Lapenna 6 years ago committed by GitHub
parent a94f2ee7b8
commit b6792461a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -21,6 +21,7 @@ const (
errEndpointsFileNotFound = portainer.Error("Unable to locate external endpoints file")
errTemplateFileNotFound = portainer.Error("Unable to locate template file on disk")
errInvalidSyncInterval = portainer.Error("Invalid synchronization interval")
errInvalidSnapshotInterval = portainer.Error("Invalid snapshot interval")
errEndpointExcludeExternal = portainer.Error("Cannot use the -H flag mutually with --external-endpoints")
errNoAuthExcludeAdminPassword = portainer.Error("Cannot use --no-auth with --admin-password or --admin-password-file")
errAdminPassExcludeAdminPassFile = portainer.Error("Cannot use --admin-password with --admin-password-file")
@ -47,6 +48,8 @@ func (*Service) ParseFlags(version string) (*portainer.CLIFlags, error) {
SSLCert: kingpin.Flag("sslcert", "Path to the SSL certificate used to secure the Portainer instance").Default(defaultSSLCertPath).String(),
SSLKey: kingpin.Flag("sslkey", "Path to the SSL key used to secure the Portainer instance").Default(defaultSSLKeyPath).String(),
SyncInterval: kingpin.Flag("sync-interval", "Duration between each synchronization via the external endpoints source").Default(defaultSyncInterval).String(),
Snapshot: kingpin.Flag("snapshot", "Start a background job to create endpoint snapshots").Default(defaultSnapshot).Bool(),
SnapshotInterval: kingpin.Flag("snapshot-interval", "Duration between each endpoint snapshot job").Default(defaultSnapshotInterval).String(),
AdminPassword: kingpin.Flag("admin-password", "Hashed admin password").String(),
AdminPasswordFile: kingpin.Flag("admin-password-file", "Path to the file containing the password for the admin user").String(),
Labels: pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')),
@ -95,6 +98,11 @@ func (*Service) ValidateFlags(flags *portainer.CLIFlags) error {
return err
}
err = validateSnapshotInterval(*flags.SnapshotInterval)
if err != nil {
return err
}
if *flags.NoAuth && (*flags.AdminPassword != "" || *flags.AdminPasswordFile != "") {
return errNoAuthExcludeAdminPassword
}
@ -156,3 +164,13 @@ func validateSyncInterval(syncInterval string) error {
}
return nil
}
func validateSnapshotInterval(snapshotInterval string) error {
if snapshotInterval != defaultSnapshotInterval {
_, err := time.ParseDuration(snapshotInterval)
if err != nil {
return errInvalidSnapshotInterval
}
}
return nil
}

@ -3,19 +3,21 @@
package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultSSL = "false"
defaultSSLCertPath = "/certs/portainer.crt"
defaultSSLKeyPath = "/certs/portainer.key"
defaultSyncInterval = "60s"
defaultTemplateFile = "/templates.json"
defaultBindAddress = ":9000"
defaultDataDirectory = "/data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "/certs/ca.pem"
defaultTLSCertPath = "/certs/cert.pem"
defaultTLSKeyPath = "/certs/key.pem"
defaultSSL = "false"
defaultSSLCertPath = "/certs/portainer.crt"
defaultSSLKeyPath = "/certs/portainer.key"
defaultSyncInterval = "60s"
defaultSnapshot = "true"
defaultSnapshotInterval = "5m"
defaultTemplateFile = "/templates.json"
)

@ -1,19 +1,21 @@
package cli
const (
defaultBindAddress = ":9000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultSSL = "false"
defaultSSLCertPath = "C:\\certs\\portainer.crt"
defaultSSLKeyPath = "C:\\certs\\portainer.key"
defaultSyncInterval = "60s"
defaultTemplateFile = "/templates.json"
defaultBindAddress = ":9000"
defaultDataDirectory = "C:\\data"
defaultAssetsDirectory = "./"
defaultNoAuth = "false"
defaultNoAnalytics = "false"
defaultTLS = "false"
defaultTLSSkipVerify = "false"
defaultTLSCACertPath = "C:\\certs\\ca.pem"
defaultTLSCertPath = "C:\\certs\\cert.pem"
defaultTLSKeyPath = "C:\\certs\\key.pem"
defaultSSL = "false"
defaultSSLCertPath = "C:\\certs\\portainer.crt"
defaultSSLKeyPath = "C:\\certs\\portainer.key"
defaultSyncInterval = "60s"
defaultSnapshot = "true"
defaultSnapshotInterval = "5m"
defaultTemplateFile = "/templates.json"
)

@ -9,6 +9,7 @@ import (
"github.com/portainer/portainer/cli"
"github.com/portainer/portainer/cron"
"github.com/portainer/portainer/crypto"
"github.com/portainer/portainer/docker"
"github.com/portainer/portainer/exec"
"github.com/portainer/portainer/filesystem"
"github.com/portainer/portainer/git"
@ -101,25 +102,37 @@ func initGitService() portainer.GitService {
return &git.Service{}
}
func initEndpointWatcher(endpointService portainer.EndpointService, externalEnpointFile string, syncInterval string) bool {
authorizeEndpointMgmt := true
if externalEnpointFile != "" {
authorizeEndpointMgmt = false
func initClientFactory(signatureService portainer.DigitalSignatureService) *docker.ClientFactory {
return docker.NewClientFactory(signatureService)
}
func initJobScheduler(endpointService portainer.EndpointService, clientFactory *docker.ClientFactory, flags *portainer.CLIFlags) (portainer.JobScheduler, error) {
jobScheduler := cron.NewJobScheduler(endpointService, clientFactory)
if *flags.ExternalEndpoints != "" {
log.Println("Using external endpoint definition. Endpoint management via the API will be disabled.")
endpointWatcher := cron.NewWatcher(endpointService, syncInterval)
err := endpointWatcher.WatchEndpointFile(externalEnpointFile)
err := jobScheduler.ScheduleEndpointSyncJob(*flags.ExternalEndpoints, *flags.SyncInterval)
if err != nil {
log.Fatal(err)
return nil, err
}
}
if *flags.Snapshot {
err := jobScheduler.ScheduleSnapshotJob(*flags.SnapshotInterval)
if err != nil {
return nil, err
}
}
return authorizeEndpointMgmt
return jobScheduler, nil
}
func initStatus(authorizeEndpointMgmt bool, flags *portainer.CLIFlags) *portainer.Status {
func initStatus(endpointManagement, snapshot bool, flags *portainer.CLIFlags) *portainer.Status {
return &portainer.Status{
Analytics: !*flags.NoAnalytics,
Authentication: !*flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
EndpointManagement: endpointManagement,
Snapshot: snapshot,
Version: portainer.APIVersion,
}
}
@ -154,6 +167,7 @@ func initSettings(settingsService portainer.SettingsService, flags *portainer.CL
},
AllowBindMountsForRegularUsers: true,
AllowPrivilegedModeForRegularUsers: true,
SnapshotInterval: *flags.SnapshotInterval,
}
if *flags.Labels != nil {
@ -283,6 +297,8 @@ func createTLSSecuredEndpoint(flags *portainer.CLIFlags, endpointService portain
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: []string{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
if strings.HasPrefix(endpoint.URL, "tcp://") {
@ -322,6 +338,8 @@ func createUnsecuredEndpoint(endpointURL string, endpointService portainer.Endpo
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: []string{},
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
return endpointService.CreateEndpoint(endpoint)
@ -366,9 +384,21 @@ func main() {
gitService := initGitService()
authorizeEndpointMgmt := initEndpointWatcher(store.EndpointService, *flags.ExternalEndpoints, *flags.SyncInterval)
clientFactory := initClientFactory(digitalSignatureService)
jobScheduler, err := initJobScheduler(store.EndpointService, clientFactory, flags)
if err != nil {
log.Fatal(err)
}
jobScheduler.Start()
endpointManagement := true
if *flags.ExternalEndpoints != "" {
endpointManagement = false
}
err := initKeyPair(fileService, digitalSignatureService)
err = initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatal(err)
}
@ -395,7 +425,7 @@ func main() {
log.Fatal(err)
}
applicationStatus := initStatus(authorizeEndpointMgmt, flags)
applicationStatus := initStatus(endpointManagement, *flags.Snapshot, flags)
err = initEndpoint(flags, store.EndpointService)
if err != nil {
@ -443,7 +473,7 @@ func main() {
BindAddress: *flags.Addr,
AssetsPath: *flags.Assets,
AuthDisabled: *flags.NoAuth,
EndpointManagement: authorizeEndpointMgmt,
EndpointManagement: endpointManagement,
UserService: store.UserService,
TeamService: store.TeamService,
TeamMembershipService: store.TeamMembershipService,
@ -464,6 +494,7 @@ func main() {
LDAPService: ldapService,
GitService: gitService,
SignatureService: digitalSignatureService,
JobScheduler: jobScheduler,
SSL: *flags.SSL,
SSLCert: *flags.SSLCert,
SSLKey: *flags.SSLKey,

@ -0,0 +1,60 @@
package cron
import (
"log"
"github.com/portainer/portainer"
)
type (
endpointSnapshotJob struct {
endpointService portainer.EndpointService
snapshotter portainer.Snapshotter
}
)
func newEndpointSnapshotJob(endpointService portainer.EndpointService, snapshotter portainer.Snapshotter) endpointSnapshotJob {
return endpointSnapshotJob{
endpointService: endpointService,
snapshotter: snapshotter,
}
}
func (job endpointSnapshotJob) Snapshot() error {
endpoints, err := job.endpointService.Endpoints()
if err != nil {
return err
}
for _, endpoint := range endpoints {
if endpoint.Type == portainer.AzureEnvironment {
continue
}
snapshot, err := job.snapshotter.CreateSnapshot(&endpoint)
endpoint.Status = portainer.EndpointStatusUp
if err != nil {
log.Printf("cron error: endpoint snapshot error (endpoint=%s, URL=%s) (err=%s)\n", endpoint.Name, endpoint.URL, err)
endpoint.Status = portainer.EndpointStatusDown
}
if snapshot != nil {
endpoint.Snapshots = []portainer.Snapshot{*snapshot}
}
err = job.endpointService.UpdateEndpoint(endpoint.ID, &endpoint)
if err != nil {
return err
}
}
return nil
}
func (job endpointSnapshotJob) Run() {
err := job.Snapshot()
if err != nil {
log.Printf("cron error: snapshot job error (err=%s)\n", err)
}
}

@ -4,7 +4,6 @@ import (
"encoding/json"
"io/ioutil"
"log"
"os"
"strings"
"github.com/portainer/portainer"
@ -12,7 +11,6 @@ import (
type (
endpointSyncJob struct {
logger *log.Logger
endpointService portainer.EndpointService
endpointFilePath string
}
@ -41,15 +39,14 @@ const (
func newEndpointSyncJob(endpointFilePath string, endpointService portainer.EndpointService) endpointSyncJob {
return endpointSyncJob{
logger: log.New(os.Stderr, "", log.LstdFlags),
endpointService: endpointService,
endpointFilePath: endpointFilePath,
}
}
func endpointSyncError(err error, logger *log.Logger) bool {
func endpointSyncError(err error) bool {
if err != nil {
logger.Printf("Endpoint synchronization error: %s", err)
log.Printf("cron error: synchronization job error (err=%s)\n", err)
return true
}
return false
@ -140,23 +137,23 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
if fidx != -1 {
endpoint := mergeEndpointIfRequired(&storedEndpoints[idx], &fileEndpoints[fidx])
if endpoint != nil {
job.logger.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
log.Printf("New definition for a stored endpoint found in file, updating database. [name: %v] [url: %v]\n", endpoint.Name, endpoint.URL)
endpointsToUpdate = append(endpointsToUpdate, endpoint)
}
} else {
job.logger.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
log.Printf("Stored endpoint not found in file (definition might be invalid), removing from database. [name: %v] [url: %v]", storedEndpoints[idx].Name, storedEndpoints[idx].URL)
endpointsToDelete = append(endpointsToDelete, &storedEndpoints[idx])
}
}
for idx, endpoint := range fileEndpoints {
if !isValidEndpoint(&endpoint) {
job.logger.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
log.Printf("Invalid file endpoint definition, skipping. [name: %v] [url: %v]", endpoint.Name, endpoint.URL)
continue
}
sidx := endpointExists(&fileEndpoints[idx], storedEndpoints)
if sidx == -1 {
job.logger.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
log.Printf("File endpoint not found in database, adding to database. [name: %v] [url: %v]", fileEndpoints[idx].Name, fileEndpoints[idx].URL)
endpointsToCreate = append(endpointsToCreate, &fileEndpoints[idx])
}
}
@ -170,13 +167,13 @@ func (job endpointSyncJob) prepareSyncData(storedEndpoints, fileEndpoints []port
func (job endpointSyncJob) Sync() error {
data, err := ioutil.ReadFile(job.endpointFilePath)
if endpointSyncError(err, job.logger) {
if endpointSyncError(err) {
return err
}
var fileEndpoints []fileEndpoint
err = json.Unmarshal(data, &fileEndpoints)
if endpointSyncError(err, job.logger) {
if endpointSyncError(err) {
return err
}
@ -185,7 +182,7 @@ func (job endpointSyncJob) Sync() error {
}
storedEndpoints, err := job.endpointService.Endpoints()
if endpointSyncError(err, job.logger) {
if endpointSyncError(err) {
return err
}
@ -194,16 +191,16 @@ func (job endpointSyncJob) Sync() error {
sync := job.prepareSyncData(storedEndpoints, convertedFileEndpoints)
if sync.requireSync() {
err = job.endpointService.Synchronize(sync.endpointsToCreate, sync.endpointsToUpdate, sync.endpointsToDelete)
if endpointSyncError(err, job.logger) {
if endpointSyncError(err) {
return err
}
job.logger.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
log.Printf("Endpoint synchronization ended. [created: %v] [updated: %v] [deleted: %v]", len(sync.endpointsToCreate), len(sync.endpointsToUpdate), len(sync.endpointsToDelete))
}
return nil
}
func (job endpointSyncJob) Run() {
job.logger.Println("Endpoint synchronization job started.")
log.Println("cron: synchronization job started")
err := job.Sync()
endpointSyncError(err, job.logger)
endpointSyncError(err)
}

@ -0,0 +1,87 @@
package cron
import (
"log"
"github.com/portainer/portainer"
"github.com/portainer/portainer/docker"
"github.com/robfig/cron"
)
// JobScheduler represents a service for managing crons.
type JobScheduler struct {
cron *cron.Cron
endpointService portainer.EndpointService
snapshotter portainer.Snapshotter
endpointFilePath string
endpointSyncInterval string
}
// NewJobScheduler initializes a new service.
func NewJobScheduler(endpointService portainer.EndpointService, clientFactory *docker.ClientFactory) *JobScheduler {
return &JobScheduler{
cron: cron.New(),
endpointService: endpointService,
snapshotter: docker.NewSnapshotter(clientFactory),
}
}
// ScheduleEndpointSyncJob schedules a cron job to synchronize the endpoints from a file
func (scheduler *JobScheduler) ScheduleEndpointSyncJob(endpointFilePath string, interval string) error {
scheduler.endpointFilePath = endpointFilePath
scheduler.endpointSyncInterval = interval
job := newEndpointSyncJob(endpointFilePath, scheduler.endpointService)
err := job.Sync()
if err != nil {
return err
}
return scheduler.cron.AddJob("@every "+interval, job)
}
// ScheduleSnapshotJob schedules a cron job to create endpoint snapshots
func (scheduler *JobScheduler) ScheduleSnapshotJob(interval string) error {
job := newEndpointSnapshotJob(scheduler.endpointService, scheduler.snapshotter)
err := job.Snapshot()
if err != nil {
return err
}
return scheduler.cron.AddJob("@every "+interval, job)
}
// UpdateSnapshotJob will update the schedules to match the new snapshot interval
func (scheduler *JobScheduler) UpdateSnapshotJob(interval string) {
// TODO: the cron library do not support removing/updating schedules.
// As a work-around we need to re-create the cron and reschedule the jobs.
// We should update the library.
jobs := scheduler.cron.Entries()
scheduler.cron.Stop()
scheduler.cron = cron.New()
for _, job := range jobs {
switch job.Job.(type) {
case endpointSnapshotJob:
scheduler.ScheduleSnapshotJob(interval)
case endpointSyncJob:
scheduler.ScheduleEndpointSyncJob(scheduler.endpointFilePath, scheduler.endpointSyncInterval)
default:
log.Println("Unsupported job")
}
}
scheduler.cron.Start()
}
// Start starts the scheduled jobs
func (scheduler *JobScheduler) Start() {
if len(scheduler.cron.Entries()) > 0 {
scheduler.cron.Start()
}
}

@ -1,40 +0,0 @@
package cron
import (
"github.com/portainer/portainer"
"github.com/robfig/cron"
)
// Watcher represents a service for managing crons.
type Watcher struct {
Cron *cron.Cron
EndpointService portainer.EndpointService
syncInterval string
}
// NewWatcher initializes a new service.
func NewWatcher(endpointService portainer.EndpointService, syncInterval string) *Watcher {
return &Watcher{
Cron: cron.New(),
EndpointService: endpointService,
syncInterval: syncInterval,
}
}
// WatchEndpointFile starts a cron job to synchronize the endpoints from a file
func (watcher *Watcher) WatchEndpointFile(endpointFilePath string) error {
job := newEndpointSyncJob(endpointFilePath, watcher.EndpointService)
err := job.Sync()
if err != nil {
return err
}
err = watcher.Cron.AddJob("@every "+watcher.syncInterval, job)
if err != nil {
return err
}
watcher.Cron.Start()
return nil
}

@ -0,0 +1,103 @@
package docker
import (
"net/http"
"strings"
"time"
"github.com/docker/docker/client"
"github.com/portainer/portainer"
"github.com/portainer/portainer/crypto"
)
const (
unsupportedEnvironmentType = portainer.Error("Environment not supported")
)
// ClientFactory is used to create Docker clients
type ClientFactory struct {
signatureService portainer.DigitalSignatureService
}
// NewClientFactory returns a new instance of a ClientFactory
func NewClientFactory(signatureService portainer.DigitalSignatureService) *ClientFactory {
return &ClientFactory{
signatureService: signatureService,
}
}
// CreateClient is a generic function to create a Docker client based on
// a specific endpoint configuration
func (factory *ClientFactory) CreateClient(endpoint *portainer.Endpoint) (*client.Client, error) {
if endpoint.Type == portainer.AzureEnvironment {
return nil, unsupportedEnvironmentType
} else if endpoint.Type == portainer.AgentOnDockerEnvironment {
return createAgentClient(endpoint, factory.signatureService)
}
if strings.HasPrefix(endpoint.URL, "unix://") {
return createUnixSocketClient(endpoint)
}
return createTCPClient(endpoint)
}
func createUnixSocketClient(endpoint *portainer.Endpoint) (*client.Client, error) {
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(portainer.SupportedDockerAPIVersion),
)
}
func createTCPClient(endpoint *portainer.Endpoint) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
if err != nil {
return nil, err
}
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(portainer.SupportedDockerAPIVersion),
client.WithHTTPClient(httpCli),
)
}
func createAgentClient(endpoint *portainer.Endpoint, signatureService portainer.DigitalSignatureService) (*client.Client, error) {
httpCli, err := httpClient(endpoint)
if err != nil {
return nil, err
}
signature, err := signatureService.Sign(portainer.PortainerAgentSignatureMessage)
if err != nil {
return nil, err
}
headers := map[string]string{
portainer.PortainerAgentPublicKeyHeader: signatureService.EncodedPublicKey(),
portainer.PortainerAgentSignatureHeader: signature,
}
return client.NewClientWithOpts(
client.WithHost(endpoint.URL),
client.WithVersion(portainer.SupportedDockerAPIVersion),
client.WithHTTPClient(httpCli),
client.WithHTTPHeaders(headers),
)
}
func httpClient(endpoint *portainer.Endpoint) (*http.Client, error) {
transport := &http.Transport{}
if endpoint.TLSConfig.TLS {
tlsConfig, err := crypto.CreateTLSConfigurationFromDisk(endpoint.TLSConfig.TLSCACertPath, endpoint.TLSConfig.TLSCertPath, endpoint.TLSConfig.TLSKeyPath, endpoint.TLSConfig.TLSSkipVerify)
if err != nil {
return nil, err
}
transport.TLSClientConfig = tlsConfig
}
return &http.Client{
Timeout: time.Second * 10,
Transport: transport,
}, nil
}

@ -0,0 +1,135 @@
package docker
import (
"context"
"time"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
"github.com/portainer/portainer"
)
func snapshot(cli *client.Client) (*portainer.Snapshot, error) {
_, err := cli.Ping(context.Background())
if err != nil {
return nil, err
}
snapshot := &portainer.Snapshot{
StackCount: 0,
}
err = snapshotInfo(snapshot, cli)
if err != nil {
return nil, err
}
if snapshot.Swarm {
err = snapshotSwarmServices(snapshot, cli)
if err != nil {
return nil, err
}
}
err = snapshotContainers(snapshot, cli)
if err != nil {
return nil, err
}
err = snapshotImages(snapshot, cli)
if err != nil {
return nil, err
}
err = snapshotVolumes(snapshot, cli)
if err != nil {
return nil, err
}
snapshot.Time = time.Now().Unix()
return snapshot, nil
}
func snapshotInfo(snapshot *portainer.Snapshot, cli *client.Client) error {
info, err := cli.Info(context.Background())
if err != nil {
return err
}
snapshot.Swarm = info.Swarm.ControlAvailable
snapshot.DockerVersion = info.ServerVersion
snapshot.TotalCPU = info.NCPU
snapshot.TotalMemory = info.MemTotal
return nil
}
func snapshotSwarmServices(snapshot *portainer.Snapshot, cli *client.Client) error {
stacks := make(map[string]struct{})
services, err := cli.ServiceList(context.Background(), types.ServiceListOptions{})
if err != nil {
return err
}
for _, service := range services {
for k, v := range service.Spec.Labels {
if k == "com.docker.stack.namespace" {
stacks[v] = struct{}{}
}
}
}
snapshot.ServiceCount = len(services)
snapshot.StackCount += len(stacks)
return nil
}
func snapshotContainers(snapshot *portainer.Snapshot, cli *client.Client) error {
containers, err := cli.ContainerList(context.Background(), types.ContainerListOptions{All: true})
if err != nil {
return err
}
runningContainers := 0
stoppedContainers := 0
stacks := make(map[string]struct{})
for _, container := range containers {
if container.State == "exited" {
stoppedContainers++
} else if container.State == "running" {
runningContainers++
}
for k, v := range container.Labels {
if k == "com.docker.compose.project" {
stacks[v] = struct{}{}
}
}
}
snapshot.RunningContainerCount = runningContainers
snapshot.StoppedContainerCount = stoppedContainers
snapshot.StackCount += len(stacks)
return nil
}
func snapshotImages(snapshot *portainer.Snapshot, cli *client.Client) error {
images, err := cli.ImageList(context.Background(), types.ImageListOptions{})
if err != nil {
return err
}
snapshot.ImageCount = len(images)
return nil
}
func snapshotVolumes(snapshot *portainer.Snapshot, cli *client.Client) error {
volumes, err := cli.VolumeList(context.Background(), filters.Args{})
if err != nil {
return err
}
snapshot.VolumeCount = len(volumes.Volumes)
return nil
}

@ -0,0 +1,27 @@
package docker
import (
"github.com/portainer/portainer"
)
// Snapshotter represents a service used to create endpoint snapshots
type Snapshotter struct {
clientFactory *ClientFactory
}
// NewSnapshotter returns a new Snapshotter instance
func NewSnapshotter(clientFactory *ClientFactory) *Snapshotter {
return &Snapshotter{
clientFactory: clientFactory,
}
}
// CreateSnapshot creates a snapshot of a specific endpoint
func (snapshotter *Snapshotter) CreateSnapshot(endpoint *portainer.Endpoint) (*portainer.Snapshot, error) {
cli, err := snapshotter.clientFactory.CreateClient(endpoint)
if err != nil {
return nil, err
}
return snapshot(cli)
}

@ -177,6 +177,8 @@ func (handler *Handler) createAzureEndpoint(payload *endpointCreatePayload) (*po
Extensions: []portainer.EndpointExtension{},
AzureCredentials: credentials,
Tags: payload.Tags,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)
@ -213,6 +215,8 @@ func (handler *Handler) createUnsecuredEndpoint(payload *endpointCreatePayload)
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: payload.Tags,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
err := handler.EndpointService.CreateEndpoint(endpoint)
@ -253,6 +257,8 @@ func (handler *Handler) createTLSSecuredEndpoint(payload *endpointCreatePayload)
AuthorizedTeams: []portainer.TeamID{},
Extensions: []portainer.EndpointExtension{},
Tags: payload.Tags,
Status: portainer.EndpointStatusUp,
Snapshots: []portainer.Snapshot{},
}
err = handler.EndpointService.CreateEndpoint(endpoint)

@ -15,6 +15,7 @@ type Handler struct {
SettingsService portainer.SettingsService
LDAPService portainer.LDAPService
FileService portainer.FileService
JobScheduler portainer.JobScheduler
}
// NewHandler creates a handler to manage settings operations.

@ -13,6 +13,7 @@ type publicSettingsResponse struct {
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
SnapshotInterval string `json:"SnapshotInterval"`
}
// GET request on /api/settings/public
@ -27,6 +28,7 @@ func (handler *Handler) settingsPublic(w http.ResponseWriter, r *http.Request) *
AuthenticationMethod: settings.AuthenticationMethod,
AllowBindMountsForRegularUsers: settings.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: settings.AllowPrivilegedModeForRegularUsers,
SnapshotInterval: settings.SnapshotInterval,
}
return response.JSON(w, publicSettings)

@ -12,22 +12,20 @@ import (
)
type settingsUpdatePayload struct {
LogoURL string
LogoURL *string
BlackListedLabels []portainer.Pair
AuthenticationMethod int
LDAPSettings portainer.LDAPSettings
AllowBindMountsForRegularUsers bool
AllowPrivilegedModeForRegularUsers bool
AuthenticationMethod *int
LDAPSettings *portainer.LDAPSettings
AllowBindMountsForRegularUsers *bool
AllowPrivilegedModeForRegularUsers *bool
SnapshotInterval *string
}
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
if payload.AuthenticationMethod == 0 {
return portainer.Error("Invalid authentication method")
}
if payload.AuthenticationMethod != 1 && payload.AuthenticationMethod != 2 {
if *payload.AuthenticationMethod != 1 && *payload.AuthenticationMethod != 2 {
return portainer.Error("Invalid authentication method value. Value must be one of: 1 (internal) or 2 (LDAP/AD)")
}
if !govalidator.IsNull(payload.LogoURL) && !govalidator.IsURL(payload.LogoURL) {
if payload.LogoURL != nil && *payload.LogoURL != "" && !govalidator.IsURL(*payload.LogoURL) {
return portainer.Error("Invalid logo URL. Must correspond to a valid URL format")
}
return nil
@ -41,15 +39,40 @@ func (handler *Handler) settingsUpdate(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
settings := &portainer.Settings{
LogoURL: payload.LogoURL,
BlackListedLabels: payload.BlackListedLabels,
LDAPSettings: payload.LDAPSettings,
AllowBindMountsForRegularUsers: payload.AllowBindMountsForRegularUsers,
AllowPrivilegedModeForRegularUsers: payload.AllowPrivilegedModeForRegularUsers,
settings, err := handler.SettingsService.Settings()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve the settings from the database", err}
}
if payload.AuthenticationMethod != nil {
settings.AuthenticationMethod = portainer.AuthenticationMethod(*payload.AuthenticationMethod)
}
if payload.LogoURL != nil {
settings.LogoURL = *payload.LogoURL
}
if payload.BlackListedLabels != nil {
settings.BlackListedLabels = payload.BlackListedLabels
}
if payload.LDAPSettings != nil {
settings.LDAPSettings = *payload.LDAPSettings
}
if payload.AllowBindMountsForRegularUsers != nil {
settings.AllowBindMountsForRegularUsers = *payload.AllowBindMountsForRegularUsers
}
if payload.AllowPrivilegedModeForRegularUsers != nil {
settings.AllowPrivilegedModeForRegularUsers = *payload.AllowPrivilegedModeForRegularUsers
}
if payload.SnapshotInterval != nil && *payload.SnapshotInterval != settings.SnapshotInterval {
settings.SnapshotInterval = *payload.SnapshotInterval
handler.JobScheduler.UpdateSnapshotJob(settings.SnapshotInterval)
}
settings.AuthenticationMethod = portainer.AuthenticationMethod(payload.AuthenticationMethod)
tlsError := handler.updateTLS(settings)
if tlsError != nil {
return tlsError

@ -40,6 +40,7 @@ type Server struct {
ComposeStackManager portainer.ComposeStackManager
CryptoService portainer.CryptoService
SignatureService portainer.DigitalSignatureService
JobScheduler portainer.JobScheduler
DockerHubService portainer.DockerHubService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
@ -120,6 +121,7 @@ func (server *Server) Start() error {
settingsHandler.SettingsService = server.SettingsService
settingsHandler.LDAPService = server.LDAPService
settingsHandler.FileService = server.FileService
settingsHandler.JobScheduler = server.JobScheduler
var stackHandler = stacks.NewHandler(requestBouncer)
stackHandler.FileService = server.FileService

@ -31,12 +31,15 @@ type (
SSLCert *string
SSLKey *string
SyncInterval *string
Snapshot *bool
SnapshotInterval *string
}
// Status represents the application status.
Status struct {
Authentication bool `json:"Authentication"`
EndpointManagement bool `json:"EndpointManagement"`
Snapshot bool `json:"Snapshot"`
Analytics bool `json:"Analytics"`
Version string `json:"Version"`
}
@ -75,6 +78,8 @@ type (
LDAPSettings LDAPSettings `json:"LDAPSettings"`
AllowBindMountsForRegularUsers bool `json:"AllowBindMountsForRegularUsers"`
AllowPrivilegedModeForRegularUsers bool `json:"AllowPrivilegedModeForRegularUsers"`
SnapshotInterval string `json:"SnapshotInterval"`
// Deprecated fields
DisplayDonationHeader bool
DisplayExternalContributors bool
@ -177,6 +182,9 @@ type (
// EndpointType represents the type of an endpoint.
EndpointType int
// EndpointStatus represents the status of an endpoint
EndpointStatus int
// Endpoint represents a Docker endpoint with all the info required
// to connect to it.
Endpoint struct {
@ -192,6 +200,8 @@ type (
Extensions []EndpointExtension `json:"Extensions"`
AzureCredentials AzureCredentials `json:"AzureCredentials,omitempty"`
Tags []string `json:"Tags"`
Status EndpointStatus `json:"Status"`
Snapshots []Snapshot `json:"Snapshots"`
// Deprecated fields
// Deprecated in DBVersion == 4
@ -209,6 +219,21 @@ type (
AuthenticationKey string `json:"AuthenticationKey"`
}
// Snapshot represents a snapshot of a specific endpoint at a specific time
Snapshot struct {
Time int64 `json:"Time"`
DockerVersion string `json:"DockerVersion"`
Swarm bool `json:"Swarm"`
TotalCPU int `json:"TotalCPU"`
TotalMemory int64 `json:"TotalMemory"`
RunningContainerCount int `json:"RunningContainerCount"`
StoppedContainerCount int `json:"StoppedContainerCount"`
VolumeCount int `json:"VolumeCount"`
ImageCount int `json:"ImageCount"`
ServiceCount int `json:"ServiceCount"`
StackCount int `json:"StackCount"`
}
// EndpointGroupID represents an endpoint group identifier.
EndpointGroupID int
@ -539,9 +564,17 @@ type (
ClonePrivateRepositoryWithBasicAuth(repositoryURL, destination, username, password string) error
}
// EndpointWatcher represents a service to synchronize the endpoints via an external source.
EndpointWatcher interface {
WatchEndpointFile(endpointFilePath string) error
// JobScheduler represents a service to run jobs on a periodic basis.
JobScheduler interface {
ScheduleEndpointSyncJob(endpointFilePath, interval string) error
ScheduleSnapshotJob(interval string) error
UpdateSnapshotJob(interval string)
Start()
}
// Snapshotter represents a service used to create endpoint snapshots.
Snapshotter interface {
CreateSnapshot(endpoint *Endpoint) (*Snapshot, error)
}
// LDAPService represents a service used to authenticate users against a LDAP/AD.
@ -581,6 +614,8 @@ const (
// PortainerAgentSignatureMessage represents the message used to create a digital signature
// to be used when communicating with an agent
PortainerAgentSignatureMessage = "Portainer-App"
// SupportedDockerAPIVersion is the minimum Docker API version supported by Portainer.
SupportedDockerAPIVersion = "1.24"
)
const (
@ -673,3 +708,11 @@ const (
// ComposeStackTemplate represents a template used to deploy a Compose stack
ComposeStackTemplate
)
const (
_ EndpointStatus = iota
// EndpointStatusUp is used to represent an available endpoint
EndpointStatusUp
// EndpointStatusDown is used to represent an unavailable endpoint
EndpointStatusDown
)

@ -4,9 +4,6 @@ angular.module('portainer.docker').component('dockerSidebarContent', {
'endpointApiVersion': '<',
'swarmManagement': '<',
'standaloneManagement': '<',
'adminAccess': '<',
'externalContributions': '<',
'sidebarToggledOn': '<',
'currentState': '<'
'adminAccess': '<'
}
});

@ -1,6 +1,6 @@
<rd-header>
<rd-header-title title-text="Home"></rd-header-title>
<rd-header-content>Dashboard</rd-header-content>
<rd-header-title title-text="Dashboard"></rd-header-title>
<rd-header-content>Endpoint summary</rd-header-content>
</rd-header>
<div class="row" ng-if="applicationState.endpoint.mode.agentProxy">

@ -187,6 +187,17 @@ angular.module('portainer.app', [])
}
};
var home = {
name: 'portainer.home',
url: '/home',
views: {
'content@': {
templateUrl: 'app/portainer/views/home/home.html',
controller: 'HomeController'
}
}
};
var registries = {
name: 'portainer.registries',
url: '/registries',
@ -404,6 +415,7 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(group);
$stateRegistryProvider.register(groupAccess);
$stateRegistryProvider.register(groupCreation);
$stateRegistryProvider.register(home);
$stateRegistryProvider.register(registries);
$stateRegistryProvider.register(registry);
$stateRegistryProvider.register(registryAccess);

@ -30,8 +30,9 @@
}
.datatable .searchBar {
border-top: 1px solid #f6f6f6;
padding: 10px;
border-top: 1px solid #d2d1d1;
border-bottom: 1px solid #d2d1d1;
padding: 8px;
}
.datatable .searchInput {

@ -0,0 +1,113 @@
<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 by name, group, tag..." auto-focus>
</div>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>
<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>
<a ng-click="$ctrl.changeOrderBy('GroupName')">
Group
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'GroupName' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'GroupName' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Status')">
Status
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Status' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Type')">
Type
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Type' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Type' && $ctrl.state.reverseOrder"></i>
</a>
</th>
<th>
<a ng-click="$ctrl.changeOrderBy('Snapshots[0].Time')">
Last snapshot
<i class="fa fa-sort-alpha-down" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Snapshots[0].Time' && !$ctrl.state.reverseOrder"></i>
<i class="fa fa-sort-alpha-up" aria-hidden="true" ng-if="$ctrl.state.orderBy === 'Snapshots[0].Time' && $ctrl.state.reverseOrder"></i>
</a>
</th>
</tr>
</thead>
<tbody>
<tr dir-paginate-start="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder | itemsPerPage: $ctrl.state.paginatedItemLimit))" ng-class="{active: item.Checked}">
<td>
<a ng-click="$ctrl.dashboardAction(item)"><i class="fa fa-sign-in-alt" aria-hidden="true"></i> {{ item.Name }}</a>
</td>
<td>{{ item.GroupName }}</td>
<td>
<span class="label label-{{ item.Status|endpointstatusbadge }}">{{ item.Status === 1 ? 'up' : 'down' }}</span>
</td>
<td>
<span>
<i ng-class="item.Type | endpointtypeicon" aria-hidden="true" style="margin-right: 2px;"></i>
{{ item.Type | endpointtypename }}
</span>
</td>
<td>
<span ng-if="item.Snapshots.length > 0">
{{ item.Snapshots[0].Time | getisodatefromtimestamp }}
</span>
<span ng-if="item.Snapshots.length === 0">-</span>
</td>
</tr>
<tr dir-paginate-end class="text-muted" ng-if="item.Snapshots.length > 0">
<td colspan="5" style="border: 0; text-align:center;">
<snapshot-details
snapshot="item.Snapshots[0]"
></snapshot-details
</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 endpoint available.</td>
</tr>
</tbody>
</table>
</div>
<div class="footer" ng-if="$ctrl.dataset">
<div class="paginationControls">
<form class="form-inline">
<span class="limitSelector">
<span style="margin-right: 5px;">
Items per page
</span>
<select class="form-control" ng-model="$ctrl.state.paginatedItemLimit" ng-change="$ctrl.changePaginationLimit()">
<option value="0">All</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</span>
<dir-pagination-controls max-size="5"></dir-pagination-controls>
</form>
</div>
</div>
</rd-widget-body>
</rd-widget>
</div>

@ -0,0 +1,13 @@
angular.module('portainer.app').component('endpointsSnapshotDatatable', {
templateUrl: 'app/portainer/components/datatables/endpoints-snapshot-datatable/endpointsSnapshotDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
dashboardAction: '<'
}
});

@ -0,0 +1,29 @@
<span style="border-top: 2px solid #e2e2e2; padding: 7px;">
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-th-list space-right" aria-hidden="true"></i>{{ $ctrl.snapshot.StackCount }} stacks
</span>
<span style="padding: 0 7px 0 7px;" ng-if="$ctrl.snapshot.Swarm">
<i class="fa fa-list-alt space-right" aria-hidden="true"></i>{{ $ctrl.snapshot.ServiceCount }} services
</span>
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-server space-right" aria-hidden="true"></i>{{ $ctrl.snapshot.RunningContainerCount + $ctrl.snapshot.StoppedContainerCount }} containers
<span ng-if="$ctrl.snapshot.RunningContainerCount > 0 || $ctrl.snapshot.StoppedContainerCount > 0">
-
<i class="fa fa-heartbeat green-icon" aria-hidden="true"></i> {{ $ctrl.snapshot.RunningContainerCount }}
<i class="fa fa-heartbeat red-icon" aria-hidden="true"></i> {{ $ctrl.snapshot.StoppedContainerCount }}
</span>
</span>
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-cubes space-right" aria-hidden="true"></i>{{ $ctrl.snapshot.VolumeCount }} volumes
</span>
<span style="padding: 0 7px 0 7px;">
<i class="fa fa-clone space-right" aria-hidden="true"></i>{{ $ctrl.snapshot.ImageCount }} images
</span>
<span style="padding: 0 7px 0 7px; border-left: 2px solid #e2e2e2;">
<i class="fa fa-memory" aria-hidden="true"></i> {{ $ctrl.snapshot.TotalMemory | humansize }}
<i class="fa fa-microchip space-left" aria-hidden="true"></i> {{ $ctrl.snapshot.TotalCPU }}
</span>
<span style="padding: 0 7px 0 7px; border-left: 2px solid #e2e2e2;">
{{ $ctrl.snapshot.Swarm ? 'Swarm' : 'Standalone' }} {{ $ctrl.snapshot.DockerVersion }}
</span>
</span>

@ -0,0 +1,6 @@
angular.module('portainer.app').component('snapshotDetails', {
templateUrl: 'app/portainer/components/datatables/endpoints-snapshot-datatable/snapshot-details/snapshotDetails.html',
bindings: {
snapshot: '<'
}
});

@ -0,0 +1,7 @@
angular.module('portainer.app').component('informationPanel', {
templateUrl: 'app/portainer/components/information-panel/informationPanel.html',
bindings: {
titleText: '@'
},
transclude: true
});

@ -0,0 +1,14 @@
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div class="col-sm-12 form-section-title">
{{ $ctrl.titleText }}
</div>
<div class="form-group">
<ng-transclude></ng-transclude>
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

@ -1,9 +0,0 @@
angular.module('portainer.app').component('sidebarEndpointSelector', {
templateUrl: 'app/portainer/components/sidebar-endpoint-selector/sidebarEndpointSelector.html',
controller: 'SidebarEndpointSelectorController',
bindings: {
'endpoints': '<',
'groups': '<',
'selectEndpoint': '<'
}
});

@ -1,27 +0,0 @@
<div ng-if="$ctrl.endpoints.length > 1">
<div ng-if="!$ctrl.state.show">
<li class="sidebar-title">
<span class="interactive" style="color: #fff;" ng-click="$ctrl.state.show = true;">
<span class="fa fa-plug space-right"></span>Change environment
</span>
</li>
</div>
<div ng-if="$ctrl.state.show">
<div ng-if="$ctrl.availableGroups.length > 1">
<li class="sidebar-title"><span>Group</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="group.Name for group in $ctrl.availableGroups" ng-model="$ctrl.state.selectedGroup" ng-change="$ctrl.selectGroup()">
<option value="" disabled selected>Select a group</option>
</select>
</li>
</div>
<div ng-if="$ctrl.state.selectedGroup || $ctrl.availableGroups.length <= 1">
<li class="sidebar-title"><span>Endpoint</span></li>
<li class="sidebar-title">
<select class="select-endpoint form-control" ng-options="endpoint.Name for endpoint in $ctrl.availableEndpoints" ng-model="$ctrl.state.selectedEndpoint" ng-change="$ctrl.selectEndpoint($ctrl.state.selectedEndpoint)">
<option value="" disabled selected>Select an endpoint</option>
</select>
</li>
</div>
</div>
</div>

@ -1,34 +0,0 @@
angular.module('portainer.app')
.controller('SidebarEndpointSelectorController', function () {
var ctrl = this;
this.state = {
show: false,
selectedGroup: null,
selectedEndpoint: null
};
this.selectGroup = function() {
this.availableEndpoints = this.endpoints.filter(function f(endpoint) {
return endpoint.GroupId === ctrl.state.selectedGroup.Id;
});
};
this.$onInit = function() {
this.availableGroups = filterEmptyGroups(this.groups, this.endpoints);
this.availableEndpoints = this.endpoints;
};
function filterEmptyGroups(groups, endpoints) {
return groups.filter(function f(group) {
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];
if (endpoint.GroupId === group.Id) {
return true;
}
}
return false;
});
}
});

@ -138,4 +138,13 @@ angular.module('portainer.app')
return 'fa fa-eye';
}
};
})
.filter('endpointstatusbadge', function () {
'use strict';
return function (status) {
if (status === 2) {
return 'danger';
}
return 'success';
};
});

@ -5,4 +5,5 @@ function SettingsViewModel(data) {
this.LDAPSettings = data.LDAPSettings;
this.AllowBindMountsForRegularUsers = data.AllowBindMountsForRegularUsers;
this.AllowPrivilegedModeForRegularUsers = data.AllowPrivilegedModeForRegularUsers;
this.SnapshotInterval = data.SnapshotInterval;
}

@ -1,5 +1,6 @@
function StatusViewModel(data) {
this.Authentication = data.Authentication;
this.Snapshot = data.Snapshot;
this.EndpointManagement = data.EndpointManagement;
this.Analytics = data.Analytics;
this.Version = data.Version;

@ -25,12 +25,19 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
LocalStorage.storeApplicationState(state.application);
};
manager.updateSnapshotInterval = function(interval) {
state.application.snapshotInterval = interval;
LocalStorage.storeApplicationState(state.application);
};
function assignStateFromStatusAndSettings(status, settings) {
state.application.authentication = status.Authentication;
state.application.analytics = status.Analytics;
state.application.endpointManagement = status.EndpointManagement;
state.application.snapshot = status.Snapshot;
state.application.version = status.Version;
state.application.logo = settings.LogoURL;
state.application.snapshotInterval = settings.SnapshotInterval;
state.application.validity = moment().unix();
}
@ -110,14 +117,11 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
return extensions;
}
manager.updateEndpointState = function(loading, type, extensions) {
manager.updateEndpointState = function(name, type, extensions) {
var deferred = $q.defer();
if (loading) {
state.loading = true;
}
if (type === 3) {
state.endpoint.name = name;
state.endpoint.mode = { provider: 'AZURE' };
LocalStorage.storeEndpointState(state.endpoint);
deferred.resolve();
@ -132,6 +136,7 @@ function StateManagerFactory($q, SystemService, InfoHelper, LocalStorage, Settin
var endpointMode = InfoHelper.determineEndpointMode(data.info, type);
var endpointAPIVersion = parseFloat(data.version.ApiVersion);
state.endpoint.mode = endpointMode;
state.endpoint.name = name;
state.endpoint.apiVersion = endpointAPIVersion;
state.endpoint.extensions = assignExtensions(extensions);
LocalStorage.storeEndpointState(state.endpoint);

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('AuthenticationController', ['$scope', '$state', '$transition$', '$window', '$timeout', '$sanitize', 'Authentication', 'Users', 'UserService', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'SettingsService', 'ExtensionManager',
function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentication, Users, UserService, EndpointService, StateManager, EndpointProvider, Notifications, SettingsService, ExtensionManager) {
.controller('AuthenticationController', ['$scope', '$state', '$transition$', '$sanitize', 'Authentication', 'UserService', 'EndpointService', 'StateManager', 'Notifications', 'SettingsService',
function ($scope, $state, $transition$, $sanitize, Authentication, UserService, EndpointService, StateManager, Notifications, SettingsService) {
$scope.logo = StateManager.getState().application.logo;
@ -13,47 +13,13 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica
AuthenticationError: ''
};
function redirectToDockerDashboard(endpoint) {
ExtensionManager.initEndpointExtensions(endpoint.Id)
.then(function success(data) {
var extensions = data;
return StateManager.updateEndpointState(true, endpoint.Type, extensions);
})
.then(function success(data) {
$state.go('docker.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
});
}
function redirectToAzureDashboard(endpoint) {
StateManager.updateEndpointState(false, endpoint.Type, [])
.then(function success(data) {
$state.go('azure.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
});
}
function redirectToDashboard(endpoint) {
EndpointProvider.setEndpointID(endpoint.Id);
if (endpoint.Type === 3) {
return redirectToAzureDashboard(endpoint);
}
redirectToDockerDashboard(endpoint);
}
function unauthenticatedFlow() {
EndpointService.endpoints()
.then(function success(data) {
var endpoints = data;
if (endpoints.length > 0) {
redirectToDashboard(endpoints[0]);
} else {
if (endpoints.length === 0) {
$state.go('portainer.init.endpoint');
} else {
$state.go('portainer.home');
}
})
.catch(function error(err) {
@ -92,13 +58,10 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica
.then(function success(data) {
var endpoints = data;
var userDetails = Authentication.getUserDetails();
if (endpoints.length > 0) {
redirectToDashboard(endpoints[0]);
} else if (endpoints.length === 0 && userDetails.role === 1) {
if (endpoints.length === 0 && userDetails.role === 1) {
$state.go('portainer.init.endpoint');
} else if (endpoints.length === 0 && userDetails.role === 2) {
Authentication.logout();
$scope.state.AuthenticationError = 'User not allowed. Please contact your administrator.';
} else {
$state.go('portainer.home');
}
})
.catch(function error() {
@ -114,7 +77,7 @@ function ($scope, $state, $transition$, $window, $timeout, $sanitize, Authentica
}
if (Authentication.isAuthenticated()) {
$state.go('docker.dashboard');
$state.go('portainer.home');
}
var authenticationEnabled = $scope.applicationState.application.authentication;

@ -0,0 +1,37 @@
<rd-header>
<rd-header-title title-text="Home">
<a data-toggle="tooltip" title="Refresh" ui-sref="portainer.home" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Endpoints</rd-header-content>
</rd-header>
<information-panel title-text="Information" ng-if="!isAdmin && endpoints.length === 0">
<span class="small">
<p class="text-muted">
<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>
</span>
</information-panel>
<information-panel title-text="Information" ng-if="isAdmin && !applicationState.application.snapshot">
<span class="small">
<p class="text-muted">
<i class="fa fa-exclamation-circle orange-icon" aria-hidden="true" style="margin-right: 2px;"></i>
Endpoint snapshot is disabled.
</p>
</span>
</information-panel>
<div class="row" ng-if="endpoints.length > 0">
<div class="col-sm-12">
<endpoints-snapshot-datatable
title-text="Endpoints" title-icon="fa-plug"
dataset="endpoints" table-key="endpoints"
order-by="Name"
dashboard-action="goToDashboard"
></endpoints-snapshot-datatable>
</div>
</div>

@ -0,0 +1,58 @@
angular.module('portainer.app')
.controller('HomeController', ['$q', '$scope', '$state', 'Authentication', 'EndpointService', 'EndpointHelper', 'GroupService', 'Notifications', 'EndpointProvider', 'StateManager', 'ExtensionManager',
function ($q, $scope, $state, Authentication, EndpointService, EndpointHelper, GroupService, Notifications, EndpointProvider, StateManager, ExtensionManager) {
$scope.goToDashboard = function(endpoint) {
EndpointProvider.setEndpointID(endpoint.Id);
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
if (endpoint.Type === 3) {
switchToAzureEndpoint(endpoint);
} else {
switchToDockerEndpoint(endpoint);
}
};
function switchToAzureEndpoint(endpoint) {
StateManager.updateEndpointState(endpoint.Name, endpoint.Type, [])
.then(function success() {
$state.go('azure.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Azure endpoint');
});
}
function switchToDockerEndpoint(endpoint) {
ExtensionManager.initEndpointExtensions(endpoint.Id)
.then(function success(data) {
var extensions = data;
return StateManager.updateEndpointState(endpoint.Name, endpoint.Type, extensions);
})
.then(function success() {
$state.go('docker.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
});
}
function initView() {
$scope.isAdmin = Authentication.getUserDetails().role === 1;
$q.all({
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
var endpoints = data.endpoints;
var groups = data.groups;
EndpointHelper.mapGroupNameToEndpoint(endpoints, groups);
$scope.endpoints = endpoints;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoint information');
});
}
initView();
}]);

@ -1,6 +1,6 @@
angular.module('portainer.app')
.controller('InitAdminController', ['$scope', '$state', '$sanitize', 'Notifications', 'Authentication', 'StateManager', 'UserService', 'EndpointService', 'EndpointProvider', 'ExtensionManager',
function ($scope, $state, $sanitize, Notifications, Authentication, StateManager, UserService, EndpointService, EndpointProvider, ExtensionManager) {
.controller('InitAdminController', ['$scope', '$state', '$sanitize', 'Notifications', 'Authentication', 'StateManager', 'UserService', 'EndpointService',
function ($scope, $state, $sanitize, Notifications, Authentication, StateManager, UserService, EndpointService) {
$scope.logo = StateManager.getState().application.logo;
@ -30,20 +30,7 @@ function ($scope, $state, $sanitize, Notifications, Authentication, StateManager
if (data.length === 0) {
$state.go('portainer.init.endpoint');
} else {
var endpoint = data[0];
endpointID = endpoint.Id;
EndpointProvider.setEndpointID(endpointID);
ExtensionManager.initEndpointExtensions(endpointID)
.then(function success(data) {
var extensions = data;
return StateManager.updateEndpointState(false, endpoint.Type, extensions);
})
.then(function success() {
$state.go('docker.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to Docker environment');
});
$state.go('portainer.home');
}
})
.catch(function error(err) {

@ -1,9 +1,9 @@
angular.module('portainer.app')
.controller('InitEndpointController', ['$scope', '$state', 'EndpointService', 'StateManager', 'EndpointProvider', 'Notifications', 'ExtensionManager',
function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notifications, ExtensionManager) {
.controller('InitEndpointController', ['$scope', '$state', 'EndpointService', 'StateManager', 'Notifications',
function ($scope, $state, EndpointService, StateManager, Notifications) {
if (!_.isEmpty($scope.applicationState.endpoint)) {
$state.go('docker.dashboard');
$state.go('portainer.home');
}
$scope.logo = StateManager.getState().application.logo;
@ -36,16 +36,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
$scope.state.actionInProgress = true;
EndpointService.createLocalEndpoint()
.then(function success(data) {
endpoint = data;
EndpointProvider.setEndpointID(endpoint.Id);
return ExtensionManager.initEndpointExtensions(endpoint.Id);
})
.then(function success(data) {
var extensions = data;
return StateManager.updateEndpointState(false, endpoint.Type, extensions);
})
.then(function success(data) {
$state.go('docker.dashboard');
$state.go('portainer.home');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker environment');
@ -92,12 +83,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
$scope.state.actionInProgress = true;
EndpointService.createAzureEndpoint(name, applicationId, tenantId, authenticationKey, 1, [])
.then(function success(data) {
endpoint = data;
EndpointProvider.setEndpointID(endpoint.Id);
return StateManager.updateEndpointState(false, endpoint.Type, []);
})
.then(function success(data) {
$state.go('azure.dashboard');
$state.go('portainer.home');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Azure environment');
@ -112,16 +98,7 @@ function ($scope, $state, EndpointService, StateManager, EndpointProvider, Notif
$scope.state.actionInProgress = true;
EndpointService.createRemoteEndpoint(name, type, URL, PublicURL, 1, [], TLS, TLSSkipVerify, TLSSKipClientVerify, TLSCAFile, TLSCertFile, TLSKeyFile)
.then(function success(data) {
endpoint = data;
EndpointProvider.setEndpointID(endpoint.Id);
return ExtensionManager.initEndpointExtensions(endpoint.Id);
})
.then(function success(data) {
var extensions = data;
return StateManager.updateEndpointState(false, endpoint.Type, extensions);
})
.then(function success(data) {
$state.go('docker.dashboard');
$state.go('portainer.home');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker environment');

@ -9,6 +9,14 @@
<rd-widget-header icon="fa-cogs" title-text="Application settings"></rd-widget-header>
<rd-widget-body>
<form class="form-horizontal">
<!-- snapshot-interval -->
<div class="form-group">
<label for="snapshot_interval" class="col-sm-2 control-label text-left">Snapshot interval</label>
<div class="col-sm-10">
<input type="text" class="form-control" ng-model="settings.SnapshotInterval" id="snapshot_interval" placeholder="e.g. 15m">
</div>
</div>
<!-- !snapshot-interval -->
<!-- logo -->
<div class="form-group">
<div class="col-sm-12">

@ -51,6 +51,7 @@ function ($scope, $state, Notifications, SettingsService, StateManager, DEFAULT_
.then(function success(data) {
Notifications.success('Settings updated');
StateManager.updateLogo(settings.LogoURL);
StateManager.updateSnapshotInterval(settings.SnapshotInterval);
$state.reload();
})
.catch(function error(err) {

@ -1,35 +1,32 @@
<!-- Sidebar -->
<div id="sidebar-wrapper">
<div class="sidebar-header">
<a ng-click="toggleSidebar()" class="interactive">
<a ui-sref="portainer.home">
<img ng-if="logo" ng-src="{{ logo }}" class="img-responsive logo">
<img ng-if="!logo" src="images/logo.png" class="img-responsive logo" alt="Portainer">
<span class="menu-icon glyphicon glyphicon-transfer"></span>
</a>
<a ng-click="toggleSidebar()"><span class="menu-icon glyphicon glyphicon-transfer"></span></a>
</div>
<div class="sidebar-content">
<ul class="sidebar">
<sidebar-endpoint-selector ng-if="endpoints && groups"
endpoints="endpoints"
groups="groups"
select-endpoint="switchEndpoint"
></sidebar-endpoint-selector>
<li class="sidebar-title"><span class="endpoint-name">{{ activeEndpoint.Name }}</span></li>
<azure-sidebar-content ng-if="applicationState.endpoint.mode.provider === 'AZURE'">
<li class="sidebar-list">
<a ui-sref="portainer.home" ui-sref-active="active">Home <span class="menu-icon fa fa-home fa-fw"></span></a>
</li>
<li class="sidebar-title endpoint-name" ng-if="applicationState.endpoint.name">
<span class="fa fa-plug space-right"></span>{{ applicationState.endpoint.name }}
</li>
<azure-sidebar-content ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider === 'AZURE'">
</azure-sidebar-content>
<docker-sidebar-content ng-if="applicationState.endpoint.mode.provider !== 'AZURE'"
<docker-sidebar-content ng-if="applicationState.endpoint.mode && applicationState.endpoint.mode.provider !== 'AZURE'"
endpoint-api-version="applicationState.endpoint.apiVersion"
swarm-management="applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'"
standalone-management="applicationState.endpoint.mode.provider === 'DOCKER_STANDALONE' || applicationState.endpoint.mode.provider === 'VMWARE_VIC'"
admin-access="!applicationState.application.authentication || isAdmin"
external-contributions="displayExternalContributors"
sidebar-toggled-on="toggle"
current-state="$state.current.name"
></docker-sidebar-content>
<li class="sidebar-title" ng-if="applicationState.endpoint.extensions.length > 0">
<li class="sidebar-title" ng-if="applicationState.endpoint.mode && applicationState.endpoint.extensions.length > 0">
<span>Extensions</span>
</li>
<li class="sidebar-list" ng-if="applicationState.endpoint.extensions.indexOf('storidge') !== -1 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<li class="sidebar-list" ng-if="applicationState.endpoint.mode && applicationState.endpoint.extensions.indexOf('storidge') !== -1 && applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE' && applicationState.endpoint.mode.role === 'MANAGER'">
<a ui-sref="storidge.cluster" ui-sref-active="active">Storidge <span class="menu-icon fa fa-bolt fa-fw"></span></a>
<div class="sidebar-sublist" ng-if="toggle && ($state.current.name === 'storidge.cluster' || $state.current.name === 'storidge.profiles' || $state.current.name === 'storidge.monitor' || $state.current.name === 'storidge.profiles.new' || $state.current.name === 'storidge.profiles.profile')">
<a ui-sref="storidge.monitor" ui-sref-active="active">Monitor</a>
@ -39,10 +36,10 @@
</div>
</li>
<li class="sidebar-title" ng-if="!applicationState.application.authentication || isAdmin || isTeamLeader">
<span>Portainer settings</span>
<span>Settings</span>
</li>
<li class="sidebar-list" ng-if="applicationState.application.authentication && (isAdmin || isTeamLeader)">
<a ui-sref="portainer.users" ui-sref-active="active">User management <span class="menu-icon fa fa-users fa-fw"></span></a>
<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')">
<a ui-sref="portainer.teams" ui-sref-active="active">Teams</a>
</div>

@ -1,64 +1,6 @@
angular.module('portainer.app')
.controller('SidebarController', ['$q', '$scope', '$state', 'EndpointService', 'GroupService', 'StateManager', 'EndpointProvider', 'Notifications', 'Authentication', 'UserService', 'ExtensionManager',
function ($q, $scope, $state, EndpointService, GroupService, StateManager, EndpointProvider, Notifications, Authentication, UserService, ExtensionManager) {
$scope.switchEndpoint = function(endpoint) {
EndpointProvider.setEndpointID(endpoint.Id);
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
if (endpoint.Type === 3) {
switchToAzureEndpoint(endpoint);
} else {
switchToDockerEndpoint(endpoint);
}
};
function switchToAzureEndpoint(endpoint) {
StateManager.updateEndpointState(false, endpoint.Type, [])
.then(function success() {
$scope.currentEndpoint = endpoint;
$state.go('azure.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
var currentEndpoint = $scope.currentEndpoint;
EndpointProvider.setEndpointID(currentEndpoint.Id);
EndpointProvider.setEndpointPublicURL(currentEndpoint.PublicURL);
return StateManager.updateEndpointState(true, currentEndpoint.Type, currentEndpoint.Extensions);
});
}
function switchToDockerEndpoint(endpoint) {
ExtensionManager.initEndpointExtensions(endpoint.Id)
.then(function success(data) {
var extensions = data;
return StateManager.updateEndpointState(true, endpoint.Type, extensions);
})
.then(function success() {
$scope.currentEndpoint = endpoint;
$state.go('docker.dashboard');
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to connect to the Docker endpoint');
var currentEndpoint = $scope.currentEndpoint;
EndpointProvider.setEndpointID(currentEndpoint.Id);
EndpointProvider.setEndpointPublicURL(currentEndpoint.PublicURL);
return StateManager.updateEndpointState(true, currentEndpoint.Type, currentEndpoint.Extensions);
});
}
function setActiveEndpoint(endpoints) {
var activeEndpointID = EndpointProvider.endpointID();
for (var i = 0; i < endpoints.length; i++) {
var endpoint = endpoints[i];
if (endpoint.Id === activeEndpointID) {
$scope.activeEndpoint = endpoint;
$scope.currentEndpoint = endpoint;
EndpointProvider.setEndpointPublicURL(endpoint.PublicURL);
break;
}
}
}
.controller('SidebarController', ['$q', '$scope', 'StateManager', 'Notifications', 'Authentication', 'UserService',
function ($q, $scope, StateManager, Notifications, Authentication, UserService) {
function checkPermissions(memberships) {
var isLeader = false;
@ -72,32 +14,18 @@ function ($q, $scope, $state, EndpointService, GroupService, StateManager, Endpo
function initView() {
$scope.uiVersion = StateManager.getState().application.version;
$scope.displayExternalContributors = StateManager.getState().application.displayExternalContributors;
$scope.logo = StateManager.getState().application.logo;
$q.all({
endpoints: EndpointService.endpoints(),
groups: GroupService.groups()
})
.then(function success(data) {
var endpoints = data.endpoints;
$scope.groups = _.sortBy(data.groups, ['Name']);
$scope.endpoints = _.sortBy(endpoints, ['Name']);
setActiveEndpoint(endpoints);
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1;
$scope.isAdmin = isAdmin;
if (StateManager.getState().application.authentication) {
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true: false;
$scope.isAdmin = isAdmin;
return $q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : []);
}
})
$q.when(!isAdmin ? UserService.userMemberships(userDetails.ID) : [])
.then(function success(data) {
checkPermissions(data);
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve endpoints');
Notifications.error('Failure', err, 'Unable to retrieve user memberships');
});
}

@ -311,8 +311,10 @@ ul.sidebar .sidebar-title {
height: auto;
}
ul.sidebar .sidebar-title .endpoint-name {
ul.sidebar .sidebar-title.endpoint-name {
color: #fff;
text-align: center;
text-indent: 0;
}
ul.sidebar .sidebar-list a {

@ -23,7 +23,7 @@
"node": ">= 0.8.4"
},
"dependencies": {
"@fortawesome/fontawesome-free-webfonts": "^1.0.4",
"@fortawesome/fontawesome-free-webfonts": "^1.0.9",
"@uirouter/angularjs": "~1.0.6",
"angular": "~1.5.0",
"angular-clipboard": "^1.6.2",

@ -2,9 +2,9 @@
# yarn lockfile v1
"@fortawesome/fontawesome-free-webfonts@^1.0.4":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free-webfonts/-/fontawesome-free-webfonts-1.0.4.tgz#bac5d89755bf3bc2d2b4deee47d92febf641bb1f"
"@fortawesome/fontawesome-free-webfonts@^1.0.9":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free-webfonts/-/fontawesome-free-webfonts-1.0.9.tgz#72f2c10453422aba0d338fa6a9cb761b50ba24d5"
"@uirouter/angularjs@~1.0.6":
version "1.0.11"

Loading…
Cancel
Save