Browse Source

Show tunnel down page when accessing a tunnel whose local service is unavailable. Retweak the web interface for better information display.

Alan Shreve 12 years ago
parent
commit
8c1657b2b7
4 changed files with 251 additions and 120 deletions
  1. 179 80
      assets/ngrok.js
  2. 57 40
      assets/page.html
  3. 12 0
      src/ngrok/client/main.go
  4. 3 0
      src/ngrok/client/views/web/http.go

+ 179 - 80
assets/ngrok.js

@@ -40,6 +40,146 @@ var hexRepr = function(bytes) {
     return buf.join("");
 }
 
+ngrok.factory("txnSvc", function() {
+    var processBody = function(body, binary) {
+        body.binary = binary;
+        body.isForm = body.ContentType == "application/x-www-form-urlencoded";
+        body.exists = body.Length > 0;
+        body.hasError = !!body.Error;
+
+        body.syntaxClass = function() {
+            return {
+                "text/xml":               "xml",
+                "application/xml":        "xml",
+                "text/html":              "xml",
+                "text/css":               "css",
+                "application/json":       "json",
+                "text/javascript":        "javascript",
+                "application/javascript": "javascript",
+            }[body.ContentType];
+        }
+
+        // decode body
+        if (binary) {
+            body.Text = "";
+        } else {
+            body.Text = Base64.decode(body.Text).text;
+        }
+        
+        // prettify
+        var transform = {
+            "xml": "xml",
+            "json": "json"
+        }[body.syntaxClass];
+
+        if (!body.hasError && !!transform) {
+            try {
+                // vkbeautify does poorly at formatting html
+                if (body.ContentType != "text/html") {
+                    body.Text = vkbeautify[transform](body.Text);
+                }
+            } catch (e) {
+            }
+        }
+    };
+
+    var processReq = function(req) {
+        if (!req.RawBytes) {
+            var decoded = Base64.decode(req.Raw);
+            req.RawBytes = hexRepr(decoded.bytes);
+
+            if (!req.Binary) {
+                req.RawText = decoded.text;
+            }
+        }
+
+        processBody(req.Body, req.Binary);
+    };
+
+    var processResp = function(resp) {
+        resp.statusClass = {
+            '2': "text-info",
+            '3': "muted",
+            '4': "text-warning",
+            '5': "text-error"
+        }[resp.Status[0]];
+
+        if (!resp.RawBytes) {
+            var decoded = Base64.decode(resp.Raw);
+            resp.RawBytes = hexRepr(decoded.bytes);
+
+            if (!resp.Binary) {
+                resp.RawText = decoded.text;
+            }
+        }
+
+        processBody(resp.Body, resp.Binary);
+    };
+
+    var processTxn = function(txn) {
+        processReq(txn.Req);
+        processResp(txn.Resp);
+    };
+
+    var preprocessTxn = function(txn) {
+        var toFixed = function(value, precision) {
+            var power = Math.pow(10, precision || 0);
+            return String(Math.round(value * power) / power);
+        }
+        // parse nanosecond count
+        var ns = txn.Duration;
+        var ms = ns / (1000 * 1000);
+        txn.Duration = ms;
+        if (ms > 1000) {
+            txn.Duration = toFixed(ms / 1000, 2) + "s";
+        } else {
+            txn.Duration = toFixed(ms, 2) + "ms";
+        }
+    };
+
+
+    var active;
+    var txns = window.data.Txns;
+    txns.forEach(function(t) {
+        preprocessTxn(t);
+    });
+
+    var activate = function(txn) {
+        if (!txn.processed) {
+            processTxn(txn);
+            txn.processed = true;
+        }
+        active = txn;
+    }
+
+    if (txns.length > 0) {
+        activate(txns[0]);
+    }
+
+    return {
+        add: function(txnData) {
+            txns.unshift(JSON.parse(txnData));
+            preprocessTxn(txns[0]);
+            if (!active) {
+                activate(txns[0]);
+            }
+        },
+        all: function() {
+            return txns;
+        },
+        active: function(txn) {
+            if (!txn) {
+                return active;
+            } else {
+                activate(txn);
+            }
+        },
+        isActive: function(txn) {
+            return !!active && txn.Id == active.Id;
+        }
+    };
+});
+
 ngrok.directive({
     "keyval": function() {
         return {
@@ -50,7 +190,7 @@ ngrok.directive({
             replace: true,
             restrict: "E",
             template: "" +
-            '<div ng-show="hasKeys">' +
+            '<div ng-show="hasKeys()">' +
                 '<h6>{{title}}</h6>' +
                 '<table class="table params">' +
                     '<tr ng-repeat="(key, value) in tuples">' +
@@ -60,8 +200,10 @@ ngrok.directive({
                 '</table>' +
             '</div>',
             link: function($scope) {
-                $scope.hasKeys = false;
-                for (key in $scope.tuples) { $scope.hasKeys = true; break; }
+                $scope.hasKeys = function() {
+                    for (key in $scope.tuples) { return true; }
+                    return false;
+                };
             }
         };
     },
@@ -101,67 +243,29 @@ ngrok.directive({
                 "binary": "="
             },
             template: '' +
-            '<h6 ng-show="hasBody">' +
-                '{{ Body.Length }} bytes ' +
-                '{{ Body.RawContentType }}' +
+            '<h6 ng-show="body.exists">' +
+                '{{ body.Length }} bytes ' +
+                '{{ body.RawContentType }}' +
             '</h6>' +
 '' +
-            '<div ng-show="!isForm && !binary">' +
-                '<pre ng-show="hasBody"><code ng-class="syntaxClass">{{ Body.Text }}</code></pre>' +
+            '<div ng-show="!body.isForm && !body.binary">' +
+                '<pre ng-show="body.exists"><code ng-class="body.syntaxClass">{{ body.Text }}</code></pre>' +
             '</div>' +
 '' +
-            '<div ng-show="isForm">' +
-                '<keyval title="Form Params" tuples="Body.Form">' +
+            '<div ng-show="body.isForm">' +
+                '<keyval title="Form Params" tuples="body.Form">' +
             '</div>' +
-            '<div ng-show="hasError" class="alert">' +
-                '{{ Body.Error }}' +
+            '<div ng-show="body.hasError" class="alert">' +
+                '{{ body.Error }}' +
             '</div>',
 
-            controller: function($scope) {
-                var body = $scope.body;
-                if ($scope.binary) {
-                    body.Text = "";
-                } else {
-                    body.Text = Base64.decode(body.Text).text;
-                }
-                $scope.isForm = (body.ContentType == "application/x-www-form-urlencoded");
-                $scope.hasBody = (body.Length > 0);
-                $scope.hasError = !!body.Error;
-                $scope.syntaxClass = {
-                    "text/xml":               "xml",
-                    "application/xml":        "xml",
-                    "text/html":              "xml",
-                    "text/css":               "css",
-                    "application/json":       "json",
-                    "text/javascript":        "javascript",
-                    "application/javascript": "javascript",
-                }[body.ContentType];
-
-                var transform = {
-                    "xml": "xml",
-                    "json": "json"
-                }[$scope.syntaxClass];
-
-                if (!$scope.hasError && !!transform) {
-                    try {
-                        // vkbeautify does poorly at formatting html
-                        if (body.ContentType != "text/html") {
-                            body.Text = vkbeautify[transform](body.Text);
-                        }
-                    } catch (e) {
-                    }
-                }
-
-                $scope.Body = body;
-            },
-
             link: function($scope, $elem) {
                 $timeout(function() {
                     $code = $elem.find("code").get(0);
                     hljs.highlightBlock($code);
 
-                    if ($scope.Body.ErrorOffset > -1) {
-                        var offset = $scope.Body.ErrorOffset;
+                    if ($scope.body.ErrorOffset > -1) {
+                        var offset = $scope.body.ErrorOffset;
 
                         function textNodes(node) {
                             var textNodes = [];
@@ -196,9 +300,9 @@ ngrok.directive({
 });
 
 ngrok.controller({
-    "HttpTxns": function($scope) {
+    "HttpTxns": function($scope, txnSvc) {
         $scope.publicUrl = window.data.UiState.Url;
-        $scope.txns = window.data.Txns;
+        $scope.txns = txnSvc.all();
 
         if (!!window.WebSocket) {
             var ws = new WebSocket("ws://localhost:4040/_ws");
@@ -208,7 +312,7 @@ ngrok.controller({
 
             ws.onmessage = function(message) {
                 $scope.$apply(function() {
-                    $scope.txns.unshift(JSON.parse(message.data));
+                    txnSvc.add(message.data);
                 });
             };
             
@@ -222,38 +326,33 @@ ngrok.controller({
         }
     },
 
-    "HttpRequest": function($scope) {
-        $scope.Req = $scope.txn.Req;
+    "HttpRequest": function($scope, txnSvc) {
         $scope.replay = function() {
             $.ajax({
                 type: "POST",
                 url: "/http/in/replay",
-                data: { txnid: $scope.txn.Id }
+                data: { txnid: txnSvc.active().Id }
             });
         }
-
-        var decoded = Base64.decode($scope.Req.Raw);
-        $scope.Req.RawBytes = hexRepr(decoded.bytes);
-
-        if (!$scope.Req.Binary) {
-            $scope.Req.RawText = decoded.text;
-        }
+        var setReq = function() {
+            $scope.Req = txnSvc.active().Req;
+        };
+        setReq();
+        $scope.$watch(function() { return txnSvc.active().Id }, setReq);
     },
 
-    "HttpResponse": function($scope) {
-        $scope.Resp = $scope.txn.Resp;
-        $scope.statusClass = {
-            '2': "text-info",
-            '3': "muted",
-            '4': "text-warning",
-            '5': "text-error"
-        }[$scope.Resp.Status[0]];
-
-        var decoded = Base64.decode($scope.Resp.Raw);
-        $scope.Resp.RawBytes = hexRepr(decoded.bytes);
+    "HttpResponse": function($scope, txnSvc) {
+        var setResp = function() {
+            $scope.Resp = txnSvc.active().Resp;
+        };
+        setResp();
+        $scope.$watch(function() { return txnSvc.active().Id }, setResp);
+    },
 
-        if (!$scope.Resp.Binary) {
-            $scope.Resp.RawText = decoded.text;
-        }
-    }
+    "TxnNavItem": function($scope, txnSvc) {
+        $scope.isActive = function() { return txnSvc.isActive($scope.txn); }
+        $scope.makeActive = function() {
+            txnSvc.active($scope.txn);
+        };
+    },
 });

+ 57 - 40
assets/page.html

@@ -15,8 +15,15 @@
         </script>
         <style type="text/css">
             body { margin-top: 50px; }
-            ul.history > li { none; margin-bottom: 20px; padding-bottom: 20px; border-bottom: 1px solid #ccc; }
             table.params { font-size: 12px; font-family: Courier, monospace; }
+            .txn-selector tr { cursor: pointer; }
+            .txn-selector tr:hover { background-color: #ddd; }
+            tr.selected, tr.selected:hover { 
+                background-color: #ff9999; 
+                background-color: #000000;
+                color:white;
+
+            }
         </style>
     </head>
 
@@ -28,8 +35,10 @@
                         <a class="brand" href="#">ngrok</a>
                         <ul class="nav">
                             <li class="active"><a href="#">Inbound Requests</a></li>
+                            <!--
                             <li><a href="#">Outbound Requests</a></li>
                             <li><a href="#">Configuration</a></li>
+                            -->
                         </ul>
                     </div>
                 </div>
@@ -42,58 +51,66 @@
                     </div>
                 </div>
             </div>
-            <ul class="history unstyled">
-                <li ng-repeat="txn in txns">
-                    <div class="row">
+            <div class="row">
+                <div class="span6">
+                    <h4>All Requests</h4>
+                    <table class="table txn-selector">
+                        <tr ng-controller="TxnNavItem" ng-class="{'selected':isActive()}" ng-repeat="txn in txns" ng-click="makeActive()">
+                            <td>{{ txn.Req.MethodPath }}</td>
+                            <td>{{ txn.Resp.Status }}</td>
+                            <td><span class="pull-right">{{ txn.Duration }}</span></td>
+                        </tr>
+                    </table>
+                </div>
+                <div class="span6">
+                    <div ng-controller="HttpRequest">
+                        <h3>{{ Req.MethodPath }}</h3>
+                        <div onbtnclick="replay()" btn="Replay" tabs="Summary,Headers,Raw,Binary">
+                        </div>
 
-                        <!-- Request -->
-                        <div class="span6" ng-controller="HttpRequest">
-                            <h3>{{ Req.MethodPath }}</h3>
-                            <div onbtnclick="replay()" btn="Replay" tabs="Summary,Headers,Raw,Binary">
-                            </div>
+                        <div ng-show="isTab('Summary')">
+                            <keyval title="Query Params" tuples="Req.Params"></keyval>
+                            <div body="Req.Body" binary="Req.Binary"></div>
+                        </div>
 
-                            <div ng-show="isTab('Summary')">
-                                <keyval title="Query Params" tuples="Req.Params"></keyval>
-                                <div body="Req.Body" binary="Req.Binary"></div>
-                            </div>
+                        <div ng-show="isTab('Headers')">
+                            <keyval title="Headers" tuples="Req.Header"></keyval>
+                        </div>
 
-                            <div ng-show="isTab('Headers')">
-                                <keyval title="Headers" tuples="Req.Header"></keyval>
-                            </div>
+                        <div ng-show="isTab('Raw')">
+                            <pre><code class="http">{{ Req.RawText }}</code></pre>
+                        </div>
 
-                            <div ng-show="isTab('Raw')">
-                                <pre><code class="http">{{ Req.RawText }}</code></pre>
-                            </div>
+                        <div ng-show="isTab('Binary')">
+                            <pre><code class="http">{{ Req.RawBytes }}</code></pre>
+                        </div>
 
-                            <div ng-show="isTab('Binary')">
-                                <pre><code class="http">{{ Req.RawBytes }}</code></pre>
-                            </div>
+                    </div>
 
-                        </div>
+                    <hr style="margin: 40px 0 20px" />
 
-                        <div class="span6" ng-controller="HttpResponse">
-                            <h3 ng-class="statusClass">{{ Resp.Status }}</h3>
+                    <div ng-controller="HttpResponse">
+                        <h3 ng-class="Resp.statusClass">{{ Resp.Status }}</h3>
 
-                            <div tabs="Summary,Headers,Raw,Binary"></div>
-                            <div ng-show="isTab('Summary')">
-                                <div body="Resp.Body" binary="Resp.Binary"></div>
-                            </div>
+                        <div tabs="Summary,Headers,Raw,Binary"></div>
+                        <div ng-show="isTab('Summary')">
+                            <div body="Resp.Body" binary="Resp.Binary"></div>
+                        </div>
 
-                            <div ng-show="isTab('Headers')">
-                                <keyval title="Headers" tuples="Resp.Header"></keyval>
-                            </div>
+                        <div ng-show="isTab('Headers')">
+                            <keyval title="Headers" tuples="Resp.Header"></keyval>
+                        </div>
 
-                            <div ng-show="isTab('Raw')">
-                                <pre><code class="http">{{ Resp.RawText }}</code></pre>
-                            </div>
+                        <div ng-show="isTab('Raw')">
+                            <pre><code class="http">{{ Resp.RawText }}</code></pre>
+                        </div>
 
-                            <div ng-show="isTab('Binary')">
-                                <pre><code class="http">{{ Resp.RawBytes }}</code></pre>
-                            </div>
+                        <div ng-show="isTab('Binary')">
+                            <pre><code class="http">{{ Resp.RawBytes }}</code></pre>
                         </div>
                     </div>
-                </li>
-            </ul>
+                </div>
+            </div>
         </div>
     </body>
 </html>

+ 12 - 0
src/ngrok/client/main.go

@@ -26,6 +26,12 @@ const (
 	maxPongLatency       = 15 * time.Second
 	versionCheckInterval = 6 * time.Hour
 	versionEndpoint      = "http://ngrok.com/dl/versions"
+	BadGateway           = `<html>
+<body style="background-color: #97a8b9">
+    <div style="margin:auto; width:400px;padding: 20px 60px; background-color: #D3D3D3; border: 5px solid maroon;">
+        <h2>Tunnel %s unavailable</h2>
+        <p>Unable to initiate connection to <strong>%s</strong>. A web server must be running on port <strong>%s</strong> to complete the tunnel.</p>
+`
 )
 
 /**
@@ -57,6 +63,12 @@ func proxy(proxyAddr string, s *State, ctl *ui.Controller) {
 	localConn, err := conn.Dial(s.opts.localaddr, "prv", nil)
 	if err != nil {
 		remoteConn.Warn("Failed to open private leg %s: %v", s.opts.localaddr, err)
+		badGatewayBody := fmt.Sprintf(BadGateway, s.publicUrl, s.opts.localaddr, s.opts.localaddr)
+		remoteConn.Write([]byte(fmt.Sprintf(`HTTP/1.0 502 Bad Gateway
+Content-Type: text/html
+Content-Length: %d
+
+%s`, len(badGatewayBody), badGatewayBody)))
 		return
 	}
 	defer localConn.Close()

+ 3 - 0
src/ngrok/client/views/web/http.go

@@ -15,11 +15,13 @@ import (
 	"ngrok/proto"
 	"ngrok/util"
 	"strings"
+	"time"
 	"unicode/utf8"
 )
 
 type SerializedTxn struct {
 	Id             string
+	Duration       time.Duration
 	*proto.HttpTxn `json:"-"`
 	Req            SerializedRequest
 	Resp           SerializedResponse
@@ -191,6 +193,7 @@ func (whv *WebHttpView) updateHttp() {
 
 			txn := htxn.UserData.(*SerializedTxn)
 			body := makeBody(htxn.Resp.Header, htxn.Resp.BodyBytes)
+			txn.Duration = htxn.Duration.Nanoseconds()
 			txn.Resp = SerializedResponse{
 				Status: htxn.Resp.Status,
 				Raw:    base64.StdEncoding.EncodeToString(rawResp),