1
0
mirror of https://github.com/bingohuang/docker-labs.git synced 2025-07-14 18:27:25 +08:00

Huge refactor to have everything working with socket.io

It fixes lots of bugs, can fallback to long polling, resize viewport of
terminals and share clients state of the session, so they all see the
same thing.
This commit is contained in:
Jonathan Leibiusky (@xetorthio) 2016-11-10 10:42:08 -03:00
parent 9b6991f130
commit 8e4981d24f
16 changed files with 566 additions and 249 deletions

View File

@ -9,8 +9,7 @@ A live version is available at: http://play-with-docker.com/
## Requirements ## Requirements
Docker 1.13-dev or higher is required as **attachable** overlay networks feature is required. Docker 1.12.1 or higher is required.
Here's a boot2docker 1.13-dev ISO you can use to spawn a VM with this version: https://dl.dropboxusercontent.com/u/29887388/boot2docker.iso
## Installation ## Installation

31
api.go
View File

@ -4,32 +4,37 @@ import (
"log" "log"
"net/http" "net/http"
"golang.org/x/net/websocket"
"github.com/franela/play-with-docker/handlers" "github.com/franela/play-with-docker/handlers"
"github.com/go-zoo/bone" "github.com/franela/play-with-docker/services"
"github.com/gorilla/mux"
"github.com/urfave/negroni" "github.com/urfave/negroni"
) )
func main() { func main() {
mux := bone.New() server := services.CreateWSServer()
mux.Get("/ping", http.HandlerFunc(handlers.Ping)) server.On("connection", handlers.WS)
mux.Get("/", http.HandlerFunc(handlers.NewSession)) server.On("error", handlers.WSError)
mux.Get("/sessions/:sessionId", http.HandlerFunc(handlers.GetSession))
mux.Post("/sessions/:sessionId/instances", http.HandlerFunc(handlers.NewInstance)) r := mux.NewRouter()
mux.Delete("/sessions/:sessionId/instances/:instanceName", http.HandlerFunc(handlers.DeleteInstance)) r.StrictSlash(false)
r.HandleFunc("/ping", http.HandlerFunc(handlers.Ping)).Methods("GET")
r.HandleFunc("/", http.HandlerFunc(handlers.NewSession)).Methods("GET")
r.HandleFunc("/sessions/{sessionId}", http.HandlerFunc(handlers.GetSession)).Methods("GET")
r.HandleFunc("/sessions/{sessionId}/instances", http.HandlerFunc(handlers.NewInstance)).Methods("POST")
r.HandleFunc("/sessions/{sessionId}/instances/{instanceName}", http.HandlerFunc(handlers.DeleteInstance)).Methods("DELETE")
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "./www/index.html") http.ServeFile(w, r, "./www/index.html")
}) })
mux.Get("/p/:sessionId", h) r.HandleFunc("/p/{sessionId}", h).Methods("GET")
mux.Get("/assets/*", http.FileServer(http.Dir("./www"))) r.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./www")))
mux.Get("/sessions/:sessionId/instances/:instanceName/attach", websocket.Handler(handlers.Exec)) r.Handle("/sessions/{sessionId}/ws/", server)
n := negroni.Classic() n := negroni.Classic()
n.UseHandler(mux) n.UseHandler(r)
log.Fatal(http.ListenAndServe("0.0.0.0:3000", n)) log.Fatal(http.ListenAndServe("0.0.0.0:3000", n))

View File

@ -4,12 +4,13 @@ import (
"net/http" "net/http"
"github.com/franela/play-with-docker/services" "github.com/franela/play-with-docker/services"
"github.com/go-zoo/bone" "github.com/gorilla/mux"
) )
func DeleteInstance(rw http.ResponseWriter, req *http.Request) { func DeleteInstance(rw http.ResponseWriter, req *http.Request) {
sessionId := bone.GetValue(req, "sessionId") vars := mux.Vars(req)
instanceName := bone.GetValue(req, "instanceName") sessionId := vars["sessionId"]
instanceName := vars["instanceName"]
s := services.GetSession(sessionId) s := services.GetSession(sessionId)
i := services.GetInstance(s, instanceName) i := services.GetInstance(s, instanceName)

View File

@ -2,14 +2,17 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"log"
"net/http" "net/http"
"github.com/franela/play-with-docker/services" "github.com/franela/play-with-docker/services"
"github.com/go-zoo/bone" "github.com/gorilla/mux"
) )
func GetSession(rw http.ResponseWriter, req *http.Request) { func GetSession(rw http.ResponseWriter, req *http.Request) {
sessionId := bone.GetValue(req, "sessionId") vars := mux.Vars(req)
sessionId := vars["sessionId"]
log.Println(sessionId)
session := services.GetSession(sessionId) session := services.GetSession(sessionId)

View File

@ -6,16 +6,18 @@ import (
"net/http" "net/http"
"github.com/franela/play-with-docker/services" "github.com/franela/play-with-docker/services"
"github.com/go-zoo/bone" "github.com/gorilla/mux"
) )
func NewInstance(rw http.ResponseWriter, req *http.Request) { func NewInstance(rw http.ResponseWriter, req *http.Request) {
sessionId := bone.GetValue(req, "sessionId") vars := mux.Vars(req)
sessionId := vars["sessionId"]
s := services.GetSession(sessionId) s := services.GetSession(sessionId)
s.Lock() s.Lock()
if len(s.Instances) >= 5 { if len(s.Instances) >= 5 {
s.Unlock()
rw.WriteHeader(http.StatusConflict) rw.WriteHeader(http.StatusConflict)
return return
} }

View File

@ -1,22 +1,42 @@
package handlers package handlers
import ( import (
"io" "log"
"golang.org/x/net/context"
"golang.org/x/net/websocket"
"golang.org/x/text/encoding"
"github.com/franela/play-with-docker/cookoo"
"github.com/franela/play-with-docker/services" "github.com/franela/play-with-docker/services"
"github.com/go-zoo/bone" "github.com/googollee/go-socket.io"
"github.com/twinj/uuid" "github.com/gorilla/mux"
) )
// Echo the data received on the WebSocket. func WS(so socketio.Socket) {
func Exec(ws *websocket.Conn) { vars := mux.Vars(so.Request())
sessionId := bone.GetValue(ws.Request(), "sessionId")
instanceName := bone.GetValue(ws.Request(), "instanceName") sessionId := vars["sessionId"]
session := services.GetSession(sessionId)
if session == nil {
log.Printf("Session with id [%s] does not exist!\n", sessionId)
return
}
session.AddNewClient(services.NewClient(so, session))
}
func WSError(so socketio.Socket) {
log.Println("error ws")
}
/*
so.Join(sessionId)
// TODO: Reset terminal geometry
so.On("resize", func(cols, rows int) {
// TODO: Reset terminal geometry
})
so.On("disconnection", func() {
//TODO: reset the best terminal geometry
})
ctx := context.Background() ctx := context.Background()
@ -62,5 +82,5 @@ func Exec(ws *websocket.Conn) {
case <-ctx.Done(): case <-ctx.Done():
} }
} }
} }
*/

60
services/client.go Normal file
View File

@ -0,0 +1,60 @@
package services
import "github.com/googollee/go-socket.io"
type ViewPort struct {
Rows uint
Cols uint
}
type Client struct {
SO socketio.Socket
ViewPort ViewPort
}
func (c *Client) ResizeViewPort(cols, rows uint) {
c.ViewPort.Rows = rows
c.ViewPort.Cols = cols
}
func NewClient(so socketio.Socket, session *Session) *Client {
so.Join(session.Id)
c := &Client{SO: so}
so.On("terminal in", func(name, data string) {
// User wrote something on the terminal. Need to write it to the instance terminal
instance := GetInstance(session, name)
if len(data) > 0 {
instance.Conn.Conn.Write([]byte(data))
}
})
so.On("viewport resize", func(cols, rows uint) {
// User resized his viewport
c.ResizeViewPort(cols, rows)
vp := session.GetSmallestViewPort()
// Resize all terminals in the session
wsServer.BroadcastTo(session.Id, "viewport resize", vp.Cols, vp.Rows)
for _, instance := range session.Instances {
instance.ResizeTerminal(vp.Cols, vp.Rows)
}
})
so.On("disconnection", func() {
// Client has disconnected. Remove from session and recheck terminal sizes.
for i, cl := range session.Clients {
if cl.SO.Id() == c.SO.Id() {
session.Clients = append(session.Clients[:i], session.Clients[i+1:]...)
break
}
}
vp := session.GetSmallestViewPort()
// Resize all terminals in the session
wsServer.BroadcastTo(session.Id, "viewport resize", vp.Cols, vp.Rows)
for _, instance := range session.Instances {
instance.ResizeTerminal(vp.Cols, vp.Rows)
}
})
return c
}

View File

@ -4,8 +4,6 @@ import (
"log" "log"
"strings" "strings"
ptypes "github.com/franela/play-with-docker/types"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/client" "github.com/docker/docker/client"
@ -29,9 +27,7 @@ func GetContainerInfo(id string) (types.ContainerJSON, error) {
} }
func CreateNetwork(name string) error { func CreateNetwork(name string) error {
// TODO: This line appears to give an error when running on localhost:3000 opts := types.NetworkCreate{}
// when driver is specified a name must be given.
opts := types.NetworkCreate{Attachable: true, Driver: "overlay"}
_, err := c.NetworkCreate(context.Background(), name, opts) _, err := c.NetworkCreate(context.Background(), name, opts)
if err != nil { if err != nil {
@ -71,16 +67,14 @@ func AttachExecConnection(execId string, ctx context.Context) (*types.HijackedRe
return nil, err return nil, err
} }
err = c.ContainerExecResize(ctx, execId, types.ResizeOptions{Height: 24, Width: 80})
if err != nil {
return nil, err
}
return &conn, nil return &conn, nil
} }
func CreateInstance(net string, dindImage string) (*ptypes.Instance, error) { func ResizeExecConnection(execId string, ctx context.Context, cols, rows uint) error {
return c.ContainerExecResize(ctx, execId, types.ResizeOptions{Height: rows, Width: cols})
}
func CreateInstance(net string, dindImage string) (*Instance, error) {
var maximumPidLimit int64 var maximumPidLimit int64
maximumPidLimit = 150 // Set a ulimit value to prevent misuse maximumPidLimit = 150 // Set a ulimit value to prevent misuse
@ -104,7 +98,7 @@ func CreateInstance(net string, dindImage string) (*ptypes.Instance, error) {
return nil, err return nil, err
} }
return &ptypes.Instance{Name: strings.Replace(cinfo.Name, "/", "", 1), IP: cinfo.NetworkSettings.Networks[net].IPAddress}, nil return &Instance{Name: strings.Replace(cinfo.Name, "/", "", 1), IP: cinfo.NetworkSettings.Networks[net].IPAddress}, nil
} }
func DeleteContainer(id string) error { func DeleteContainer(id string) error {

View File

@ -1,12 +1,25 @@
package services package services
import ( import (
"context"
"io"
"log" "log"
"os" "os"
"github.com/franela/play-with-docker/types" "golang.org/x/text/encoding"
"github.com/docker/docker/api/types"
) )
type Instance struct {
Session *Session `json:"-"`
Name string `json:"name"`
IP string `json:"ip"`
Conn *types.HijackedResponse `json:"-"`
ExecId string `json:"-"`
Ctx context.Context `json:"-"`
}
var dindImage string var dindImage string
var defaultDindImageName string var defaultDindImageName string
@ -23,31 +36,78 @@ func getDindImageName() string {
return dindImage return dindImage
} }
func NewInstance(session *types.Session) (*types.Instance, error) { func NewInstance(session *Session) (*Instance, error) {
//TODO: Validate that a session can only have 5 instances //TODO: Validate that a session can only have 5 instances
//TODO: Create in redis
log.Printf("NewInstance - using image: [%s]\n", dindImage) log.Printf("NewInstance - using image: [%s]\n", dindImage)
instance, err := CreateInstance(session.Id, dindImage) instance, err := CreateInstance(session.Id, dindImage)
instance.Session = session
if err != nil { if err != nil {
return nil, err return nil, err
} }
if session.Instances == nil { if session.Instances == nil {
session.Instances = make(map[string]*types.Instance) session.Instances = make(map[string]*Instance)
} }
session.Instances[instance.Name] = instance session.Instances[instance.Name] = instance
go instance.Exec()
wsServer.BroadcastTo(session.Id, "new instance", instance.Name, instance.IP)
return instance, nil return instance, nil
} }
func GetInstance(session *types.Session, name string) *types.Instance { type sessionWriter struct {
instance *Instance
}
func (s *sessionWriter) Write(p []byte) (n int, err error) {
wsServer.BroadcastTo(s.instance.Session.Id, "terminal out", s.instance.Name, string(p))
return len(p), nil
}
func (i *Instance) ResizeTerminal(cols, rows uint) {
ResizeExecConnection(i.ExecId, i.Ctx, cols, rows)
}
func (i *Instance) Exec() {
i.Ctx = context.Background()
id, err := CreateExecConnection(i.Name, i.Ctx)
if err != nil {
return
}
i.ExecId = id
conn, err := AttachExecConnection(id, i.Ctx)
if err != nil {
return
}
i.Conn = conn
go func() {
encoder := encoding.Replacement.NewEncoder()
sw := &sessionWriter{instance: i}
io.Copy(encoder.Writer(sw), conn.Reader)
}()
select {
case <-i.Ctx.Done():
}
}
func GetInstance(session *Session, name string) *Instance {
//TODO: Use redis //TODO: Use redis
return session.Instances[name] return session.Instances[name]
} }
func DeleteInstance(session *types.Session, instance *types.Instance) error { func DeleteInstance(session *Session, instance *Instance) error {
//TODO: Use redis //TODO: Use redis
delete(session.Instances, instance.Name) delete(session.Instances, instance.Name)
return DeleteContainer(instance.Name) err := DeleteContainer(instance.Name)
wsServer.BroadcastTo(session.Id, "delete instance", instance.Name)
return err
} }

View File

@ -2,30 +2,66 @@ package services
import ( import (
"log" "log"
"math"
"sync"
"time" "time"
"github.com/franela/play-with-docker/types" "github.com/googollee/go-socket.io"
"github.com/twinj/uuid" "github.com/twinj/uuid"
) )
var sessions map[string]*types.Session var wsServer *socketio.Server
func init() { type Session struct {
sessions = make(map[string]*types.Session) sync.Mutex
Id string `json:"id"`
Instances map[string]*Instance `json:"instances"`
Clients []*Client `json:"-"`
} }
func NewSession() (*types.Session, error) { func (s *Session) GetSmallestViewPort() ViewPort {
s := &types.Session{} minRows := s.Clients[0].ViewPort.Rows
minCols := s.Clients[0].ViewPort.Cols
for _, c := range s.Clients {
minRows = uint(math.Min(float64(minRows), float64(c.ViewPort.Rows)))
minCols = uint(math.Min(float64(minCols), float64(c.ViewPort.Cols)))
}
return ViewPort{Rows: minRows, Cols: minCols}
}
func (s *Session) AddNewClient(c *Client) {
s.Clients = append(s.Clients, c)
}
var sessions map[string]*Session
func init() {
sessions = make(map[string]*Session)
}
func CreateWSServer() *socketio.Server {
server, err := socketio.NewServer(nil)
if err != nil {
log.Fatal(err)
}
wsServer = server
return server
}
func NewSession() (*Session, error) {
s := &Session{}
s.Id = uuid.NewV4().String() s.Id = uuid.NewV4().String()
s.Instances = map[string]*types.Instance{} s.Instances = map[string]*Instance{}
log.Printf("NewSession id=[%s]\n", s.Id) log.Printf("NewSession id=[%s]\n", s.Id)
//TODO: Store in something like redis
sessions[s.Id] = s sessions[s.Id] = s
// Schedule cleanup of the session // Schedule cleanup of the session
time.AfterFunc(1*time.Hour, func() { time.AfterFunc(1*time.Hour, func() {
s = GetSession(s.Id) s = GetSession(s.Id)
wsServer.BroadcastTo(s.Id, "session end")
log.Printf("Starting clean up of session [%s]\n", s.Id) log.Printf("Starting clean up of session [%s]\n", s.Id)
for _, i := range s.Instances { for _, i := range s.Instances {
i.Conn.Close() i.Conn.Close()
@ -41,13 +77,13 @@ func NewSession() (*types.Session, error) {
}) })
if err := CreateNetwork(s.Id); err != nil { if err := CreateNetwork(s.Id); err != nil {
log.Println("ERROR NETWORKING")
return nil, err return nil, err
} }
return s, nil return s, nil
} }
func GetSession(sessionId string) *types.Session { func GetSession(sessionId string) *Session {
//TODO: Use redis
return sessions[sessionId] return sessions[sessionId]
} }

View File

@ -1,21 +0,0 @@
package types
import (
"sync"
"github.com/docker/docker/api/types"
"github.com/franela/play-with-docker/cookoo"
)
type Session struct {
sync.Mutex
Id string `json:"id"`
Instances map[string]*Instance `json:"instances"`
}
type Instance struct {
Name string `json:"name"`
IP string `json:"ip"`
Stdout *cookoo.MultiWriter `json:"-"`
Conn *types.HijackedResponse `json:"-"`
}

View File

@ -3,34 +3,59 @@
var app = angular.module('DockerPlay', ['ngMaterial']); var app = angular.module('DockerPlay', ['ngMaterial']);
app.controller('PlayController', ['$scope', '$log', '$http', '$location', '$timeout', '$mdDialog', function($scope, $log, $http, $location, $timeout, $mdDialog) { app.controller('PlayController', ['$scope', '$log', '$http', '$location', '$timeout', '$mdDialog', '$window', function($scope, $log, $http, $location, $timeout, $mdDialog, $window) {
$scope.sessionId = window.location.pathname.replace('/p/', ''); $scope.sessionId = window.location.pathname.replace('/p/', '');
$scope.instances = []; $scope.instances = [];
$scope.idx = {};
$scope.selectedInstance = null; $scope.selectedInstance = null;
$scope.isAlive = true;
$scope.showAlert = function(title, content) { angular.element($window).bind('resize', function(){
if ($scope.selectedInstance) {
$scope.resize($scope.selectedInstance.term.proposeGeometry());
}
});
$scope.showAlert = function(title, content, parent) {
$mdDialog.show( $mdDialog.show(
$mdDialog.alert() $mdDialog.alert()
.parent(angular.element(document.querySelector('#popupContainer'))) .parent(angular.element(document.querySelector(parent || '#popupContainer')))
.clickOutsideToClose(true) .clickOutsideToClose(true)
.title(title) .title(title)
.textContent(content) .textContent(content)
.ok('Got it!') .ok('Got it!')
); );
} }
$scope.resize = function(geometry) {
$scope.socket.emit('viewport resize', geometry.cols, geometry.rows);
}
$scope.upsertInstance = function(info) {
var i = info;
if (!$scope.idx[i.name]) {
$scope.instances.push(i);
i.buffer = '';
$scope.idx[i.name] = i;
} else {
$scope.idx[i.name].ip = i.ip;
}
return $scope.idx[i.name];
}
$scope.newInstance = function() { $scope.newInstance = function() {
$http({ $http({
method: 'POST', method: 'POST',
url: '/sessions/' + $scope.sessionId + '/instances', url: '/sessions/' + $scope.sessionId + '/instances',
}).then(function(response) { }).then(function(response) {
var i = response.data; var i = $scope.upsertInstance(response.data);
$scope.instances.push(i); $scope.showInstance(i);
$scope.showInstance(i);
}, function(response) { }, function(response) {
if (response.status == 409) { if (response.status == 409) {
$scope.showAlert('Max instances reached', 'Maximum number of instances reached') $scope.showAlert('Max instances reached', 'Maximum number of instances reached')
} }
}); });
} }
@ -39,18 +64,64 @@
method: 'GET', method: 'GET',
url: '/sessions/' + $scope.sessionId, url: '/sessions/' + $scope.sessionId,
}).then(function(response) { }).then(function(response) {
var socket = io({path: '/sessions/' + sessionId + '/ws'});
socket.on('terminal out', function(name, data) {
var instance = $scope.idx[name];
if (!instance) {
// instance is new and was created from another client, we should add it
$scope.upsertInstance({name: name});
instance = $scope.idx[name];
}
if (!instance.term) {
instance.buffer += data;
} else {
instance.term.write(data);
}
});
socket.on('session end', function() {
$scope.showAlert('Session timedout!', 'Your session has expire and all your instances has been deleted.', '#sessionEnd')
$scope.isAlive = false;
});
socket.on('viewport', function(rows, cols) {
});
socket.on('new instance', function(name, ip) {
$scope.upsertInstance({name: name, ip: ip});
$scope.$apply(function() {
if ($scope.instances.length == 1) {
$scope.showInstance($scope.instances[0]);
}
});
});
socket.on('delete instance', function(name) {
$scope.removeInstance(name);
$scope.$apply();
});
socket.on('viewport resize', function(cols, rows) {
// viewport has changed, we need to resize all terminals
$scope.instances.forEach(function(instance) {
instance.term.resize(cols, rows);
});
});
$scope.socket = socket;
var i = response.data; var i = response.data;
for (var k in i.instances) { for (var k in i.instances) {
var instance = i.instances[k]; var instance = i.instances[k];
$scope.instances.push(instance); $scope.instances.push(instance);
$scope.idx[instance.name] = instance;
} }
if ($scope.instances.length) { if ($scope.instances.length) {
$scope.showInstance($scope.instances[0]); $scope.showInstance($scope.instances[0]);
} }
// Since session exists, we check it still exists every 10 seconds
$scope.checkHandler = setInterval(checkSession, 10*1000);
}, function(response) { }, function(response) {
if (response.status == 404) { if (response.status == 404) {
document.write('session not found'); document.write('session not found');
@ -59,25 +130,41 @@
}); });
} }
$scope.showInstance = function(instance) { $scope.showInstance = function(instance) {
$scope.selectedInstance = instance; $scope.selectedInstance = instance;
if (!instance.isAttached) { if (!instance.creatingTerminal) {
$timeout(function() {instance.term = createTerminal(instance.name);}); instance.creatingTerminal = true;
instance.isAttached = true; if (!instance.term) {
} else { $timeout(function() {
$timeout(function() {instance.term.focus()}); createTerminal(instance);
} instance.term.focus();
} }, 0, false);
} else {
$timeout(function() {
instance.term.focus();
}, 0, false);
}
}
}
$scope.removeInstance = function(name) {
if ($scope.idx[name]) {
delete $scope.idx[name];
$scope.instances = $scope.instances.filter(function(i) {
return i.name != name;
});
if ($scope.instances.length) {
$scope.showInstance($scope.instances[0]);
}
}
}
$scope.deleteInstance = function(instance) { $scope.deleteInstance = function(instance) {
$http({ $http({
method: 'DELETE', method: 'DELETE',
url: '/sessions/' + $scope.sessionId + '/instances/' + instance.name, url: '/sessions/' + $scope.sessionId + '/instances/' + instance.name,
}).then(function(response) { }).then(function(response) {
$scope.instances = $scope.instances.filter(function(i) { return i.name != instance.name }); $scope.removeInstance(instance.name);
if ($scope.instances.length) {
$scope.showInstance($scope.instances[0]);
}
}, function(response) { }, function(response) {
console.log('error', response); console.log('error', response);
}); });
@ -85,16 +172,35 @@
$scope.getSession($scope.sessionId); $scope.getSession($scope.sessionId);
function checkSession() { function createTerminal(instance, cb) {
$http({ if (instance.term) {
method: 'GET', return instance.term;
url: '/sessions/' + $scope.sessionId, }
}).then(function(response) {}, function(response) {
if (response.status == 404) { var terminalContainer = document.getElementById('terminal-'+ instance.name);
clearInterval($scope.checkHandler);
$scope.showAlert('Session timedout!', 'Your session has expire and all your instances has been deleted.') var term = new Terminal({
} cursorBlink: false
}); });
}
term.open(terminalContainer);
$scope.resize(term.proposeGeometry());
term.on('data', function(d) {
$scope.socket.emit('terminal in', instance.name, d);
});
instance.term = term;
if (instance.buffer) {
term.write(instance.buffer);
instance.buffer = '';
}
if (cb) {
cb();
}
}
}]); }]);
})(); })();

View File

@ -1,61 +0,0 @@
//var term,
//protocol,
//socketURL,
//socket,
//pid,
//charWidth,
//charHeight;
//var terminalContainer = document.getElementById('terminal-container'),
//optionElements = {
//cursorBlink: document.querySelector('#option-cursor-blink')
//},
//colsElement = document.getElementById('cols'),
//rowsElement = document.getElementById('rows');
//function setTerminalSize () {
//var cols = parseInt(colsElement.value),
//rows = parseInt(rowsElement.value),
//width = (cols * charWidth).toString() + 'px',
//height = (rows * charHeight).toString() + 'px';
//terminalContainer.style.width = width;
//terminalContainer.style.height = height;
//term.resize(cols, rows);
//}
//colsElement.addEventListener('change', setTerminalSize);
//rowsElement.addEventListener('change', setTerminalSize);
//optionElements.cursorBlink.addEventListener('change', createTerminal);
//createTerminal();
//
//
function createTerminal(name) {
var terminalContainer = document.getElementById('terminal-' + name);
// Clean terminal
while (terminalContainer.children.length) {
terminalContainer.removeChild(terminalContainer.children[0]);
}
var term = new Terminal({
cursorBlink: false
});
var sessionId = location.pathname.substr(location.pathname.lastIndexOf("/")+1);
protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
socketURL = protocol + location.hostname + ((location.port) ? (':' + location.port) : '') + '/sessions/' + sessionId + '/instances/' + name + '/attach';
term.open(terminalContainer);
socket = new WebSocket(socketURL);
socket.onopen = runRealTerminal(term);
return term;
}
function runRealTerminal(term) {
term.attach(socket);
term._initialized = true;
}

View File

@ -1,3 +1,20 @@
.selected button { .selected button {
background-color: rgba(158,158,158,0.2); background-color: rgba(158,158,158,0.2);
} }
md-card-content.terminal {
background-color: rgb(113, 113, 113);
padding: 0;
}
.terminal {
background-color: rgb(113, 113, 113);
}
.terminal .xterm-viewport {
background-color: rgb(113, 113, 113);
}
.terminal .xterm-rows {
background-color: #000;
}

View File

@ -0,0 +1,86 @@
/*
* Fit terminal columns and rows to the dimensions of its
* DOM element.
*
* Approach:
* - Rows: Truncate the division of the terminal parent element height
* by the terminal row height
*
* - Columns: Truncate the division of the terminal parent element width by
* the terminal character width (apply display: inline at the
* terminal row and truncate its width with the current number
* of columns)
*/
(function (fit) {
if (typeof exports === 'object' && typeof module === 'object') {
/*
* CommonJS environment
*/
module.exports = fit(require('../../dist/xterm'));
} else if (typeof define == 'function') {
/*
* Require.js is available
*/
define(['../../dist/xterm'], fit);
} else {
/*
* Plain browser environment
*/
fit(window.Terminal);
}
})(function (Xterm) {
/**
* This module provides methods for fitting a terminal's size to a parent container.
*
* @module xterm/addons/fit/fit
*/
var exports = {};
exports.proposeGeometry = function (term) {
var parentElementStyle = window.getComputedStyle(term.element.parentElement),
parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')),
parentElementWidth = parseInt(parentElementStyle.getPropertyValue('width')),
elementStyle = window.getComputedStyle(term.element),
elementPaddingVer = parseInt(elementStyle.getPropertyValue('padding-top')) + parseInt(elementStyle.getPropertyValue('padding-bottom')),
elementPaddingHor = parseInt(elementStyle.getPropertyValue('padding-right')) + parseInt(elementStyle.getPropertyValue('padding-left')),
availableHeight = parentElementHeight - elementPaddingVer,
availableWidth = parentElementWidth - elementPaddingHor,
container = term.rowContainer,
subjectRow = term.rowContainer.firstElementChild,
contentBuffer = subjectRow.innerHTML,
characterHeight,
rows,
characterWidth,
cols,
geometry;
subjectRow.style.display = 'inline';
subjectRow.innerHTML = 'W'; // Common character for measuring width, although on monospace
characterWidth = subjectRow.getBoundingClientRect().width;
subjectRow.style.display = ''; // Revert style before calculating height, since they differ.
characterHeight = parseInt(subjectRow.offsetHeight);
subjectRow.innerHTML = contentBuffer;
rows = parseInt(availableHeight / characterHeight);
cols = parseInt(availableWidth / characterWidth) - 1;
geometry = {cols: cols, rows: rows};
return geometry;
};
exports.fit = function (term) {
var geometry = exports.proposeGeometry(term);
term.resize(geometry.cols, geometry.rows);
};
Xterm.prototype.proposeGeometry = function () {
return exports.proposeGeometry(this);
};
Xterm.prototype.fit = function () {
return exports.fit(this);
};
return exports;
});

View File

@ -2,81 +2,91 @@
<html ng-app="DockerPlay" ng-controller="PlayController"> <html ng-app="DockerPlay" ng-controller="PlayController">
<head> <head>
<title>Docker Playground</title> <title>Docker Playground</title>
<link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/angular_material/1.1.0/angular-material.min.css"> <link rel="stylesheet" href="http://ajax.googleapis.com/ajax/libs/angular_material/1.1.0/angular-material.min.css">
<link rel="stylesheet" href="/assets/xterm.css" /> <link rel="stylesheet" href="/assets/xterm.css" />
<link rel="stylesheet" href="/assets/style.css" /> <link rel="stylesheet" href="/assets/style.css" />
</head> </head>
<body> <body>
<div layout="column" style="height:100%;" ng-cloak> <div layout="column" style="height:100%;" ng-cloak>
<section id="sessionEnd" layout="row" flex ng-if="!isAlive">
<md-content flex layout-padding ng-if="!instances.length">
<div layout="column" layout-align="top center">
<p>
<strong>Your session has expired.</strong>
</p>
</div>
<div flex></div>
</md-content>
</section>
<section id="popupContainer" layout="row" flex ng-if="isAlive">
<md-sidenav
class="md-sidenav-left"
md-component-id="left"
md-is-locked-open="$mdMedia('gt-sm')"
md-whiteframe="4" layout="column">
<section id="popupContainer" layout="row" flex> <md-toolbar class="md-theme-indigo">
<h1 class="md-toolbar-tools">Instances</h1>
</md-toolbar>
<md-content layout-padding>
<md-button ng-click="newInstance()" class="md-primary">
+ Add new instance
</md-button>
<md-list>
<md-list-item class="md-3-line" ng-repeat="instance in instances" ng-click="showInstance(instance)" ng-class="instance.name == selectedInstance.name ? 'selected' : false">
<div class="md-list-item-text">{{instance.name}}</div>
<md-divider ng-if="!$last"></md-divider>
</md-list-item>
</md-list>
</md-content>
<md-sidenav </md-sidenav>
class="md-sidenav-left"
md-component-id="left"
md-is-locked-open="$mdMedia('gt-sm')"
md-whiteframe="4">
<md-toolbar class="md-theme-indigo"> <md-content flex layout-padding ng-if="!instances.length">
<h1 class="md-toolbar-tools">Instances</h1> <div layout="column" layout-align="top center">
</md-toolbar> <p>
<md-content layout-padding> Add instances to your playground.
<md-button ng-click="newInstance()" class="md-primary"> </p>
+ Add new instance <p>
</md-button> <strong>Sessions and all their instances are deleted after 1 hour.</strong>
</p>
<md-list> </div>
<md-list-item class="md-3-line" ng-repeat="instance in instances" ng-click="showInstance(instance)" ng-class="instance.name == selectedInstance.name ? 'selected' : false">
<div class="md-list-item-text">{{instance.name}}</div>
<md-divider ng-if="!$last"></md-divider>
</md-list-item>
</md-list>
</md-content>
</md-sidenav> <div flex></div>
</md-content>
<md-content flex layout-padding ng-if="!instances.length"> <md-content flex layout="column" ng-repeat="instance in instances" ng-show="instance.name == selectedInstance.name">
<div layout="column" layout-align="top center"> <md-card md-theme="default" md-theme-watch>
<p> <md-card-title>
Add instances to your playground. <md-card-title-text>
</p> <span class="md-headline">{{instance.name}}</span>
<p> <span class="md-subhead">{{instance.ip}}</span>
<strong>Sessions and all their instances are deleted after 1 hour.</strong> <md-card-title-text>
</p> </md-card-title>
</div> <md-card-actions>
<md-button class="md-warn md-raised" ng-click="deleteInstance(instance)">Delete</md-button>
<div flex></div> </md-card-actions>
</md-content> </md-card>
<md-content flex layout-padding ng-repeat="instance in instances" ng-show="instance.name == selectedInstance.name"> <md-card flex md-theme="default" md-theme-watch >
<md-card md-theme="default" md-theme-watch> <md-card-content flex id="terminal-{{instance.name}}" class="terminal">
<md-card-title> </md-card-content>
<md-card-title-text> </md-card>
<span class="md-headline">{{instance.name}}</span> </md-content>
<span class="md-subhead">{{instance.ip}}</span>
<md-card-title-text>
</md-card-title>
<md-card-actions layout="row">
<md-button class="md-warn md-raised" ng-click="deleteInstance(instance)">Delete</md-button>
</md-card-actions>
<md-card-content>
<div id="terminal-{{instance.name}}"></div>
</md-card-content>
</md-card>
</md-content>
</section> </section>
</div> </div>
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular.min.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular.min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-animate.min.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-animate.min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-aria.min.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-aria.min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-messages.min.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.5.5/angular-messages.min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/angular_material/1.1.0/angular-material.min.js"></script> <script src="http://ajax.googleapis.com/ajax/libs/angular_material/1.1.0/angular-material.min.js"></script>
<script src="/assets/app.js"></script> <script src="https://cdn.socket.io/socket.io-1.3.7.js"></script>
<script src="/assets/main.js"></script> <script src="/assets/app.js"></script>
<script src="/assets/xterm.js"></script> <script src="/assets/xterm.js"></script>
<script src="/assets/xterm-addons/fit.js"></script>
<script src="/assets/attach.js"></script> <script src="/assets/attach.js"></script>
<script type="text/javascript" charset="utf-8"> <script type="text/javascript" charset="utf-8">
window.onbeforeunload = function (e) { window.onbeforeunload = function (e) {