Browse Source

Edit configuration in GUI; use XML configuration

Jakob Borg 12 years ago
parent
commit
a1d575894a

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


+ 82 - 76
config.go

@@ -1,33 +1,48 @@
 package main
 
 import (
-	"fmt"
+	"encoding/xml"
 	"io"
 	"reflect"
 	"strconv"
 	"strings"
-	"text/template"
-	"time"
 )
 
-type Options struct {
-	Listen            string        `ini:"listen-address" default:":22000" description:"ip:port to for incoming sync connections"`
-	ReadOnly          bool          `ini:"read-only" description:"Allow changes to the local repository"`
-	Delete            bool          `ini:"allow-delete" default:"true" description:"Allow deletes of files in the local repository"`
-	Symlinks          bool          `ini:"follow-symlinks" default:"true" description:"Follow symbolic links at the top level of the repository"`
-	GUI               bool          `ini:"gui-enabled" default:"true" description:"Enable the HTTP GUI"`
-	GUIAddr           string        `ini:"gui-address" default:"127.0.0.1:8080" description:"ip:port for GUI connections"`
-	ExternalServer    string        `ini:"global-announce-server" default:"syncthing.nym.se:22025" description:"Global server for announcements"`
-	ExternalDiscovery bool          `ini:"global-announce-enabled" default:"true" description:"Announce to the global announce server"`
-	LocalDiscovery    bool          `ini:"local-announce-enabled" default:"true" description:"Announce to the local network"`
-	ParallelRequests  int           `ini:"parallel-requests" default:"16" description:"Maximum number of blocks to request in parallel"`
-	LimitRate         int           `ini:"max-send-kbps" description:"Limit outgoing data rate (kbyte/s)"`
-	ScanInterval      time.Duration `ini:"rescan-interval" default:"60s" description:"Scan repository for changes this often"`
-	ConnInterval      time.Duration `ini:"reconnection-interval" default:"60s" description:"Attempt to (re)connect to peers this often"`
-	MaxChangeBW       int           `ini:"max-change-bw" default:"1000" description:"Suppress files changing more than this (kbyte/s)"`
+type Configuration struct {
+	Version      int                       `xml:"version,attr" default:"1"`
+	Repositories []RepositoryConfiguration `xml:"repository"`
+	Options      OptionsConfiguration      `xml:"options"`
+	XMLName      xml.Name                  `xml:"configuration" json:"-"`
 }
 
-func loadConfig(m map[string]string, data interface{}) error {
+type RepositoryConfiguration struct {
+	Directory string              `xml:"directory,attr"`
+	Nodes     []NodeConfiguration `xml:"node"`
+}
+
+type NodeConfiguration struct {
+	NodeID    string   `xml:"id,attr"`
+	Addresses []string `xml:"address"`
+}
+
+type OptionsConfiguration struct {
+	ListenAddress      string `xml:"listenAddress" default:":22000" ini:"listen-address"`
+	ReadOnly           bool   `xml:"readOnly" ini:"read-only"`
+	AllowDelete        bool   `xml:"allowDelete" default:"true" ini:"allow-delete"`
+	FollowSymlinks     bool   `xml:"followSymlinks" default:"true" ini:"follow-symlinks"`
+	GUIEnabled         bool   `xml:"guiEnabled" default:"true" ini:"gui-enabled"`
+	GUIAddress         string `xml:"guiAddress" default:"127.0.0.1:8080" ini:"gui-address"`
+	GlobalAnnServer    string `xml:"globalAnnounceServer" default:"syncthing.nym.se:22025" ini:"global-announce-server"`
+	GlobalAnnEnabled   bool   `xml:"globalAnnounceEnabled" default:"true" ini:"global-announce-enabled"`
+	LocalAnnEnabled    bool   `xml:"localAnnounceEnabled" default:"true" ini:"local-announce-enabled"`
+	ParallelRequests   int    `xml:"parallelRequests" default:"16" ini:"parallel-requests"`
+	MaxSendKbps        int    `xml:"maxSendKbps" ini:"max-send-kbps"`
+	RescanIntervalS    int    `xml:"rescanIntervalS" default:"60" ini:"rescan-interval"`
+	ReconnectIntervalS int    `xml:"reconnectionIntervalS" default:"60" ini:"reconnection-interval"`
+	MaxChangeKbps      int    `xml:"maxChangeKbps" default:"1000" ini:"max-change-bw"`
+}
+
+func setDefaults(data interface{}) error {
 	s := reflect.ValueOf(data).Elem()
 	t := s.Type()
 
@@ -35,24 +50,9 @@ func loadConfig(m map[string]string, data interface{}) error {
 		f := s.Field(i)
 		tag := t.Field(i).Tag
 
-		name := tag.Get("ini")
-		if len(name) == 0 {
-			name = strings.ToLower(t.Field(i).Name)
-		}
-
-		v, ok := m[name]
-		if !ok {
-			v = tag.Get("default")
-		}
+		v := tag.Get("default")
 		if len(v) > 0 {
 			switch f.Interface().(type) {
-			case time.Duration:
-				d, err := time.ParseDuration(v)
-				if err != nil {
-					return err
-				}
-				f.SetInt(int64(d))
-
 			case string:
 				f.SetString(v)
 
@@ -74,56 +74,62 @@ func loadConfig(m map[string]string, data interface{}) error {
 	return nil
 }
 
-type cfg struct {
-	Key     string
-	Value   string
-	Comment string
-}
-
-func structToValues(data interface{}) []cfg {
+func readConfigINI(m map[string]string, data interface{}) error {
 	s := reflect.ValueOf(data).Elem()
 	t := s.Type()
 
-	var vals []cfg
 	for i := 0; i < s.NumField(); i++ {
 		f := s.Field(i)
 		tag := t.Field(i).Tag
 
-		var c cfg
-		c.Key = tag.Get("ini")
-		if len(c.Key) == 0 {
-			c.Key = strings.ToLower(t.Field(i).Name)
+		name := tag.Get("ini")
+		if len(name) == 0 {
+			name = strings.ToLower(t.Field(i).Name)
+		}
+
+		if v, ok := m[name]; ok {
+			switch f.Interface().(type) {
+			case string:
+				f.SetString(v)
+
+			case int:
+				i, err := strconv.ParseInt(v, 10, 64)
+				if err == nil {
+					f.SetInt(i)
+				}
+
+			case bool:
+				f.SetBool(v == "true")
+
+			default:
+				panic(f.Type())
+			}
 		}
-		c.Value = fmt.Sprint(f.Interface())
-		c.Comment = tag.Get("description")
-		vals = append(vals, c)
 	}
-	return vals
+	return nil
 }
 
-var configTemplateStr = `[repository]
-{{if .comments}}; The directory to synchronize. Will be created if it does not exist.
-{{end}}dir = {{.dir}}
-
-[nodes]
-{{if .comments}}; Map of node ID to addresses, or "dynamic" for automatic discovery. Examples:
-; J3MZ4G5O4CLHJKB25WX47K5NUJUWDOLO2TTNY3TV3NRU4HVQRKEQ = 172.16.32.24:22000
-; ZNJZRXQKYHF56A2VVNESRZ6AY4ZOWGFJCV6FXDZJUTRVR3SNBT6Q = dynamic
-{{end}}{{range $n, $a := .nodes}}{{$n}} = {{$a}}
-{{end}}
-[settings]
-{{range $v := .settings}}; {{$v.Comment}}
-{{$v.Key}} = {{$v.Value}}
-{{end}}
-`
-
-var configTemplate = template.Must(template.New("config").Parse(configTemplateStr))
-
-func writeConfig(wr io.Writer, dir string, nodes map[string]string, opts Options, comments bool) {
-	configTemplate.Execute(wr, map[string]interface{}{
-		"dir":      dir,
-		"nodes":    nodes,
-		"settings": structToValues(&opts),
-		"comments": comments,
-	})
+func writeConfigXML(wr io.Writer, cfg Configuration) error {
+	e := xml.NewEncoder(wr)
+	e.Indent("", "    ")
+	err := e.Encode(cfg)
+	if err != nil {
+		return err
+	}
+	_, err = wr.Write([]byte("\n"))
+	return err
+}
+
+func readConfigXML(rd io.Reader) (Configuration, error) {
+	var cfg Configuration
+
+	setDefaults(&cfg)
+	setDefaults(&cfg.Options)
+
+	var err error
+	if rd != nil {
+		err = xml.NewDecoder(rd).Decode(&cfg)
+	}
+
+	return cfg, err
 }

+ 13 - 45
gui.go

@@ -2,19 +2,13 @@ package main
 
 import (
 	"encoding/json"
-	"fmt"
 	"log"
-	"mime"
 	"net/http"
-	"path/filepath"
 	"runtime"
 	"sync"
-	"time"
 
-	"github.com/calmh/syncthing/auto"
 	"github.com/calmh/syncthing/model"
 	"github.com/codegangsta/martini"
-	"github.com/cratonica/embed"
 )
 
 func startGUI(addr string, m *model.Model) {
@@ -27,14 +21,11 @@ func startGUI(addr string, m *model.Model) {
 	router.Get("/rest/need", restGetNeed)
 	router.Get("/rest/system", restGetSystem)
 
-	fs, err := embed.Unpack(auto.Resources)
-	if err != nil {
-		panic(err)
-	}
+	router.Post("/rest/config", restPostConfig)
 
 	go func() {
 		mr := martini.New()
-		mr.Use(embeddedStatic(fs))
+		mr.Use(embeddedStatic())
 		mr.Use(martini.Recovery())
 		mr.Action(router.Handle)
 		mr.Map(m)
@@ -43,7 +34,6 @@ func startGUI(addr string, m *model.Model) {
 			warnln("GUI not possible:", err)
 		}
 	}()
-
 }
 
 func getRoot(w http.ResponseWriter, r *http.Request) {
@@ -80,13 +70,16 @@ func restGetConnections(m *model.Model, w http.ResponseWriter) {
 }
 
 func restGetConfig(w http.ResponseWriter) {
-	var res = make(map[string]interface{})
-	res["myID"] = myID
-	res["repository"] = config.OptionMap("repository")
-	res["nodes"] = config.OptionMap("nodes")
-	res["nodes"].(map[string]string)[myID] = "self"
-	w.Header().Set("Content-Type", "application/json")
-	json.NewEncoder(w).Encode(res)
+	json.NewEncoder(w).Encode(cfg)
+}
+
+func restPostConfig(req *http.Request) {
+	err := json.NewDecoder(req.Body).Decode(&cfg)
+	if err != nil {
+		log.Println(err)
+	} else {
+		saveConfig()
+	}
 }
 
 type guiFile model.File
@@ -120,6 +113,7 @@ func restGetSystem(w http.ResponseWriter) {
 	runtime.ReadMemStats(&m)
 
 	res := make(map[string]interface{})
+	res["myID"] = myID
 	res["goroutines"] = runtime.NumGoroutine()
 	res["alloc"] = m.Alloc
 	res["sys"] = m.Sys
@@ -130,29 +124,3 @@ func restGetSystem(w http.ResponseWriter) {
 	w.Header().Set("Content-Type", "application/json")
 	json.NewEncoder(w).Encode(res)
 }
-
-func embeddedStatic(fs map[string][]byte) interface{} {
-	var modt = time.Now().UTC().Format(http.TimeFormat)
-
-	return func(res http.ResponseWriter, req *http.Request, log *log.Logger) {
-		file := req.URL.Path
-
-		if file[0] == '/' {
-			file = file[1:]
-		}
-
-		bs, ok := fs[file]
-		if !ok {
-			return
-		}
-
-		mtype := mime.TypeByExtension(filepath.Ext(req.URL.Path))
-		if len(mtype) != 0 {
-			res.Header().Set("Content-Type", mtype)
-		}
-		res.Header().Set("Content-Size", fmt.Sprintf("%d", len(bs)))
-		res.Header().Set("Last-Modified", modt)
-
-		res.Write(bs)
-	}
-}

+ 171 - 4
gui/app.js

@@ -4,6 +4,28 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     var prevDate = 0;
     var modelGetOK = true;
 
+    $scope.connections = {};
+    $scope.config = {};
+    $scope.myID = "";
+    $scope.nodes = [];
+
+    // Strings before bools look better
+    $scope.settings = [
+        {id: 'ListenAddress', descr:"Sync Protocol Listen Address", type: 'string', restart: true},
+        {id: 'GUIAddress', descr: "GUI Listen Address", type: 'string', restart: true},
+        {id: 'MaxSendKbps', descr: "Outgoing Rate Limit (KBps)", type: 'string', restart: true},
+        {id: 'RescanIntervalS', descr: "Rescan Interval (s)", type: 'string', restart: true},
+        {id: 'ReconnectIntervalS', descr: "Reconnect Interval (s)", type: 'string', restart: true},
+        {id: 'ParallelRequests', descr: "Max Outstanding Requests", type: 'string', restart: true},
+        {id: 'MaxChangeKbps', descr: "Max File Change Rate (KBps)", type: 'string', restart: true},
+
+        {id: 'ReadOnly', descr: "Read Only", type: 'bool', restart: true},
+        {id: 'AllowDelete', descr: "Allow Delete", 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},
+    ];
+
     function modelGetSucceeded() {
         if (!modelGetOK) {
             $('#networkError').modal('hide');
@@ -21,8 +43,25 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
     $http.get("/rest/version").success(function (data) {
         $scope.version = data;
     });
-    $http.get("/rest/config").success(function (data) {
-        $scope.config = 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;
+
+            var nodes = $scope.config.Repositories[0].Nodes;
+            nodes.sort(function (a, b) {
+                if (a.NodeID == $scope.myID)
+                    return -1;
+                if (b.NodeID == $scope.myID)
+                    return 1;
+                if (a.NodeID < b.NodeID)
+                    return -1;
+                return a.NodeID > b.NodeID;
+            })
+            $scope.nodes = nodes;
+        });
     });
 
     $scope.refresh = function () {
@@ -70,6 +109,122 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
         });
     };
 
+    $scope.nodeIcon = function (nodeCfg) {
+        if (nodeCfg.NodeID === $scope.myID) {
+            return "ok";
+        }
+
+        if ($scope.connections[nodeCfg.NodeID]) {
+            return "ok";
+        }
+
+        return "minus";
+    };
+
+    $scope.nodeClass = function (nodeCfg) {
+        if (nodeCfg.NodeID === $scope.myID) {
+            return "default";
+        }
+
+        var conn = $scope.connections[nodeCfg.NodeID];
+        if (conn) {
+            return "success";
+        }
+
+        return "info";
+    };
+
+    $scope.nodeAddr = function (nodeCfg) {
+        if (nodeCfg.NodeID === $scope.myID) {
+            return "this node";
+        }
+        var conn = $scope.connections[nodeCfg.NodeID];
+        if (conn) {
+            return conn.Address;
+        }
+        return nodeCfg.Addresses.join(", ");
+    };
+
+    $scope.nodeVer = function (nodeCfg) {
+        if (nodeCfg.NodeID === $scope.myID) {
+            return $scope.version;
+        }
+        var conn = $scope.connections[nodeCfg.NodeID];
+        if (conn) {
+            return conn.ClientVersion;
+        }
+        return "";
+    };
+
+    $scope.saveSettings = function () {
+        $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
+        $('#settingsTable').collapse('hide');
+    };
+
+    $scope.editNode = function (nodeCfg) {
+        $scope.currentNode = nodeCfg;
+        $scope.editingExisting = true;
+        $scope.currentNode.AddressesStr = nodeCfg.Addresses.join(", ")
+        $('#editNode').modal({backdrop: 'static', keyboard: false});
+    };
+
+    $scope.addNode = function () {
+        $scope.currentNode = {NodeID: "", AddressesStr: "dynamic"};
+        $scope.editingExisting = false;
+        $('#editNode').modal({backdrop: 'static', keyboard: false});
+    };
+
+    $scope.deleteNode = function () {
+        $('#editNode').modal('hide');
+        if (!$scope.editingExisting)
+            return;
+
+        var newNodes = [];
+        for (var i = 0; i < $scope.nodes.length; i++) {
+            if ($scope.nodes[i].NodeID !== $scope.currentNode.NodeID) {
+                newNodes.push($scope.nodes[i]);
+            }
+        } 
+
+        $scope.nodes = newNodes;
+        $scope.config.Repositories[0].Nodes = newNodes;
+
+        $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}})
+    }
+
+    $scope.saveNode = function () {
+        $('#editNode').modal('hide');
+        nodeCfg = $scope.currentNode;
+        nodeCfg.Addresses = nodeCfg.AddressesStr.split(',').map(function (x) { return x.trim(); });
+
+        var done = false;
+        for (var i = 0; i < $scope.nodes.length; i++) {
+            if ($scope.nodes[i].NodeID === nodeCfg.NodeID) {
+                $scope.nodes[i] = nodeCfg;
+                done = true;
+                break;
+            }
+        } 
+
+        if (!done) {
+            $scope.nodes.push(nodeCfg);
+        }
+
+        $scope.nodes.sort(function (a, b) {
+            if (a.NodeID == $scope.myID)
+                return -1;
+            if (b.NodeID == $scope.myID)
+                return 1;
+            if (a.NodeID < b.NodeID)
+                return -1;
+            return a.NodeID > b.NodeID;
+        })
+
+        $scope.config.Repositories[0].Nodes = $scope.nodes;
+
+        $http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}})
+    };
+
     $scope.refresh();
     setInterval($scope.refresh, 10000);
 });
@@ -90,7 +245,7 @@ syncthing.filter('natural', function() {
 syncthing.filter('binary', function() {
     return function(input) {
         if (input === undefined) {
-            return '- '
+            return '0 '
         }
         if (input > 1024 * 1024 * 1024) {
             input /= 1024 * 1024 * 1024;
@@ -111,7 +266,7 @@ syncthing.filter('binary', function() {
 syncthing.filter('metric', function() {
     return function(input) {
         if (input === undefined) {
-            return '- '
+            return '0 '
         }
         if (input > 1000 * 1000 * 1000) {
             input /= 1000 * 1000 * 1000;
@@ -143,3 +298,15 @@ syncthing.filter('alwaysNumber', function() {
         return input;
     }
 });
+
+syncthing.directive('optionEditor', function() {
+    return {
+        restrict: 'C',
+        replace: true,
+        transclude: true,
+        scope: {
+            setting: '=setting',
+        },
+        template: '<input type="text" ng-model="config.Options[setting.id]"></input>',
+    };
+})

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


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


File diff suppressed because it is too large
+ 3 - 3
gui/bootstrap/fonts/glyphicons-halflings-regular.eot


+ 120 - 48
gui/index.html

@@ -35,6 +35,11 @@ html, body {
 .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;
+}
+
 </style>
 </head>
 
@@ -63,6 +68,60 @@ html, body {
             </div>
         </div>
 
+        <div class="row">
+            <div class="col-md-12">
+                <div class="panel panel-info">
+                    <div class="panel-heading"><h3 class="panel-title">Cluster</h3></div>
+                    <table class="table table-condensed">
+                        <tbody>
+                        <tr ng-repeat="nodeCfg in nodes" ng-class="{'text-muted': nodeCfg.NodeID == myID}">
+                            <td>
+                                <span class="label label-{{nodeClass(nodeCfg)}}">
+                                    <span class="glyphicon glyphicon-{{nodeIcon(nodeCfg)}}"></span>
+                                </span>
+                            </td>
+                            <td>
+                                <span class="text-monospace">{{nodeCfg.NodeID | short}}</span>
+                            </td>
+                            <td>
+                                {{nodeVer(nodeCfg)}}
+                            </td>
+                            <td>
+                                {{nodeAddr(nodeCfg)}}
+                            </td>
+                            <td class="text-right">
+                                <span ng-show="nodeCfg.NodeID != myID">
+                                    <abbr title="{{connections[nodeCfg.NodeID].InBytesTotal | binary}}B">{{connections[nodeCfg.NodeID].inbps | metric}}bps</abbr>
+                                    <span class="text-muted glyphicon glyphicon-chevron-down"></span>
+                                </span>
+                            </td>
+                            <td class="text-right">
+                                <span ng-show="nodeCfg.NodeID != myID">
+                                    <abbr title="{{connections[nodeCfg.NodeID].OutBytesTotal | binary}}B">{{connections[nodeCfg.NodeID].outbps | metric}}bps</abbr>
+                                    <span class="text-muted glyphicon glyphicon-chevron-up"></span>
+                                </span>
+                            </td>
+                            <td class="text-right">
+                                <button ng-show="nodeCfg.NodeID != myID" 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>
+            </div>
+        </div>
+
         <div class="row">
             <div class="col-md-6">
                 <div class="panel panel-info">
@@ -75,7 +134,9 @@ html, body {
                         <span class="text-muted">(+{{model.localDeleted | alwaysNumber}} delete records)</span></p>
                     </div>
                 </div>
+            </div>
 
+            <div class="col-md-6">
                 <div class="panel panel-info">
                     <div class="panel-heading"><h3 class="panel-title">System</h3></div>
                     <div class="panel-body">
@@ -83,57 +144,34 @@ html, body {
                         <p>{{system.cpuPercent | alwaysNumber | natural:1}}% CPU, {{system.goroutines | alwaysNumber}} goroutines</p>
                     </div>
                 </div>
-
-                <div ng-show="model.needFiles > 0">
-                    <h2>Files to Synchronize</h2>
-                    <table class="table table-condensed table-striped">
-                        <tr ng-repeat="file in need track by $index">
-                            <td><abbr title="{{file.Name}}">{{file.ShortName}}</abbr></td>
-                            <td class="text-right">{{file.Size | binary}}B</td>
-                        </tr>
-                    </table>
-                </div>
             </div>
+        </div>
+
+        <div class="row">
             <div class="col-md-6">
                 <div class="panel panel-info">
-                    <div class="panel-heading"><h3 class="panel-title">Cluster</h3></div>
-                    <table class="table table-condensed">
-                        <tbody>
-                        <tr ng-repeat="(node, address) in config.nodes" ng-class="{'text-primary': !!connections[node], 'text-muted': node == config.myID}">
-                            <td><span class="text-monospace">{{node | short}}</span></td>
-                            <td>
-                                <span ng-show="node != config.myID">{{connections[node].ClientVersion}}</span>
-                                <span ng-show="node == config.myID">{{version}}</span>
-                            </td>
-                            <td>
-                                <span ng-show="node == config.myID">
-                                    <span class="glyphicon glyphicon-ok"></span>
-                                    (this node)
-                                </span>
-                                <span ng-show="node != config.myID && !!connections[node]">
-                                    <span class="glyphicon glyphicon-link"></span>
-                                    {{connections[node].Address}}
-                                </span>
-                                <span ng-show="node != config.myID && !connections[node]">
-                                    <span class="glyphicon glyphicon-cog"></span>
-                                    {{address}}
-                                </span>
-                            </td>
-                            <td class="text-right">
-                                <span ng-show="node != config.myID">
-                                    <abbr title="{{connections[node].InBytesTotal | binary}}B">{{connections[node].inbps | metric}}b/s</abbr>
-                                    <span class="text-muted glyphicon glyphicon-cloud-download"></span>
-                                </span>
-                            </td>
-                            <td class="text-right">
-                                <span ng-show="node != config.myID">
-                                    <abbr title="{{connections[node].OutBytesTotal | binary}}B">{{connections[node].outbps | metric}}b/s</abbr>
-                                    <span class="text-muted glyphicon glyphicon-cloud-upload"></span>
-                                </span>
-                            </td>
-                        </tr>
-                        </tbody>
-                    </table>
+                    <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 == 'string'">
+                                        <label for="{{setting.id}}">{{setting.descr}}</label>
+                                        <input id="{{setting.id}}" class="form-control" type="text" 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>
+                        <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>
+                        </div>
+                    </div>
                 </div>
             </div>
         </div>
@@ -166,6 +204,40 @@ html, body {
     </div>
 </div>
 
+<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 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 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="addresses">Addresses</label>
+                        <input placeholder="dynamic" 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>
+
 <script src="angular.min.js"></script>
 <script src="jquery-2.0.3.min.js"></script>
 <script src="bootstrap/js/bootstrap.min.js"></script>

+ 9 - 0
gui_development.go

@@ -0,0 +1,9 @@
+//+build guidev
+
+package main
+
+import "github.com/codegangsta/martini"
+
+func embeddedStatic() interface{} {
+	return martini.Static("gui")
+}

+ 46 - 0
gui_embedded.go

@@ -0,0 +1,46 @@
+//+build !guidev
+
+package main
+
+import (
+	"fmt"
+	"log"
+	"mime"
+	"net/http"
+	"path/filepath"
+	"time"
+
+	"github.com/calmh/syncthing/auto"
+	"github.com/cratonica/embed"
+)
+
+func embeddedStatic() interface{} {
+	fs, err := embed.Unpack(auto.Resources)
+	if err != nil {
+		panic(err)
+	}
+
+	var modt = time.Now().UTC().Format(http.TimeFormat)
+
+	return func(res http.ResponseWriter, req *http.Request, log *log.Logger) {
+		file := req.URL.Path
+
+		if file[0] == '/' {
+			file = file[1:]
+		}
+
+		bs, ok := fs[file]
+		if !ok {
+			return
+		}
+
+		mtype := mime.TypeByExtension(filepath.Ext(req.URL.Path))
+		if len(mtype) != 0 {
+			res.Header().Set("Content-Type", mtype)
+		}
+		res.Header().Set("Content-Size", fmt.Sprintf("%d", len(bs)))
+		res.Header().Set("Last-Modified", modt)
+
+		res.Write(bs)
+	}
+}

+ 124 - 73
main.go

@@ -23,17 +23,12 @@ import (
 	"github.com/calmh/syncthing/protocol"
 )
 
-var opts Options
+var cfg Configuration
 var Version string = "unknown-dev"
 
-const (
-	confFileName = "syncthing.ini"
-)
-
 var (
-	myID      string
-	config    ini.Config
-	nodeAddrs = make(map[string][]string)
+	myID   string
+	config ini.Config
 )
 
 var (
@@ -89,46 +84,75 @@ func main() {
 	log.SetPrefix("[" + myID[0:5] + "] ")
 	logger.SetPrefix("[" + myID[0:5] + "] ")
 
+	// Prepare to be able to save configuration
+
+	cfgFile := path.Join(confDir, "config.xml")
+	go saveConfigLoop(cfgFile)
+
 	// Load the configuration file, if it exists.
 	// If it does not, create a template.
 
-	cfgFile := path.Join(confDir, confFileName)
 	cf, err := os.Open(cfgFile)
-
-	if err != nil {
-		infoln("My ID:", myID)
-
-		infoln("No config file; creating a template")
-
-		loadConfig(nil, &opts) //loads defaults
-		fd, err := os.Create(cfgFile)
+	if err == nil {
+		// Read config.xml
+		cfg, err = readConfigXML(cf)
 		if err != nil {
 			fatalln(err)
 		}
+		cf.Close()
+	} else {
+		// No config.xml, let's try the old syncthing.ini
+		iniFile := path.Join(confDir, "syncthing.ini")
+		cf, err := os.Open(iniFile)
+		if err == nil {
+			infoln("Migrating syncthing.ini to config.xml")
+			iniCfg := ini.Parse(cf)
+			cf.Close()
+			os.Rename(iniFile, path.Join(confDir, "migrated_syncthing.ini"))
+
+			cfg, _ = readConfigXML(nil)
+			cfg.Repositories = []RepositoryConfiguration{
+				{Directory: iniCfg.Get("repository", "dir")},
+			}
+			readConfigINI(iniCfg.OptionMap("settings"), &cfg.Options)
+			for name, addrs := range iniCfg.OptionMap("nodes") {
+				n := NodeConfiguration{
+					NodeID:    name,
+					Addresses: strings.Fields(addrs),
+				}
+				cfg.Repositories[0].Nodes = append(cfg.Repositories[0].Nodes, n)
+			}
 
-		writeConfig(fd, "~/Sync", map[string]string{myID: "dynamic"}, opts, true)
-		fd.Close()
-		infof("Edit %s to suit and restart syncthing.", cfgFile)
-
-		os.Exit(0)
+			saveConfig()
+		}
 	}
 
-	config = ini.Parse(cf)
-	cf.Close()
+	if len(cfg.Repositories) == 0 {
+		infoln("No config file; starting with empty defaults")
+
+		cfg, err = readConfigXML(nil)
+		cfg.Repositories = []RepositoryConfiguration{
+			{
+				Directory: "~/Sync",
+				Nodes: []NodeConfiguration{
+					{NodeID: myID, Addresses: []string{"dynamic"}},
+				},
+			},
+		}
 
-	loadConfig(config.OptionMap("settings"), &opts)
+		saveConfig()
+		infof("Edit %s to taste or use the GUI\n", cfgFile)
+	}
 
 	if showConfig {
-		writeConfig(os.Stdout,
-			config.Get("repository", "dir"),
-			config.OptionMap("nodes"), opts, false)
+		writeConfigXML(os.Stdout, cfg)
 		os.Exit(0)
 	}
 
 	infoln("Version", Version)
 	infoln("My ID:", myID)
 
-	var dir = expandTilde(config.Get("repository", "dir"))
+	var dir = expandTilde(cfg.Repositories[0].Directory)
 	if len(dir) == 0 {
 		fatalln("No repository directory. Set dir under [repository] in syncthing.ini.")
 	}
@@ -145,7 +169,7 @@ func main() {
 	// The TLS configuration is used for both the listening socket and outgoing
 	// connections.
 
-	cfg := &tls.Config{
+	tlsCfg := &tls.Config{
 		Certificates:           []tls.Certificate{cert},
 		NextProtos:             []string{"bep/1.0"},
 		ServerName:             myID,
@@ -155,35 +179,27 @@ func main() {
 		MinVersion:             tls.VersionTLS12,
 	}
 
-	// Create a map of desired node connections based on the configuration file
-	// directives.
-
-	for nodeID, addrs := range config.OptionMap("nodes") {
-		addrs := strings.Fields(addrs)
-		nodeAddrs[nodeID] = addrs
-	}
-
 	ensureDir(dir, -1)
-	m := model.NewModel(dir, opts.MaxChangeBW*1000)
+	m := model.NewModel(dir, cfg.Options.MaxChangeKbps*1000)
 	for _, t := range strings.Split(trace, ",") {
 		m.Trace(t)
 	}
-	if opts.LimitRate > 0 {
-		m.LimitRate(opts.LimitRate)
+	if cfg.Options.MaxSendKbps > 0 {
+		m.LimitRate(cfg.Options.MaxSendKbps)
 	}
 
 	// GUI
-	if opts.GUI && opts.GUIAddr != "" {
-		host, port, err := net.SplitHostPort(opts.GUIAddr)
+	if cfg.Options.GUIEnabled && cfg.Options.GUIAddress != "" {
+		host, port, err := net.SplitHostPort(cfg.Options.GUIAddress)
 		if err != nil {
-			warnf("Cannot start GUI on %q: %v", opts.GUIAddr, err)
+			warnf("Cannot start GUI on %q: %v", cfg.Options.GUIAddress, err)
 		} else {
 			if len(host) > 0 {
-				infof("Starting web GUI on http://%s", opts.GUIAddr)
+				infof("Starting web GUI on http://%s", cfg.Options.GUIAddress)
 			} else {
 				infof("Starting web GUI on port %s", port)
 			}
-			startGUI(opts.GUIAddr, m)
+			startGUI(cfg.Options.GUIAddress, m)
 		}
 	}
 
@@ -196,22 +212,22 @@ func main() {
 
 	// Routine to listen for incoming connections
 	infoln("Listening for incoming connections")
-	go listen(myID, opts.Listen, m, cfg)
+	go listen(myID, cfg.Options.ListenAddress, m, tlsCfg)
 
 	// Routine to connect out to configured nodes
 	infoln("Attempting to connect to other nodes")
-	go connect(myID, opts.Listen, nodeAddrs, m, cfg)
+	go connect(myID, cfg.Options.ListenAddress, m, tlsCfg)
 
 	// Routine to pull blocks from other nodes to synchronize the local
 	// repository. Does not run when we are in read only (publish only) mode.
-	if !opts.ReadOnly {
-		if opts.Delete {
+	if !cfg.Options.ReadOnly {
+		if cfg.Options.AllowDelete {
 			infoln("Deletes from peer nodes are allowed")
 		} else {
 			infoln("Deletes from peer nodes will be ignored")
 		}
 		okln("Ready to synchronize (read-write)")
-		m.StartRW(opts.Delete, opts.ParallelRequests)
+		m.StartRW(cfg.Options.AllowDelete, cfg.Options.ParallelRequests)
 	} else {
 		okln("Ready to synchronize (read only; no external updates accepted)")
 	}
@@ -219,9 +235,10 @@ func main() {
 	// Periodically scan the repository and update the local model.
 	// XXX: Should use some fsnotify mechanism.
 	go func() {
+		td := time.Duration(cfg.Options.RescanIntervalS) * time.Second
 		for {
-			time.Sleep(opts.ScanInterval)
-			if m.LocalAge() > opts.ScanInterval.Seconds()/2 {
+			time.Sleep(td)
+			if m.LocalAge() > (td / 2).Seconds() {
 				updateLocalModel(m)
 			}
 		}
@@ -233,6 +250,40 @@ func main() {
 	select {}
 }
 
+var saveConfigCh = make(chan struct{})
+
+func saveConfigLoop(cfgFile string) {
+	for _ = range saveConfigCh {
+		fd, err := os.Create(cfgFile + ".tmp")
+		if err != nil {
+			warnln(err)
+			continue
+		}
+
+		err = writeConfigXML(fd, cfg)
+		if err != nil {
+			warnln(err)
+			fd.Close()
+			continue
+		}
+
+		err = fd.Close()
+		if err != nil {
+			warnln(err)
+			continue
+		}
+
+		err = os.Rename(cfgFile+".tmp", cfgFile)
+		if err != nil {
+			warnln(err)
+		}
+	}
+}
+
+func saveConfig() {
+	saveConfigCh <- struct{}{}
+}
+
 func printStatsLoop(m *model.Model) {
 	var lastUpdated int64
 	var lastStats = make(map[string]model.ConnectionInfo)
@@ -264,8 +315,8 @@ func printStatsLoop(m *model.Model) {
 	}
 }
 
-func listen(myID string, addr string, m *model.Model, cfg *tls.Config) {
-	l, err := tls.Listen("tcp", addr, cfg)
+func listen(myID string, addr string, m *model.Model, tlsCfg *tls.Config) {
+	l, err := tls.Listen("tcp", addr, tlsCfg)
 	fatalErr(err)
 
 	connOpts := map[string]string{
@@ -305,8 +356,8 @@ listen:
 			warnf("Connect from connected node (%s)", remoteID)
 		}
 
-		for nodeID := range nodeAddrs {
-			if nodeID == remoteID {
+		for _, nodeCfg := range cfg.Repositories[0].Nodes {
+			if nodeCfg.NodeID == remoteID {
 				protoConn := protocol.NewConnection(remoteID, conn, conn, m, connOpts)
 				m.AddConnection(conn, protoConn)
 				continue listen
@@ -316,24 +367,24 @@ listen:
 	}
 }
 
-func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.Model, cfg *tls.Config) {
+func connect(myID string, addr string, m *model.Model, tlsCfg *tls.Config) {
 	_, portstr, err := net.SplitHostPort(addr)
 	fatalErr(err)
 	port, _ := strconv.Atoi(portstr)
 
-	if !opts.LocalDiscovery {
+	if !cfg.Options.LocalAnnEnabled {
 		port = -1
 	} else {
 		infoln("Sending local discovery announcements")
 	}
 
-	if !opts.ExternalDiscovery {
-		opts.ExternalServer = ""
+	if !cfg.Options.GlobalAnnEnabled {
+		cfg.Options.GlobalAnnServer = ""
 	} else {
 		infoln("Sending external discovery announcements")
 	}
 
-	disc, err := discover.NewDiscoverer(myID, port, opts.ExternalServer)
+	disc, err := discover.NewDiscoverer(myID, port, cfg.Options.GlobalAnnServer)
 
 	if err != nil {
 		warnf("No discovery possible (%v)", err)
@@ -346,18 +397,18 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M
 
 	for {
 	nextNode:
-		for nodeID, addrs := range nodeAddrs {
-			if nodeID == myID {
+		for _, nodeCfg := range cfg.Repositories[0].Nodes {
+			if nodeCfg.NodeID == myID {
 				continue
 			}
-			if m.ConnectedTo(nodeID) {
+			if m.ConnectedTo(nodeCfg.NodeID) {
 				continue
 			}
-			for _, addr := range addrs {
+			for _, addr := range nodeCfg.Addresses {
 				if addr == "dynamic" {
 					var ok bool
 					if disc != nil {
-						addr, ok = disc.Lookup(nodeID)
+						addr, ok = disc.Lookup(nodeCfg.NodeID)
 					}
 					if !ok {
 						continue
@@ -365,9 +416,9 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M
 				}
 
 				if strings.Contains(trace, "connect") {
-					debugln("NET: Dial", nodeID, addr)
+					debugln("NET: Dial", nodeCfg.NodeID, addr)
 				}
-				conn, err := tls.Dial("tcp", addr, cfg)
+				conn, err := tls.Dial("tcp", addr, tlsCfg)
 				if err != nil {
 					if strings.Contains(trace, "connect") {
 						debugln("NET:", err)
@@ -376,8 +427,8 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M
 				}
 
 				remoteID := certId(conn.ConnectionState().PeerCertificates[0].Raw)
-				if remoteID != nodeID {
-					warnln("Unexpected nodeID", remoteID, "!=", nodeID)
+				if remoteID != nodeCfg.NodeID {
+					warnln("Unexpected nodeID", remoteID, "!=", nodeCfg.NodeID)
 					conn.Close()
 					continue
 				}
@@ -388,12 +439,12 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M
 			}
 		}
 
-		time.Sleep(opts.ConnInterval)
+		time.Sleep(time.Duration(cfg.Options.ReconnectIntervalS) * time.Second)
 	}
 }
 
 func updateLocalModel(m *model.Model) {
-	files, _ := m.Walk(opts.Symlinks)
+	files, _ := m.Walk(cfg.Options.FollowSymlinks)
 	m.ReplaceLocal(files)
 	saveIndex(m)
 }

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