1
0
mirror of https://github.com/bingohuang/docker-labs.git synced 2025-07-13 17:42:53 +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
Docker 1.13-dev or higher is required as **attachable** overlay networks feature 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
Docker 1.12.1 or higher is required.
## Installation

31
api.go
View File

@ -4,32 +4,37 @@ import (
"log"
"net/http"
"golang.org/x/net/websocket"
"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"
)
func main() {
mux := bone.New()
server := services.CreateWSServer()
mux.Get("/ping", http.HandlerFunc(handlers.Ping))
mux.Get("/", http.HandlerFunc(handlers.NewSession))
mux.Get("/sessions/:sessionId", http.HandlerFunc(handlers.GetSession))
mux.Post("/sessions/:sessionId/instances", http.HandlerFunc(handlers.NewInstance))
mux.Delete("/sessions/:sessionId/instances/:instanceName", http.HandlerFunc(handlers.DeleteInstance))
server.On("connection", handlers.WS)
server.On("error", handlers.WSError)
r := mux.NewRouter()
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) {
http.ServeFile(w, r, "./www/index.html")
})
mux.Get("/p/:sessionId", h)
mux.Get("/assets/*", http.FileServer(http.Dir("./www")))
r.HandleFunc("/p/{sessionId}", h).Methods("GET")
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.UseHandler(mux)
n.UseHandler(r)
log.Fatal(http.ListenAndServe("0.0.0.0:3000", n))

View File

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

View File

@ -2,14 +2,17 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/franela/play-with-docker/services"
"github.com/go-zoo/bone"
"github.com/gorilla/mux"
)
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)

View File

@ -6,16 +6,18 @@ import (
"net/http"
"github.com/franela/play-with-docker/services"
"github.com/go-zoo/bone"
"github.com/gorilla/mux"
)
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.Lock()
if len(s.Instances) >= 5 {
s.Unlock()
rw.WriteHeader(http.StatusConflict)
return
}

View File

@ -1,22 +1,42 @@
package handlers
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/go-zoo/bone"
"github.com/twinj/uuid"
"github.com/googollee/go-socket.io"
"github.com/gorilla/mux"
)
// Echo the data received on the WebSocket.
func Exec(ws *websocket.Conn) {
sessionId := bone.GetValue(ws.Request(), "sessionId")
instanceName := bone.GetValue(ws.Request(), "instanceName")
func WS(so socketio.Socket) {
vars := mux.Vars(so.Request())
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()
@ -62,5 +82,5 @@ func Exec(ws *websocket.Conn) {
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"
"strings"
ptypes "github.com/franela/play-with-docker/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
@ -29,9 +27,7 @@ func GetContainerInfo(id string) (types.ContainerJSON, error) {
}
func CreateNetwork(name string) error {
// TODO: This line appears to give an error when running on localhost:3000
// when driver is specified a name must be given.
opts := types.NetworkCreate{Attachable: true, Driver: "overlay"}
opts := types.NetworkCreate{}
_, err := c.NetworkCreate(context.Background(), name, opts)
if err != nil {
@ -71,16 +67,14 @@ func AttachExecConnection(execId string, ctx context.Context) (*types.HijackedRe
return nil, err
}
err = c.ContainerExecResize(ctx, execId, types.ResizeOptions{Height: 24, Width: 80})
if err != nil {
return nil, err
}
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
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 &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 {

View File

@ -1,12 +1,25 @@
package services
import (
"context"
"io"
"log"
"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 defaultDindImageName string
@ -23,31 +36,78 @@ func getDindImageName() string {
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: Create in redis
log.Printf("NewInstance - using image: [%s]\n", dindImage)
instance, err := CreateInstance(session.Id, dindImage)
instance.Session = session
if err != nil {
return nil, err
}
if session.Instances == nil {
session.Instances = make(map[string]*types.Instance)
session.Instances = make(map[string]*Instance)
}
session.Instances[instance.Name] = instance
go instance.Exec()
wsServer.BroadcastTo(session.Id, "new instance", instance.Name, instance.IP)
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
return session.Instances[name]
}
func DeleteInstance(session *types.Session, instance *types.Instance) error {
func DeleteInstance(session *Session, instance *Instance) error {
//TODO: Use redis
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 (
"log"
"math"
"sync"
"time"
"github.com/franela/play-with-docker/types"
"github.com/googollee/go-socket.io"
"github.com/twinj/uuid"
)
var sessions map[string]*types.Session
var wsServer *socketio.Server
func init() {
sessions = make(map[string]*types.Session)
type Session struct {
sync.Mutex
Id string `json:"id"`
Instances map[string]*Instance `json:"instances"`
Clients []*Client `json:"-"`
}
func NewSession() (*types.Session, error) {
s := &types.Session{}
func (s *Session) GetSmallestViewPort() ViewPort {
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.Instances = map[string]*types.Instance{}
s.Instances = map[string]*Instance{}
log.Printf("NewSession id=[%s]\n", s.Id)
//TODO: Store in something like redis
sessions[s.Id] = s
// Schedule cleanup of the session
time.AfterFunc(1*time.Hour, func() {
s = GetSession(s.Id)
wsServer.BroadcastTo(s.Id, "session end")
log.Printf("Starting clean up of session [%s]\n", s.Id)
for _, i := range s.Instances {
i.Conn.Close()
@ -41,13 +77,13 @@ func NewSession() (*types.Session, error) {
})
if err := CreateNetwork(s.Id); err != nil {
log.Println("ERROR NETWORKING")
return nil, err
}
return s, nil
}
func GetSession(sessionId string) *types.Session {
//TODO: Use redis
func GetSession(sessionId string) *Session {
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']);
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.instances = [];
$scope.idx = {};
$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.alert()
.parent(angular.element(document.querySelector('#popupContainer')))
.parent(angular.element(document.querySelector(parent || '#popupContainer')))
.clickOutsideToClose(true)
.title(title)
.textContent(content)
.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() {
$http({
method: 'POST',
url: '/sessions/' + $scope.sessionId + '/instances',
}).then(function(response) {
var i = response.data;
$scope.instances.push(i);
$scope.showInstance(i);
var i = $scope.upsertInstance(response.data);
$scope.showInstance(i);
}, function(response) {
if (response.status == 409) {
$scope.showAlert('Max instances reached', 'Maximum number of instances reached')
}
if (response.status == 409) {
$scope.showAlert('Max instances reached', 'Maximum number of instances reached')
}
});
}
@ -39,18 +64,64 @@
method: 'GET',
url: '/sessions/' + $scope.sessionId,
}).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;
for (var k in i.instances) {
var instance = i.instances[k];
$scope.instances.push(instance);
$scope.idx[instance.name] = instance;
}
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) {
if (response.status == 404) {
document.write('session not found');
@ -59,25 +130,41 @@
});
}
$scope.showInstance = function(instance) {
$scope.selectedInstance = instance;
if (!instance.isAttached) {
$timeout(function() {instance.term = createTerminal(instance.name);});
instance.isAttached = true;
} else {
$timeout(function() {instance.term.focus()});
}
}
$scope.showInstance = function(instance) {
$scope.selectedInstance = instance;
if (!instance.creatingTerminal) {
instance.creatingTerminal = true;
if (!instance.term) {
$timeout(function() {
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) {
$http({
method: 'DELETE',
url: '/sessions/' + $scope.sessionId + '/instances/' + instance.name,
}).then(function(response) {
$scope.instances = $scope.instances.filter(function(i) { return i.name != instance.name });
if ($scope.instances.length) {
$scope.showInstance($scope.instances[0]);
}
$scope.removeInstance(instance.name);
}, function(response) {
console.log('error', response);
});
@ -85,16 +172,35 @@
$scope.getSession($scope.sessionId);
function checkSession() {
$http({
method: 'GET',
url: '/sessions/' + $scope.sessionId,
}).then(function(response) {}, function(response) {
if (response.status == 404) {
clearInterval($scope.checkHandler);
$scope.showAlert('Session timedout!', 'Your session has expire and all your instances has been deleted.')
}
});
}
function createTerminal(instance, cb) {
if (instance.term) {
return instance.term;
}
var terminalContainer = document.getElementById('terminal-'+ instance.name);
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 {
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">
<head>
<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/style.css" />
</head>
<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
class="md-sidenav-left"
md-component-id="left"
md-is-locked-open="$mdMedia('gt-sm')"
md-whiteframe="4">
</md-sidenav>
<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-content flex layout-padding ng-if="!instances.length">
<div layout="column" layout-align="top center">
<p>
Add instances to your playground.
</p>
<p>
<strong>Sessions and all their instances are deleted after 1 hour.</strong>
</p>
</div>
</md-sidenav>
<md-content flex layout-padding ng-if="!instances.length">
<div layout="column" layout-align="top center">
<p>
Add instances to your playground.
</p>
<p>
<strong>Sessions and all their instances are deleted after 1 hour.</strong>
</p>
</div>
<div flex></div>
</md-content>
<md-content flex layout-padding ng-repeat="instance in instances" ng-show="instance.name == selectedInstance.name">
<md-card md-theme="default" md-theme-watch>
<md-card-title>
<md-card-title-text>
<span class="md-headline">{{instance.name}}</span>
<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>
<div flex></div>
</md-content>
<md-content flex layout="column" ng-repeat="instance in instances" ng-show="instance.name == selectedInstance.name">
<md-card md-theme="default" md-theme-watch>
<md-card-title>
<md-card-title-text>
<span class="md-headline">{{instance.name}}</span>
<span class="md-subhead">{{instance.ip}}</span>
<md-card-title-text>
</md-card-title>
<md-card-actions>
<md-button class="md-warn md-raised" ng-click="deleteInstance(instance)">Delete</md-button>
</md-card-actions>
</md-card>
<md-card flex md-theme="default" md-theme-watch >
<md-card-content flex id="terminal-{{instance.name}}" class="terminal">
</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-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-messages.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="/assets/main.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-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/angular_material/1.1.0/angular-material.min.js"></script>
<script src="https://cdn.socket.io/socket.io-1.3.7.js"></script>
<script src="/assets/app.js"></script>
<script src="/assets/xterm.js"></script>
<script src="/assets/xterm-addons/fit.js"></script>
<script src="/assets/attach.js"></script>
<script type="text/javascript" charset="utf-8">
window.onbeforeunload = function (e) {