Compare commits

...

27 Commits

Author SHA1 Message Date
Anthony Lapenna
58afb8be3d Merge branch 'deploy-sshKey' of github.com:rushirajsilvertouch/portainer into rushirajsilvertouch-deploy-sshKey 2018-10-16 09:49:13 +13:00
Jan Jansen
d6ba46ed7f feat(ux): Redirect from init/admin to home when admin already exists (#2340)
Fixes #1853
2018-10-13 19:29:44 +13:00
Rushiraj
0e5fbf298e Rebase code and implement changes 2018-10-12 16:04:17 +05:30
Chaim Lev-Ari
c5aecfe6f3 feat(host): Add host file browser with upload/download files (#2337)
* feat(agent): add new host page

* feat(agent): convert volume-browser to files-datatable

* fix(agent): browse folders in file-datatable

* feat(engine-details): replace engine view with host view

* feat(engine-details): remove old panels

* feat(engine-details): add basic engine-details-panel component

* feat(engine-details): pass details to the different components

* feat(engine-details): replace host-view with host-overview

* feat(engine-details): add commaseperated filter

* feat(engine-details): add host-view container component

* feat(engine-details): add host-details component

* feat(engine-details): build host details object

* feat(engine-details): format engine version

* feat(engine-details): get details for one node

* feat(engine-details): pass is-agent from view

* feat(engine-details): replace old node view with a new component

* feat(engine-details): add swarm-node-details component

* feat(engine-details): remove isSwarm binding

* feat(engine-details): remove node-details and include in parent

* feat(engine-details): add labels-table component

* feat(engine-details): add update node service

* feat(engine-details): add update label functionality

* style(engine-details): remove whitespaces

* feat(engine-details): remove old node page

* feat(engine-details): pass is agent to host details

* feat(host-details): hide missing info

* feat(host-details): update node availability

* style(host-details): remove obsolete event object

* feat(host-details): fix labels not sending

* feat(host-details): remove flags for hiding data

* feat(host-details): create mock call to server for agent host info

* style(host-details): fix spelling mistake in filter's name

* feat(host-details): get info from agent

* feat(host-details): hide engine labels when empty

* feat(node-details): move labels table and save button

* feat(host-info): add different urls for refresh

* feat(host-details): show disk/devices info for agent

* feat(host-view): add loading indicator to devices-panel

* feat(host-details): add loading indicator to disks panel

* feat(agent): fix browse volume

* feat(agent): browse files

* feat(agent): enable rename

* feat(agent): download file

* fix(agent): download file from root

* feat(agent): delete file

* style(agent): remove whitespaces

* fix(agent): fix link on node browser

* feat(agent): basic file uploader

* feat(agent): add basic file upload

* fix(volume-browser): move volume id to query params

* feat(node-browser): moved uploader into browser

* feat(node-browser): add upload spinner

* feat(agent): browse files relative to root

* feat(agent): browse standalone agent

* feat(agent): move browse button from header

* fix(agent): fix url of browser view

* fix(agent): fix breadcrumb on title of host-browser

* feat(agent): fix url on node-browser breadcrumb

* refactor(agent): remove unused controller

* refactor(docker): remove unused filter

* refactor(docker): remove unused controllers

* refactor(docker): remove isAgent binding
2018-10-12 11:32:17 +13:00
Anthony Lapenna
5341ad33af docs(swagger): update StackUpdateRequest model (#2360) 2018-10-11 13:09:51 +13:00
baron_l
e948d606f4 fix(container-creation): set a default runtime value (#2325)
* fix(containers): creating a container with default runtime let the docker daemon assume the correct value

* refactor(containers): implementation simplification of default runtime value
2018-10-09 09:28:26 +13:00
Chaim Lev-Ari
ca08b2fa2a feat(host): replace engine view with host view (#2255)
* feat(engine-details): remove old panels

* feat(engine-details): add basic engine-details-panel component

* feat(engine-details): pass details to the different components

* feat(engine-details): replace host-view with host-overview

* feat(engine-details): add commaseperated filter

* feat(engine-details): add host-view container component

* feat(engine-details): add host-details component

* feat(engine-details): build host details object

* feat(engine-details): format engine version

* feat(engine-details): get details for one node

* feat(engine-details): pass is-agent from view

* feat(engine-details): replace old node view with a new component

* feat(engine-details): add swarm-node-details component

* feat(engine-details): remove isSwarm binding

* feat(engine-details): remove node-details and include in parent

* feat(engine-details): add labels-table component

* feat(engine-details): add update node service

* feat(engine-details): add update label functionality

* style(engine-details): remove whitespaces

* feat(engine-details): remove old node page

* feat(engine-details): pass is agent to host details

* feat(host-details): hide missing info

* feat(host-details): update node availability

* style(host-details): remove obsolete event object

* feat(host-details): fix labels not sending

* feat(host-details): remove flags for hiding data

* feat(host-details): create mock call to server for agent host info

* style(host-details): fix spelling mistake in filter's name

* feat(host-details): get info from agent

* feat(host-details): hide engine labels when empty

* feat(node-details): move labels table and save button

* feat(host-info): add different urls for refresh

* feat(host-details): show disk/devices info for agent

* feat(host-view): add loading indicator to devices-panel

* feat(host-details): add loading indicator to disks panel

* feat(host-details): show devices/disks on standalone agent

* refactor(host-details): remove default value

* refactor(host-details): remove redundant commaSeperated filter

* refactor(host-details): remove unused functions

* style(host-details): remove whitespace
2018-10-08 11:44:08 +13:00
Chaim Lev-Ari
275fcf5587 fix(volume-browser): move volume id to query params (#2338) 2018-10-08 11:34:47 +13:00
Anthony Lapenna
3422662191 fix(app): fix invalid state name (#2330)
* fix(app): fix invalid state name

* fix(app): update ui-sref
2018-10-04 13:28:39 +13:00
Brian Kabiro
f6d9a4c7c1 feat(nodes): display node name when available (#2328)
- check if the name of a node is available, otherwise default to the Hostname
2018-10-04 12:07:31 +13:00
Ricardo Cardona Ramirez
575735a6f7 feat(ux): sort networks alphabetically in network selection dropdowns (#2326)
* Sort network lists
2018-10-04 12:04:38 +13:00
Brian Kabiro
b7c48fcbed feat(visualizer): sort tasks in alphabetical order on refresh (#2329)
- sort the tasks on each node in alphabetical order to make it easier to track what has changed
2018-10-04 11:57:07 +13:00
Tolik Litovsky
6e8a10d72f fix(api): remove x-frame-options header (#2322) 2018-10-03 14:18:03 +13:00
Chaim Lev-Ari
bad95987ec feat(backend): trigger startup snapshot job in a goroutine (#2309)
* feat(backend): wrap init enpoint with goroutine

* feat(backend): wrap job snapshot with goroutine

* feat(snapshots): reset changes for main and job_endpoint

* feat(snapshot): run first job.snapshot as a goroutine
2018-10-01 14:38:14 +13:00
Chaim Lev-Ari
9b4870d57e feat(stack-details): Add the ability to duplicate a stack (#2278)
* feat(stack-details): add duplicate-stack button

* feat(stack-details): add stack-duplication-form component

* feat(stack-details): add duplicate stack method on controller

* feat(stack-details): add duplicate stack method

* feat(stack-details): remove old duplication in progress flag

* feat(stack-details): combine migration and duplication forms

* feat(stack-details): pass new stack name to server

* feat(stack-details): add option to rename migrated stack

* feat(stack-details): disable both migrate/duplicate buttons

* feat(stack-details): disable migration button on same endpoint

* feat(stack-details): change duplicate icon

* style(stack-details): remove whitespaces and fix pattern

* feat(stack-details): add name to migration payload in swagger.yml

* style(stack-details): add semicolon

* bug(stack-details): toggle endpoints before and after duplication
2018-10-01 14:36:49 +13:00
Chaim Lev-Ari
6e262e6e89 feat(home): support search in multiple fields (name, group, tag, status) (#2285)
* feat(home): search multiple fields (group/tag)

* feat(home): change search from "OR" to "AND"

* feat(home): search only for a tag or a group

* feat(home): search by keywords in name,group,tag

* feat(home): support case insensitive search

* style(home): remove unused $filter

* feat(home): search state

* style(home): update search input placeholder
2018-10-01 09:06:58 +13:00
Chaim Lev-Ari
5be2684442 feat(home): add the ability to edit an endpoint (#2305)
* feat(home): add edit button

* feat(home): style edit button

* feat(home): make endpoint editable on admin only
2018-09-30 11:20:10 +13:00
Chaim Lev-Ari
226c45f035 fix(template-creation): fix an issue related to the network setting (#2312)
* bug(template): pass network name on creation

* bug(templates): choose network object on update

* fix(templates): set network only when available
2018-09-28 15:06:47 +12:00
Angele
92b15523f0 feat(containers): add container name in error notification
* containersDatable: add containers name if error on executeActionOnContainerList

* Update containersDatatableActionsController.js

* Update containersDatatableActionsController.js
2018-09-28 10:49:30 +12:00
Anthony Lapenna
f0f01c33bd feat(endpoint-creation): add requirement message for agent endpoint (#2303) 2018-09-26 18:59:50 +12:00
Lukas Joergensen
94b202fedc fix(authentication): escape LDAP filters (#2209) 2018-09-25 11:10:41 +12:00
Anthony Lapenna
d5dd362d53 feat(api): update client.Get with a new timeout parameter and default… (#2297)
* feat(api): update client.Get with a new timeout parameter and default to 5s

* fix(api): fix invalid type
2018-09-24 12:09:12 +12:00
Anthony Lapenna
c3d80a1b21 docs(project): update CONTRIBUTING.md 2018-09-19 11:40:06 +08:00
Anthony Lapenna
b192b098ca feat(build-system): update shippedDockerVersion to 18.06.1-ce (#2281) 2018-09-17 09:26:37 +08:00
Anthony Lapenna
22450bbdeb chore(build): update build script and add grunt yarn script (#2276) 2018-09-16 10:34:46 +08:00
Anthony Lapenna
313c8be997 chore(version): bump version number 2018-09-15 19:26:03 +08:00
Anthony Lapenna
885c61fb7b Merge tag '1.19.2' into develop
Release 1.19.2
2018-09-15 16:40:43 +08:00
129 changed files with 4731 additions and 751 deletions

View File

@@ -77,14 +77,14 @@ The subject contains succinct description of the change:
## Contribution process
Our contribution process is described below. Some of the steps can be visualized inside Github via specific `contrib/` labels, such as `contrib/func-review-in-progress` or `contrib/tech-review-approved`.
Our contribution process is described below. Some of the steps can be visualized inside Github via specific `status/` labels, such as `status/1-functional-review` or `status/2-technical-review`.
### Bug report
![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/43569306-5571b3a0-9637-11e8-8559-786cfc82a14f.png)
![portainer_bugreport_workflow](https://user-images.githubusercontent.com/5485061/45727219-50190a00-bbf5-11e8-9fe8-3a563bb8d5d7.png)
### Feature request
The feature request process is similar to the bug report process but has an extra functional validation before the technical validation.
The feature request process is similar to the bug report process but has an extra functional validation before the technical validation as well as a documentation validation before the testing phase.
![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/43569315-5d30a308-9637-11e8-8292-3c62b5612925.png)
![portainer_featurerequest_workflow](https://user-images.githubusercontent.com/5485061/45727229-5ad39f00-bbf5-11e8-9550-16ba66c50615.png)

View File

@@ -7,6 +7,7 @@ import (
"github.com/boltdb/bolt"
"github.com/portainer/portainer"
"github.com/portainer/portainer/bolt/deploykey"
"github.com/portainer/portainer/bolt/dockerhub"
"github.com/portainer/portainer/bolt/endpoint"
"github.com/portainer/portainer/bolt/endpointgroup"
@@ -49,6 +50,7 @@ type Store struct {
UserService *user.Service
VersionService *version.Service
WebhookService *webhook.Service
DeploykeyService *deploykey.Service
}
// NewStore initializes a new Store and the associated services
@@ -204,6 +206,12 @@ func (store *Store) initServices() error {
}
store.TagService = tagService
deploykeyService, err := deploykey.NewService(store.db)
if err != nil {
return err
}
store.DeploykeyService = deploykeyService
teammembershipService, err := teammembership.NewService(store.db)
if err != nil {
return err

View File

@@ -0,0 +1,75 @@
package deploykey
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 = "deploykeys"
)
// 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
}
// Keys return an array containing all the keys.
func (service *Service) Deploykeys() ([]portainer.Deploykey, error) {
var deploykeys = make([]portainer.Deploykey, 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 deploykey portainer.Deploykey
err := internal.UnmarshalObject(v, &deploykey)
if err != nil {
return err
}
deploykeys = append(deploykeys, deploykey)
}
return nil
})
return deploykeys, err
}
// CreateKey creates a new key.
func (service *Service) CreateDeploykey(deploykey *portainer.Deploykey) error {
return service.db.Update(func(tx *bolt.Tx) error {
bucket := tx.Bucket([]byte(BucketName))
id, _ := bucket.NextSequence()
deploykey.ID = portainer.DeploykeyID(id)
data, err := internal.MarshalObject(deploykey)
if err != nil {
return err
}
return bucket.Put(internal.Itob(int(deploykey.ID)), data)
})
}
// DeleteDeploykey deletes a key.
func (service *Service) DeleteDeploykey(ID portainer.DeploykeyID) error {
identifier := internal.Itob(int(ID))
return internal.DeleteObject(service.db, BucketName, identifier)
}

View File

@@ -94,6 +94,11 @@ func initCryptoService() portainer.CryptoService {
return &crypto.Service{}
}
func initDigitalDeploykeyService() portainer.DigitalDeploykeyService {
return &crypto.ECDSAService{}
}
func initLDAPService() portainer.LDAPService {
return &ldap.Service{}
}
@@ -401,6 +406,8 @@ func main() {
digitalSignatureService := initDigitalSignatureService()
digitalDeploykeyService := initDigitalDeploykeyService()
err := initKeyPair(fileService, digitalSignatureService)
if err != nil {
log.Fatal(err)
@@ -498,6 +505,8 @@ func main() {
TeamMembershipService: store.TeamMembershipService,
EndpointService: store.EndpointService,
EndpointGroupService: store.EndpointGroupService,
DeploykeyService: store.DeploykeyService,
DigitalDeploykeyService: digitalDeploykeyService,
ResourceControlService: store.ResourceControlService,
SettingsService: store.SettingsService,
RegistryService: store.RegistryService,

View File

@@ -45,11 +45,7 @@ func (scheduler *JobScheduler) ScheduleEndpointSyncJob(endpointFilePath string,
// 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
}
go job.Snapshot()
return scheduler.cron.AddJob("@every "+interval, job)
}

378
api/crypto/certs.go Normal file
View File

@@ -0,0 +1,378 @@
// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package crypto
import (
"time"
)
// These constants from [PROTOCOL.certkeys] represent the algorithm names
// for certificate types supported by this package.
const (
CertAlgoRSAv01 = "ssh-rsa-cert-v01@openssh.com"
CertAlgoDSAv01 = "ssh-dss-cert-v01@openssh.com"
CertAlgoECDSA256v01 = "ecdsa-sha2-nistp256-cert-v01@openssh.com"
CertAlgoECDSA384v01 = "ecdsa-sha2-nistp384-cert-v01@openssh.com"
CertAlgoECDSA521v01 = "ecdsa-sha2-nistp521-cert-v01@openssh.com"
)
// Certificate types are used to specify whether a certificate is for identification
// of a user or a host. Current identities are defined in [PROTOCOL.certkeys].
const (
UserCert = 1
HostCert = 2
)
type signature struct {
Format string
Blob []byte
}
type tuple struct {
Name string
Data string
}
const (
maxUint64 = 1<<64 - 1
maxInt64 = 1<<63 - 1
)
// CertTime represents an unsigned 64-bit time value in seconds starting from
// UNIX epoch. We use CertTime instead of time.Time in order to properly handle
// the "infinite" time value ^0, which would become negative when expressed as
// an int64.
type CertTime uint64
func (ct CertTime) Time() time.Time {
if ct > maxInt64 {
return time.Unix(maxInt64, 0)
}
return time.Unix(int64(ct), 0)
}
func (ct CertTime) IsInfinite() bool {
return ct == maxUint64
}
// An OpenSSHCertV01 represents an OpenSSH certificate as defined in
// [PROTOCOL.certkeys]?rev=1.8.
type OpenSSHCertV01 struct {
Nonce []byte
Key PublicKey
Serial uint64
Type uint32
KeyId string
ValidPrincipals []string
ValidAfter, ValidBefore CertTime
CriticalOptions []tuple
Extensions []tuple
Reserved []byte
SignatureKey PublicKey
Signature *signature
}
// validateOpenSSHCertV01Signature uses the cert's SignatureKey to verify that
// the cert's Signature.Blob is the result of signing the cert bytes starting
// from the algorithm string and going up to and including the SignatureKey.
func validateOpenSSHCertV01Signature(cert *OpenSSHCertV01) bool {
return cert.SignatureKey.Verify(cert.BytesForSigning(), cert.Signature.Blob)
}
var certAlgoNames = map[string]string{
KeyAlgoRSA: CertAlgoRSAv01,
KeyAlgoDSA: CertAlgoDSAv01,
KeyAlgoECDSA256: CertAlgoECDSA256v01,
KeyAlgoECDSA384: CertAlgoECDSA384v01,
KeyAlgoECDSA521: CertAlgoECDSA521v01,
}
// certToPrivAlgo returns the underlying algorithm for a certificate algorithm.
// Panics if a non-certificate algorithm is passed.
func certToPrivAlgo(algo string) string {
for privAlgo, pubAlgo := range certAlgoNames {
if pubAlgo == algo {
return privAlgo
}
}
panic("unknown cert algorithm")
}
func (cert *OpenSSHCertV01) marshal(includeAlgo, includeSig bool) []byte {
algoName := cert.PublicKeyAlgo()
pubKey := cert.Key.Marshal()
sigKey := MarshalPublicKey(cert.SignatureKey)
var length int
if includeAlgo {
length += stringLength(len(algoName))
}
length += stringLength(len(cert.Nonce))
length += len(pubKey)
length += 8 // Length of Serial
length += 4 // Length of Type
length += stringLength(len(cert.KeyId))
length += lengthPrefixedNameListLength(cert.ValidPrincipals)
length += 8 // Length of ValidAfter
length += 8 // Length of ValidBefore
length += tupleListLength(cert.CriticalOptions)
length += tupleListLength(cert.Extensions)
length += stringLength(len(cert.Reserved))
length += stringLength(len(sigKey))
if includeSig {
length += signatureLength(cert.Signature)
}
ret := make([]byte, length)
r := ret
if includeAlgo {
r = marshalString(r, []byte(algoName))
}
r = marshalString(r, cert.Nonce)
copy(r, pubKey)
r = r[len(pubKey):]
r = marshalUint64(r, cert.Serial)
r = marshalUint32(r, cert.Type)
r = marshalString(r, []byte(cert.KeyId))
r = marshalLengthPrefixedNameList(r, cert.ValidPrincipals)
r = marshalUint64(r, uint64(cert.ValidAfter))
r = marshalUint64(r, uint64(cert.ValidBefore))
r = marshalTupleList(r, cert.CriticalOptions)
r = marshalTupleList(r, cert.Extensions)
r = marshalString(r, cert.Reserved)
r = marshalString(r, sigKey)
if includeSig {
r = marshalSignature(r, cert.Signature)
}
if len(r) > 0 {
panic("ssh: internal error, marshaling certificate did not fill the entire buffer")
}
return ret
}
func (cert *OpenSSHCertV01) BytesForSigning() []byte {
return cert.marshal(true, false)
}
func (cert *OpenSSHCertV01) Marshal() []byte {
return cert.marshal(false, true)
}
func (c *OpenSSHCertV01) PublicKeyAlgo() string {
algo, ok := certAlgoNames[c.Key.PublicKeyAlgo()]
if !ok {
panic("unknown cert key type")
}
return algo
}
func (c *OpenSSHCertV01) PrivateKeyAlgo() string {
return c.Key.PrivateKeyAlgo()
}
func (c *OpenSSHCertV01) Verify(data []byte, sig []byte) bool {
return c.Key.Verify(data, sig)
}
func parseOpenSSHCertV01(in []byte, algo string) (out *OpenSSHCertV01, rest []byte, ok bool) {
cert := new(OpenSSHCertV01)
if cert.Nonce, in, ok = parseString(in); !ok {
return
}
privAlgo := certToPrivAlgo(algo)
cert.Key, in, ok = parsePubKey(in, privAlgo)
if !ok {
return
}
// We test PublicKeyAlgo to make sure we don't use some weird sub-cert.
if cert.Key.PublicKeyAlgo() != privAlgo {
ok = false
return
}
if cert.Serial, in, ok = parseUint64(in); !ok {
return
}
if cert.Type, in, ok = parseUint32(in); !ok {
return
}
keyId, in, ok := parseString(in)
if !ok {
return
}
cert.KeyId = string(keyId)
if cert.ValidPrincipals, in, ok = parseLengthPrefixedNameList(in); !ok {
return
}
va, in, ok := parseUint64(in)
if !ok {
return
}
cert.ValidAfter = CertTime(va)
vb, in, ok := parseUint64(in)
if !ok {
return
}
cert.ValidBefore = CertTime(vb)
if cert.CriticalOptions, in, ok = parseTupleList(in); !ok {
return
}
if cert.Extensions, in, ok = parseTupleList(in); !ok {
return
}
if cert.Reserved, in, ok = parseString(in); !ok {
return
}
sigKey, in, ok := parseString(in)
if !ok {
return
}
if cert.SignatureKey, _, ok = ParsePublicKey(sigKey); !ok {
return
}
if cert.Signature, in, ok = parseSignature(in); !ok {
return
}
ok = true
return cert, in, ok
}
func lengthPrefixedNameListLength(namelist []string) int {
length := 4 // length prefix for list
for _, name := range namelist {
length += 4 // length prefix for name
length += len(name)
}
return length
}
func marshalLengthPrefixedNameList(to []byte, namelist []string) []byte {
length := uint32(lengthPrefixedNameListLength(namelist) - 4)
to = marshalUint32(to, length)
for _, name := range namelist {
to = marshalString(to, []byte(name))
}
return to
}
func parseLengthPrefixedNameList(in []byte) (out []string, rest []byte, ok bool) {
list, rest, ok := parseString(in)
if !ok {
return
}
for len(list) > 0 {
var next []byte
if next, list, ok = parseString(list); !ok {
return nil, nil, false
}
out = append(out, string(next))
}
ok = true
return
}
func tupleListLength(tupleList []tuple) int {
length := 4 // length prefix for list
for _, t := range tupleList {
length += 4 // length prefix for t.Name
length += len(t.Name)
length += 4 // length prefix for t.Data
length += len(t.Data)
}
return length
}
func marshalTupleList(to []byte, tuplelist []tuple) []byte {
length := uint32(tupleListLength(tuplelist) - 4)
to = marshalUint32(to, length)
for _, t := range tuplelist {
to = marshalString(to, []byte(t.Name))
to = marshalString(to, []byte(t.Data))
}
return to
}
func parseTupleList(in []byte) (out []tuple, rest []byte, ok bool) {
list, rest, ok := parseString(in)
if !ok {
return
}
for len(list) > 0 {
var name, data []byte
var ok bool
name, list, ok = parseString(list)
if !ok {
return nil, nil, false
}
data, list, ok = parseString(list)
if !ok {
return nil, nil, false
}
out = append(out, tuple{string(name), string(data)})
}
ok = true
return
}
func signatureLength(sig *signature) int {
length := 4 // length prefix for signature
length += stringLength(len(sig.Format))
length += stringLength(len(sig.Blob))
return length
}
func marshalSignature(to []byte, sig *signature) []byte {
length := uint32(signatureLength(sig) - 4)
to = marshalUint32(to, length)
to = marshalString(to, []byte(sig.Format))
to = marshalString(to, sig.Blob)
return to
}
func parseSignatureBody(in []byte) (out *signature, rest []byte, ok bool) {
var format []byte
if format, in, ok = parseString(in); !ok {
return
}
out = &signature{
Format: string(format),
}
if out.Blob, in, ok = parseString(in); !ok {
return
}
return out, in, ok
}
func parseSignature(in []byte) (out *signature, rest []byte, ok bool) {
var sigBytes []byte
if sigBytes, rest, ok = parseString(in); !ok {
return
}
out, sigBytes, ok = parseSignatureBody(sigBytes)
if !ok || len(sigBytes) > 0 {
return nil, nil, false
}
return
}

View File

@@ -1,4 +1,4 @@
package crypto
package crypto
import (
"crypto/ecdsa"
@@ -8,6 +8,8 @@ import (
"encoding/base64"
"encoding/hex"
"math/big"
"io/ioutil"
"log"
)
const (
@@ -91,6 +93,29 @@ func (service *ECDSAService) GenerateKeyPair() ([]byte, []byte, error) {
return private, public, nil
}
// GenerateKeyPair will create a new key pair using ECDSA.
func (service *ECDSAService) GenerateSshKey() ([]byte, error) {
//savePublicFileTo := "./id_ecdsa_test.pub"
pubkeyCurve := elliptic.P256()
byteKeys, err := ecdsa.GenerateKey(pubkeyCurve, rand.Reader)
if err != nil {
return nil,err
}
publicKeyBytes, err := generateECDSAPublicKey(&byteKeys.PublicKey)
if err != nil {
return nil,err
}
log.Println(publicKeyBytes)
err = writeKeyToFile(publicKeyBytes,"./testFile" )
if err != nil {
return nil,err
}
return publicKeyBytes, nil
}
// Sign creates a signature from a message.
// It automatically hash the message using MD5 and creates a signature from
// that hash.
@@ -120,3 +145,22 @@ func (service *ECDSAService) Sign(message string) (string, error) {
return base64.RawStdEncoding.EncodeToString(signature), nil
}
func generateECDSAPublicKey(privatekey *ecdsa.PublicKey) ([]byte, error) {
publicRsaKey, err := NewPublicKey(privatekey)
if err != nil {
return nil, err
}
pubKeyBytes := MarshalAuthorizedKey(publicRsaKey)
return pubKeyBytes, nil
}
// writePemToFile writes keys to a file
func writeKeyToFile(keyBytes []byte, saveFileTo string) error {
log.Println("hey")
err := ioutil.WriteFile(saveFileTo, keyBytes, 0777)
log.Println("here")
if err != nil {
return err
}
return nil
}

615
api/crypto/keys.go Normal file
View File

@@ -0,0 +1,615 @@
// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This package is a duplicate of 32844aa1ae54: https://code.google.com/p/go/source/browse/ssh/keys.go?repo=crypto
package crypto
import (
"bytes"
"crypto"
"crypto/dsa"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"io"
"math/big"
)
// These constants represent the algorithm names for key types supported by this
// package.
const (
KeyAlgoRSA = "ssh-rsa"
KeyAlgoDSA = "ssh-dss"
KeyAlgoECDSA256 = "ecdsa-sha2-nistp256"
KeyAlgoECDSA384 = "ecdsa-sha2-nistp384"
KeyAlgoECDSA521 = "ecdsa-sha2-nistp521"
)
// parsePubKey parses a public key of the given algorithm.
// Use ParsePublicKey for keys with prepended algorithm.
func parsePubKey(in []byte, algo string) (pubKey PublicKey, rest []byte, ok bool) {
switch algo {
case KeyAlgoRSA:
return parseRSA(in)
case KeyAlgoDSA:
return parseDSA(in)
case KeyAlgoECDSA256, KeyAlgoECDSA384, KeyAlgoECDSA521:
return parseECDSA(in)
case CertAlgoRSAv01, CertAlgoDSAv01, CertAlgoECDSA256v01, CertAlgoECDSA384v01, CertAlgoECDSA521v01:
return parseOpenSSHCertV01(in, algo)
}
return nil, nil, false
}
// parseAuthorizedKey parses a public key in OpenSSH authorized_keys format
// (see sshd(8) manual page) once the options and key type fields have been
// removed.
func parseAuthorizedKey(in []byte) (out PublicKey, comment string, ok bool) {
in = bytes.TrimSpace(in)
i := bytes.IndexAny(in, " \t")
if i == -1 {
i = len(in)
}
base64Key := in[:i]
key := make([]byte, base64.StdEncoding.DecodedLen(len(base64Key)))
n, err := base64.StdEncoding.Decode(key, base64Key)
if err != nil {
return
}
key = key[:n]
out, _, ok = ParsePublicKey(key)
if !ok {
return nil, "", false
}
comment = string(bytes.TrimSpace(in[i:]))
return
}
// ParseAuthorizedKeys parses a public key from an authorized_keys
// file used in OpenSSH according to the sshd(8) manual page.
func ParseAuthorizedKey(in []byte) (out PublicKey, comment string, options []string, rest []byte, ok bool) {
for len(in) > 0 {
end := bytes.IndexByte(in, '\n')
if end != -1 {
rest = in[end+1:]
in = in[:end]
} else {
rest = nil
}
end = bytes.IndexByte(in, '\r')
if end != -1 {
in = in[:end]
}
in = bytes.TrimSpace(in)
if len(in) == 0 || in[0] == '#' {
in = rest
continue
}
i := bytes.IndexAny(in, " \t")
if i == -1 {
in = rest
continue
}
if out, comment, ok = parseAuthorizedKey(in[i:]); ok {
return
}
// No key type recognised. Maybe there's an options field at
// the beginning.
var b byte
inQuote := false
var candidateOptions []string
optionStart := 0
for i, b = range in {
isEnd := !inQuote && (b == ' ' || b == '\t')
if (b == ',' && !inQuote) || isEnd {
if i-optionStart > 0 {
candidateOptions = append(candidateOptions, string(in[optionStart:i]))
}
optionStart = i + 1
}
if isEnd {
break
}
if b == '"' && (i == 0 || (i > 0 && in[i-1] != '\\')) {
inQuote = !inQuote
}
}
for i < len(in) && (in[i] == ' ' || in[i] == '\t') {
i++
}
if i == len(in) {
// Invalid line: unmatched quote
in = rest
continue
}
in = in[i:]
i = bytes.IndexAny(in, " \t")
if i == -1 {
in = rest
continue
}
if out, comment, ok = parseAuthorizedKey(in[i:]); ok {
options = candidateOptions
return
}
in = rest
continue
}
return
}
// ParsePublicKey parses an SSH public key formatted for use in
// the SSH wire protocol according to RFC 4253, section 6.6.
func ParsePublicKey(in []byte) (out PublicKey, rest []byte, ok bool) {
algo, in, ok := parseString(in)
if !ok {
return
}
return parsePubKey(in, string(algo))
}
// MarshalAuthorizedKey returns a byte stream suitable for inclusion
// in an OpenSSH authorized_keys file following the format specified
// in the sshd(8) manual page.
func MarshalAuthorizedKey(key PublicKey) []byte {
b := &bytes.Buffer{}
b.WriteString(key.PublicKeyAlgo())
b.WriteByte(' ')
e := base64.NewEncoder(base64.StdEncoding, b)
e.Write(MarshalPublicKey(key))
e.Close()
b.WriteByte('\n')
return b.Bytes()
}
// PublicKey is an abstraction of different types of public keys.
type PublicKey interface {
// PrivateKeyAlgo returns the name of the encryption system.
PrivateKeyAlgo() string
// PublicKeyAlgo returns the algorithm for the public key,
// which may be different from PrivateKeyAlgo for certificates.
PublicKeyAlgo() string
// Marshal returns the serialized key data in SSH wire format,
// without the name prefix. Callers should typically use
// MarshalPublicKey().
Marshal() []byte
// Verify that sig is a signature on the given data using this
// key. This function will hash the data appropriately first.
Verify(data []byte, sigBlob []byte) bool
}
// A Signer is can create signatures that verify against a public key.
type Signer interface {
// PublicKey returns an associated PublicKey instance.
PublicKey() PublicKey
// Sign returns raw signature for the given data. This method
// will apply the hash specified for the keytype to the data.
Sign(rand io.Reader, data []byte) ([]byte, error)
}
type rsaPublicKey rsa.PublicKey
func (r *rsaPublicKey) PrivateKeyAlgo() string {
return "ssh-rsa"
}
func (r *rsaPublicKey) PublicKeyAlgo() string {
return r.PrivateKeyAlgo()
}
// parseRSA parses an RSA key according to RFC 4253, section 6.6.
func parseRSA(in []byte) (out PublicKey, rest []byte, ok bool) {
key := new(rsa.PublicKey)
bigE, in, ok := parseInt(in)
if !ok || bigE.BitLen() > 24 {
return
}
e := bigE.Int64()
if e < 3 || e&1 == 0 {
ok = false
return
}
key.E = int(e)
if key.N, in, ok = parseInt(in); !ok {
return
}
ok = true
return (*rsaPublicKey)(key), in, ok
}
func (r *rsaPublicKey) Marshal() []byte {
// See RFC 4253, section 6.6.
e := new(big.Int).SetInt64(int64(r.E))
length := intLength(e)
length += intLength(r.N)
ret := make([]byte, length)
rest := marshalInt(ret, e)
marshalInt(rest, r.N)
return ret
}
func (r *rsaPublicKey) Verify(data []byte, sig []byte) bool {
h := crypto.SHA1.New()
h.Write(data)
digest := h.Sum(nil)
return rsa.VerifyPKCS1v15((*rsa.PublicKey)(r), crypto.SHA1, digest, sig) == nil
}
type rsaPrivateKey struct {
*rsa.PrivateKey
}
func (r *rsaPrivateKey) PublicKey() PublicKey {
return (*rsaPublicKey)(&r.PrivateKey.PublicKey)
}
func (r *rsaPrivateKey) Sign(rand io.Reader, data []byte) ([]byte, error) {
h := crypto.SHA1.New()
h.Write(data)
digest := h.Sum(nil)
return rsa.SignPKCS1v15(rand, r.PrivateKey, crypto.SHA1, digest)
}
type dsaPublicKey dsa.PublicKey
func (r *dsaPublicKey) PrivateKeyAlgo() string {
return "ssh-dss"
}
func (r *dsaPublicKey) PublicKeyAlgo() string {
return r.PrivateKeyAlgo()
}
// parseDSA parses an DSA key according to RFC 4253, section 6.6.
func parseDSA(in []byte) (out PublicKey, rest []byte, ok bool) {
key := new(dsa.PublicKey)
if key.P, in, ok = parseInt(in); !ok {
return
}
if key.Q, in, ok = parseInt(in); !ok {
return
}
if key.G, in, ok = parseInt(in); !ok {
return
}
if key.Y, in, ok = parseInt(in); !ok {
return
}
ok = true
return (*dsaPublicKey)(key), in, ok
}
func (r *dsaPublicKey) Marshal() []byte {
// See RFC 4253, section 6.6.
length := intLength(r.P)
length += intLength(r.Q)
length += intLength(r.G)
length += intLength(r.Y)
ret := make([]byte, length)
rest := marshalInt(ret, r.P)
rest = marshalInt(rest, r.Q)
rest = marshalInt(rest, r.G)
marshalInt(rest, r.Y)
return ret
}
func (k *dsaPublicKey) Verify(data []byte, sigBlob []byte) bool {
h := crypto.SHA1.New()
h.Write(data)
digest := h.Sum(nil)
// Per RFC 4253, section 6.6,
// The value for 'dss_signature_blob' is encoded as a string containing
// r, followed by s (which are 160-bit integers, without lengths or
// padding, unsigned, and in network byte order).
// For DSS purposes, sig.Blob should be exactly 40 bytes in length.
if len(sigBlob) != 40 {
return false
}
r := new(big.Int).SetBytes(sigBlob[:20])
s := new(big.Int).SetBytes(sigBlob[20:])
return dsa.Verify((*dsa.PublicKey)(k), digest, r, s)
}
type dsaPrivateKey struct {
*dsa.PrivateKey
}
func (k *dsaPrivateKey) PublicKey() PublicKey {
return (*dsaPublicKey)(&k.PrivateKey.PublicKey)
}
func (k *dsaPrivateKey) Sign(rand io.Reader, data []byte) ([]byte, error) {
h := crypto.SHA1.New()
h.Write(data)
digest := h.Sum(nil)
r, s, err := dsa.Sign(rand, k.PrivateKey, digest)
if err != nil {
return nil, err
}
sig := make([]byte, 40)
copy(sig[:20], r.Bytes())
copy(sig[20:], s.Bytes())
return sig, nil
}
type ecdsaPublicKey ecdsa.PublicKey
func (key *ecdsaPublicKey) PrivateKeyAlgo() string {
return "ecdsa-sha2-" + key.nistID()
}
func (key *ecdsaPublicKey) nistID() string {
switch key.Params().BitSize {
case 256:
return "nistp256"
case 384:
return "nistp384"
case 521:
return "nistp521"
}
panic("ssh: unsupported ecdsa key size")
}
func supportedEllipticCurve(curve elliptic.Curve) bool {
return (curve == elliptic.P256() || curve == elliptic.P384() || curve == elliptic.P521())
}
// ecHash returns the hash to match the given elliptic curve, see RFC
// 5656, section 6.2.1
func ecHash(curve elliptic.Curve) crypto.Hash {
bitSize := curve.Params().BitSize
switch {
case bitSize <= 256:
return crypto.SHA256
case bitSize <= 384:
return crypto.SHA384
}
return crypto.SHA512
}
func (key *ecdsaPublicKey) PublicKeyAlgo() string {
return key.PrivateKeyAlgo()
}
// parseECDSA parses an ECDSA key according to RFC 5656, section 3.1.
func parseECDSA(in []byte) (out PublicKey, rest []byte, ok bool) {
var identifier []byte
if identifier, in, ok = parseString(in); !ok {
return
}
key := new(ecdsa.PublicKey)
switch string(identifier) {
case "nistp256":
key.Curve = elliptic.P256()
case "nistp384":
key.Curve = elliptic.P384()
case "nistp521":
key.Curve = elliptic.P521()
default:
ok = false
return
}
var keyBytes []byte
if keyBytes, in, ok = parseString(in); !ok {
return
}
key.X, key.Y = elliptic.Unmarshal(key.Curve, keyBytes)
if key.X == nil || key.Y == nil {
ok = false
return
}
return (*ecdsaPublicKey)(key), in, ok
}
func (key *ecdsaPublicKey) Marshal() []byte {
// See RFC 5656, section 3.1.
keyBytes := elliptic.Marshal(key.Curve, key.X, key.Y)
ID := key.nistID()
length := stringLength(len(ID))
length += stringLength(len(keyBytes))
ret := make([]byte, length)
r := marshalString(ret, []byte(ID))
r = marshalString(r, keyBytes)
return ret
}
func (key *ecdsaPublicKey) Verify(data []byte, sigBlob []byte) bool {
h := ecHash(key.Curve).New()
h.Write(data)
digest := h.Sum(nil)
// Per RFC 5656, section 3.1.2,
// The ecdsa_signature_blob value has the following specific encoding:
// mpint r
// mpint s
r, rest, ok := parseInt(sigBlob)
if !ok {
return false
}
s, rest, ok := parseInt(rest)
if !ok || len(rest) > 0 {
return false
}
return ecdsa.Verify((*ecdsa.PublicKey)(key), digest, r, s)
}
type ecdsaPrivateKey struct {
*ecdsa.PrivateKey
}
func (k *ecdsaPrivateKey) PublicKey() PublicKey {
return (*ecdsaPublicKey)(&k.PrivateKey.PublicKey)
}
func (k *ecdsaPrivateKey) Sign(rand io.Reader, data []byte) ([]byte, error) {
h := ecHash(k.PrivateKey.PublicKey.Curve).New()
h.Write(data)
digest := h.Sum(nil)
r, s, err := ecdsa.Sign(rand, k.PrivateKey, digest)
if err != nil {
return nil, err
}
sig := make([]byte, intLength(r)+intLength(s))
rest := marshalInt(sig, r)
marshalInt(rest, s)
return sig, nil
}
// NewPrivateKey takes a pointer to rsa, dsa or ecdsa PrivateKey
// returns a corresponding Signer instance. EC keys should use P256,
// P384 or P521.
func NewSignerFromKey(k interface{}) (Signer, error) {
var sshKey Signer
switch t := k.(type) {
case *rsa.PrivateKey:
sshKey = &rsaPrivateKey{t}
case *dsa.PrivateKey:
sshKey = &dsaPrivateKey{t}
case *ecdsa.PrivateKey:
if !supportedEllipticCurve(t.Curve) {
return nil, errors.New("ssh: only P256, P384 and P521 EC keys are supported.")
}
sshKey = &ecdsaPrivateKey{t}
default:
return nil, fmt.Errorf("ssh: unsupported key type %T", k)
}
return sshKey, nil
}
// NewPublicKey takes a pointer to rsa, dsa or ecdsa PublicKey
// and returns a corresponding ssh PublicKey instance. EC keys should use P256, P384 or P521.
func NewPublicKey(k interface{}) (PublicKey, error) {
var sshKey PublicKey
switch t := k.(type) {
case *rsa.PublicKey:
sshKey = (*rsaPublicKey)(t)
case *ecdsa.PublicKey:
if !supportedEllipticCurve(t.Curve) {
return nil, errors.New("ssh: only P256, P384 and P521 EC keys are supported.")
}
sshKey = (*ecdsaPublicKey)(t)
case *dsa.PublicKey:
sshKey = (*dsaPublicKey)(t)
default:
return nil, fmt.Errorf("ssh: unsupported key type %T", k)
}
return sshKey, nil
}
// ParsePublicKey parses a PEM encoded private key. It supports
// PKCS#1, RSA, DSA and ECDSA private keys.
func ParsePrivateKey(pemBytes []byte) (Signer, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("ssh: no key found")
}
var rawkey interface{}
switch block.Type {
case "RSA PRIVATE KEY":
rsa, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rawkey = rsa
case "EC PRIVATE KEY":
ec, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, err
}
rawkey = ec
case "DSA PRIVATE KEY":
ec, err := parseDSAPrivate(block.Bytes)
if err != nil {
return nil, err
}
rawkey = ec
default:
return nil, fmt.Errorf("ssh: unsupported key type %q", block.Type)
}
return NewSignerFromKey(rawkey)
}
// parseDSAPrivate parses a DSA key in ASN.1 DER encoding, as
// documented in the OpenSSL DSA manpage.
// TODO(hanwen): move this in to crypto/x509 after the Go 1.2 freeze.
func parseDSAPrivate(p []byte) (*dsa.PrivateKey, error) {
k := struct {
Version int
P *big.Int
Q *big.Int
G *big.Int
Priv *big.Int
Pub *big.Int
}{}
rest, err := asn1.Unmarshal(p, &k)
if err != nil {
return nil, errors.New("ssh: failed to parse DSA key: " + err.Error())
}
if len(rest) > 0 {
return nil, errors.New("ssh: garbage after DSA key")
}
return &dsa.PrivateKey{
PublicKey: dsa.PublicKey{
Parameters: dsa.Parameters{
P: k.P,
Q: k.Q,
G: k.G,
},
Y: k.Priv,
},
X: k.Pub,
}, nil
}

187
api/crypto/private.go Normal file
View File

@@ -0,0 +1,187 @@
// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package crypto
// Copy only of used functions from message.go, common.go
import (
"encoding/binary"
"math/big"
)
const (
kexAlgoDH1SHA1 = "diffie-hellman-group1-sha1"
kexAlgoDH14SHA1 = "diffie-hellman-group14-sha1"
kexAlgoECDH256 = "ecdh-sha2-nistp256"
kexAlgoECDH384 = "ecdh-sha2-nistp384"
kexAlgoECDH521 = "ecdh-sha2-nistp521"
)
var bigOne = big.NewInt(1)
func stringLength(n int) int {
return 4 + n
}
// MarshalPublicKey serializes a supported key or certificate for use
// by the SSH wire protocol. It can be used for comparison with the
// pubkey argument of ServerConfig's PublicKeyCallback as well as for
// generating an authorized_keys or host_keys file.
func MarshalPublicKey(key PublicKey) []byte {
// See also RFC 4253 6.6.
algoname := key.PublicKeyAlgo()
blob := key.Marshal()
length := stringLength(len(algoname))
length += len(blob)
ret := make([]byte, length)
r := marshalString(ret, []byte(algoname))
copy(r, blob)
return ret
}
func parseInt(in []byte) (out *big.Int, rest []byte, ok bool) {
contents, rest, ok := parseString(in)
if !ok {
return
}
out = new(big.Int)
if len(contents) > 0 && contents[0]&0x80 == 0x80 {
// This is a negative number
notBytes := make([]byte, len(contents))
for i := range notBytes {
notBytes[i] = ^contents[i]
}
out.SetBytes(notBytes)
out.Add(out, bigOne)
out.Neg(out)
} else {
// Positive number
out.SetBytes(contents)
}
ok = true
return
}
func parseUint32(in []byte) (uint32, []byte, bool) {
if len(in) < 4 {
return 0, nil, false
}
return binary.BigEndian.Uint32(in), in[4:], true
}
func parseUint64(in []byte) (uint64, []byte, bool) {
if len(in) < 8 {
return 0, nil, false
}
return binary.BigEndian.Uint64(in), in[8:], true
}
func parseString(in []byte) (out, rest []byte, ok bool) {
if len(in) < 4 {
return
}
length := binary.BigEndian.Uint32(in)
if uint32(len(in)) < 4+length {
return
}
out = in[4 : 4+length]
rest = in[4+length:]
ok = true
return
}
func intLength(n *big.Int) int {
length := 4 /* length bytes */
if n.Sign() < 0 {
nMinus1 := new(big.Int).Neg(n)
nMinus1.Sub(nMinus1, bigOne)
bitLen := nMinus1.BitLen()
if bitLen%8 == 0 {
// The number will need 0xff padding
length++
}
length += (bitLen + 7) / 8
} else if n.Sign() == 0 {
// A zero is the zero length string
} else {
bitLen := n.BitLen()
if bitLen%8 == 0 {
// The number will need 0x00 padding
length++
}
length += (bitLen + 7) / 8
}
return length
}
func marshalUint32(to []byte, n uint32) []byte {
binary.BigEndian.PutUint32(to, n)
return to[4:]
}
func marshalUint64(to []byte, n uint64) []byte {
binary.BigEndian.PutUint64(to, n)
return to[8:]
}
func marshalInt(to []byte, n *big.Int) []byte {
lengthBytes := to
to = to[4:]
length := 0
if n.Sign() < 0 {
// A negative number has to be converted to two's-complement
// form. So we'll subtract 1 and invert. If the
// most-significant-bit isn't set then we'll need to pad the
// beginning with 0xff in order to keep the number negative.
nMinus1 := new(big.Int).Neg(n)
nMinus1.Sub(nMinus1, bigOne)
bytes := nMinus1.Bytes()
for i := range bytes {
bytes[i] ^= 0xff
}
if len(bytes) == 0 || bytes[0]&0x80 == 0 {
to[0] = 0xff
to = to[1:]
length++
}
nBytes := copy(to, bytes)
to = to[nBytes:]
length += nBytes
} else if n.Sign() == 0 {
// A zero is the zero length string
} else {
bytes := n.Bytes()
if len(bytes) > 0 && bytes[0]&0x80 != 0 {
// We'll have to pad this with a 0x00 in order to
// stop it looking like a negative number.
to[0] = 0
to = to[1:]
length++
}
nBytes := copy(to, bytes)
to = to[nBytes:]
length += nBytes
}
lengthBytes[0] = byte(length >> 24)
lengthBytes[1] = byte(length >> 16)
lengthBytes[2] = byte(length >> 8)
lengthBytes[3] = byte(length)
return to
}
func marshalString(to []byte, s []byte) []byte {
to[0] = byte(len(s) >> 24)
to[1] = byte(len(s) >> 16)
to[2] = byte(len(s) >> 8)
to[3] = byte(len(s))
to = to[4:]
copy(to, s)
return to[len(s):]
}

View File

@@ -71,6 +71,11 @@ const (
ErrEndpointExtensionAlreadyAssociated = Error("This extension is already associated to the endpoint")
)
// Deploykey errors
const (
ErrDeploykeyAlreadyExists = Error("A key already exists with this name")
)
// Crypto errors.
const (
ErrCryptoHashFailure = Error("Unable to hash data")

View File

@@ -15,6 +15,7 @@ import (
const (
errInvalidResponseStatus = portainer.Error("Invalid response status (expecting 200)")
defaultHTTPTimeout = 5
)
// HTTPClient represents a client to send HTTP requests.
@@ -26,7 +27,7 @@ type HTTPClient struct {
func NewHTTPClient() *HTTPClient {
return &HTTPClient{
&http.Client{
Timeout: time.Second * 5,
Timeout: time.Second * time.Duration(defaultHTTPTimeout),
},
}
}
@@ -67,10 +68,16 @@ func (client *HTTPClient) ExecuteAzureAuthenticationRequest(credentials *portain
}
// Get executes a simple HTTP GET to the specified URL and returns
// the content of the response body.
func Get(url string) ([]byte, error) {
// the content of the response body. Timeout can be specified via the timeout parameter,
// will default to defaultHTTPTimeout if set to 0.
func Get(url string, timeout int) ([]byte, error) {
if timeout == 0 {
timeout = defaultHTTPTimeout
}
client := &http.Client{
Timeout: time.Second * 3,
Timeout: time.Second * time.Duration(timeout),
}
response, err := client.Get(url)

View File

@@ -0,0 +1,76 @@
package deploykeys
import (
"encoding/hex"
"net/http"
"time"
"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 deploykeyCreatePayload struct {
Name string
Privatekeypath string
Publickeypath string
UserID int
LastUsage string
}
func (payload *deploykeyCreatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.Name) {
return portainer.Error("Invalid deploykey Name")
}
return nil
}
// POST request on /api/deploykeys
func (handler *Handler) deploykeyCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
var payload deploykeyCreatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
}
deploykeys, err := handler.DeploykeyService.Deploykeys()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve deploykeys from the database", err}
}
for _, deploykey := range deploykeys {
if deploykey.Name == payload.Name {
return &httperror.HandlerError{http.StatusConflict, "This name is already associated to a deploykey", portainer.ErrDeploykeyAlreadyExists}
}
}
pubkeypath, errrrr := handler.DigitalDeploykeyService.GenerateSshKey()
if errrrr != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid key payload", errrrr}
}
//encodedStr := hex.EncodeToString(privatepath)
encodedStr1 := hex.EncodeToString(pubkeypath)
dateTime := time.Now().Local().Format("2006-01-02 15:04:05")
deploykey := &portainer.Deploykey{
Name: payload.Name,
Privatekeypath: "abc",
Publickeypath: encodedStr1,
UserID: payload.UserID,
LastUsage: dateTime,
}
err = handler.DeploykeyService.CreateDeploykey(deploykey)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the deploykey inside the database", err}
}
return response.JSON(w, deploykey)
}
func BytesToString(data []byte) string {
return string(data[:])
}

View File

@@ -0,0 +1,25 @@
package deploykeys
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/deploykeys/:id
func (handler *Handler) deploykeyDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
id, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid deploykey identifier route variable", err}
}
err = handler.DeploykeyService.DeleteDeploykey(portainer.DeploykeyID(id))
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the deploykey from the database", err}
}
return response.Empty(w)
}

View File

@@ -0,0 +1,18 @@
package deploykeys
import (
"net/http"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
)
// GET request on /api/deploykeys
func (handler *Handler) deploykeyList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
deploykeys, err := handler.DeploykeyService.Deploykeys()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve deploykeys from the database", err}
}
return response.JSON(w, deploykeys)
}

View File

@@ -0,0 +1,35 @@
package deploykeys
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 deploykey operations.
type Handler struct {
*mux.Router
DeploykeyService portainer.DeploykeyService
CryptoService portainer.CryptoService
DigitalDeploykeyService portainer.DigitalDeploykeyService
signatureService portainer.DigitalSignatureService
}
// NewHandler creates a handler to manage deploykey operations.
func NewHandler(bouncer *security.RequestBouncer) *Handler {
h := &Handler{
Router: mux.NewRouter(),
}
h.Handle("/deploykeys",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.deploykeyCreate))).Methods(http.MethodPost)
h.Handle("/deploykeys",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.deploykeyList))).Methods(http.MethodGet)
h.Handle("/deploykeys/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.deploykeyDelete))).Methods(http.MethodDelete)
return h
}

View File

@@ -34,7 +34,6 @@ func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
}
w.Header().Add("X-Frame-Options", "DENY")
w.Header().Add("X-XSS-Protection", "1; mode=block")
w.Header().Add("X-Content-Type-Options", "nosniff")
handler.Handler.ServeHTTP(w, r)

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/portainer/portainer/http/handler/auth"
"github.com/portainer/portainer/http/handler/deploykeys"
"github.com/portainer/portainer/http/handler/dockerhub"
"github.com/portainer/portainer/http/handler/endpointgroups"
"github.com/portainer/portainer/http/handler/endpointproxy"
@@ -49,6 +50,7 @@ type Handler struct {
UserHandler *users.Handler
WebSocketHandler *websocket.Handler
WebhookHandler *webhooks.Handler
DeploykeyHandler *deploykeys.Handler
}
// ServeHTTP delegates a request to the appropriate subhandler.
@@ -87,6 +89,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.StripPrefix("/api", h.TagHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/templates"):
http.StripPrefix("/api", h.TemplatesHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/deploykeys"):
http.StripPrefix("/api", h.DeploykeyHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/upload"):
http.StripPrefix("/api", h.UploadHandler).ServeHTTP(w, r)
case strings.HasPrefix(r.URL.Path, "/api/users"):

View File

@@ -16,7 +16,7 @@ type motdResponse struct {
func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
motd, err := client.Get(portainer.MessageOfTheDayURL)
motd, err := client.Get(portainer.MessageOfTheDayURL, 0)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return

View File

@@ -14,6 +14,7 @@ import (
type stackMigratePayload struct {
EndpointID int
SwarmID string
Name string
}
func (payload *stackMigratePayload) Validate(r *http.Request) error {
@@ -89,11 +90,17 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
stack.SwarmID = payload.SwarmID
}
oldName := stack.Name
if payload.Name != "" {
stack.Name = payload.Name
}
migrationError := handler.migrateStack(r, stack, targetEndpoint)
if migrationError != nil {
return migrationError
}
stack.Name = oldName
err = handler.deleteStack(stack, endpoint)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err}

View File

@@ -26,7 +26,7 @@ func (handler *Handler) templateList(w http.ResponseWriter, r *http.Request) *ht
}
} else {
var templateData []byte
templateData, err = client.Get(settings.TemplatesURL)
templateData, err = client.Get(settings.TemplatesURL, 0)
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve external templates", err}
}

View File

@@ -114,7 +114,6 @@ func (bouncer *RequestBouncer) EndpointAccess(r *http.Request, endpoint *portain
// mwSecureHeaders provides secure headers middleware for handlers.
func mwSecureHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Frame-Options", "DENY")
w.Header().Add("X-XSS-Protection", "1; mode=block")
w.Header().Add("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)

View File

@@ -7,6 +7,7 @@ import (
"github.com/portainer/portainer/docker"
"github.com/portainer/portainer/http/handler"
"github.com/portainer/portainer/http/handler/auth"
"github.com/portainer/portainer/http/handler/deploykeys"
"github.com/portainer/portainer/http/handler/dockerhub"
"github.com/portainer/portainer/http/handler/endpointgroups"
"github.com/portainer/portainer/http/handler/endpointproxy"
@@ -48,6 +49,7 @@ type Server struct {
DockerHubService portainer.DockerHubService
EndpointService portainer.EndpointService
EndpointGroupService portainer.EndpointGroupService
DigitalDeploykeyService portainer.DigitalDeploykeyService
FileService portainer.FileService
GitService portainer.GitService
JWTService portainer.JWTService
@@ -62,6 +64,7 @@ type Server struct {
TeamMembershipService portainer.TeamMembershipService
TemplateService portainer.TemplateService
UserService portainer.UserService
DeploykeyService portainer.DeploykeyService
WebhookService portainer.WebhookService
Handler *handler.Handler
SSL bool
@@ -152,6 +155,10 @@ func (server *Server) Start() error {
teamHandler.TeamService = server.TeamService
teamHandler.TeamMembershipService = server.TeamMembershipService
var deploykeyHandler = deploykeys.NewHandler(requestBouncer)
deploykeyHandler.DeploykeyService = server.DeploykeyService
deploykeyHandler.DigitalDeploykeyService = server.DigitalDeploykeyService
var teamMembershipHandler = teammemberships.NewHandler(requestBouncer)
teamMembershipHandler.TeamMembershipService = server.TeamMembershipService
var statusHandler = status.NewHandler(requestBouncer, server.Status)
@@ -201,6 +208,7 @@ func (server *Server) Start() error {
UserHandler: userHandler,
WebSocketHandler: websocketHandler,
WebhookHandler: webhookHandler,
DeploykeyHandler: deploykeyHandler,
}
if server.SSL {

View File

@@ -22,11 +22,13 @@ type Service struct{}
func searchUser(username string, conn *ldap.Conn, settings []portainer.LDAPSearchSettings) (string, error) {
var userDN string
found := false
usernameEscaped := ldap.EscapeFilter(username)
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, username),
fmt.Sprintf("(&%s(%s=%s))", searchSettings.Filter, searchSettings.UserNameAttribute, usernameEscaped),
[]string{"dn"},
nil,
)
@@ -134,12 +136,13 @@ func (*Service) GetUserGroups(username string, settings *portainer.LDAPSettings)
// Get a list of group names for specified user from LDAP/AD
func getGroups(userDN string, conn *ldap.Conn, settings []portainer.LDAPGroupSearchSettings) []string {
groups := make([]string, 0)
userDNEscaped := ldap.EscapeFilter(userDN)
for _, searchSettings := range settings {
searchRequest := ldap.NewSearchRequest(
searchSettings.GroupBaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDN),
fmt.Sprintf("(&%s(%s=%s))", searchSettings.GroupFilter, searchSettings.GroupAttribute, userDNEscaped),
[]string{"cn"},
nil,
)

View File

@@ -321,6 +321,19 @@ type (
AccessLevel ResourceAccessLevel `json:"AccessLevel"`
}
// DeploykeyID represents a key identifier
DeploykeyID int
// Deploykey represents a key that can be associated to a resource
Deploykey struct {
ID DeploykeyID
Name string `json:"Name"`
Privatekeypath string `json:"Privatekeypath"`
Publickeypath string `json:"Publickeypath"`
UserID int `json:"UserID"`
LastUsage string `json:"LastUsage"`
}
// TagID represents a tag identifier
TagID int
@@ -450,6 +463,13 @@ type (
DeleteTeam(ID TeamID) error
}
// DeploykeyService represents a service for managing key data
DeploykeyService interface {
Deploykeys() ([]Deploykey, error)
CreateDeploykey(deploykey *Deploykey) error
DeleteDeploykey(ID DeploykeyID) error
}
// TeamMembershipService represents a service for managing team membership data
TeamMembershipService interface {
TeamMembership(ID TeamMembershipID) (*TeamMembership, error)
@@ -572,6 +592,16 @@ type (
Sign(message string) (string, error)
}
//DigitalDeploykeyService represents a service to manage digital deploykey
DigitalDeploykeyService interface {
ParseKeyPair(private, public []byte) error
GenerateKeyPair() ([]byte, []byte, error)
GenerateSshKey()([]byte, error)
EncodedPublicKey() string
PEMHeaders() (string, string)
Sign(message string) (string, error)
}
// JWTService represents a service for managing JWT tokens
JWTService interface {
GenerateToken(data *TokenData) (string, error)
@@ -639,7 +669,7 @@ type (
const (
// APIVersion is the version number of the Portainer API
APIVersion = "1.19.2"
APIVersion = "1.20-dev"
// DBVersion is the version number of the Portainer database
DBVersion = 14
// MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved

View File

@@ -54,13 +54,15 @@ info:
**NOTE**: You can find more information on how to query the Docker API in the [Docker official documentation](https://docs.docker.com/engine/api/v1.30/) as well as in [this Portainer example](https://gist.github.com/deviantony/77026d402366b4b43fa5918d41bc42f8).
version: "1.19.2"
version: "1.20-dev"
title: "Portainer API"
contact:
email: "info@portainer.io"
host: "portainer.domain"
basePath: "/api"
tags:
- name: "deploykeys"
description: "Manage deploykeys"
- name: "auth"
description: "Authenticate against Portainer HTTP API"
- name: "dockerhub"
@@ -2311,6 +2313,105 @@ paths:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/deploykeys:
get:
tags:
- "deploykeys"
summary: "List deploykeys"
description: |
List deploykeys.
**Access policy**: administrator
operationId: "DeploykeysList"
produces:
- "application/json"
parameters: []
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/DeploykeyListResponse"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
404:
description: "Key not found"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
post:
tags:
- "deploykeys"
summary: "Create a new deploykeys"
description: |
Create a new deploykeys.
**Access policy**: administrator
operationId: "DeploykeyCreate"
consumes:
- "application/json"
produces:
- "application/json"
parameters:
- in: "body"
name: "body"
description: "Deploykey details"
required: true
schema:
$ref: "#/definitions/DeploykeyCreateRequest"
responses:
200:
description: "Success"
schema:
$ref: "#/definitions/Deploykey"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request data format"
409:
description: "Conflict"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "A deploykeys with the specified name already exists"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/deploykeys/{id}:
delete:
tags:
- "deploykeys"
summary: "Remove a deploykeys"
description: |
Remove a deploykeys.
**Access policy**: administrator
operationId: "DeploykeyDelete"
parameters:
- name: "id"
in: "path"
description: "Deploykey identifier"
required: true
type: "integer"
responses:
204:
description: "Success"
400:
description: "Invalid request"
schema:
$ref: "#/definitions/GenericError"
examples:
application/json:
err: "Invalid request"
500:
description: "Server error"
schema:
$ref: "#/definitions/GenericError"
/teams/{id}/memberships:
get:
@@ -2735,6 +2836,35 @@ definitions:
type: "string"
example: "org/acme"
description: "Tag name"
Deploykey:
type: "object"
properties:
Id:
type: "integer"
example: 1
description: "Deploykey identifier"
Name:
type: "string"
example: "abcd"
description: "Deploykey name"
Privatekeypath:
type: "string"
example: "abc@1dd45%"
description: "Deploykey private key path"
Publickeypath:
type: "string"
example: "abc@1dd45%"
description: "Deploykey public key path"
UserID:
type: "integer"
example: 1
description: "Deploykey user id"
LastUsage:
type: "string"
example: "12:03:32"
description: " last usage deploykey date"
Team:
type: "object"
properties:
@@ -2816,7 +2946,7 @@ definitions:
description: "Is analytics enabled"
Version:
type: "string"
example: "1.19.2"
example: "1.20-dev"
description: "Portainer API version"
PublicSettingsInspectResponse:
type: "object"
@@ -3717,6 +3847,37 @@ definitions:
type: "array"
items:
$ref: "#/definitions/TeamMembership"
DeploykeyListResponse:
type: "array"
items:
$ref: "#/definitions/Deploykey"
DeploykeyCreateRequest:
type: "object"
required:
- "Name"
properties:
Name:
type: "string"
example: "abcd"
description: "Deploykey name"
Privatekeypath:
type: "string"
example: "abc@1dd45%"
description: "Deploykey private key path"
Publickeypath:
type: "string"
example: "abc@1dd45%"
description: "Deploykey public key path"
UserID:
type: "integer"
example: 1
description: "Deploykey user id"
LastUsage:
type: "string"
example: "12:03:32"
description: "last usage deploykey date"
TeamMembershipCreateRequest:
type: "object"
@@ -4160,6 +4321,10 @@ definitions:
type: "string"
example: "jpofkc0i9uo9wtx1zesuk649w"
description: "Swarm cluster identifier, must match the identifier of the cluster where the stack will be relocated"
Name:
type: "string"
example: "new-stack"
description: "If provided will rename the migrated stack"
StackCreateRequest:
type: "object"
required:
@@ -4269,7 +4434,7 @@ definitions:
Prune:
type: "boolean"
example: false
description: "Prune services that are no longer referenced"
description: "Prune services that are no longer referenced (only available for Swarm stacks)"
StackFileInspectResponse:
type: "object"
properties:

View File

@@ -0,0 +1,23 @@
angular.module('portainer.agent').controller('FileUploaderController', [
'$q',
function FileUploaderController($q) {
var ctrl = this;
ctrl.state = {
uploadInProgress: false
};
ctrl.onFileSelected = onFileSelected;
function onFileSelected(file) {
if (!file) {
return;
}
ctrl.state.uploadInProgress = true;
$q.when(ctrl.uploadFile(file)).finally(function toggleProgress() {
ctrl.state.uploadInProgress = false;
});
}
}
]);

View File

@@ -0,0 +1,6 @@
<button
ngf-select="$ctrl.onFileSelected($file)"
class="btn ng-scope"
button-spinner="$ctrl.state.uploadInProgress">
<i style="margin:0" class="fa fa-upload" ng-if="!$ctrl.state.uploadInProgress"></i>
</button>

View File

@@ -0,0 +1,7 @@
angular.module('portainer.agent').component('fileUploader', {
templateUrl: 'app/agent/components/file-uploader/file-uploader.html',
controller: 'FileUploaderController',
bindings: {
uploadFile: '<onFileSelected'
}
});

View File

@@ -1,14 +1,14 @@
<div class="datatable">
<rd-widget>
<rd-widget-header icon="{{$ctrl.titleIcon}}" title-text="{{ $ctrl.titleText }}">
<file-uploader ng-if="$ctrl.isUploadAllowed" on-file-selected="$ctrl.onFileSelectedForUpload">
</file-uploader>
</rd-widget-header>
<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>
<input type="text" class="searchInput" ng-model="$ctrl.state.textFilter"
placeholder="Search..." auto-focus>
</div>
<div class="table-responsive">
<table class="table">
@@ -41,23 +41,29 @@
</tr>
</thead>
<tbody>
<tr ng-if="$ctrl.volumeBrowser.state.path !== '/'">
<tr ng-if="!$ctrl.isRoot">
<td colspan="4">
<a ng-click="$ctrl.volumeBrowser.up()"><i class="fa fa-level-up-alt space-right"></i>Go to parent</a>
<a ng-click="$ctrl.goToParent()"><i class="fa fa-level-up-alt space-right"></i>Go
to parent</a>
</td>
</tr>
<tr ng-repeat="item in ($ctrl.state.filteredDataSet = ($ctrl.dataset | filter:$ctrl.state.textFilter | orderBy:$ctrl.state.orderBy:$ctrl.state.reverseOrder))">
<td>
<span ng-if="item.edit">
<input class="input-sm" type="text" ng-model="item.newName" on-enter-key="$ctrl.volumeBrowser.rename(item.Name, item.newName); item.edit = false;" auto-focus />
<input class="input-sm" type="text" ng-model="item.newName"
on-enter-key="$ctrl.rename({name: item.Name, newName: item.newName}); item.edit = false;"
auto-focus />
<a class="interactive" ng-click="item.edit = false;"><i class="fa fa-times"></i></a>
<a class="interactive" ng-click="$ctrl.volumeBrowser.rename(item.Name, item.newName); item.edit = false;"><i class="fa fa-check-square"></i></a>
<a class="interactive" ng-click="$ctrl.rename({name: item.Name, newName: item.newName}); item.edit = false;"><i
class="fa fa-check-square"></i></a>
</span>
<span ng-if="!item.edit && item.Dir">
<a ng-click="$ctrl.volumeBrowser.browse(item.Name)"><i class="fa fa-folder space-right" aria-hidden="true"></i>{{ item.Name }}</a>
<a ng-click="$ctrl.browse({name: item.Name})"><i class="fa fa-folder space-right"
aria-hidden="true"></i>{{ item.Name }}</a>
</span>
<span ng-if="!item.edit && !item.Dir">
<i class="fa fa-file space-right" aria-hidden="true"></i>{{ item.Name }}
<i class="fa fa-file space-right" aria-hidden="true"></i>{{
item.Name }}
</span>
</td>
<td>{{ item.Size | humansize }}</td>
@@ -65,13 +71,14 @@
{{ item.ModTime | getisodatefromtimestamp }}
</td>
<td>
<btn class="btn btn-xs btn-primary space-right" ng-click="$ctrl.volumeBrowser.download(item.Name)" ng-if="!item.Dir">
<btn class="btn btn-xs btn-primary space-right" ng-click="$ctrl.download({ name: item.Name })"
ng-if="!item.Dir">
<i class="fa fa-download" aria-hidden="true"></i> Download
</btn>
<btn class="btn btn-xs btn-primary space-right" ng-click="item.newName = item.Name; item.edit = true">
<i class="fa fa-edit" aria-hidden="true"></i> Rename
</btn>
<btn class="btn btn-xs btn-danger" ng-click="$ctrl.volumeBrowser.delete(item.Name)">
<btn class="btn btn-xs btn-danger" ng-click="$ctrl.delete({ name: item.Name })">
<i class="fa fa-trash" aria-hidden="true"></i> Delete
</btn>
</td>
@@ -87,4 +94,4 @@
</div>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,22 @@
angular.module('portainer.agent').component('filesDatatable', {
templateUrl: 'app/agent/components/files-datatable/files-datatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
isRoot: '<',
goToParent: '&',
browse: '&',
rename: '&',
download: '&',
delete: '&',
isUploadAllowed: '<',
onFileSelectedForUpload: '<'
}
});

View File

@@ -0,0 +1,147 @@
angular.module('portainer.agent').controller('HostBrowserController', [
'HostBrowserService', 'Notifications', 'FileSaver', 'ModalService',
function HostBrowserController(HostBrowserService, Notifications, FileSaver, ModalService) {
var ctrl = this;
var ROOT_PATH = '/host';
ctrl.state = {
path: ROOT_PATH
};
ctrl.goToParent = goToParent;
ctrl.browse = browse;
ctrl.renameFile = renameFile;
ctrl.downloadFile = downloadFile;
ctrl.deleteFile = confirmDeleteFile;
ctrl.isRoot = isRoot;
ctrl.onFileSelectedForUpload = onFileSelectedForUpload;
ctrl.$onInit = $onInit;
ctrl.getRelativePath = getRelativePath;
function getRelativePath(path) {
path = path || ctrl.state.path;
var rootPathRegex = new RegExp('^' + ROOT_PATH + '\/?');
var relativePath = path.replace(rootPathRegex, '/');
return relativePath;
}
function goToParent() {
getFilesForPath(parentPath(this.state.path));
}
function isRoot() {
return ctrl.state.path === ROOT_PATH;
}
function browse(folder) {
getFilesForPath(buildPath(ctrl.state.path, folder));
}
function getFilesForPath(path) {
HostBrowserService.ls(path)
.then(function onFilesLoaded(files) {
ctrl.state.path = path;
ctrl.files = files;
})
.catch(function onLoadingFailed(err) {
Notifications.error('Failure', err, 'Unable to browse');
});
}
function renameFile(name, newName) {
var filePath = buildPath(ctrl.state.path, name);
var newFilePath = buildPath(ctrl.state.path, newName);
HostBrowserService.rename(filePath, newFilePath)
.then(function onRenameSuccess() {
Notifications.success('File successfully renamed', getRelativePath(newFilePath));
return HostBrowserService.ls(ctrl.state.path);
})
.then(function onFilesLoaded(files) {
ctrl.files = files;
})
.catch(function notifyOnError(err) {
Notifications.error('Failure', err, 'Unable to rename file');
});
}
function downloadFile(file) {
var filePath = buildPath(ctrl.state.path, file);
HostBrowserService.get(filePath)
.then(function onFileReceived(data) {
var downloadData = new Blob([data.file], {
type: 'text/plain;charset=utf-8'
});
FileSaver.saveAs(downloadData, file);
})
.catch(function notifyOnError(err) {
Notifications.error('Failure', err, 'Unable to download file');
});
}
function confirmDeleteFile(name) {
var filePath = buildPath(ctrl.state.path, name);
ModalService.confirmDeletion(
'Are you sure that you want to delete ' + getRelativePath(filePath) + ' ?',
function onConfirm(confirmed) {
if (!confirmed) {
return;
}
return deleteFile(filePath);
}
);
}
function deleteFile(path) {
HostBrowserService.delete(path)
.then(function onDeleteSuccess() {
Notifications.success('File successfully deleted', getRelativePath(path));
return HostBrowserService.ls(ctrl.state.path);
})
.then(function onFilesLoaded(data) {
ctrl.files = data;
})
.catch(function notifyOnError(err) {
Notifications.error('Failure', err, 'Unable to delete file');
});
}
function $onInit() {
getFilesForPath(ROOT_PATH);
}
function parentPath(path) {
if (path === ROOT_PATH) {
return ROOT_PATH;
}
var split = _.split(path, '/');
return _.join(_.slice(split, 0, split.length - 1), '/');
}
function buildPath(parent, file) {
if (parent.lastIndexOf('/') === parent.length - 1) {
return parent + file;
}
return parent + '/' + file;
}
function onFileSelectedForUpload(file) {
HostBrowserService.upload(ctrl.state.path, file)
.then(function onFileUpload() {
onFileUploaded();
})
.catch(function onFileUpload(err) {
Notifications.error('Failure', err, 'Unable to upload file');
});
}
function onFileUploaded() {
refreshList();
}
function refreshList() {
getFilesForPath(ctrl.state.path);
}
}
]);

View File

@@ -0,0 +1,16 @@
<files-datatable
title-text="Host browser - {{$ctrl.getRelativePath()}}" title-icon="fa-file"
dataset="$ctrl.files" table-key="host_browser"
order-by="Dir"
is-root="$ctrl.isRoot()"
go-to-parent="$ctrl.goToParent()"
browse="$ctrl.browse(name)"
rename="$ctrl.renameFile(name, newName)"
download="$ctrl.downloadFile(name)"
delete="$ctrl.deleteFile(name)"
is-upload-allowed="true"
on-file-selected-for-upload="$ctrl.onFileSelectedForUpload"
>
</files-datatable>

View File

@@ -0,0 +1,5 @@
angular.module('portainer.agent').component('hostBrowser', {
controller: 'HostBrowserController',
templateUrl: 'app/agent/components/host-browser/host-browser.html',
bindings: {}
});

View File

@@ -1,15 +0,0 @@
angular.module('portainer.agent').component('volumeBrowserDatatable', {
templateUrl: 'app/agent/components/volume-browser/volume-browser-datatable/volumeBrowserDatatable.html',
controller: 'GenericDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<'
},
require: {
volumeBrowser: '^^volumeBrowser'
}
});

View File

@@ -1,5 +1,11 @@
<volume-browser-datatable
<files-datatable
title-text="Volume browser" title-icon="fa-file"
dataset="$ctrl.files" table-key="volume_browser"
order-by="Dir"
></volume-browser-datatable>
is-root="$ctrl.state.path === '/'"
go-to-parent="$ctrl.up()"
browse="$ctrl.browse(name)"
rename="$ctrl.rename(name, newName)"
download="$ctrl.download(name)"
delete="$ctrl.delete(name)"
></files-datatable>

View File

@@ -1,22 +1,22 @@
angular.module('portainer.agent')
.factory('Browse', ['$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', function BrowseFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:id/:action', {
return $resource(API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/browse/:action', {
endpointId: EndpointProvider.endpointID
},
{
ls: {
method: 'GET', isArray: true, params: { id: '@id', action: 'ls' }
method: 'GET', isArray: true, params: { action: 'ls' }
},
get: {
method: 'GET', params: { id: '@id', action: 'get' },
method: 'GET', params: { action: 'get' },
transformResponse: browseGetResponse
},
delete: {
method: 'DELETE', params: { id: '@id', action: 'delete' }
method: 'DELETE', params: { action: 'delete' }
},
rename: {
method: 'PUT', params: { id: '@id', action: 'rename' }
method: 'PUT', params: { action: 'rename' }
}
});
}]);

15
app/agent/rest/host.js Normal file
View File

@@ -0,0 +1,15 @@
angular.module('portainer.agent').factory('Host', [
'$resource', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider',
function AgentFactory($resource, API_ENDPOINT_ENDPOINTS, EndpointProvider) {
'use strict';
return $resource(
API_ENDPOINT_ENDPOINTS + '/:endpointId/docker/host/:action',
{
endpointId: EndpointProvider.endpointID
},
{
info: { method: 'GET', params: { action: 'info' } }
}
);
}
]);

View File

@@ -1,24 +1,34 @@
angular.module('portainer.agent')
.factory('AgentService', ['$q', 'Agent', function AgentServiceFactory($q, Agent) {
'use strict';
var service = {};
angular.module('portainer.agent').factory('AgentService', [
'$q', 'Agent','HttpRequestHelper', 'Host',
function AgentServiceFactory($q, Agent, HttpRequestHelper, Host) {
'use strict';
var service = {};
service.agents = function() {
var deferred = $q.defer();
service.agents = agents;
service.hostInfo = hostInfo;
Agent.query({}).$promise
.then(function success(data) {
var agents = data.map(function (item) {
return new AgentViewModel(item);
});
deferred.resolve(agents);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve agents', err: err });
});
function hostInfo(nodeName) {
HttpRequestHelper.setPortainerAgentTargetHeader(nodeName);
return Host.info().$promise;
}
return deferred.promise;
};
function agents() {
var deferred = $q.defer();
return service;
}]);
Agent.query({})
.$promise.then(function success(data) {
var agents = data.map(function(item) {
return new AgentViewModel(item);
});
deferred.resolve(agents);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve agents', err: err });
});
return deferred.promise;
}
return service;
}
]);

View File

@@ -0,0 +1,44 @@
angular.module('portainer.agent').factory('HostBrowserService', [
'Browse', 'Upload', 'API_ENDPOINT_ENDPOINTS', 'EndpointProvider', '$q',
function HostBrowserServiceFactory(Browse, Upload, API_ENDPOINT_ENDPOINTS, EndpointProvider, $q) {
var service = {};
service.ls = ls;
service.get = get;
service.delete = deletePath;
service.rename = rename;
service.upload = upload;
function ls(path) {
return Browse.ls({ path: path }).$promise;
}
function get(path) {
return Browse.get({ path: path }).$promise;
}
function deletePath(path) {
return Browse.delete({ path: path }).$promise;
}
function rename(path, newPath) {
var payload = {
CurrentFilePath: path,
NewFilePath: newPath
};
return Browse.rename({}, payload).$promise;
}
function upload(path, file, onProgress) {
var deferred = $q.defer();
var url = API_ENDPOINT_ENDPOINTS + '/' + EndpointProvider.endpointID() + '/docker/browse/put';
Upload.upload({
url: url,
data: { file: file, Path: path }
}).then(deferred.resolve, deferred.reject, onProgress);
return deferred.promise;
}
return service;
}
]);

View File

@@ -1,27 +1,29 @@
angular.module('portainer.agent')
.factory('VolumeBrowserService', ['$q', 'Browse', function VolumeBrowserServiceFactory($q, Browse) {
'use strict';
var service = {};
angular.module('portainer.agent').factory('VolumeBrowserService', [
'$q', 'Browse',
function VolumeBrowserServiceFactory($q, Browse) {
'use strict';
var service = {};
service.ls = function(volumeId, path) {
return Browse.ls({ 'id': volumeId, 'path': path }).$promise;
};
service.get = function(volumeId, path) {
return Browse.get({ 'id': volumeId, 'path': path }).$promise;
};
service.delete = function(volumeId, path) {
return Browse.delete({ 'id': volumeId, 'path': path }).$promise;
};
service.rename = function(volumeId, path, newPath) {
var payload = {
CurrentFilePath: path,
NewFilePath: newPath
service.ls = function(volumeId, path) {
return Browse.ls({ volumeID: volumeId, path: path }).$promise;
};
return Browse.rename({ 'id': volumeId }, payload).$promise;
};
return service;
}]);
service.get = function(volumeId, path) {
return Browse.get({ volumeID: volumeId, path: path }).$promise;
};
service.delete = function(volumeId, path) {
return Browse.delete({ volumeID: volumeId, path: path }).$promise;
};
service.rename = function(volumeId, path, newPath) {
var payload = {
CurrentFilePath: path,
NewFilePath: newPath
};
return Browse.rename({ volumeID: volumeId }, payload).$promise;
};
return service;
}
]);

View File

@@ -10,6 +10,7 @@ angular.module('portainer')
.constant('API_ENDPOINT_STACKS', 'api/stacks')
.constant('API_ENDPOINT_STATUS', 'api/status')
.constant('API_ENDPOINT_USERS', 'api/users')
.constant('API_ENDPOINT_DEPLOYKEYS', 'api/deploykeys')
.constant('API_ENDPOINT_TAGS', 'api/tags')
.constant('API_ENDPOINT_TEAMS', 'api/teams')
.constant('API_ENDPOINT_TEAM_MEMBERSHIPS', 'api/team_memberships')

View File

@@ -129,13 +129,22 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
var engine = {
name: 'docker.engine',
url: '/engine',
var host = {
name: 'docker.host',
url: '/host',
views: {
'content@': {
templateUrl: 'app/docker/views/engine/engine.html',
controller: 'EngineController'
component: 'hostView'
}
}
};
var hostBrowser = {
name: 'docker.host.browser',
url: '/browser',
views: {
'content@': {
component: 'hostBrowserView'
}
}
};
@@ -239,8 +248,17 @@ angular.module('portainer.docker', ['portainer.app'])
url: '/:id',
views: {
'content@': {
templateUrl: 'app/docker/views/nodes/edit/node.html',
controller: 'NodeController'
component: 'nodeDetailsView'
}
}
};
var nodeBrowser = {
name: 'docker.nodes.node.browse',
url: '/browse',
views: {
'content@': {
component: 'nodeBrowserView'
}
}
};
@@ -416,6 +434,8 @@ angular.module('portainer.docker', ['portainer.app'])
}
};
$stateRegistryProvider.register(configs);
$stateRegistryProvider.register(config);
$stateRegistryProvider.register(configCreation);
@@ -428,7 +448,8 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(containerStats);
$stateRegistryProvider.register(docker);
$stateRegistryProvider.register(dashboard);
$stateRegistryProvider.register(engine);
$stateRegistryProvider.register(host);
$stateRegistryProvider.register(hostBrowser);
$stateRegistryProvider.register(events);
$stateRegistryProvider.register(images);
$stateRegistryProvider.register(image);
@@ -439,6 +460,7 @@ angular.module('portainer.docker', ['portainer.app'])
$stateRegistryProvider.register(networkCreation);
$stateRegistryProvider.register(nodes);
$stateRegistryProvider.register(node);
$stateRegistryProvider.register(nodeBrowser);
$stateRegistryProvider.register(secrets);
$stateRegistryProvider.register(secret);
$stateRegistryProvider.register(secretCreation);

View File

@@ -13,7 +13,7 @@
<div class="col-sm-5 col-lg-4">
<select class="form-control" ng-model="$ctrl.selectedNetwork" id="container_network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in $ctrl.availableNetworks" ng-value="net.Id">{{ net.Name }}</option>
<option ng-repeat="net in $ctrl.availableNetworks | orderBy: 'Name'" ng-value="net.Id">{{ net.Name }}</option>
</select>
</div>
<div class="col-sm-1">

View File

@@ -72,6 +72,7 @@ function ($state, ContainerService, ModalService, Notifications, HttpRequestHelp
Notifications.success(successMessage, container.Names[0]);
})
.catch(function error(err) {
errorMessage = errorMessage + ':' + container.Names[0];
Notifications.error('Failure', err, errorMessage);
})
.finally(function final() {

View File

@@ -68,8 +68,8 @@
<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>
<a ui-sref="docker.nodes.node({id: item.Id})" ng-if="$ctrl.accessToNodeDetails">{{ item.Hostname }}</a>
<span ng-if="!$ctrl.accessToNodeDetails">{{ item.Hostname }}</span>
<a ui-sref="docker.nodes.node({id: item.Id})" ng-if="$ctrl.accessToNodeDetails">{{ item.Name || item.Hostname }}</a>
<span ng-if="!$ctrl.accessToNodeDetails">{{ item.Name || item.Hostname }}</span>
</td>
<td>{{ item.Role }}</td>
<td>{{ item.CPUs / 1000000000 }}</td>

View File

@@ -1,9 +1,9 @@
angular.module('portainer.docker').component('dockerSidebarContent', {
templateUrl: 'app/docker/components/dockerSidebarContent/dockerSidebarContent.html',
bindings: {
'endpointApiVersion': '<',
'swarmManagement': '<',
'standaloneManagement': '<',
'adminAccess': '<'
endpointApiVersion: '<',
swarmManagement: '<',
standaloneManagement: '<',
adminAccess: '<'
}
});

View File

@@ -35,5 +35,5 @@
<a ui-sref="docker.swarm" ui-sref-active="active">Swarm <span class="menu-icon fa fa-object-group fa-fw"></span></a>
</li>
<li class="sidebar-list" ng-if="$ctrl.standaloneManagement">
<a ui-sref="docker.engine" ui-sref-active="active">Engine <span class="menu-icon fa fa-th fa-fw"></span></a>
<a ui-sref="docker.host" ui-sref-active="active">Host <span class="menu-icon fa fa-th fa-fw"></span></a>
</li>

View File

@@ -0,0 +1,21 @@
<rd-header>
<rd-header-title title-text="Host overview">
<a data-toggle="tooltip" title="Refresh" ui-sref="{{$ctrl.refreshUrl}}"
ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Docker</rd-header-content>
</rd-header>
<host-details-panel
host="$ctrl.hostDetails"
is-agent="$ctrl.isAgent"
browse-url="{{$ctrl.browseUrl}}"></host-details-panel>
<engine-details-panel engine="$ctrl.engineDetails"></engine-details-panel>
<devices-panel ng-if="$ctrl.isAgent" devices="$ctrl.devices"></devices-panel>
<disks-panel ng-if="$ctrl.isAgent" disks="$ctrl.disks"></disks-panel>
<ng-transclude></ng-transclude>

View File

@@ -0,0 +1,13 @@
angular.module('portainer.docker').component('hostOverview', {
templateUrl: 'app/docker/components/host-overview/host-overview.html',
bindings: {
hostDetails: '<',
engineDetails: '<',
devices: '<',
disks: '<',
isAgent: '<',
refreshUrl: '@',
browseUrl: '@'
},
transclude: true
});

View File

@@ -0,0 +1,31 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-code" title-text="PCI Devices"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Vendor</th>
</tr>
</thead>
<tbody>
<tr ng-if="$ctrl.devices && $ctrl.devices.length" ng-repeat="device in $ctrl.devices">
<td>{{device.Name}}</td>
<td>{{device.Vendor}}</td>
</tr>
<tr ng-if="!$ctrl.devices">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.devices.length === 0">
<td colspan="2" class="text-center text-muted">
No device available.
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,7 @@
angular.module('portainer.docker').component('devicesPanel', {
templateUrl:
'app/docker/components/host-view-panels/devices-panel/devices-panel.html',
bindings: {
devices: '<'
}
});

View File

@@ -0,0 +1,31 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-code" title-text="Physical Disks"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<thead>
<tr>
<th>Vendor</th>
<th>Size</th>
</tr>
</thead>
<tbody>
<tr ng-if="$ctrl.disks" ng-repeat="disk in $ctrl.disks">
<td>{{disk.Vendor}}</td>
<td>{{disk.Size | humansize}}</td>
</tr>
<tr ng-if="!$ctrl.disks">
<td colspan="2" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.disks.length === 0">
<td colspan="2" class="text-center text-muted">
No disks available.
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,7 @@
angular.module('portainer.docker').component('disksPanel', {
templateUrl:
'app/docker/components/host-view-panels/disks-panel/disks-panel.html',
bindings: {
disks: '<'
}
});

View File

@@ -0,0 +1,37 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-code" title-text="Engine Details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ $ctrl.engine.releaseVersion }} <span ng-if="$ctrl.engine.apiVersion">(API: {{ $ctrl.engine.apiVersion }})</span></td>
</tr>
<tr ng-if="$ctrl.engine.rootDirectory">
<td>Root directory</td>
<td>{{ $ctrl.engine.rootDirectory }}</td>
</tr>
<tr ng-if="$ctrl.engine.storageDriver">
<td>Storage Driver</td>
<td>{{ $ctrl.engine.storageDriver }}</td>
</tr>
<tr ng-if="$ctrl.engine.loggingDriver">
<td>Logging Driver</td>
<td>{{ $ctrl.engine.loggingDriver }}</td>
</tr>
<tr>
<td>Volume Plugins</td>
<td>{{ $ctrl.engine.volumePlugins | arraytostr: ', ' }}</td>
</tr>
<tr>
<td>Network Plugins</td>
<td>{{ $ctrl.engine.networkPlugins | arraytostr: ', ' }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,7 @@
angular.module('portainer.docker').component('engineDetailsPanel', {
templateUrl:
'app/docker/components/host-view-panels/engine-details-panel/engine-details-panel.html',
bindings: {
engine: '<'
}
});

View File

@@ -0,0 +1,45 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-code" title-text="Host Details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Hostname</td>
<td>{{ $ctrl.host.name }}</td>
</tr>
<tr ng-if="$ctrl.host.os">
<td>OS Information</td>
<td>{{ $ctrl.host.os.type }} {{$ctrl.host.os.arch}}
{{$ctrl.host.os.name}}</td>
</tr>
<tr ng-if="$ctrl.host.kernelVersion">
<td>Kernel Version</td>
<td>{{ $ctrl.host.kernelVersion }}</td>
</tr>
<tr>
<td>Total CPU</td>
<td>{{ $ctrl.host.totalCPU }}</td>
</tr>
<tr>
<td>Total memory</td>
<td>{{ $ctrl.host.totalMemory | humansize }}</td>
</tr>
<tr ng-if="$ctrl.isAgent">
<td colspan="2">
<button
class="btn btn-primary btn-sm"
title="Browse"
ui-sref="{{$ctrl.browseUrl}}">
Browse
</button>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,9 @@
angular.module('portainer.docker').component('hostDetailsPanel', {
templateUrl:
'app/docker/components/host-view-panels/host-details-panel/host-details-panel.html',
bindings: {
host: '<',
isAgent: '<',
browseUrl: '@'
}
});

View File

@@ -0,0 +1,11 @@
angular
.module('portainer.docker')
.controller('NodeAvailabilitySelectController', [
function NodeAvailabilitySelectController() {
this.onChange = onChange;
function onChange() {
this.onSave({ availability: this.availability });
}
}
]);

View File

@@ -0,0 +1,8 @@
<div class="input-group input-group-sm">
<select name="nodeAvailability" class="selectpicker form-control" ng-model="$ctrl.availability"
ng-change="$ctrl.onChange()">
<option value="active">Active</option>
<option value="pause">Pause</option>
<option value="drain">Drain</option>
</select>
</div>

View File

@@ -0,0 +1,10 @@
angular.module('portainer.docker').component('nodeAvailabilitySelect', {
templateUrl:
'app/docker/components/host-view-panels/node-availability-select/node-availability-select.html',
controller: 'NodeAvailabilitySelectController',
bindings: {
availability: '<',
originalValue: '<',
onSave: '&'
}
});

View File

@@ -0,0 +1,23 @@
angular.module('portainer.docker').controller('NodeLabelsTableController', [
function NodeLabelsTableController() {
var ctrl = this;
ctrl.removeLabel = removeLabel;
ctrl.updateLabel = updateLabel;
function removeLabel(index) {
var label = ctrl.labels.splice(index, 1);
if (label !== null) {
ctrl.onChangedLabels({ labels: ctrl.labels });
}
}
function updateLabel(label) {
if (
label.value !== label.originalValue ||
label.key !== label.originalKey
) {
ctrl.onChangedLabels({ labels: ctrl.labels });
}
}
}
]);

View File

@@ -0,0 +1,35 @@
<div ng-if="!$ctrl.labels.length">
There are no labels for this node.
</div>
<table class="table" ng-if="$ctrl.labels.length">
<thead>
<tr>
<th>Label</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="label in $ctrl.labels">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">Name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo"
ng-change="$ctrl.updateLabel(label)">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">Value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar"
ng-change="$ctrl.updateLabel(label)">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="$ctrl.removeLabel($index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>

View File

@@ -0,0 +1,9 @@
angular.module('portainer.docker').component('nodeLabelsTable', {
templateUrl:
'app/docker/components/host-view-panels/node-labels-table/node-labels-table.html',
controller: 'NodeLabelsTableController',
bindings: {
labels: '<',
onChangedLabels: '&'
}
});

View File

@@ -0,0 +1,96 @@
angular
.module('portainer.docker')
.controller('SwarmNodeDetailsPanelController', [
'NodeService', 'LabelHelper', 'Notifications', '$state',
function SwarmNodeDetailsPanelController(NodeService, LabelHelper, Notifications, $state) {
var ctrl = this;
ctrl.state = {
managerAddress: '',
hasChanges: false
};
ctrl.$onChanges = $onChanges;
ctrl.addLabel = addLabel;
ctrl.updateNodeLabels = updateNodeLabels;
ctrl.updateNodeAvailability = updateNodeAvailability;
ctrl.saveChanges = saveChanges;
ctrl.cancelChanges = cancelChanges;
var managerRole = 'manager';
function $onChanges() {
if (!ctrl.details) {
return;
}
if (ctrl.details.role === managerRole) {
ctrl.state.managerAddress = '(' + ctrl.details.managerAddress + ')';
}
}
function addLabel() {
ctrl.details.nodeLabels.push({
key: '',
value: '',
originalValue: '',
originalKey: ''
});
}
function updateNodeLabels(labels) {
ctrl.details.nodeLabels = labels;
ctrl.state.hasChanges = true;
}
function updateNodeAvailability(availability) {
ctrl.details.availability = availability;
ctrl.state.hasChanges = true;
}
function saveChanges() {
var originalNode = ctrl.originalNode;
var config = {
Name: originalNode.Name,
Availability: ctrl.details.availability,
Role: originalNode.Role,
Labels: LabelHelper.fromKeyValueToLabelHash(ctrl.details.nodeLabels),
Id: originalNode.Id,
Version: originalNode.Version
};
NodeService.updateNode(config)
.then(onUpdateSuccess)
.catch(notifyOnError);
function onUpdateSuccess() {
Notifications.success('Node successfully updated', 'Node updated');
$state.go(
'docker.nodes.node',
{ id: originalNode.Id },
{ reload: true }
);
}
function notifyOnError(error) {
Notifications.error('Failure', error, 'Failed to update node');
}
}
function cancelChanges() {
cancelLabelChanges();
ctrl.details.availability = ctrl.originalNode.Availability;
ctrl.state.hasChanges = false;
}
function cancelLabelChanges() {
ctrl.details.nodeLabels = ctrl.details.nodeLabels
.filter(function(label) {
return label.originalValue || label.originalKey;
})
.map(function(label) {
return Object.assign(label, {
value: label.originalValue,
key: label.originalKey
});
});
}
}
]);

View File

@@ -0,0 +1,75 @@
<div class="row">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-code" title-text="Node Details"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr ng-if="$ctrl.details.name">
<td>Node name</td>
<td>{{ $ctrl.details.name }}</td>
</tr>
<tr>
<td>Role</td>
<td>{{ $ctrl.details.role }} {{ $ctrl.state.managerAddress }}</td>
</tr>
<tr>
<td>Availability</td>
<td>
<node-availability-select on-save="$ctrl.updateNodeAvailability(availability)"
availability="$ctrl.details.availability" original-value="$ctrl.details.availability">
</node-availability-select>
</td>
</tr>
<tr>
<td>Status</td>
<td><span class="label label-{{ $ctrl.details.status | nodestatusbadge }}">{{
$ctrl.details.status }}</span></td>
</tr>
<tr ng-if=" $ctrl.details.engineLabels.length">
<td>Engine Labels</td>
<td>{{ $ctrl.details.engineLabels | arraytostr:', ' }}</td>
</tr>
<tr>
<td>
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="$ctrl.addLabel(node)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
</a>
</div>
Node Labels
</td>
<td></td>
</tr>
<tr>
<td colspan="2">
<node-labels-table labels="$ctrl.details.nodeLabels"
on-changed-labels="$ctrl.updateNodeLabels(labels)"></node-labels-table>
</td>
</tr>
<tr>
<td>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm"
ng-disabled="!$ctrl.state.hasChanges" ng-click="$ctrl.saveChanges()">
Apply changes
</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle"
data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="$ctrl.cancelChanges()">Reset changes</a></li>
</ul>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -0,0 +1,9 @@
angular.module('portainer.docker').component('swarmNodeDetailsPanel', {
templateUrl:
'app/docker/components/host-view-panels/swarm-node-details-panel/swarm-node-details-panel.html',
controller: 'SwarmNodeDetailsPanelController',
bindings: {
details: '<',
originalNode: '<'
}
});

View File

@@ -1,24 +1,48 @@
angular.module('portainer.docker')
.factory('NodeService', ['$q', 'Node', function NodeServiceFactory($q, Node) {
'use strict';
var service = {};
angular.module('portainer.docker').factory('NodeService', [
'$q', 'Node',
function NodeServiceFactory($q, Node) {
'use strict';
var service = {};
service.nodes = function() {
var deferred = $q.defer();
service.nodes = nodes;
service.node = node;
service.updateNode = updateNode;
Node.query({}).$promise
.then(function success(data) {
var nodes = data.map(function (item) {
return new NodeViewModel(item);
});
deferred.resolve(nodes);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve nodes', err: err });
});
function node(id) {
var deferred = $q.defer();
Node.get({ id: id })
.$promise.then(function onNodeLoaded(rawNode) {
var node = new NodeViewModel(rawNode);
return deferred.resolve(node);
})
.catch(function onFailed(err) {
deferred.reject({ msg: 'Unable to retrieve node', err: err });
});
return deferred.promise;
};
return deferred.promise;
}
return service;
}]);
function nodes() {
var deferred = $q.defer();
Node.query({})
.$promise.then(function success(data) {
var nodes = data.map(function(item) {
return new NodeViewModel(item);
});
deferred.resolve(nodes);
})
.catch(function error(err) {
deferred.reject({ msg: 'Unable to retrieve nodes', err: err });
});
return deferred.promise;
}
function updateNode(node) {
return Node.update({ id: node.Id, version: node.Version }, node).$promise;
}
return service;
}
]);

View File

@@ -544,14 +544,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai
SystemService.info()
.then(function success(data) {
var runtimes = data.Runtimes;
$scope.availableRuntimes = runtimes;
if ('runc' in runtimes) {
$scope.config.HostConfig.Runtime = 'runc';
}
else if (Object.keys(runtimes).length !== 0) {
$scope.config.HostConfig.Runtime = Object.keys(runtimes)[0];
}
$scope.availableRuntimes = Object.keys(data.Runtimes);
$scope.config.HostConfig.Runtime = '';
$scope.state.sliderMaxCpu = 32;
if (data.NCPU) {
$scope.state.sliderMaxCpu = data.NCPU;

View File

@@ -309,7 +309,7 @@
<div class="col-sm-9">
<select class="form-control" ng-model="config.HostConfig.NetworkMode" id="container_network" ng-change="resetNetworkConfig()">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
<option ng-repeat="net in availableNetworks | orderBy: 'Name'" ng-value="net.Name">{{ net.Name }}</option>
</select>
</div>
</div>
@@ -503,11 +503,11 @@
<!-- !privileged-mode -->
<!-- runtimes -->
<div class="form-group">
<label for="container_runtime" class="col-sm-2 col-lg-1 control-label text-left">Runtime</label>
<div class="col-sm-1">
<select class="form-control" ng-model="config.HostConfig.Runtime" id="container_runtime">
<option selected disabled hidden value="">Select a runtime</option>
<option ng-repeat="(runtime, _) in availableRuntimes" ng-value="runtime">{{ runtime }}</option>
<label for="container_runtime" class="col-sm-1 control-label text-left">Runtime</label>
<div class="col-sm-11">
<select class="form-control" ng-model="config.HostConfig.Runtime"
id="container_runtime" ng-options="runtime for runtime in availableRuntimes">
<option selected value="">Default</option>
</select>
</div>
</div>

View File

@@ -1,118 +0,0 @@
<rd-header>
<rd-header-title title-text="Engine overview">
<a data-toggle="tooltip" title="Refresh" ui-sref="docker.engine" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Docker</rd-header-content>
</rd-header>
<div class="row" ng-if="info && version">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-code" title-text="Engine version"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Version</td>
<td>{{ version.Version }}</td>
</tr>
<tr>
<td>API version</td>
<td>{{ version.ApiVersion }}</td>
</tr>
<tr>
<td>Go version</td>
<td>{{ version.GoVersion }}</td>
</tr>
<tr>
<td>OS type</td>
<td>{{ version.Os }}</td>
</tr>
<tr>
<td>OS</td>
<td>{{ info.OperatingSystem }}</td>
</tr>
<tr>
<td>Architecture</td>
<td>{{ version.Arch }}</td>
</tr>
<tr>
<td>Kernel version</td>
<td>{{ version.KernelVersion }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="info && version">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-th" title-text="Engine status"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Total CPU</td>
<td>{{ info.NCPU }}</td>
</tr>
<tr>
<td>Total memory</td>
<td>{{ info.MemTotal|humansize }}</td>
</tr>
<tr>
<td>Docker root directory</td>
<td>{{ info.DockerRootDir }}</td>
</tr>
<tr>
<td>Storage driver</td>
<td>{{ info.Driver }}</td>
</tr>
<tr>
<td>Logging driver</td>
<td>{{ info.LoggingDriver }}</td>
</tr>
<tr ng-if="info.CgroupDriver">
<td>Cgroup driver</td>
<td>{{ info.CgroupDriver }}</td>
</tr>
<tr ng-if="info.ExecutionDriver">
<td>Execution driver</td>
<td>{{ info.ExecutionDriver }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="info && info.Plugins">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-plug" title-text="Engine plugins"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr ng-if="info.Plugins.Volume">
<td>Volume</td>
<td>{{ info.Plugins.Volume|arraytostr: ', '}}</td>
</tr>
<tr ng-if="info.Plugins.Network">
<td>Network</td>
<td>{{ info.Plugins.Network|arraytostr: ', '}}</td>
</tr>
<tr ng-if="info.Plugins.Authorization">
<td>Authorization</td>
<td>{{ info.Plugins.Authorization|arraytostr: ', '}}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@@ -1,22 +0,0 @@
angular.module('portainer.docker')
.controller('EngineController', ['$q', '$scope', 'SystemService', 'Notifications',
function ($q, $scope, SystemService, Notifications) {
function initView() {
$q.all({
version: SystemService.version(),
info: SystemService.info()
})
.then(function success(data) {
$scope.version = data.version;
$scope.info = data.info;
})
.catch(function error(err) {
$scope.info = {};
$scope.version = {};
Notifications.error('Failure', err, 'Unable to retrieve engine details');
});
}
initView();
}]);

View File

@@ -0,0 +1,21 @@
angular
.module('portainer.docker')
.controller('HostBrowserViewController', [
'SystemService', 'HttpRequestHelper',
function HostBrowserViewController(SystemService, HttpRequestHelper) {
var ctrl = this;
ctrl.$onInit = $onInit;
function $onInit() {
loadInfo();
}
function loadInfo() {
SystemService.info().then(function onInfoLoaded(host) {
HttpRequestHelper.setPortainerAgentTargetHeader(host.Name);
ctrl.host = host;
});
}
}
]);

View File

@@ -0,0 +1,14 @@
<rd-header>
<rd-header-title title-text="Host Browser"></rd-header-title>
<rd-header-content>
Host &gt; <a ui-sref="docker.host">{{ $ctrl.host.Name }}</a> &gt; browse
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<host-browser
ng-if="$ctrl.host"
></host-browser>
</div>
</div>

View File

@@ -0,0 +1,4 @@
angular.module('portainer.docker').component('hostBrowserView', {
templateUrl: 'app/docker/views/host/host-browser-view/host-browser-view.html',
controller: 'HostBrowserViewController'
});

View File

@@ -0,0 +1,70 @@
angular.module('portainer.docker').controller('HostViewController', [
'$q', 'SystemService', 'Notifications', 'StateManager', 'AgentService',
function HostViewController($q, SystemService, Notifications, StateManager, AgentService) {
var ctrl = this;
this.$onInit = initView;
ctrl.state = {
isAgent: false
};
this.engineDetails = {};
this.hostDetails = {};
function initView() {
var applicationState = StateManager.getState();
ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy;
$q.all({
version: SystemService.version(),
info: SystemService.info()
})
.then(function success(data) {
ctrl.engineDetails = buildEngineDetails(data);
ctrl.hostDetails = buildHostDetails(data.info);
if (ctrl.state.isAgent) {
return AgentService.hostInfo(data.info.Hostname).then(function onHostInfoLoad(agentHostInfo) {
ctrl.devices = agentHostInfo.PCIDevices;
ctrl.disks = agentHostInfo.PhysicalDisks;
});
}
})
.catch(function error(err) {
Notifications.error(
'Failure',
err,
'Unable to retrieve engine details'
);
});
}
function buildEngineDetails(data) {
var versionDetails = data.version;
var info = data.info;
return {
releaseVersion: versionDetails.Version,
apiVersion: versionDetails.ApiVersion,
rootDirectory: info.DockerRootDir,
storageDriver: info.Driver,
loggingDriver: info.LoggingDriver,
volumePlugins: info.Plugins.Volume,
networkPlugins: info.Plugins.Network
};
}
function buildHostDetails(info) {
return {
os: {
arch: info.Architecture,
type: info.OSType,
name: info.OperatingSystem
},
name: info.Name,
kernelVersion: info.KernelVersion,
totalCPU: info.NCPU,
totalMemory: info.MemTotal
};
}
}
]);

View File

@@ -0,0 +1,10 @@
<host-overview
engine-details="$ctrl.engineDetails"
host-details="$ctrl.hostDetails"
is-agent="$ctrl.state.isAgent"
disks="$ctrl.disks"
devices="$ctrl.devices"
refresh-url="docker.host"
browse-url="docker.host.browser"
></host-overview>

View File

@@ -0,0 +1,4 @@
angular.module('portainer.docker').component('hostView', {
templateUrl: 'app/docker/views/host/host-view.html',
controller: 'HostViewController'
});

View File

@@ -1,243 +0,0 @@
<rd-header>
<rd-header-title title-text="Node details">
<a data-toggle="tooltip" title="Refresh" ui-sref="docker.nodes.node({id: node.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>
<a ui-sref="docker.swarm">Swarm nodes</a> &gt; <a ui-sref="docker.nodes.node({id: node.Id})">{{ node.Hostname }}</a>
</rd-header-content>
</rd-header>
<div class="row" ng-if="!node">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<div ng-if="loading">
<i class="fa fa-cog fa-spin"></i> Loading...
</div>
<rd-widget ng-if="!loading">
<rd-widget-header icon="fa-object-group" title-text="Node does not exist"></rd-widget-header>
<rd-widget-body>
<p>It looks like the node you wish to inspect does not exist.</p>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="node">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-object-group" title-text="Node specification"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Name</td>
<td>
<input type="text" class="input-sm" ng-model="node.Name" placeholder="e.g. my-manager" ng-change="updateNodeAttribute(node, 'Name')">
</td>
</tr>
<tr>
<td>Host name</td>
<td>{{ node.Hostname }}</td>
</tr>
<tr>
<td>Role</td>
<td>{{ node.Role }}</td>
</tr>
<tr>
<td>Availability</td>
<td>
<div class="input-group input-group-sm">
<select name="nodeAvailability" class="selectpicker form-control" ng-model="node.Availability" ng-change="updateNodeAttribute(node, 'Availability')">
<option value="active">Active</option>
<option value="pause">Pause</option>
<option value="drain">Drain</option>
</select>
</div>
</td>
</tr>
<tr>
<td>Status</td>
<td><span class="label label-{{ node.Status|nodestatusbadge }}">{{ node.Status }}</span></td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<p class="small text-muted">
View the Docker Swarm mode Node documentation <a href="https://docs.docker.com/engine/swarm/manage-nodes/" target="self">here</a>.
</p>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary" ng-disabled="!hasChanges(node, ['Name', 'Availability'])" ng-click="updateNode(node)">Apply changes</button>
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(node)">Reset changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>
</div>
<div class="row" ng-if="node && node.Role === 'manager'">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-object-group" title-text="Manager status"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>Leader</td>
<td>
<span ng-if="node.Leader"><i class="fa fa-check green-icon" aria-hidden="true"></i> Yes</span>
<span ng-if="!node.Leader"><i class="fa fa-times red-icon" aria-hidden="true"></i> No</span>
</td>
</tr>
<tr>
<td>Reachability</td>
<td>{{ node.Reachability }}</td>
</tr>
<tr>
<td>Manager address</td>
<td>{{ node.ManagerAddr }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="node">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-object-group" title-text="Node description"></rd-widget-header>
<rd-widget-body classes="no-padding">
<table class="table">
<tbody>
<tr>
<td>CPU</td>
<td>{{ node.CPUs / 1000000000 }}</td>
</tr>
<tr>
<td>Memory</td>
<td>{{ node.Memory|humansize: 2 }}</td>
</tr>
<tr>
<td>Platform</td>
<td>{{ node.PlatformOS }} {{ node.PlatformArchitecture }} </td>
</tr>
<tr>
<td>Docker Engine version</td>
<td>{{ node.EngineVersion }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="node">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-object-group" title-text="Engine labels"></rd-widget-header>
<rd-widget-body ng-if="!node.EngineLabels || node.EngineLabels.length === 0">
<p>There are no engine labels for this node.</p>
</rd-widget-body>
<rd-widget-body classes="no-padding" ng-if="node.EngineLabels && node.EngineLabels.length > 0">
<table class="table">
<thead>
<tr>
<th>Label</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="engineLabel in node.EngineLabels">
<td>{{ engineLabel.key }}</td>
<td>{{ engineLabel.value }}</td>
</tr>
</tbody>
</table>
</rd-widget-body>
</rd-widget>
</div>
</div>
<div class="row" ng-if="node">
<div class="col-lg-12 col-md-12 col-sm-12 col-xs-12">
<rd-widget>
<rd-widget-header icon="fa-tasks" title-text="Node labels">
<div class="nopadding">
<a class="btn btn-default btn-sm pull-right" ng-click="addLabel(node)">
<i class="fa fa-plus-circle" aria-hidden="true"></i> label
</a>
</div>
</rd-widget-header>
<rd-widget-body ng-if="!node.Labels || node.Labels.length === 0">
<p>There are no labels for this node.</p>
</rd-widget-body>
<rd-widget-body classes="no-padding" ng-if="node.Labels && node.Labels.length > 0">
<table class="table">
<thead>
<tr>
<th>Label</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="label in node.Labels">
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">name</span>
<input type="text" class="form-control" ng-model="label.key" placeholder="e.g. com.example.foo" ng-change="updateLabel(node, label)">
</div>
</td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-addon fit-text-size">value</span>
<input type="text" class="form-control" ng-model="label.value" placeholder="e.g. bar" ng-change="updateLabel(node, label)">
<span class="input-group-btn">
<button class="btn btn-sm btn-danger" type="button" ng-click="removeLabel(node, $index)">
<i class="fa fa-trash" aria-hidden="true"></i>
</button>
</span>
</div>
</td>
</tr>
</tbody>
</table>
</rd-widget-body>
<rd-widget-footer>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary btn-sm" ng-disabled="!hasChanges(node, ['Labels'])" ng-click="updateNode(node)">Apply changes</button>
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a ng-click="cancelChanges(node)">Reset changes</a></li>
</ul>
</div>
</div>
</rd-widget-footer>
</rd-widget>
</div>
</div>
<div class="row" ng-if="node && tasks.length > 0">
<div class="col-sm-12">
<node-tasks-datatable
title-text="Tasks" title-icon="fa-tasks"
dataset="tasks" table-key="node-tasks"
order-by="Updated" reverse-order="true"
></node-tasks-datatable>
</div>
</div>

View File

@@ -1,96 +0,0 @@
angular.module('portainer.docker')
.controller('NodeController', ['$scope', '$state', '$transition$', 'LabelHelper', 'Node', 'NodeHelper', 'Task', 'Notifications',
function ($scope, $state, $transition$, LabelHelper, Node, NodeHelper, Task, Notifications) {
$scope.loading = true;
$scope.tasks = [];
var originalNode = {};
var editedKeys = [];
$scope.updateNodeAttribute = function updateNodeAttribute(node, key) {
editedKeys.push(key);
};
$scope.addLabel = function addLabel(node) {
node.Labels.push({ key: '', value: '', originalValue: '', originalKey: '' });
$scope.updateNodeAttribute(node, 'Labels');
};
$scope.removeLabel = function removeLabel(node, index) {
var removedElement = node.Labels.splice(index, 1);
if (removedElement !== null) {
$scope.updateNodeAttribute(node, 'Labels');
}
};
$scope.updateLabel = function updateLabel(node, label) {
if (label.value !== label.originalValue || label.key !== label.originalKey) {
$scope.updateNodeAttribute(node, 'Labels');
}
};
$scope.hasChanges = function(node, elements) {
if (!elements) {
elements = Object.keys(originalNode);
}
var hasChanges = false;
elements.forEach(function(key) {
hasChanges = hasChanges || ((editedKeys.indexOf(key) >= 0) && node[key] !== originalNode[key]);
});
return hasChanges;
};
$scope.cancelChanges = function(node) {
editedKeys.forEach(function(key) {
node[key] = originalNode[key];
});
editedKeys = [];
};
$scope.updateNode = function updateNode(node) {
var config = NodeHelper.nodeToConfig(node.Model);
config.Name = node.Name;
config.Availability = node.Availability;
config.Role = node.Role;
config.Labels = LabelHelper.fromKeyValueToLabelHash(node.Labels);
Node.update({ id: node.Id, version: node.Version }, config, function () {
Notifications.success('Node successfully updated', 'Node updated');
$state.go('docker.nodes.node', {id: node.Id}, {reload: true});
}, function (e) {
Notifications.error('Failure', e, 'Failed to update node');
});
};
function loadNodeAndTasks() {
$scope.loading = true;
if ($scope.applicationState.endpoint.mode.provider === 'DOCKER_SWARM_MODE') {
Node.get({ id: $transition$.params().id}, function(d) {
if (d.message) {
Notifications.error('Failure', e, 'Unable to inspect the node');
} else {
var node = new NodeViewModel(d);
originalNode = angular.copy(node);
$scope.node = node;
getTasks(d);
}
$scope.loading = false;
});
} else {
$scope.loading = false;
}
}
function getTasks(node) {
if (node) {
Task.query({filters: {node: [node.ID]}}, function (tasks) {
$scope.tasks = tasks.map(function (task) {
return new TaskViewModel(task);
});
}, function (e) {
Notifications.error('Failure', e, 'Unable to retrieve tasks associated to the node');
});
}
}
loadNodeAndTasks();
}]);

View File

@@ -0,0 +1,20 @@
angular.module('portainer.docker').controller('NodeBrowserController', [
'NodeService', 'HttpRequestHelper', '$stateParams',
function NodeBrowserController(NodeService, HttpRequestHelper, $stateParams) {
var ctrl = this;
ctrl.$onInit = $onInit;
function $onInit() {
ctrl.nodeId = $stateParams.id;
loadNode();
}
function loadNode() {
NodeService.node(ctrl.nodeId).then(function onNodeLoaded(node) {
HttpRequestHelper.setPortainerAgentTargetHeader(node.Hostname);
ctrl.node = node;
});
}
}
]);

View File

@@ -0,0 +1,14 @@
<rd-header>
<rd-header-title title-text="Node Browser"></rd-header-title>
<rd-header-content>
<a ui-sref="docker.swarm">Swarm</a> &gt; <a ui-sref="docker.nodes.node({ id: $ctrl.nodeId })">{{ $ctrl.node.Hostname }}</a> &gt; browse
</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<host-browser
ng-if="$ctrl.node"
></host-browser>
</div>
</div>

View File

@@ -0,0 +1,4 @@
angular.module('portainer.docker').component('nodeBrowserView', {
templateUrl: 'app/docker/views/nodes/node-browser/node-browser.html',
controller: 'NodeBrowserController'
});

View File

@@ -0,0 +1,75 @@
angular.module('portainer.docker').controller('NodeDetailsViewController', [
'$stateParams', 'NodeService', 'StateManager', 'AgentService',
function NodeDetailsViewController($stateParams, NodeService, StateManager, AgentService) {
var ctrl = this;
ctrl.$onInit = initView;
ctrl.state = {
isAgent: false
};
function initView() {
var applicationState = StateManager.getState();
ctrl.state.isAgent = applicationState.endpoint.mode.agentProxy;
var nodeId = $stateParams.id;
NodeService.node(nodeId).then(function(node) {
ctrl.originalNode = node;
ctrl.hostDetails = buildHostDetails(node);
ctrl.engineDetails = buildEngineDetails(node);
ctrl.nodeDetails = buildNodeDetails(node);
if (ctrl.state.isAgent) {
AgentService.hostInfo(node.Hostname).then(function onHostInfoLoad(
agentHostInfo
) {
ctrl.devices = agentHostInfo.PCIDevices;
ctrl.disks = agentHostInfo.PhysicalDisks;
});
}
});
}
function buildHostDetails(node) {
return {
os: {
arch: node.PlatformArchitecture,
type: node.PlatformOS
},
name: node.Hostname,
totalCPU: node.CPUs / 1e9,
totalMemory: node.Memory
};
}
function buildEngineDetails(node) {
return {
releaseVersion: node.EngineVersion,
volumePlugins: transformPlugins(node.Plugins, 'Volume'),
networkPlugins: transformPlugins(node.Plugins, 'Network')
};
}
function buildNodeDetails(node) {
return {
name: node.Name,
role: node.Role,
managerAddress: node.ManagerAddr,
availability: node.Availability,
status: node.Status,
engineLabels: node.EngineLabels,
nodeLabels: node.Labels
};
}
function transformPlugins(pluginsList, type) {
return pluginsList
.filter(function(plugin) {
return plugin.Type === type;
})
.map(function(plugin) {
return plugin.Name;
});
}
}
]);

View File

@@ -0,0 +1,15 @@
<host-overview
is-agent="$ctrl.state.isAgent"
host-details="$ctrl.hostDetails"
engine-details="$ctrl.engineDetails"
disks="$ctrl.disks"
devices="$ctrl.devices"
refresh-url="docker.nodes.node"
browse-url="docker.nodes.node.browse"
>
<swarm-node-details-panel
details="$ctrl.nodeDetails"
original-node="$ctrl.originalNode"
></swarm-node-details-panel>
</host-overview>

View File

@@ -0,0 +1,4 @@
angular.module('portainer.docker').component('nodeDetailsView', {
templateUrl: 'app/docker/views/nodes/node-details/node-details-view.html',
controller: 'NodeDetailsViewController'
});

View File

@@ -354,7 +354,7 @@
<div class="col-sm-9">
<select class="form-control" ng-model="formValues.Network">
<option selected disabled hidden value="">Select a network</option>
<option ng-repeat="net in availableNetworks" ng-value="net.Name">{{ net.Name }}</option>
<option ng-repeat="net in availableNetworks | orderBy: 'Name'" ng-value="net.Name">{{ net.Name }}</option>
</select>
</div>
<div class="col-sm-2"></div>

View File

@@ -84,7 +84,7 @@
<div class="node_info">
<div>
<div>
<b>{{ node.Hostname }}</b>
<b>{{ node.Name || node.Hostname }}</b>
<span class="node_platform">
<i class="fab fa-linux" aria-hidden="true" ng-if="node.PlatformOS === 'linux'"></i>
<i class="fab fa-windows" aria-hidden="true" ng-if="node.PlatformOS === 'windows'"></i>
@@ -97,7 +97,7 @@
<div><span class="label label-{{ node.Status | nodestatusbadge }}">{{ node.Status }}</span></div>
</div>
<div class="tasks">
<div class="task task_{{ task.Status.State | visualizerTask }}" style="border: 2px solid {{ task.ServiceId | visualizerTaskBorderColor }}" ng-repeat="task in node.Tasks | filter: (state.DisplayOnlyRunningTasks || '') && { Status: { State: 'running' } }">
<div class="task task_{{ task.Status.State | visualizerTask }}" style="border: 2px solid {{ task.ServiceId | visualizerTaskBorderColor }}" ng-repeat="task in node.Tasks | orderBy: 'ServiceName' | filter: (state.DisplayOnlyRunningTasks || '') && { Status: { State: 'running' } }">
<div class="service_name">{{ task.ServiceName }}</div>
<div>Image: {{ task.Spec.ContainerSpec.Image | hideshasum }}</div>
<div>Status: {{ task.Status.State }}</div>

View File

@@ -287,8 +287,8 @@ angular.module('portainer.app', [])
};
var stackCreation = {
name: 'portainer.stacks.new',
url: '/new',
name: 'portainer.newstack',
url: '/newstack',
views: {
'content@': {
templateUrl: 'app/portainer/views/stacks/create/createstack.html',
@@ -318,6 +318,17 @@ angular.module('portainer.app', [])
}
};
var deploykeys = {
name: 'portainer.deploykeys',
url: '/deploykeys',
views: {
'content@': {
templateUrl: 'app/portainer/views/deploykeys/deploykeys.html',
controller: 'DeploykeysController'
}
}
};
var updatePassword = {
name: 'portainer.updatePassword',
url: '/update-password',
@@ -434,6 +445,7 @@ angular.module('portainer.app', [])
$stateRegistryProvider.register(stack);
$stateRegistryProvider.register(stackCreation);
$stateRegistryProvider.register(support);
$stateRegistryProvider.register(deploykeys);
$stateRegistryProvider.register(tags);
$stateRegistryProvider.register(updatePassword);
$stateRegistryProvider.register(users);

View File

@@ -0,0 +1,13 @@
angular.module('portainer.app').component('porStackauthControlForm', {
templateUrl: 'app/portainer/components/StackAuthControlForm/porStackauthControlForm.html',
controller: 'porStackauthControlFormController',
bindings: {
// This object will be populated with the form data.
// Model reference in porStackauthControlFromModel.js
formData: '=',
// Optional. An existing resource control object that will be used to set
// the default values of the component.
resourceControl: '<'
}
});

View File

@@ -0,0 +1,46 @@
<div style="margin-bottom: 0">
<div class="form-group">
<div class="col-sm-3">
<label for="stack_sshrepository_path" class="col-sm-12 control-label text-left">Select deploy key</label>
</div>
<div class="col-sm-4">
<div class="col-sm-12">
<span ng-class="{ 'pull-right': $ctrl.formData.showAddAction }" style="width: 25%;">
<ui-select ng-model="$ctrl.formData.GenrateSshkey.selected" on-select="$ctrl.formData.change($select.selected.Publickeypath)">
<ui-select-match placeholder="Select a deploy key"> <!-- allow-clear="true" -->
<span>{{ $select.selected.Name }}</span>
</ui-select-match>
<ui-select-choices repeat="category as category in ($ctrl.formData.GenrateSshkey | filter: $select.search)" >
<span>{{ category.Name }}</span>
</ui-select-choices>
</ui-select>
</span>
<input style="display: none;" id="selPKey" value="{{$ctrl.formData.GenrateSshkey.selected.Publickeypath}}" />
</div>
</div>
<div class="col-sm-5">
<div class="col-sm-5">
<button disabled type="button" class="btn btn-primary btn-sm copyToClip" ng-click="$ctrl.formData.copytoclipboard($ctrl.formData.GenrateSshkey.selected)">
<i class="fas fa-key space-right" aria-hidden="true"></i>Copy to clipboard
</button>
</div>
<div class="col-sm-3">
<span>
<i id="refreshRateChange" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none;"></i>
</span>
</div>
</div>
</div>
<div class="col-sm-3">
<button type="button" class="btn btn-primary btn-sm" disabled id="btngeneratekey" ng-click="$ctrl.formData.createNewkey()">
<i class="fas fa-key space-right" aria-hidden="true"></i>Generate a new deploy key
</button>
</div>
<div class="col-sm-4">
<p id="warningStackname" style="margin-left: 10px; float: left;"><i class="fa fa-exclamation-triangle text-warning"></i> Please specify a stack name.</p>
</div>
</div>

View File

@@ -0,0 +1,91 @@
angular.module('portainer.app')
.controller('porStackauthControlFormController', ['$q', '$state','$scope','DeploykeyService','StackService', 'UserService', 'TeamService', 'Notifications', 'Authentication', 'ResourceControlService',
function ($q,$state,$scope, DeploykeyService, StackService, UserService, TeamService, Notifications, Authentication, ResourceControlService) {
var ctrl = this;
ctrl.formData.existingsshKey = [];
ctrl.formData.newgenratedeploykey = [];
ctrl.enableGenratekey = false;
ctrl.formData.copytoclipboard = function (){
var $temp = $("<input>");
$("body").append($temp);
$temp.val($('#selPKey').val()).select();
document.execCommand("copy");
$temp.remove();
$('#refreshRateChange').show();
$('#refreshRateChange').fadeOut(2000);
}
ctrl.formData.change = function(key){
if(key != ''){
$('.copyToClip').removeAttr("disabled")
}
else{
$('.copyToClip').attr('disabled','disabled');
}
}
ctrl.formData.createNewkey = function() {
if(StackService.getStackName() == ''){
$('#stack_name').focus();
} else {
var createKeyName = 'deploykey_' + StackService.getStackName();
var userID = Authentication.getUserDetails().ID;
DeploykeyService.createNewdeploykey(createKeyName,userID)
.then(function success(data) {
Notifications.success('Key successfully created', createKeyName);
//$state.reload();
getAlldeploykeydata(data)
$('.copyToClip').removeAttr("disabled")
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to create key');
});
}
}
function initComponent() {
var userDetails = Authentication.getUserDetails();
var isAdmin = userDetails.role === 1 ? true: false;
ctrl.isAdmin = isAdmin;
if (isAdmin) {
ctrl.formData.Ownership = 'administrators';
}
/*Enable / Disable deploykey button based on text entering in stack name textbox*/
if($('#stack_name').val() !=""){
$('#btngeneratekey').removeAttr("disabled")
$('#warningStackname').hide();
} else {
$('#btngeneratekey').attr('disabled','disabled');
$('#warningStackname').show();
}
DeploykeyService.deploykeys()
.then(function success(data) {
ctrl.formData.GenrateSshkey = data;
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve keys');
ctrl.formData.GenrateSshkey = [];
});
}
function getAlldeploykeydata(getdata){
DeploykeyService.deploykeys()
.then(function success(data) {
ctrl.formData.GenrateSshkey = data;
ctrl.formData.GenrateSshkey.selected = getdata;//{value : ctrl.formData.GenrateSshkey[data.length - 1]}
})
.catch(function error(err) {
Notifications.error('Failure', err, 'Unable to retrieve keys');
ctrl.formData.GenrateSshkey = [];
});
}
initComponent();
}]);

View File

@@ -0,0 +1,6 @@
function StackAuthControlFormData() {
this.StackAuthControlEnabled = true;
this.Ownership = 'private';
this.ExistingsshKey = [];
this.GenrateSshkey = [];
}

View File

@@ -0,0 +1,91 @@
<div class="datatable">
<rd-widget>
<rd-widget-body classes="no-padding">
<div class="toolBar">
<div class="toolBarTitle">
<i class="fas fa-key" 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...">
</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>
<a>Action</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 class="col-sm-3">
<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 class="col-sm-3">
<input style="display: none;" type="text" id="pubkey{{ $index }}" class="form-control" ng-model="rootFolders" ng-init="rootFolders='{{ item.Publickeypath }}'" value="{{ item.Publickeypath }}"/>
<button type="button" class="btn btn-sm btn-information" ng-click = "$ctrl.copyToClipboard('pubkey'+$index, $index)">
<i class="fas fa-key space-right" aria-hidden="true"></i>Copy to clipboard
</button>
<span>
<i id="refreshRateChange{{ $index }}" class="fa fa-check green-icon" aria-hidden="true" style="margin-left: 7px; display: none;"></i>
</span>
</td>
</tr>
<tr ng-if="!$ctrl.dataset">
<td colspan="1" class="text-center text-muted">Loading...</td>
</tr>
<tr ng-if="$ctrl.state.filteredDataSet.length === 0">
<td colspan="1" class="text-center text-muted">No key 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.app').component('deploykeysDatatable', {
templateUrl: 'app/portainer/components/datatables/deploykeys-datatable/deploykeysDatatable.html',
controller: 'deploykeysDatatableController',
bindings: {
titleText: '@',
titleIcon: '@',
dataset: '<',
tableKey: '@',
orderBy: '@',
reverseOrder: '<',
removeAction: '<'
}
});

View File

@@ -0,0 +1,68 @@
angular.module('portainer.app')
.controller('deploykeysDatatableController', ['PaginationService', 'DatatableService',
function (PaginationService, DatatableService) {
this.state = {
selectAll: false,
orderBy: this.orderBy,
paginatedItemLimit: PaginationService.getPaginationLimit(this.tableKey),
displayTextFilter: false,
selectedItemCount: 0,
selectedItems: []
};
this.changeOrderBy = function(orderField) {
this.state.reverseOrder = this.state.orderBy === orderField ? !this.state.reverseOrder : false;
this.state.orderBy = orderField;
DatatableService.setDataTableOrder(this.tableKey, orderField, this.state.reverseOrder);
};
this.selectItem = function(item) {
if (item.Checked) {
this.state.selectedItemCount++;
this.state.selectedItems.push(item);
} else {
this.state.selectedItems.splice(this.state.selectedItems.indexOf(item), 1);
this.state.selectedItemCount--;
}
};
this.selectAll = function() {
for (var i = 0; i < this.state.filteredDataSet.length; i++) {
var item = this.state.filteredDataSet[i];
if (!(item.External && item.Type === 2) && item.Checked !== this.state.selectAll) {
item.Checked = this.state.selectAll;
this.selectItem(item);
}
}
};
this.changePaginationLimit = function() {
PaginationService.setPaginationLimit(this.tableKey, this.state.paginatedItemLimit);
};
this.$onInit = function() {
setDefaults(this);
var storedOrder = DatatableService.getDataTableOrder(this.tableKey);
if (storedOrder !== null) {
this.state.reverseOrder = storedOrder.reverse;
this.state.orderBy = storedOrder.orderBy;
}
};
function setDefaults(ctrl) {
ctrl.showTextFilter = ctrl.showTextFilter ? ctrl.showTextFilter : false;
ctrl.state.reverseOrder = ctrl.reverseOrder ? ctrl.reverseOrder : false;
}
this.copyToClipboard = function(cpotyText, indexNo){
var $temp = $("<input>");
$("body").append($temp);
$temp.val($('#'+cpotyText).val()).select();
document.execCommand("copy");
$temp.remove();
$('#refreshRateChange'+indexNo).show();
$('#refreshRateChange'+indexNo).fadeOut(2000);
}
}]);

View File

@@ -11,7 +11,7 @@
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>
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.stacks.new">
<button type="button" class="btn btn-sm btn-primary" ui-sref="portainer.newstack">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add stack
</button>
</div>

View File

@@ -0,0 +1,12 @@
angular.module('portainer.app').controller('EndpointItemController', [
function EndpointItemController() {
var ctrl = this;
ctrl.editEndpoint = editEndpoint;
function editEndpoint(event) {
event.stopPropagation();
ctrl.onEdit(ctrl.model.Id);
}
}
]);

View File

@@ -21,8 +21,16 @@
</span>
</span>
<span class="small">
Group: {{ $ctrl.model.GroupName }}
<span>
<span class="small">
Group: {{ $ctrl.model.GroupName }}
</span>
<button
ng-if="$ctrl.isAdmin"
class="btn btn-link btn-xs"
ng-click="$ctrl.editEndpoint($event)"><i class="fa fa-pencil-alt"></i>
</button>
</span>
</div>

View File

@@ -2,6 +2,9 @@ angular.module('portainer.app').component('endpointItem', {
templateUrl: 'app/portainer/components/endpoint-list/endpoint-item/endpointItem.html',
bindings: {
model: '<',
onSelect: '<'
}
onSelect: '<',
onEdit: '<',
isAdmin:'<'
},
controller: 'EndpointItemController'
});

View File

@@ -0,0 +1,60 @@
angular.module('portainer.app').controller('EndpointListController', [
function EndpointListController() {
var ctrl = this;
ctrl.state = {
textFilter: '',
filteredEndpoints: []
};
ctrl.$onChanges = $onChanges;
ctrl.onFilterChanged = onFilterChanged;
function $onChanges(changesObj) {
handleEndpointsChange(changesObj.endpoints);
}
function handleEndpointsChange(endpoints) {
if (!endpoints) {
return;
}
if (!endpoints.currentValue) {
return;
}
onFilterChanged();
}
function onFilterChanged() {
var filterValue = ctrl.state.textFilter;
ctrl.state.filteredEndpoints = filterEndpoints(
ctrl.endpoints,
filterValue
);
}
function filterEndpoints(endpoints, filterValue) {
if (!endpoints || !endpoints.length || !filterValue) {
return endpoints;
}
var keywords = filterValue.split(' ');
return _.filter(endpoints, function(endpoint) {
var statusString = convertStatusToString(endpoint.Status);
return _.every(keywords, function(keyword) {
var lowerCaseKeyword = keyword.toLowerCase();
return (
_.includes(endpoint.Name.toLowerCase(), lowerCaseKeyword) ||
_.includes(endpoint.GroupName.toLowerCase(), lowerCaseKeyword) ||
_.some(endpoint.Tags, function(tag) {
return _.includes(tag.toLowerCase(), lowerCaseKeyword);
}) ||
_.includes(statusString, keyword)
);
});
});
}
function convertStatusToString(status) {
return status === 1 ? 'up' : 'down';
}
}
]);

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