Browse Source

New GUI (fixes #73, fixes #87)

Jakob Borg 11 years ago
parent
commit
433a0cb9cc
5 changed files with 594 additions and 346 deletions
  1. 0 0
      auto/gui.files.go
  2. 2 1
      build.sh
  3. 163 99
      gui/app.js
  4. 0 6
      gui/bootstrap/css/bootstrap.min.css
  5. 429 240
      gui/index.html

File diff suppressed because it is too large
+ 0 - 0
auto/gui.files.go


+ 2 - 1
build.sh

@@ -62,7 +62,8 @@ deps() {
 
 case "$1" in
 	"")
-		build
+		shift
+		build $*
 		;;
 
 	race)

+ 163 - 99
gui/app.js

@@ -7,7 +7,7 @@ var syncthing = angular.module('syncthing', []);
 
 syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     var prevDate = 0,
-        modelGetOK = true;
+    getOK = true;
 
     $scope.connections = {};
     $scope.config = {};
@@ -16,35 +16,40 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     $scope.configInSync = true;
     $scope.errors = [];
     $scope.seenError = '';
+    $scope.model = {};
+    $scope.repos = [];
 
     // Strings before bools look better
     $scope.settings = [
-        {id: 'ListenStr', descr: 'Sync Protocol Listen Addresses', type: 'text', restart: true},
-        {id: 'GUIAddress', descr: 'GUI Listen Address', type: 'text', restart: true},
-        {id: 'MaxSendKbps', descr: 'Outgoing Rate Limit (KBps)', type: 'number', restart: true},
-        {id: 'RescanIntervalS', descr: 'Rescan Interval (s)', type: 'number', restart: true},
-        {id: 'ReconnectIntervalS', descr: 'Reconnect Interval (s)', type: 'number', restart: true},
-        {id: 'ParallelRequests', descr: 'Max Outstanding Requests', type: 'number', restart: true},
-        {id: 'MaxChangeKbps', descr: 'Max File Change Rate (KBps)', type: 'number', restart: true},
-
-        {id: 'ReadOnly', descr: 'Read Only', type: 'bool', restart: true},
-        {id: 'FollowSymlinks', descr: 'Follow Symlinks', type: 'bool', restart: true},
-        {id: 'GlobalAnnEnabled', descr: 'Global Announce', type: 'bool', restart: true},
-        {id: 'LocalAnnEnabled', descr: 'Local Announce', type: 'bool', restart: true},
-        {id: 'StartBrowser', descr: 'Start Browser', type: 'bool'},
+    {id: 'ListenStr', descr: 'Sync Protocol Listen Addresses', type: 'text', restart: true},
+    {id: 'MaxSendKbps', descr: 'Outgoing Rate Limit (KBps)', type: 'number', restart: true},
+    {id: 'RescanIntervalS', descr: 'Rescan Interval (s)', type: 'number', restart: true},
+    {id: 'ReconnectIntervalS', descr: 'Reconnect Interval (s)', type: 'number', restart: true},
+    {id: 'ParallelRequests', descr: 'Max Outstanding Requests', type: 'number', restart: true},
+    {id: 'MaxChangeKbps', descr: 'Max File Change Rate (KBps)', type: 'number', restart: true},
+
+    {id: 'GlobalAnnEnabled', descr: 'Global Announce', type: 'bool', restart: true},
+    {id: 'LocalAnnEnabled', descr: 'Local Announce', type: 'bool', restart: true},
+    {id: 'StartBrowser', descr: 'Start Browser', type: 'bool'},
     ];
 
-    function modelGetSucceeded() {
-        if (!modelGetOK) {
+    $scope.guiSettings = [
+    {id: 'Address', descr: 'GUI Listen Addresses', type: 'text', restart: true},
+    {id: 'User', descr: 'GUI Authentication User', type: 'text', restart: true},
+    {id: 'Password', descr: 'GUI Authentication Password', type: 'password', restart: true},
+    ];
+
+    function getSucceeded() {
+        if (!getOK) {
             $('#networkError').modal('hide');
-            modelGetOK = true;
+            getOK = true;
         }
     }
 
-    function modelGetFailed() {
-        if (modelGetOK) {
+    function getFailed() {
+        if (getOK) {
             $('#networkError').modal({backdrop: 'static', keyboard: false});
-            modelGetOK = false;
+            getOK = false;
         }
     }
 
@@ -61,40 +66,22 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         return a.NodeID > b.NodeID;
     }
 
-    $http.get('/rest/version').success(function (data) {
-        $scope.version = data;
-    });
-    $http.get('/rest/system').success(function (data) {
-        $scope.system = data;
-        $scope.myID = data.myID;
-
-        $http.get('/rest/config').success(function (data) {
-            $scope.config = data;
-            $scope.config.Options.ListenStr = $scope.config.Options.ListenAddress.join(', ');
-
-            var nodes = $scope.config.Repositories[0].Nodes;
-            nodes.sort(nodeCompare);
-            $scope.nodes = nodes;
-        });
-        $http.get('/rest/config/sync').success(function (data) {
-            $scope.configInSync = data.configInSync;
-        });
-    });
-
     $scope.refresh = function () {
         $http.get('/rest/system').success(function (data) {
+            getSucceeded();
             $scope.system = data;
-        });
-        $http.get('/rest/model').success(function (data) {
-            $scope.model = data;
-            modelGetSucceeded();
         }).error(function () {
-            modelGetFailed();
+            getFailed();
+        });
+        $scope.repos.forEach(function (repo) {
+            $http.get('/rest/model/' + repo.ID).success(function (data) {
+                $scope.model[repo.ID] = data;
+            });
         });
         $http.get('/rest/connections').success(function (data) {
             var now = Date.now(),
-                td = (now - prevDate) / 1000,
-                id;
+            td = (now - prevDate) / 1000,
+            id;
 
             prevDate = now;
             $scope.inbps = 0;
@@ -116,28 +103,23 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
             }
             $scope.connections = data;
         });
-        $http.get('/rest/need').success(function (data) {
-            var i, name;
-            for (i = 0; i < data.length; i++) {
-                name = data[i].Name.split('/');
-                data[i].ShortName = name[name.length - 1];
-            }
-            data.sort(function (a, b) {
-                if (a.ShortName < b.ShortName) {
-                    return -1;
-                }
-                if (a.ShortName > b.ShortName) {
-                    return 1;
-                }
-                return 0;
-            });
-            $scope.need = data;
-        });
         $http.get('/rest/errors').success(function (data) {
             $scope.errors = data;
         });
     };
 
+    $scope.syncPercentage = function (repo) {
+        if (typeof $scope.model[repo] === 'undefined') {
+            return 100;
+        }
+        if ($scope.model[repo].globalBytes === 0) {
+            return 100;
+        }
+
+        var pct = 100 * $scope.model[repo].inSyncBytes / $scope.model[repo].globalBytes;
+        return Math.ceil(pct);
+    };
+
     $scope.nodeStatus = function (nodeCfg) {
         var conn = $scope.connections[nodeCfg.NodeID];
         if (conn) {
@@ -182,7 +164,7 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         if (conn) {
             return conn.Address;
         }
-        return '(unknown address)';
+        return '?';
     };
 
     $scope.nodeCompletion = function (nodeCfg) {
@@ -201,7 +183,7 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         if (conn) {
             return conn.ClientVersion;
         }
-        return '(unknown version)';
+        return '?';
     };
 
     $scope.nodeName = function (nodeCfg) {
@@ -211,11 +193,15 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         return nodeCfg.NodeID.substr(0, 6);
     };
 
+    $scope.editSettings = function () {
+        $('#settings').modal({backdrop: 'static', keyboard: true});
+    }
+
     $scope.saveSettings = function () {
         $scope.configInSync = false;
         $scope.config.Options.ListenAddress = $scope.config.Options.ListenStr.split(',').map(function (x) { return x.trim(); });
         $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
-        $('#settingsTable').collapse('hide');
+        $('#settings').modal("hide");
     };
 
     $scope.restart = function () {
@@ -224,34 +210,34 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     };
 
     $scope.editNode = function (nodeCfg) {
-        $scope.currentNode = nodeCfg;
+        $scope.currentNode = $.extend({}, nodeCfg);
         $scope.editingExisting = true;
         $scope.currentNode.AddressesStr = nodeCfg.Addresses.join(', ');
-        $('#editNode').modal({backdrop: 'static', keyboard: false});
+        $('#editNode').modal({backdrop: 'static', keyboard: true});
     };
 
     $scope.addNode = function () {
-        $scope.currentNode = {NodeID: '', AddressesStr: 'dynamic'};
+        $scope.currentNode = {AddressesStr: 'dynamic'};
         $scope.editingExisting = false;
-        $('#editNode').modal({backdrop: 'static', keyboard: false});
+        $('#editNode').modal({backdrop: 'static', keyboard: true});
     };
 
     $scope.deleteNode = function () {
-        var newNodes = [], i;
-
         $('#editNode').modal('hide');
         if (!$scope.editingExisting) {
             return;
         }
 
-        for (i = 0; i < $scope.nodes.length; i++) {
-            if ($scope.nodes[i].NodeID !== $scope.currentNode.NodeID) {
-                newNodes.push($scope.nodes[i]);
-            }
-        }
+        $scope.nodes = $scope.nodes.filter(function (n) {
+            return n.NodeID !== $scope.currentNode.NodeID;
+        });
+        $scope.config.Nodes = $scope.nodes;
 
-        $scope.nodes = newNodes;
-        $scope.config.Repositories[0].Nodes = newNodes;
+        for (var i = 0; i < $scope.repos.length; i++) {
+            $scope.repos[i].Nodes = $scope.repos[i].Nodes.filter(function (n) {
+                return n.NodeID !== $scope.currentNode.NodeID;
+            });
+        }
 
         $scope.configInSync = false;
         $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
@@ -279,21 +265,15 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         }
 
         $scope.nodes.sort(nodeCompare);
-        $scope.config.Repositories[0].Nodes = $scope.nodes;
+        $scope.config.Nodes = $scope.nodes;
 
         $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
     };
 
     $scope.otherNodes = function () {
-        var nodes = [], i, n;
-
-        for (i = 0; i < $scope.nodes.length; i++) {
-            n = $scope.nodes[i];
-            if (n.NodeID !== $scope.myID) {
-                nodes.push(n);
-            }
-        }
-        return nodes;
+        return $scope.nodes.filter(function (n){
+            return n.NodeID !== $scope.myID;
+        });
     };
 
     $scope.thisNode = function () {
@@ -308,14 +288,9 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     };
 
     $scope.errorList = function () {
-        var errors = [];
-        for (var i = 0; i < $scope.errors.length; i++) {
-            var e = $scope.errors[i];
-            if (e.Time > $scope.seenError) {
-                errors.push(e);
-            }
-        }
-        return errors;
+        return $scope.errors.filter(function (e) {
+            return e.Time > $scope.seenError;
+        });
     };
 
     $scope.clearErrors = function () {
@@ -330,7 +305,96 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         return str;
     };
 
-    $scope.refresh();
+    $scope.editRepo = function (nodeCfg) {
+        $scope.currentRepo = $.extend({selectedNodes: {}}, nodeCfg);
+        $scope.currentRepo.Nodes.forEach(function (n) {
+            $scope.currentRepo.selectedNodes[n.NodeID] = true;
+        });
+        $scope.editingExisting = true;
+        $('#editRepo').modal({backdrop: 'static', keyboard: true});
+    };
+
+    $scope.addRepo = function () {
+        $scope.currentRepo = {selectedNodes: {}};
+        $scope.editingExisting = false;
+        $('#editRepo').modal({backdrop: 'static', keyboard: true});
+    };
+
+    $scope.saveRepo = function () {
+        var repoCfg, done, i;
+
+        $scope.configInSync = false;
+        $('#editRepo').modal('hide');
+        repoCfg = $scope.currentRepo;
+        repoCfg.Nodes = [];
+        repoCfg.selectedNodes[$scope.myID] = true;
+        for (var nodeID in repoCfg.selectedNodes) {
+            if (repoCfg.selectedNodes[nodeID] === true) {
+                repoCfg.Nodes.push({NodeID: nodeID});
+            }
+        }
+        delete repoCfg.selectedNodes;
+
+        done = false;
+        for (i = 0; i < $scope.repos.length; i++) {
+            if ($scope.repos[i].ID === repoCfg.ID) {
+                $scope.repos[i] = repoCfg;
+                done = true;
+                break;
+            }
+        }
+
+        if (!done) {
+            $scope.repos.push(repoCfg);
+        }
+
+        $scope.config.Repositories = $scope.repos;
+
+        $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
+    };
+
+    $scope.deleteRepo = function () {
+        $('#editRepo').modal('hide');
+        if (!$scope.editingExisting) {
+            return;
+        }
+
+        $scope.repos = $scope.repos.filter(function (r) {
+            return r.ID !== $scope.currentRepo.ID;
+        });
+
+        $scope.config.Repositories = $scope.repos;
+
+        $scope.configInSync = false;
+        $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
+    };
+
+    $http.get('/rest/version').success(function (data) {
+        $scope.version = data;
+    });
+
+    $http.get('/rest/system').success(function (data) {
+        $scope.system = data;
+        $scope.myID = data.myID;
+    });
+
+    $http.get('/rest/config').success(function (data) {
+        $scope.config = data;
+        $scope.config.Options.ListenStr = $scope.config.Options.ListenAddress.join(', ');
+
+        var nodes = $scope.config.Nodes;
+        nodes.sort(nodeCompare);
+        $scope.nodes = nodes;
+
+        $scope.repos = $scope.config.Repositories;
+
+        $scope.refresh();
+    });
+
+    $http.get('/rest/config/sync').success(function (data) {
+        $scope.configInSync = data.configInSync;
+    });
+
     setInterval($scope.refresh, 10000);
 });
 

File diff suppressed because it is too large
+ 0 - 6
gui/bootstrap/css/bootstrap.min.css


+ 429 - 240
gui/index.html

@@ -1,281 +1,470 @@
 <!DOCTYPE html>
 <html lang="en" ng-app="syncthing">
 <head>
-<meta charset="utf-8">
-<meta http-equiv="X-UA-Compatible" content="IE=edge">
-<meta name="viewport" content="width=device-width, initial-scale=1.0">
-<meta name="description" content="">
-<meta name="author" content="">
-<link rel="shortcut icon" href="favicon.png">
-
-<title>syncthing</title>
-<link href="bootstrap/css/bootstrap.min.css" rel="stylesheet">
-<style type="text/css">
-
-body {
-    padding-top: 70px;
-    padding-bottom: 70px;
-}
-
-.text-monospace {
-    font-family: monospace;
-}
-
-.table-condensed>thead>tr>th, .table-condensed>tbody>tr>th, .table-condensed>tfoot>tr>th, .table-condensed>thead>tr>td, .table-condensed>tbody>tr>td, .table-condensed>tfoot>tr>td {
-    border-top: none;
-}
-
-thead tr th {
-    text-align: center;
-}
-
-.logo {
-    margin: 0;
-    padding: 0;
-    top: -5px;
-    position: relative;
-}
-
-</style>
+  <meta charset="utf-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <meta name="description" content="">
+  <meta name="author" content="">
+  <link rel="shortcut icon" href="favicon.png">
+
+  <title>syncthing</title>
+  <link href="bootstrap/css/bootstrap.min.css" rel="stylesheet">
+  <style type="text/css">
+    body {
+      padding-bottom: 70px;
+      font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+    }
+
+    h1, h2, h3, h4, h5, h6 {
+      font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+      font-weight: 300;
+    }
+
+    a.btn {
+      text-decoration: none;
+    }
+
+    ul+h5 {
+      margin-top: 1.5em;
+    }
+
+    .text-monospace {
+      font-family: monospace;
+    }
+
+    .table-condensed>thead>tr>th, .table-condensed>tbody>tr>th, .table-condensed>tfoot>tr>th, .table-condensed>thead>tr>td, .table-condensed>tbody>tr>td, .table-condensed>tfoot>tr>td {
+      border-top: none;
+    }
+
+    thead tr th {
+      text-align: center;
+    }
+
+    .logo {
+      margin: 0;
+      padding: 0;
+      top: -5px;
+      position: relative;
+    }
+
+    .progress {
+      height: 21px;
+      margin-bottom: inherit;
+    }
+
+    .progress .progress-bar {
+      line-height: 21px;
+      font-size: 12px;
+    }
+
+    .collapsed-visible {
+      display: none;
+    }
+    .collapsed .collapsed-visible {
+      display: inline;
+    }
+
+    .list-no-bullet {
+      list-style-type: none
+    }
+
+    .li-column {
+      display: inline-block;
+      min-width: 7em;
+      margin-right: 1em;
+      background-color: rgb(236, 240, 241);
+      border-radius: 3px;
+      padding: 1px 4px;
+      margin: 2px 2px;
+    }
+    .li-column span.data {
+      margin-left: 0.5em;
+      min-width: 10em;
+      text-align: right;
+      display: inline-block;
+    }
+  </style>
 </head>
 
 <body ng-controller="SyncthingCtrl">
-<div class="navbar navbar-fixed-top navbar-default">
+
+  <!-- Top bar -->
+
+  <nav class="navbar navbar-top navbar-default" role="navigation">
     <div class="container">
-        <a class="navbar-brand"><img class="logo" src="st-logo-128.png" width="32" height="32"> Syncthing</a>
-        <div ng-if="!configInSync">
-            <form class="navbar-form navbar-right">
-                <button type="button" class="btn btn-primary" ng-click="restart()">Restart Now</button>
-            </form>
-            <p class="navbar-text navbar-right">The configuration has been changed but not activated. Syncthing must restart to activate the new configuration.</p>
-        </div>
+      <span class="navbar-brand"><img class="logo" src="st-logo-128.png" width="32" height="32"> Syncthing</span>
+      <button type="button" class="btn btn-default btn-sm pull-right navbar-btn" ng-click="editSettings()"><span class="glyphicon glyphicon-cog"></span> Settings</button>
     </div>
-</div>
+  </nav>
 
-<div class="container">
-    <div class="row">
-        <div class="col-md-12">
-            <div ng-if="errorList().length > 0" class="alert alert-warning">
-                <p ng-repeat="err in errorList()"><small>{{err.Time | date:"hh:mm:ss.sss"}}:</small> {{friendlyNodes(err.Error)}}</p>
-                    <button type="button" class="pull-right btn btn-warning" ng-click="clearErrors()">OK</button>
-            <div class="clearfix"></div>
-            </div>
+  <div class="container">
 
-            <div class="panel panel-info">
-                <div class="panel-heading"><h3 class="panel-title">Cluster</h3></div>
-                <table class="table table-condensed">
-                    <tbody>
-                    <!-- myself -->
-                    <tr class="text-muted" ng-repeat="nodeCfg in thisNode()">
-                        <td style="width:12%">
-                            <span class="label label-default">
-                                <span class="glyphicon glyphicon-ok"></span> This node
-                            </span>
-                        </td>
-                        <td style="width:10%">
-                            <span class="text-monospace">{{nodeName(nodeCfg)}}</span>
-                        </td>
-                        <td style="width:20%">{{version}}</td>
-                        <td style="width:25%">(this node)</td>
-                        <td style="width:9%" class="text-right">
-                            {{inbps | metric}}bps
-                            <span class="text-muted glyphicon glyphicon-chevron-down"></span>
-                        </td>
-                        <td style="width:9%" class="text-right">
-                            {{outbps | metric}}bps
-                            <span class="text-muted glyphicon glyphicon-chevron-up"></span>
-                        </td>
-                        <td style="width:7%" class="text-right">
-                            <button type="button" ng-click="editNode(nodeCfg)" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-pencil"></span> Edit</button>
-                        </td>
-                    </tr>
-                    <!-- all other nodes -->
-                    <tr ng-repeat="nodeCfg in otherNodes()">
-                        <td>
-                            <span class="label label-{{nodeClass(nodeCfg)}}">
-                                <span class="glyphicon glyphicon-{{nodeIcon(nodeCfg)}}"></span> {{nodeStatus(nodeCfg)}}
-                            </span>
-                        </td>
-                        <td>
-                            <span class="text-monospace">{{nodeName(nodeCfg)}}</span>
-                        </td>
-                        <td>{{nodeVer(nodeCfg)}}</td>
-                        <td>{{nodeAddr(nodeCfg)}}</td>
-                        <td class="text-right">
-                            <abbr title="{{connections[nodeCfg.NodeID].InBytesTotal | binary}}B">{{connections[nodeCfg.NodeID].inbps | metric}}bps</abbr>
-                            <span class="text-muted glyphicon glyphicon-chevron-down"></span>
-                        </td>
-                        <td class="text-right">
-                            <abbr title="{{connections[nodeCfg.NodeID].OutBytesTotal | binary}}B">{{connections[nodeCfg.NodeID].outbps | metric}}bps</abbr>
-                            <span class="text-muted glyphicon glyphicon-chevron-up"></span>
-                        </td>
-                        <td class="text-right">
-                            <button type="button" ng-click="editNode(nodeCfg)" class="btn btn-default btn-xs"><span class="glyphicon glyphicon-pencil"></span> Edit</button>
-                        </td>
-                    </tr>
-                    <tr>
-                        <td></td>
-                        <td></td>
-                        <td></td>
-                        <td></td>
-                        <td></td>
-                        <td></td>
-                        <td class="text-right">
-                            <button type="button" class="btn btn-default btn-xs" ng-click="addNode()"><span class="glyphicon glyphicon-plus"></span> Add</button>
-                        </td>
-                    </tr>
-                    </tbody>
-                </table>
-            </div>
+    <!-- First row, only shown if necessary; Restart warning -->
+
+    <div ng-if="!configInSync" class="row">
+      <div class="col-md-12">
+        <div class="panel panel-warning">
+          <div class="panel-heading"><h3 class="panel-title">Restart Needed</h3></div>
+          <div class="panel-body">
+            <p>The configuration has been saved but not activated. Syncthing must restart to activate the new configuration.</p>
+          </div>
+          <div class="panel-footer">
+            <button type="button" class="btn btn-sm btn-default pull-right" ng-click="restart()"><span class="glyphicon glyphicon-off"></span> Restart Now</button>
+            <div class="clearfix"></div>
+          </div>
         </div>
+      </div>
     </div>
 
+    <!-- First regular row -->
+
     <div class="row">
-        <div class="col-md-6">
-            <div class="panel panel-info">
-                <div class="panel-heading"><h3 class="panel-title">Repository</h3></div>
-                <div class="panel-body">
-                    <p>Cluster contains {{model.globalFiles | alwaysNumber}} files, {{model.globalBytes | binary}}B
-                    <span class="text-muted">(+{{model.globalDeleted | alwaysNumber}} delete records)</span></p>
-
-                    <p>Local repository has {{model.localFiles | alwaysNumber}} files, {{model.localBytes | binary}}B
-                    <span class="text-muted">(+{{model.localDeleted | alwaysNumber}} delete records)</span></p>
-                </div>
-            </div>
-        </div>
-        <div class="col-md-6">
-            <div class="panel" ng-class="{'panel-success': model.needBytes === 0, 'panel-primary': model.needBytes !== 0}">
-                <div class="panel-heading"><h3 class="panel-title">Synchronization</h3></div>
-                <div class="panel-body">
-                    <div class="progress">
-                        <div class="progress-bar" role="progressbar" aria-valuenow="60" aria-valuemin="0" aria-valuemax="100"
-                            ng-class="{'progress-bar-success': model.needBytes === 0, 'progress-bar-info': model.needBytes !== 0}"
-                            ng-style="{width: (100 * model.inSyncBytes / model.globalBytes) + '%'}">
-                            {{100 * model.inSyncBytes / model.globalBytes | alwaysNumber | number:0}}%
-                        </div>
+
+      <!-- Repository list (top left) -->
+
+      <div class="col-md-6">
+        <div class="panel panel-default">
+          <div class="panel-heading"><h3 class="panel-title">Repositories</h3></div>
+          <div class="panel-body">
+            <ul class="list-unstyled" ng-repeat="repo in repos">
+              <li>
+                <span class="text-monospace">{{repo.Directory}}</span>
+                <ul class="list-no-bullet">
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-tag"></span>
+                      <span class="data">{{repo.ID}}</span>
                     </div>
-                    <p ng-show="model.needBytes > 0">Need {{model.needFiles | alwaysNumber}} files, {{model.needBytes | binary}}B</p>
-                </div>
-            </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-home"></span>
+                      <span class="data text-success" ng-show="syncPercentage(repo.ID) == 100">In Sync</span>
+                      <span class="data text-primary" ng-hide="syncPercentage(repo.ID) == 100">Syncing ({{syncPercentage(repo.ID)}}%)</span>
+                    </div>
+                  </li>
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-globe"></span>
+                      <span class="data">{{model[repo.ID].globalFiles | alwaysNumber}} files, {{model[repo.ID].globalBytes | binary}}B</span>
+                    </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-home"></span>
+                      <span class="data">{{model[repo.ID].localFiles | alwaysNumber}} files, {{model[repo.ID].localBytes | binary}}B</span>
+                    </div>
+                  </li>
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cloud-download"></span>
+                      <span class="data">{{model[repo.ID].needFiles | alwaysNumber}} files, {{model[repo.ID].needBytes | binary}}B</span>
+                    </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cog"></span>
+                      <span class="data"><a href="" ng-click="editRepo(repo)"><span class="glyphicon glyphicon-pencil"></span> Edit</a></span>
+                    </div>
+                  </li>
+                </ul>
+              </li>
+            </ul>
+          </div>
+          <div class="panel-footer">
+            <button type="button" class="pull-right btn btn-sm btn-default" ng-click="addRepo()"><span class="glyphicon glyphicon-plus"></span> Add Repository</button>
+            <div class="clearfix"></div>
+          </div>
         </div>
-    </div>
+      </div>
 
-    <div class="row">
-        <div class="col-md-6">
-            <div class="panel panel-info">
-                <div class="panel-heading"><h3 class="panel-title"><a href="" data-toggle="collapse" data-target="#system">System</a></h3></div>
-                <div id="system" class="panel-collapse collapse">
-                    <div class="panel-body">
-                        <p>{{system.sys | binary}}B RAM allocated, {{system.alloc | binary}}B in use</p>
-                        <p>{{system.cpuPercent | alwaysNumber | natural:1}}% CPU, {{system.goroutines | alwaysNumber}} goroutines</p>
+      <!-- Node list (top right) -->
+
+      <div class="col-md-6">
+        <div class="panel panel-default">
+          <div class="panel-heading"><h3 class="panel-title">Nodes</h3></div>
+          <div class="panel-body">
+
+            <h5>Peer Nodes</h5>
+            <ul class="list-unstyled" ng-repeat="nodeCfg in otherNodes()">
+              <li>
+                <span class="text-monospace">{{nodeName(nodeCfg)}}</span>
+                <ul class="list-no-bullet">
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-link"></span>
+                      <span class="data">{{nodeAddr(nodeCfg)}}</span>
                     </div>
-                </div>
-            </div>
-        </div>
-        <div class="col-md-6">
-            <div class="panel panel-info">
-                <div class="panel-heading"><h3 class="panel-title"><a href="" data-toggle="collapse" data-target="#settingsTable">Settings</a></h3></div>
-                <div id="settingsTable" class="panel-collapse collapse">
-                    <div class="panel-body">
-                        <form role="form">
-                            <div class="form-group" ng-repeat="setting in settings">
-                                <div ng-if="setting.type == 'text' || setting.type == 'number'">
-                                    <label for="{{setting.id}}">{{setting.descr}}</label>
-                                    <input id="{{setting.id}}" class="form-control" type="{{setting.type}}" ng-model="config.Options[setting.id]"></input>
-                                </div>
-                                <div class="checkbox" ng-if="setting.type == 'bool'">
-                                    <label>
-                                        {{setting.descr}} <input id="{{setting.id}}" type="checkbox" ng-model="config.Options[setting.id]"></input>
-                                    </label>
-                                </div>
-                            </div>
-                        </form>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-dashboard"></span>
+                      <span class="data text-{{nodeClass(nodeCfg)}}">{{nodeStatus(nodeCfg)}}</span>
                     </div>
-                    <div class="panel-footer">
-                        <button type="button" class="btn btn-sm btn-default" ng-click="saveSettings()">Save</button>
-                        <small><span class="text-muted">Changes take effect when restarting syncthing.</span></small>
+                  </li>
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cloud-download"></span>
+                      <span class="data">{{connections[nodeCfg.NodeID].inbps | metric}}bps</span>
                     </div>
-                </div>
-            </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cloud-upload"></span>
+                      <span class="data">{{connections[nodeCfg.NodeID].outbps | metric}}bps</span>
+                    </div>
+                  </li>
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-tag"></span>
+                      <span class="data">{{nodeVer(nodeCfg)}}</span>
+                    </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cog"></span>
+                      <span class="data"><a href="" ng-click="editNode(nodeCfg)"><span class="glyphicon glyphicon-pencil"></span> Edit</a></span>
+                    </div>
+                  </li>
+                </ul>
+              </li>
+            </ul>
+
+            <h5>This Node</h5>
+            <ul class="list-unstyled" ng-repeat="nodeCfg in thisNode()">
+              <li>
+                <span class="text-monospace">{{nodeName(nodeCfg)}}</span>&emsp;
+                <ul class="list-no-bullet">
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-th"></span>
+                      <span class="data">{{system.sys | binary}}B RAM</span>
+                    </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-tasks"></span>
+                      <span class="data">{{system.cpuPercent | alwaysNumber | natural:1}}% CPU</span>
+                    </div>
+                  </li>
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cloud-download"></span>
+                      <span class="data">{{inbps | metric}}bps</span>
+                    </div>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cloud-upload"></span>
+                      <span class="data">{{outbps | metric}}bps</span>
+                    </div>
+                  </li>
+                  <li>
+                    <div class="li-column">
+                      <span class="text-muted glyphicon glyphicon-cog"></span>
+                      <span class="data"><a href="" ng-click="editNode(nodeCfg)"><span class="glyphicon glyphicon-pencil"></span> Edit</a></span>
+                    </div>
+                  </li>
+                </ul>
+              </li>
+            </ul>
+          </div>
+          <div class="panel-footer">
+            <button type="button" class="pull-right btn btn-sm btn-default" ng-click="addNode()"><span class="glyphicon glyphicon-plus"></span> Add Node</button>
+            <div class="clearfix"></div>
+          </div>
         </div>
+      </div>
+
+    </div> <!-- /row -->
+
+    <!-- Errors -->
+
+    <div ng-if="errorList().length > 0" class="row">
+      <div class="col-md-12">
+        <div class="panel panel-warning">
+          <div class="panel-heading"><h3 class="panel-title">Notice</h3></div>
+          <div class="panel-body">
+            <p ng-repeat="err in errorList()"><small>{{err.Time | date:"hh:mm:ss.sss"}}:</small> {{friendlyNodes(err.Error)}}</p>
+          </div>
+          <div class="panel-footer">
+            <button type="button" class="pull-right btn btn-sm btn-default" ng-click="clearErrors()">OK</button>
+            <div class="clearfix"></div>
+          </div>
+        </div>
+      </div>
     </div>
-</div>
 
-<div class="navbar navbar-default navbar-fixed-bottom">
+  </div> <!-- /container -->
+
+  <!-- Bottom bar -->
+
+  <nav class="navbar navbar-default navbar-fixed-bottom hidden-xs">
     <div class="container">
-        <p class="navbar-text">{{version}}</p>
-        <ul class="nav navbar-nav navbar-right">
-            <li><a class="navbar-link" href="http://discourse.syncthing.net/">Support / Forum</a></li>
-            <li><a class="navbar-link" href="https://github.com/calmh/syncthing/releases">Latest Release</a></li>
-            <li><a class="navbar-link" href="https://github.com/calmh/syncthing/wiki">Documentation</a></li>
-            <li><a class="navbar-link" href="https://github.com/calmh/syncthing/issues">Bugs</a></li>
-            <li><a class="navbar-link" href="https://github.com/calmh/syncthing">Source Code</a></li>
-        </ul>
-        </p>
+      <p class="navbar-text">{{version}}</p>
+      <ul class="nav navbar-nav navbar-right">
+        <li><a class="navbar-link" href="http://discourse.syncthing.net/">Support / Forum</a></li>
+        <li><a class="navbar-link hidden-sm" href="https://github.com/calmh/syncthing/releases">Latest Release</a></li>
+        <li><a class="navbar-link" href="https://github.com/calmh/syncthing/wiki">Documentation</a></li>
+        <li><a class="navbar-link hidden-sm" href="https://github.com/calmh/syncthing/issues">Bugs</a></li>
+        <li><a class="navbar-link hidden-sm" href="https://github.com/calmh/syncthing">Source Code</a></li>
+      </ul>
     </div>
-</div>
+  </nav>
+
+  <!-- Network error modal -->
 
-<div id="networkError" class="modal fade">
+  <div id="networkError" class="modal fade">
     <div class="modal-dialog">
-        <div class="modal-content">
-            <div class="modal-header alert alert-danger">
-                <h4 class="modal-title">
-                    <span class="glyphicon glyphicon-exclamation-sign"></span>
-                    Connection Error
-                </h4>
+      <div class="modal-content">
+        <div class="modal-header alert alert-danger">
+          <h4 class="modal-title">
+            <span class="glyphicon glyphicon-exclamation-sign"></span>
+            Connection Error
+          </h4>
+        </div>
+        <div class="modal-body">
+          <p>
+            Syncthing seems to be down, or there is a problem with your Internet connection.
+            Retrying&hellip;
+          </p>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Node editor modal -->
+
+  <div id="editNode" class="modal fade">
+    <div class="modal-dialog modal-lg">
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 ng-show="!editingExisting" class="modal-title">Add Node</h4>
+          <h4 ng-show="editingExisting" class="modal-title">Edit Node</h4>
+        </div>
+        <div class="modal-body">
+          <form role="form">
+            <div class="form-group">
+              <label for="nodeID">Node ID</label>
+              <input ng-disabled="editingExisting" id="nodeID" class="form-control" type="text" ng-model="currentNode.NodeID"></input>
+              <p class="help-block">The node ID can be found in the logs or in the "Add Node" dialog on the other node.</p>
+            </div>
+            <div class="form-group">
+              <label for="name">Name</label>
+              <input placeholder="Home Server" id="name" class="form-control" type="text" ng-model="currentNode.Name"></input>
+              <p class="help-block">Shown instead of Node ID in the cluster status.</p>
             </div>
-            <div class="modal-body">
-                <p>
-                Syncthing seems to be down, or there is a problem with your Internet connection.
-                Retrying&hellip;
-                </p>
+            <div class="form-group">
+              <label for="addresses">Addresses</label>
+              <input placeholder="dynamic" ng-disabled="currentNode.NodeID == myID" id="addresses" class="form-control" type="text" ng-model="currentNode.AddressesStr"></input>
+              <p class="help-block">Enter comma separated <span class="text-monospace">ip:port</span> addresses or <span class="text-monospace">dynamic</span> to perform automatic discovery of the address.</p>
             </div>
+          </form>
+          <div ng-show="!editingExisting">
+            When adding a new node, keep in mind that <em>this node</em> must be added on the other side too. The Node ID of this node is:
+            <pre>{{myID}}</pre>
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-primary" ng-click="saveNode()">Save</button>
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+          <button ng-if="editingExisting" type="button" class="btn btn-danger pull-left" ng-click="deleteNode()">Delete</button>
         </div>
+      </div>
     </div>
-</div>
+  </div>
 
-<div id="editNode" class="modal fade">
+  <!-- Repo editor modal -->
+
+  <div id="editRepo" class="modal fade">
     <div class="modal-dialog modal-lg">
-        <div class="modal-content">
-            <div class="modal-header">
-                <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
-                <h4 class="modal-title">Edit Node</h4>
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 ng-show="!editingExisting" class="modal-title">Add Repository</h4>
+          <h4 ng-show="editingExisting" class="modal-title">Edit Repository</h4>
+        </div>
+        <div class="modal-body">
+          <form role="form">
+            <div class="form-group">
+              <label for="repoID">Repository ID</label>
+              <input placeholder="documents" ng-disabled="editingExisting" id="repoID" class="form-control" type="text" ng-model="currentRepo.ID"></input>
+              <p class="help-block">Short, unique identifier for the repository. Must be the same on all cluster nodes.</p>
             </div>
-            <div class="modal-body">
-                <form role="form">
-                    <div class="form-group">
-                        <label for="nodeID">Node ID</label>
-                        <input placeholder="YUFJOUDPORCMA..." ng-disabled="editingExisting" id="nodeID" class="form-control" type="text" ng-model="currentNode.NodeID"></input>
-                        <p class="help-block">The node ID can be found in the logs or in the "Add Node" dialog on the other node.</p>
-                    </div>
-                    <div class="form-group">
-                        <label for="name">Name</label>
-                        <input placeholder="Home Server" id="name" class="form-control" type="text" ng-model="currentNode.Name"></input>
-                        <p class="help-block">Shown instead of Node ID in the cluster status.</p>
-                    </div>
-                    <div class="form-group">
-                        <label for="addresses">Addresses</label>
-                        <input placeholder="dynamic" ng-disabled="currentNode.NodeID == myID" id="addresses" class="form-control" type="text" ng-model="currentNode.AddressesStr"></input>
-                        <p class="help-block">Enter comma separated <span class="text-monospace">ip:port</span> addresses or <span class="text-monospace">dynamic</span> to perform automatic discovery of the address.</p>
-                    </div>
-                </form>
-                <div ng-show="!editingExisting">
-                    When adding a new node, keep in mind that <em>this node</em> must be added on the other side too. The Node ID of this node is:
-                    <pre>{{myID}}</pre>
-                </div>
+            <div class="form-group">
+              <label for="repoPath">Repository Path</label>
+              <input placeholder="~/Documents" id="repoPath" class="form-control" type="text" ng-model="currentRepo.Directory"></input>
+              <p class="help-block">Path to the repository on the local computer. Will be created if it does not exist.</p>
+            </div>
+            <div class="form-group">
+              <div class="checkbox">
+                <label>
+                  <input type="checkbox" ng-model="currentRepo.ReadOnly"> Read Only
+                </label>
+              </div>
+            </div>
+            <div class="form-group">
+              <label for="nodes">Nodes</label>
+              <div class="checkbox" ng-repeat="node in otherNodes()">
+                <label>
+                  <input type="checkbox" ng-model="currentRepo.selectedNodes[node.NodeID]"> {{nodeName(node)}}
+                </label>
+              </div>
+              <p class="help-block">Select the nodes to share this repository with.</p>
             </div>
-            <div class="modal-footer">
-                <button type="button" class="btn btn-primary" ng-click="saveNode()">Save</button>
-                <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
-                <button ng-if="editingExisting" type="button" class="btn btn-danger pull-left" ng-click="deleteNode()">Delete</button>
+          </form>
+          <div ng-show="!editingExisting">
+            When adding a new repository, keep in mind that the Repository ID is used to tie repositories together between nodes. They are case sensitive and must match exactly between all nodes.
+          </div>
+        </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-primary" ng-click="saveRepo()">Save</button>
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+          <button ng-if="editingExisting" type="button" class="btn btn-danger pull-left" ng-click="deleteRepo()">Delete</button>
+        </div>
+      </div>
+    </div>
+  </div>
+
+  <!-- Settings modal -->
+
+  <div id="settings" class="modal fade">
+    <div class="modal-dialog modal-lg">
+      <div class="modal-content">
+        <div class="modal-header">
+          <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
+          <h4 class="modal-title"> Settings</h4>
+        </div>
+        <div class="modal-body">
+          <form role="form">
+            <div class="row">
+              <div class="col-md-6">
+                <div class="form-group" ng-repeat="setting in settings">
+                  <div ng-if="setting.type == 'text' || setting.type == 'number'">
+                    <label for="{{setting.id}}">{{setting.descr}}</label>
+                    <input id="{{setting.id}}" class="form-control" type="{{setting.type}}" ng-model="config.Options[setting.id]"></input>
+                  </div>
+                  <div class="checkbox" ng-if="setting.type == 'bool'">
+                    <label>
+                      {{setting.descr}} <input id="{{setting.id}}" type="checkbox" ng-model="config.Options[setting.id]"></input>
+                    </label>
+                  </div>
+                </div>
+              </div>
+              <div class="col-md-6">
+                <div class="form-group" ng-repeat="setting in guiSettings">
+                  <div ng-if="setting.type == 'text' || setting.type == 'number' || setting.type == 'password'">
+                    <label for="{{setting.id}}">{{setting.descr}}</label>
+                    <input id="{{setting.id}}" class="form-control" type="{{setting.type}}" ng-model="config.GUI[setting.id]"></input>
+                  </div>
+                  <div class="checkbox" ng-if="setting.type == 'bool'">
+                    <label>
+                      {{setting.descr}} <input id="{{setting.id}}" type="checkbox" ng-model="config.GUI[setting.id]"></input>
+                    </label>
+                  </div>
+                </div>
+              </div>
             </div>
+          </form>
         </div>
+        <div class="modal-footer">
+          <button type="button" class="btn btn-primary" ng-click="saveSettings()">Save</button>
+          <button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
+        </div>
+      </div>
     </div>
-</div>
+  </div>
+
 
-<script src="angular.min.js"></script>
-<script src="jquery-2.0.3.min.js"></script>
-<script src="bootstrap/js/bootstrap.min.js"></script>
-<script src="app.js"></script>
+  <script src="angular.min.js"></script>
+  <script src="jquery-2.0.3.min.js"></script>
+  <script src="bootstrap/js/bootstrap.min.js"></script>
+  <script src="app.js"></script>
 </body>
 </html>

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