diff --git a/api/api.go b/api/api.go new file mode 100644 index 000000000..0047e622e --- /dev/null +++ b/api/api.go @@ -0,0 +1,57 @@ +package main + +import ( + "crypto/tls" + "log" + "net/http" + "net/url" +) + +type ( + api struct { + endpoint *url.URL + bindAddress string + assetPath string + dataPath string + tlsConfig *tls.Config + } + + apiConfig struct { + Endpoint string + BindAddress string + AssetPath string + DataPath string + SwarmSupport bool + TLSEnabled bool + TLSCACertPath string + TLSCertPath string + TLSKeyPath string + } +) + +func (a *api) run(settings *Settings) { + handler := a.newHandler(settings) + if err := http.ListenAndServe(a.bindAddress, handler); err != nil { + log.Fatal(err) + } +} + +func newAPI(apiConfig apiConfig) *api { + endpointURL, err := url.Parse(apiConfig.Endpoint) + if err != nil { + log.Fatal(err) + } + + var tlsConfig *tls.Config + if apiConfig.TLSEnabled { + tlsConfig = newTLSConfig(apiConfig.TLSCACertPath, apiConfig.TLSCertPath, apiConfig.TLSKeyPath) + } + + return &api{ + endpoint: endpointURL, + bindAddress: apiConfig.BindAddress, + assetPath: apiConfig.AssetPath, + dataPath: apiConfig.DataPath, + tlsConfig: tlsConfig, + } +} diff --git a/api/csrf.go b/api/csrf.go new file mode 100644 index 000000000..4377ee343 --- /dev/null +++ b/api/csrf.go @@ -0,0 +1,48 @@ +package main + +import ( + "github.com/gorilla/csrf" + "github.com/gorilla/securecookie" + "io/ioutil" + "log" + "net/http" +) + +const keyFile = "authKey.dat" + +// newAuthKey reuses an existing CSRF authkey if present or generates a new one +func newAuthKey(path string) []byte { + var authKey []byte + authKeyPath := path + "/" + keyFile + data, err := ioutil.ReadFile(authKeyPath) + if err != nil { + log.Print("Unable to find an existing CSRF auth key. Generating a new key.") + authKey = securecookie.GenerateRandomKey(32) + err := ioutil.WriteFile(authKeyPath, authKey, 0644) + if err != nil { + log.Fatal("Unable to persist CSRF auth key.") + log.Fatal(err) + } + } else { + authKey = data + } + return authKey +} + +// newCSRF initializes a new CSRF handler +func newCSRFHandler(keyPath string) func(h http.Handler) http.Handler { + authKey := newAuthKey(keyPath) + return csrf.Protect( + authKey, + csrf.HttpOnly(false), + csrf.Secure(false), + ) +} + +// newCSRFWrapper wraps a http.Handler to add the CSRF token +func newCSRFWrapper(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-CSRF-Token", csrf.Token(r)) + h.ServeHTTP(w, r) + }) +} diff --git a/api/exec.go b/api/exec.go new file mode 100644 index 000000000..4b139aeb7 --- /dev/null +++ b/api/exec.go @@ -0,0 +1,24 @@ +package main + +import ( + "golang.org/x/net/websocket" + "log" +) + +// execContainer is used to create a websocket communication with an exec instance +func (a *api) execContainer(ws *websocket.Conn) { + qry := ws.Request().URL.Query() + execID := qry.Get("id") + + var host string + if a.endpoint.Scheme == "tcp" { + host = a.endpoint.Host + } else if a.endpoint.Scheme == "unix" { + host = a.endpoint.Path + } + + if err := hijack(host, a.endpoint.Scheme, "POST", "/exec/"+execID+"/start", a.tlsConfig, true, ws, ws, ws, nil, nil); err != nil { + log.Fatalf("error during hijack: %s", err) + return + } +} diff --git a/api/flags.go b/api/flags.go new file mode 100644 index 000000000..47578a748 --- /dev/null +++ b/api/flags.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "gopkg.in/alecthomas/kingpin.v2" + "strings" +) + +// pair defines a key/value pair +type pair struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// pairList defines an array of Label +type pairList []pair + +// Set implementation for Labels +func (l *pairList) Set(value string) error { + parts := strings.SplitN(value, "=", 2) + if len(parts) != 2 { + return fmt.Errorf("expected NAME=VALUE got '%s'", value) + } + p := new(pair) + p.Name = parts[0] + p.Value = parts[1] + *l = append(*l, *p) + return nil +} + +// String implementation for Labels +func (l *pairList) String() string { + return "" +} + +// IsCumulative implementation for Labels +func (l *pairList) IsCumulative() bool { + return true +} + +// LabelParser defines a custom parser for Labels flags +func pairs(s kingpin.Settings) (target *[]pair) { + target = new([]pair) + s.SetValue((*pairList)(target)) + return +} diff --git a/api/handler.go b/api/handler.go new file mode 100644 index 000000000..3e48f258e --- /dev/null +++ b/api/handler.go @@ -0,0 +1,75 @@ +package main + +import ( + "golang.org/x/net/websocket" + "log" + "net/http" + "net/http/httputil" + "net/url" + "os" +) + +// newHandler creates a new http.Handler with CSRF protection +func (a *api) newHandler(settings *Settings) http.Handler { + var ( + mux = http.NewServeMux() + fileHandler = http.FileServer(http.Dir(a.assetPath)) + ) + + handler := a.newAPIHandler() + CSRFHandler := newCSRFHandler(a.dataPath) + + mux.Handle("/", fileHandler) + mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", handler)) + mux.Handle("/ws/exec", websocket.Handler(a.execContainer)) + mux.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { + settingsHandler(w, r, settings) + }) + return CSRFHandler(newCSRFWrapper(mux)) +} + +// newAPIHandler initializes a new http.Handler based on the URL scheme +func (a *api) newAPIHandler() http.Handler { + var handler http.Handler + var endpoint = *a.endpoint + if endpoint.Scheme == "tcp" { + if a.tlsConfig != nil { + handler = a.newTCPHandlerWithTLS(&endpoint) + } else { + handler = a.newTCPHandler(&endpoint) + } + } else if endpoint.Scheme == "unix" { + socketPath := endpoint.Path + if _, err := os.Stat(socketPath); err != nil { + if os.IsNotExist(err) { + log.Fatalf("Unix socket %s does not exist", socketPath) + } + log.Fatal(err) + } + handler = a.newUnixHandler(socketPath) + } else { + log.Fatalf("Bad Docker enpoint: %v. Only unix:// and tcp:// are supported.", &endpoint) + } + return handler +} + +// newUnixHandler initializes a new UnixHandler +func (a *api) newUnixHandler(e string) http.Handler { + return &unixHandler{e} +} + +// newTCPHandler initializes a HTTP reverse proxy +func (a *api) newTCPHandler(u *url.URL) http.Handler { + u.Scheme = "http" + return httputil.NewSingleHostReverseProxy(u) +} + +// newTCPHandlerWithL initializes a HTTPS reverse proxy with a TLS configuration +func (a *api) newTCPHandlerWithTLS(u *url.URL) http.Handler { + u.Scheme = "https" + proxy := httputil.NewSingleHostReverseProxy(u) + proxy.Transport = &http.Transport{ + TLSClientConfig: a.tlsConfig, + } + return proxy +} diff --git a/api/hijack.go b/api/hijack.go new file mode 100644 index 000000000..ff6cd9071 --- /dev/null +++ b/api/hijack.go @@ -0,0 +1,123 @@ +package main + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httputil" + "time" +) + +type execConfig struct { + Tty bool + Detach bool +} + +// hijack allows to upgrade an HTTP connection to a TCP connection +// It redirects IO streams for stdin, stdout and stderr to a websocket +func hijack(addr, scheme, method, path string, tlsConfig *tls.Config, setRawTerminal bool, in io.ReadCloser, stdout, stderr io.Writer, started chan io.Closer, data interface{}) error { + execConfig := &execConfig{ + Tty: true, + Detach: false, + } + + buf, err := json.Marshal(execConfig) + if err != nil { + return fmt.Errorf("error marshaling exec config: %s", err) + } + + rdr := bytes.NewReader(buf) + + req, err := http.NewRequest(method, path, rdr) + if err != nil { + return fmt.Errorf("error during hijack request: %s", err) + } + + req.Header.Set("User-Agent", "Docker-Client") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Upgrade", "tcp") + req.Host = addr + + var ( + dial net.Conn + dialErr error + ) + + if tlsConfig == nil { + dial, dialErr = net.Dial(scheme, addr) + } else { + dial, dialErr = tls.Dial(scheme, addr, tlsConfig) + } + + if dialErr != nil { + return dialErr + } + + // When we set up a TCP connection for hijack, there could be long periods + // of inactivity (a long running command with no output) that in certain + // network setups may cause ECONNTIMEOUT, leaving the client in an unknown + // state. Setting TCP KeepAlive on the socket connection will prohibit + // ECONNTIMEOUT unless the socket connection truly is broken + if tcpConn, ok := dial.(*net.TCPConn); ok { + tcpConn.SetKeepAlive(true) + tcpConn.SetKeepAlivePeriod(30 * time.Second) + } + if err != nil { + return err + } + clientconn := httputil.NewClientConn(dial, nil) + defer clientconn.Close() + + // Server hijacks the connection, error 'connection closed' expected + clientconn.Do(req) + + rwc, br := clientconn.Hijack() + defer rwc.Close() + + if started != nil { + started <- rwc + } + + var receiveStdout chan error + + if stdout != nil || stderr != nil { + go func() (err error) { + if setRawTerminal && stdout != nil { + _, err = io.Copy(stdout, br) + } + return err + }() + } + + go func() error { + if in != nil { + io.Copy(rwc, in) + } + + if conn, ok := rwc.(interface { + CloseWrite() error + }); ok { + if err := conn.CloseWrite(); err != nil { + } + } + return nil + }() + + if stdout != nil || stderr != nil { + if err := <-receiveStdout; err != nil { + return err + } + } + go func() { + for { + fmt.Println(br) + } + }() + + return nil +} diff --git a/api/main.go b/api/main.go new file mode 100644 index 000000000..a2c5a0f15 --- /dev/null +++ b/api/main.go @@ -0,0 +1,43 @@ +package main // import "github.com/cloudinovasi/ui-for-docker" + +import ( + "gopkg.in/alecthomas/kingpin.v2" +) + +// main is the entry point of the program +func main() { + kingpin.Version("1.6.0") + var ( + endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() + addr = kingpin.Flag("bind", "Address and port to serve UI For Docker").Default(":9000").Short('p').String() + assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() + data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() + tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() + tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() + tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() + tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() + swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() + labels = pairs(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) + ) + kingpin.Parse() + + apiConfig := apiConfig{ + Endpoint: *endpoint, + BindAddress: *addr, + AssetPath: *assets, + DataPath: *data, + SwarmSupport: *swarm, + TLSEnabled: *tlsverify, + TLSCACertPath: *tlscacert, + TLSCertPath: *tlscert, + TLSKeyPath: *tlskey, + } + + settings := &Settings{ + Swarm: *swarm, + HiddenLabels: *labels, + } + + api := newAPI(apiConfig) + api.run(settings) +} diff --git a/api/settings.go b/api/settings.go new file mode 100644 index 000000000..801904f61 --- /dev/null +++ b/api/settings.go @@ -0,0 +1,17 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +// Settings defines the settings available under the /settings endpoint +type Settings struct { + Swarm bool `json:"swarm"` + HiddenLabels pairList `json:"hiddenLabels"` +} + +// configurationHandler defines a handler function used to encode the configuration in JSON +func settingsHandler(w http.ResponseWriter, r *http.Request, s *Settings) { + json.NewEncoder(w).Encode(*s) +} diff --git a/api/ssl.go b/api/ssl.go new file mode 100644 index 000000000..89f76e85e --- /dev/null +++ b/api/ssl.go @@ -0,0 +1,27 @@ +package main + +import ( + "crypto/tls" + "crypto/x509" + "io/ioutil" + "log" +) + +// newTLSConfig initializes a tls.Config using a CA certificate, a certificate and a key +func newTLSConfig(caCertPath, certPath, keyPath string) *tls.Config { + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + log.Fatal(err) + } + caCert, err := ioutil.ReadFile(caCertPath) + if err != nil { + log.Fatal(err) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{cert}, + RootCAs: caCertPool, + } + return tlsConfig +} diff --git a/api/unix_handler.go b/api/unix_handler.go new file mode 100644 index 000000000..15a5119d3 --- /dev/null +++ b/api/unix_handler.go @@ -0,0 +1,47 @@ +package main + +import ( + "io" + "log" + "net" + "net/http" + "net/http/httputil" +) + +// unixHandler defines a handler holding the path to a socket under UNIX +type unixHandler struct { + path string +} + +// ServeHTTP implementation for unixHandler +func (h *unixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := net.Dial("unix", h.path) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + c := httputil.NewClientConn(conn, nil) + defer c.Close() + + res, err := c.Do(r) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + log.Println(err) + return + } + defer res.Body.Close() + + copyHeader(w.Header(), res.Header) + if _, err := io.Copy(w, res.Body); err != nil { + log.Println(err) + } +} + +func copyHeader(dst, src http.Header) { + for k, vv := range src { + for _, v := range vv { + dst.Add(k, v) + } + } +} diff --git a/app/app.js b/app/app.js index 0b7d4fa58..206161c75 100644 --- a/app/app.js +++ b/app/app.js @@ -5,16 +5,18 @@ angular.module('uifordocker', [ 'ui.select', 'ngCookies', 'ngSanitize', - 'dockerui.services', - 'dockerui.filters', + 'uifordocker.services', + 'uifordocker.filters', 'dashboard', 'container', + 'containerConsole', + 'containerLogs', 'containers', 'createContainer', 'docker', + 'events', 'images', 'image', - 'containerLogs', 'stats', 'swarm', 'network', @@ -56,6 +58,11 @@ angular.module('uifordocker', [ templateUrl: 'app/components/containerLogs/containerlogs.html', controller: 'ContainerLogsController' }) + .state('console', { + url: "^/containers/:id/console", + templateUrl: 'app/components/containerConsole/containerConsole.html', + controller: 'ContainerConsoleController' + }) .state('actions', { abstract: true, url: "/actions", @@ -86,6 +93,11 @@ angular.module('uifordocker', [ templateUrl: 'app/components/docker/docker.html', controller: 'DockerController' }) + .state('events', { + url: '/events/', + templateUrl: 'app/components/events/events.html', + controller: 'EventsController' + }) .state('images', { url: '/images/', templateUrl: 'app/components/images/images.html', @@ -143,5 +155,5 @@ angular.module('uifordocker', [ // You need to set this to the api endpoint without the port i.e. http://192.168.1.9 .constant('DOCKER_ENDPOINT', 'dockerapi') .constant('DOCKER_PORT', '') // Docker port, leave as an empty string if no port is requred. If you have a port, prefix it with a ':' i.e. :4243 - .constant('CONFIG_ENDPOINT', '/config') - .constant('UI_VERSION', 'v1.5.0'); + .constant('CONFIG_ENDPOINT', 'settings') + .constant('UI_VERSION', 'v1.6.0'); diff --git a/app/components/container/container.html b/app/components/container/container.html index d3e334f28..bc76f31cc 100644 --- a/app/components/container/container.html +++ b/app/components/container/container.html @@ -64,6 +64,7 @@
Stats Logs + Console
diff --git a/app/components/containerConsole/containerConsole.html b/app/components/containerConsole/containerConsole.html new file mode 100644 index 000000000..751bf738e --- /dev/null +++ b/app/components/containerConsole/containerConsole.html @@ -0,0 +1,43 @@ + + + + + + Containers > {{ container.Name|trimcontainername }} > Console + + + +
+
+ + +
+ +
+
+ +
+ +
+
+ +
+
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
diff --git a/app/components/containerConsole/containerConsoleController.js b/app/components/containerConsole/containerConsoleController.js new file mode 100644 index 000000000..566529458 --- /dev/null +++ b/app/components/containerConsole/containerConsoleController.js @@ -0,0 +1,104 @@ +angular.module('containerConsole', []) +.controller('ContainerConsoleController', ['$scope', '$stateParams', 'Settings', 'Container', 'Exec', '$timeout', 'Messages', 'errorMsgFilter', +function ($scope, $stateParams, Settings, Container, Exec, $timeout, Messages, errorMsgFilter) { + $scope.state = {}; + $scope.state.command = "bash"; + $scope.connected = false; + + var socket, term; + + // Ensure the socket is closed before leaving the view + $scope.$on('$stateChangeStart', function (event, next, current) { + if (socket !== null) { + socket.close(); + } + }); + + Container.get({id: $stateParams.id}, function(d) { + $scope.container = d; + $('#loadingViewSpinner').hide(); + }); + + $scope.connect = function() { + $('#loadConsoleSpinner').show(); + var termWidth = Math.round($('#terminal-container').width() / 8.2); + var termHeight = 30; + var execConfig = { + id: $stateParams.id, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + Cmd: $scope.state.command.replace(" ", ",").split(",") + }; + + Container.exec(execConfig, function(d) { + if (d.Id) { + var execId = d.Id; + resizeTTY(execId, termHeight, termWidth); + var url = window.location.href.split('#')[0].replace('http://', 'ws://') + 'ws/exec?id=' + execId; + initTerm(url, termHeight, termWidth); + } else { + $('#loadConsoleSpinner').hide(); + Messages.error('Error', errorMsgFilter(d)); + } + }, function (e) { + $('#loadConsoleSpinner').hide(); + Messages.error("Failure", e.data); + }); + }; + + $scope.disconnect = function() { + $scope.connected = false; + if (socket !== null) { + socket.close(); + } + if (term !== null) { + term.destroy(); + } + }; + + function resizeTTY(execId, height, width) { + $timeout(function() { + Exec.resize({id: execId, height: height, width: width}, function (d) { + var error = errorMsgFilter(d); + if (error) { + Messages.error('Error', 'Unable to resize TTY'); + } + }); + }, 2000); + + } + + function initTerm(url, height, width) { + socket = new WebSocket(url); + + $scope.connected = true; + socket.onopen = function(evt) { + $('#loadConsoleSpinner').hide(); + term = new Terminal({ + cols: width, + rows: height, + cursorBlink: true + }); + + term.on('data', function (data) { + socket.send(data); + }); + term.open(document.getElementById('terminal-container')); + + socket.onmessage = function (e) { + term.write(e.data); + }; + socket.onerror = function (error) { + $scope.connected = false; + + }; + socket.onclose = function(evt) { + $scope.connected = false; + // term.write("Session terminated"); + // term.destroy(); + }; + }; + } +}]); diff --git a/app/components/dashboard/dashboard.html b/app/components/dashboard/dashboard.html index 61ba5f16e..4980a5c8b 100644 --- a/app/components/dashboard/dashboard.html +++ b/app/components/dashboard/dashboard.html @@ -8,7 +8,7 @@
- + @@ -35,7 +35,7 @@
- +
diff --git a/app/components/events/events.html b/app/components/events/events.html new file mode 100644 index 000000000..17764e1fb --- /dev/null +++ b/app/components/events/events.html @@ -0,0 +1,63 @@ + + + + + + + Events + + +
+
+ + +
+ +
+
+ +
+ +
+
+ +
+
+ + + + + + + + + + + + + + +
+ + Date + + + + + + Category + + + + + + Details + + + +
{{ event.Time|getdatefromtimestamp }}{{ event.Type }}{{ event.Details }}
+
+ + +
+
diff --git a/app/components/events/eventsController.js b/app/components/events/eventsController.js new file mode 100644 index 000000000..8450bdb71 --- /dev/null +++ b/app/components/events/eventsController.js @@ -0,0 +1,27 @@ +angular.module('events', []) +.controller('EventsController', ['$scope', 'Settings', 'Messages', 'Events', +function ($scope, Settings, Messages, Events) { + $scope.state = {}; + $scope.sortType = 'Time'; + $scope.sortReverse = true; + + $scope.order = function(sortType) { + $scope.sortReverse = ($scope.sortType === sortType) ? !$scope.sortReverse : false; + $scope.sortType = sortType; + }; + + var from = moment().subtract(24, 'hour').unix(); + var to = moment().unix(); + + Events.query({since: from, until: to}, + function(d) { + $scope.events = d.map(function (item) { + return new EventViewModel(item); + }); + $('#loadEventsSpinner').hide(); + }, + function (e) { + Messages.error("Unable to load events", e.data); + $('#loadEventsSpinner').hide(); + }); +}]); diff --git a/app/components/networks/networksController.js b/app/components/networks/networksController.js index 124b18129..3cde28bfa 100644 --- a/app/components/networks/networksController.js +++ b/app/components/networks/networksController.js @@ -45,9 +45,14 @@ function ($scope, Network, Messages, errorMsgFilter) { if (network.Checked) { counter = counter + 1; Network.remove({id: network.Id}, function (d) { - Messages.send("Network deleted", network.Id); - var index = $scope.networks.indexOf(network); - $scope.networks.splice(index, 1); + var error = errorMsgFilter(d); + if (error) { + Messages.send("Error", "Unable to remove network with active endpoints"); + } else { + Messages.send("Network deleted", network.Id); + var index = $scope.networks.indexOf(network); + $scope.networks.splice(index, 1); + } complete(); }, function (e) { Messages.error("Failure", e.data); diff --git a/app/components/swarm/swarm.html b/app/components/swarm/swarm.html index 35e3ca6af..2e09be30a 100644 --- a/app/components/swarm/swarm.html +++ b/app/components/swarm/swarm.html @@ -8,43 +8,7 @@
-
- - -
- -
-
{{ docker.Version }}
-
Swarm version
-
-
-
-
- - -
- -
-
{{ docker.ApiVersion }}
-
API version
-
-
-
-
- - -
- -
-
{{ docker.GoVersion }}
-
Go version
-
-
-
-
- -
-
+
@@ -58,34 +22,48 @@ Images {{ info.Images }} + + Swarm version + {{ docker.Version|swarmversion }} + + + Docker API version + {{ docker.ApiVersion }} + Strategy {{ swarm.Strategy }} - CPUs + Total CPU {{ info.NCPU }} - Total Memory + Total memory {{ info.MemTotal|humansize }} - Operating System + Operating system {{ info.OperatingSystem }} - Kernel Version + Kernel version {{ info.KernelVersion }} + + Go version + {{ docker.GoVersion }} +
-
+
+
+
- + @@ -97,6 +75,20 @@ + + + + diff --git a/app/components/swarm/swarmController.js b/app/components/swarm/swarmController.js index 840c6281d..eca575753 100644 --- a/app/components/swarm/swarmController.js +++ b/app/components/swarm/swarmController.js @@ -53,8 +53,8 @@ angular.module('swarm', []) node.id = info[offset + 1][1]; node.status = info[offset + 2][1]; node.containers = info[offset + 3][1]; - node.cpu = info[offset + 4][1]; - node.memory = info[offset + 5][1]; + node.cpu = info[offset + 4][1].split('/')[1]; + node.memory = info[offset + 5][1].split('/')[1]; node.labels = info[offset + 6][1]; node.version = info[offset + 8][1]; $scope.swarm.Status.push(node); diff --git a/app/shared/filters.js b/app/shared/filters.js index 10fe596ff..ae0cb58b1 100644 --- a/app/shared/filters.js +++ b/app/shared/filters.js @@ -1,4 +1,4 @@ -angular.module('dockerui.filters', []) +angular.module('uifordocker.filters', []) .filter('truncate', function () { 'use strict'; return function (text, length, end) { @@ -163,6 +163,12 @@ angular.module('dockerui.filters', []) return date.toDateString(); }; }) +.filter('getdatefromtimestamp', function () { + 'use strict'; + return function (timestamp) { + return moment.unix(timestamp).format('YYYY-MM-DD HH:mm:ss'); + }; +}) .filter('errorMsg', function () { return function (object) { var idx = 0; diff --git a/app/shared/services.js b/app/shared/services.js index 75d979db1..d5cee721b 100644 --- a/app/shared/services.js +++ b/app/shared/services.js @@ -1,4 +1,4 @@ -angular.module('dockerui.services', ['ngResource', 'ngSanitize']) +angular.module('uifordocker.services', ['ngResource', 'ngSanitize']) .factory('Container', ['$resource', 'Settings', function ContainerFactory($resource, Settings) { 'use strict'; // Resource for interacting with the docker containers @@ -18,9 +18,17 @@ angular.module('dockerui.services', ['ngResource', 'ngSanitize']) create: {method: 'POST', params: {action: 'create'}}, remove: {method: 'DELETE', params: {id: '@id', v: 0}}, rename: {method: 'POST', params: {id: '@id', action: 'rename'}, isArray: false}, - stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000} + stats: {method: 'GET', params: {id: '@id', stream: false, action: 'stats'}, timeout: 5000}, + exec: {method: 'POST', params: {id: '@id', action: 'exec'}} }); }]) + .factory('Exec', ['$resource', 'Settings', function ExecFactory($resource, Settings) { + 'use strict'; + // https://docs.docker.com/engine/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/exec-resize + return $resource(Settings.url + '/exec/:id/:action', {}, { + resize: {method: 'POST', params: {id: '@id', action: 'resize', h: '@height', w: '@width'}} + }); + }]) .factory('ContainerCommit', ['$resource', '$http', 'Settings', function ContainerCommitFactory($resource, $http, Settings) { 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#create-a-new-image-from-a-container-s-changes @@ -98,6 +106,16 @@ angular.module('dockerui.services', ['ngResource', 'ngSanitize']) inspect: {method: 'GET', params: {id: '@id', action: 'json'}} }); }]) + .factory('Events', ['$resource', 'Settings', function EventFactory($resource, Settings) { + 'use strict'; + // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#/monitor-docker-s-events + return $resource(Settings.url + '/events', {}, { + query: {method: 'GET', params: {since: '@since', until: '@until'}, isArray: true, transformResponse: [function f(data) { + var str = "[" + data.replace(/\n/g, " ").replace(/\}\s*\{/g, "}, {") + "]"; + return angular.fromJson(str); + }]} + }); + }]) .factory('Version', ['$resource', 'Settings', function VersionFactory($resource, Settings) { 'use strict'; // http://docs.docker.com/reference/api/docker_remote_api_<%= remoteApiVersion %>/#show-the-docker-version-information @@ -142,7 +160,7 @@ angular.module('dockerui.services', ['ngResource', 'ngSanitize']) remove: {method: 'DELETE'} }); }]) - .factory('Config', ['$resource', 'CONFIG_ENDPOINT', function($resource, CONFIG_ENDPOINT) { + .factory('Config', ['$resource', 'CONFIG_ENDPOINT', function ConfigFactory($resource, CONFIG_ENDPOINT) { return $resource(CONFIG_ENDPOINT).get(); }]) .factory('Settings', ['DOCKER_ENDPOINT', 'DOCKER_PORT', 'UI_VERSION', function SettingsFactory(DOCKER_ENDPOINT, DOCKER_PORT, UI_VERSION) { diff --git a/app/shared/viewmodel.js b/app/shared/viewmodel.js index 1b222692e..4e9939940 100644 --- a/app/shared/viewmodel.js +++ b/app/shared/viewmodel.js @@ -1,22 +1,134 @@ function ImageViewModel(data) { - this.Id = data.Id; - this.Tag = data.Tag; - this.Repository = data.Repository; - this.Created = data.Created; - this.Checked = false; - this.RepoTags = data.RepoTags; - this.VirtualSize = data.VirtualSize; + this.Id = data.Id; + this.Tag = data.Tag; + this.Repository = data.Repository; + this.Created = data.Created; + this.Checked = false; + this.RepoTags = data.RepoTags; + this.VirtualSize = data.VirtualSize; } function ContainerViewModel(data) { - this.Id = data.Id; - this.Status = data.Status; - this.Names = data.Names; - // Unavailable in Docker < 1.10 - if (data.NetworkSettings) { - this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; - } - this.Image = data.Image; - this.Command = data.Command; - this.Checked = false; + this.Id = data.Id; + this.Status = data.Status; + this.Names = data.Names; + // Unavailable in Docker < 1.10 + if (data.NetworkSettings) { + this.IP = data.NetworkSettings.Networks[Object.keys(data.NetworkSettings.Networks)[0]].IPAddress; + } + this.Image = data.Image; + this.Command = data.Command; + this.Checked = false; +} + +function createEventDetails(event) { + var eventAttr = event.Actor.Attributes; + var details = ''; + switch (event.Type) { + case 'container': + switch (event.Action) { + case 'stop': + details = 'Container ' + eventAttr.name + ' stopped'; + break; + case 'destroy': + details = 'Container ' + eventAttr.name + ' deleted'; + break; + case 'create': + details = 'Container ' + eventAttr.name + ' created'; + break; + case 'start': + details = 'Container ' + eventAttr.name + ' started'; + break; + case 'kill': + details = 'Container ' + eventAttr.name + ' killed'; + break; + case 'die': + details = 'Container ' + eventAttr.name + ' exited with status code ' + eventAttr.exitCode; + break; + case 'commit': + details = 'Container ' + eventAttr.name + ' committed'; + break; + case 'restart': + details = 'Container ' + eventAttr.name + ' restarted'; + break; + case 'pause': + details = 'Container ' + eventAttr.name + ' paused'; + break; + case 'unpause': + details = 'Container ' + eventAttr.name + ' unpaused'; + break; + default: + if (event.Action.indexOf('exec_create') === 0) { + details = 'Exec instance created'; + } else if (event.Action.indexOf('exec_start') === 0) { + details = 'Exec instance started'; + } else { + details = 'Unsupported event'; + } + } + break; + case 'image': + switch (event.Action) { + case 'delete': + details = 'Image deleted'; + break; + case 'tag': + details = 'New tag created for ' + eventAttr.name; + break; + case 'untag': + details = 'Image untagged'; + break; + case 'pull': + details = 'Image ' + event.Actor.ID + ' pulled'; + break; + default: + details = 'Unsupported event'; + } + break; + case 'network': + switch (event.Action) { + case 'create': + details = 'Network ' + eventAttr.name + ' created'; + break; + case 'destroy': + details = 'Network ' + eventAttr.name + ' deleted'; + break; + case 'connect': + details = 'Container connected to ' + eventAttr.name + ' network'; + break; + case 'disconnect': + details = 'Container disconnected from ' + eventAttr.name + ' network'; + break; + default: + details = 'Unsupported event'; + } + break; + case 'volume': + switch (event.Action) { + case 'create': + details = 'Volume ' + event.Actor.ID + ' created'; + break; + case 'destroy': + details = 'Volume ' + event.Actor.ID + ' deleted'; + break; + default: + details = 'Unsupported event'; + } + break; + default: + details = 'Unsupported event'; + } + return details; +} + +function EventViewModel(data) { + // Type, Action, Actor unavailable in Docker < 1.10 + this.Time = data.time; + if (data.Type) { + this.Type = data.Type; + this.Details = createEventDetails(data); + } else { + this.Type = data.status; + this.Details = data.from; + } } diff --git a/assets/css/app.css b/assets/css/app.css index 3921400ff..ecfd4e977 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -183,5 +183,10 @@ input[type="radio"] { } .widget .widget-body table tbody .image-tag { - font-size: 90% !important; + font-size: 90% !important; +} + +.terminal-container { + width: 100%; + padding: 10px 5px; } diff --git a/bower.json b/bower.json index 56800d37d..8220a2dde 100644 --- a/bower.json +++ b/bower.json @@ -1,6 +1,6 @@ { "name": "uifordocker", - "version": "1.5.0", + "version": "1.6.0", "homepage": "https://github.com/kevana/ui-for-docker", "authors": [ "Michael Crosby ", @@ -30,7 +30,6 @@ "angular-ui-router": "^0.2.15", "angular-sanitize": "~1.5.0", "angular-mocks": "~1.5.0", - "angular-oboe": "*", "angular-resource": "~1.5.0", "angular-ui-select": "~0.17.1", "bootstrap": "~3.3.6", @@ -39,6 +38,8 @@ "jquery.gritter": "1.7.4", "lodash": "4.12.0", "rdash-ui": "1.0.*", + "moment": "~2.14.1", + "xterm.js": "~1.0.0" }, "resolutions": { "angular": "1.5.5" diff --git a/dashboard.png b/dashboard.png index f7ebe5dc8..b9ac37b36 100644 Binary files a/dashboard.png and b/dashboard.png differ diff --git a/dockerui.go b/dockerui.go deleted file mode 100644 index 3cc089dbe..000000000 --- a/dockerui.go +++ /dev/null @@ -1,243 +0,0 @@ -package main // import "github.com/cloudinovasi/ui-for-docker" - -import ( - "io" - "log" - "net" - "net/http" - "net/http/httputil" - "net/url" - "encoding/json" - "os" - "strings" - "github.com/gorilla/csrf" - "io/ioutil" - "fmt" - "github.com/gorilla/securecookie" - "gopkg.in/alecthomas/kingpin.v2" - "crypto/tls" - "crypto/x509" -) - -var ( - endpoint = kingpin.Flag("host", "Dockerd endpoint").Default("unix:///var/run/docker.sock").Short('H').String() - addr = kingpin.Flag("bind", "Address and port to serve UI For Docker").Default(":9000").Short('p').String() - assets = kingpin.Flag("assets", "Path to the assets").Default(".").Short('a').String() - data = kingpin.Flag("data", "Path to the data").Default(".").Short('d').String() - swarm = kingpin.Flag("swarm", "Swarm cluster support").Default("false").Short('s').Bool() - tlsverify = kingpin.Flag("tlsverify", "TLS support").Default("false").Bool() - tlscacert = kingpin.Flag("tlscacert", "Path to the CA").Default("/certs/ca.pem").String() - tlscert = kingpin.Flag("tlscert", "Path to the TLS certificate file").Default("/certs/cert.pem").String() - tlskey = kingpin.Flag("tlskey", "Path to the TLS key").Default("/certs/key.pem").String() - labels = LabelParser(kingpin.Flag("hide-label", "Hide containers with a specific label in the UI").Short('l')) - authKey []byte - authKeyFile = "authKey.dat" -) - -type UnixHandler struct { - path string -} - -type TlsFlags struct { - tls bool - caPath string - certPath string - keyPath string -} - -type Config struct { - Swarm bool `json:"swarm"` - HiddenLabels Labels `json:"hiddenLabels"` -} - -type Label struct { - Name string `json:"name"` - Value string `json:"value"` -} - -type Labels []Label - -func (l *Labels) Set(value string) error { - parts := strings.SplitN(value, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("expected HEADER=VALUE got '%s'", value) - } - label := new(Label) - label.Name = parts[0] - label.Value = parts[1] - *l = append(*l, *label) - return nil -} - -func (l *Labels) String() string { - return "" -} - -func (l *Labels) IsCumulative() bool { - return true -} - -func LabelParser(s kingpin.Settings) (target *[]Label) { - target = new([]Label) - s.SetValue((*Labels)(target)) - return -} - -func (h *UnixHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - conn, err := net.Dial("unix", h.path) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - c := httputil.NewClientConn(conn, nil) - defer c.Close() - - res, err := c.Do(r) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - log.Println(err) - return - } - defer res.Body.Close() - - copyHeader(w.Header(), res.Header) - if _, err := io.Copy(w, res.Body); err != nil { - log.Println(err) - } -} - -func copyHeader(dst, src http.Header) { - for k, vv := range src { - for _, v := range vv { - dst.Add(k, v) - } - } -} - -func configurationHandler(w http.ResponseWriter, r *http.Request, c Config) { - json.NewEncoder(w).Encode(c) -} - -func createTcpHandler(u *url.URL) http.Handler { - u.Scheme = "http"; - return httputil.NewSingleHostReverseProxy(u) -} - -func createTlsConfig(tlsFlags TlsFlags) *tls.Config { - cert, err := tls.LoadX509KeyPair(tlsFlags.certPath, tlsFlags.keyPath) - if err != nil { - log.Fatal(err) - } - caCert, err := ioutil.ReadFile(tlsFlags.caPath) - if err != nil { - log.Fatal(err) - } - caCertPool := x509.NewCertPool() - caCertPool.AppendCertsFromPEM(caCert) - tlsConfig := &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: caCertPool, - } - return tlsConfig; -} - -func createTcpHandlerWithTLS(u *url.URL, tlsFlags TlsFlags) http.Handler { - u.Scheme = "https"; - var tlsConfig = createTlsConfig(tlsFlags) - proxy := httputil.NewSingleHostReverseProxy(u) - proxy.Transport = &http.Transport{ - TLSClientConfig: tlsConfig, - } - return proxy; -} - -func createUnixHandler(e string) http.Handler { - return &UnixHandler{e} -} - -func createHandler(dir string, d string, e string, c Config, tlsFlags TlsFlags) http.Handler { - var ( - mux = http.NewServeMux() - fileHandler = http.FileServer(http.Dir(dir)) - h http.Handler - ) - u, perr := url.Parse(e) - if perr != nil { - log.Fatal(perr) - } - if u.Scheme == "tcp" { - if tlsFlags.tls { - h = createTcpHandlerWithTLS(u, tlsFlags) - } else { - h = createTcpHandler(u) - } - } else if u.Scheme == "unix" { - var socketPath = u.Path - if _, err := os.Stat(socketPath); err != nil { - if os.IsNotExist(err) { - log.Fatalf("unix socket %s does not exist", socketPath) - } - log.Fatal(err) - } - h = createUnixHandler(socketPath) - } else { - log.Fatalf("Bad Docker enpoint: %s. Only unix:// and tcp:// are supported.", e) - } - - // Use existing csrf authKey if present or generate a new one. - var authKeyPath = d + "/" + authKeyFile - dat, err := ioutil.ReadFile(authKeyPath) - if err != nil { - fmt.Println(err) - authKey = securecookie.GenerateRandomKey(32) - err := ioutil.WriteFile(authKeyPath, authKey, 0644) - if err != nil { - fmt.Println("unable to persist auth key", err) - } - } else { - authKey = dat - } - - CSRF := csrf.Protect( - authKey, - csrf.HttpOnly(false), - csrf.Secure(false), - ) - - mux.Handle("/dockerapi/", http.StripPrefix("/dockerapi", h)) - mux.Handle("/", fileHandler) - mux.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) { - configurationHandler(w, r, c) - }) - return CSRF(csrfWrapper(mux)) -} - -func csrfWrapper(h http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-CSRF-Token", csrf.Token(r)) - h.ServeHTTP(w, r) - }) -} - -func main() { - kingpin.Version("1.5.0") - kingpin.Parse() - - configuration := Config{ - Swarm: *swarm, - HiddenLabels: *labels, - } - - tlsFlags := TlsFlags{ - tls: *tlsverify, - caPath: *tlscacert, - certPath: *tlscert, - keyPath: *tlskey, - } - - handler := createHandler(*assets, *data, *endpoint, configuration, tlsFlags) - if err := http.ListenAndServe(*addr, handler); err != nil { - log.Fatal(err) - } -} diff --git a/gruntFile.js b/gruntFile.js index 3ff9bbc31..86c2f3ebe 100644 --- a/gruntFile.js +++ b/gruntFile.js @@ -68,11 +68,12 @@ module.exports = function (grunt) { jsTpl: ['<%= distdir %>/templates/**/*.js'], jsVendor: [ 'bower_components/jquery/dist/jquery.min.js', - 'assets/js/jquery.gritter.js', // Using custom version to fix error in minified build due to "use strict" 'bower_components/bootstrap/dist/js/bootstrap.min.js', 'bower_components/Chart.js/Chart.min.js', 'bower_components/lodash/dist/lodash.min.js', - 'bower_components/oboe/dist/oboe-browser.js', + 'bower_components/moment/min/moment.min.js', + 'bower_components/xterm.js/src/xterm.js', + 'assets/js/jquery.gritter.js', // Using custom version to fix error in minified build due to "use strict" 'assets/js/legend.js' // Not a bower package ], specs: ['test/**/*.spec.js'], @@ -85,7 +86,8 @@ module.exports = function (grunt) { 'bower_components/jquery.gritter/css/jquery.gritter.css', 'bower_components/font-awesome/css/font-awesome.min.css', 'bower_components/rdash-ui/dist/css/rdash.min.css', - 'bower_components/angular-ui-select/dist/select.min.css' + 'bower_components/angular-ui-select/dist/select.min.css', + 'bower_components/xterm.js/src/xterm.css' ] }, clean: { @@ -156,7 +158,6 @@ module.exports = function (grunt) { 'bower_components/angular-ui-router/release/angular-ui-router.min.js', 'bower_components/angular-resource/angular-resource.min.js', 'bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js', - 'bower_components/angular-oboe/dist/angular-oboe.min.js', 'bower_components/angular-ui-select/dist/select.min.js'], dest: '<%= distdir %>/js/angular.js' } @@ -255,10 +256,10 @@ module.exports = function (grunt) { }, buildBinary: { command: [ - 'docker run --rm -v $(pwd):/src centurylink/golang-builder', - 'shasum ui-for-docker > ui-for-docker-checksum.txt', + 'docker run --rm -v $(pwd)/api:/src centurylink/golang-builder', + 'shasum api/ui-for-docker > ui-for-docker-checksum.txt', 'mkdir -p dist', - 'mv ui-for-docker dist/' + 'mv api/ui-for-docker dist/' ].join(' && ') }, run: { diff --git a/index.html b/index.html index a42f292a7..434d0f048 100644 --- a/index.html +++ b/index.html @@ -52,6 +52,9 @@ + diff --git a/package.json b/package.json index 829adeb49..025680d1e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "author": "Michael Crosby & Kevan Ahlquist", "name": "uifordocker", "homepage": "https://github.com/kevana/ui-for-docker", - "version": "1.5.0", + "version": "1.6.0", "repository": { "type": "git", "url": "git@github.com:kevana/ui-for-docker.git"
+ + CPU + + + + + + Memory + + + + IP @@ -123,6 +115,8 @@
{{ node.name }}{{ node.cpu }}{{ node.memory }} {{ node.ip }} {{ node.version }} {{ node.status }}