mirror of
https://github.com/syncthing/syncthing.git
synced 2026-01-03 19:39:20 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725f748b17 | ||
|
|
f3a793ce91 | ||
|
|
c171780c0d | ||
|
|
5daf6ecf70 | ||
|
|
6c8135126d | ||
|
|
91d5c4a1ae | ||
|
|
2cbe81f1c7 | ||
|
|
a26ce61d92 | ||
|
|
478300f6d8 | ||
|
|
3a5b816125 | ||
|
|
b6814241cc | ||
|
|
fc6eabea28 | ||
|
|
14b3791b2b |
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
46
config.go
46
config.go
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -154,3 +157,46 @@ func readConfigXML(rd io.Reader) (Configuration, error) {
|
||||
cfg.Options.ListenAddress = uniqueStrings(cfg.Options.ListenAddress)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
type NodeConfigurationList []NodeConfiguration
|
||||
|
||||
func (l NodeConfigurationList) Less(a, b int) bool {
|
||||
return l[a].NodeID < l[b].NodeID
|
||||
}
|
||||
func (l NodeConfigurationList) Swap(a, b int) {
|
||||
l[a], l[b] = l[b], l[a]
|
||||
}
|
||||
func (l NodeConfigurationList) Len() int {
|
||||
return len(l)
|
||||
}
|
||||
|
||||
func clusterHash(nodes []NodeConfiguration) string {
|
||||
sort.Sort(NodeConfigurationList(nodes))
|
||||
h := sha256.New()
|
||||
for _, n := range nodes {
|
||||
h.Write([]byte(n.NodeID))
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
func cleanNodeList(nodes []NodeConfiguration, myID string) []NodeConfiguration {
|
||||
var myIDExists bool
|
||||
for _, node := range nodes {
|
||||
if node.NodeID == myID {
|
||||
myIDExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !myIDExists {
|
||||
nodes = append(nodes, NodeConfiguration{
|
||||
NodeID: myID,
|
||||
Addresses: []string{"dynamic"},
|
||||
Name: "",
|
||||
})
|
||||
}
|
||||
|
||||
sort.Sort(NodeConfigurationList(nodes))
|
||||
|
||||
return nodes
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
@@ -184,7 +184,8 @@ func (q *FileQueue) Done(file string, offset int64, data []byte) {
|
||||
return
|
||||
}
|
||||
}
|
||||
panic("unreachable")
|
||||
|
||||
// We found nothing, might have errored out already
|
||||
}
|
||||
|
||||
func (q *FileQueue) QueuedFiles() (files []string) {
|
||||
@@ -198,7 +199,7 @@ func (q *FileQueue) QueuedFiles() (files []string) {
|
||||
}
|
||||
|
||||
func (q *FileQueue) deleteAt(i int) {
|
||||
q.files = q.files[:i+copy(q.files[i:], q.files[i+1:])]
|
||||
q.files = append(q.files[:i], q.files[i+1:]...)
|
||||
}
|
||||
|
||||
func (q *FileQueue) deleteFile(n string) {
|
||||
@@ -219,8 +220,10 @@ func (q *FileQueue) SetAvailable(file string, nodes []string) {
|
||||
}
|
||||
|
||||
func (q *FileQueue) RemoveAvailable(toRemove string) {
|
||||
q.fmut.Lock()
|
||||
q.amut.Lock()
|
||||
defer q.amut.Unlock()
|
||||
defer q.fmut.Unlock()
|
||||
|
||||
for file, nodes := range q.availability {
|
||||
for i, node := range nodes {
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
@@ -275,3 +275,21 @@ func TestFileQueueThreadHandling(t *testing.T) {
|
||||
t.Error("Total mismatch; %d != %d", gotTot, total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteAt(t *testing.T) {
|
||||
q := FileQueue{}
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
q.files = queuedFileList{{name: "a"}, {name: "b"}, {name: "c"}, {name: "d"}}
|
||||
q.deleteAt(i)
|
||||
if l := len(q.files); l != 3 {
|
||||
t.Fatal("deleteAt(%d) failed; %d != 3", i, l)
|
||||
}
|
||||
}
|
||||
|
||||
q.files = queuedFileList{{name: "a"}}
|
||||
q.deleteAt(0)
|
||||
if l := len(q.files); l != 0 {
|
||||
t.Fatal("deleteAt(only) failed; %d != 0", l)
|
||||
}
|
||||
}
|
||||
60
gui.go
60
gui.go
@@ -2,26 +2,42 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/calmh/syncthing/model"
|
||||
"github.com/codegangsta/martini"
|
||||
)
|
||||
|
||||
func startGUI(addr string, m *model.Model) {
|
||||
type guiError struct {
|
||||
Time time.Time
|
||||
Error string
|
||||
}
|
||||
|
||||
var (
|
||||
configInSync = true
|
||||
guiErrors = []guiError{}
|
||||
guiErrorsMut sync.Mutex
|
||||
)
|
||||
|
||||
func startGUI(addr string, m *Model) {
|
||||
router := martini.NewRouter()
|
||||
router.Get("/", getRoot)
|
||||
router.Get("/rest/version", restGetVersion)
|
||||
router.Get("/rest/model", restGetModel)
|
||||
router.Get("/rest/connections", restGetConnections)
|
||||
router.Get("/rest/config", restGetConfig)
|
||||
router.Get("/rest/config/sync", restGetConfigInSync)
|
||||
router.Get("/rest/need", restGetNeed)
|
||||
router.Get("/rest/system", restGetSystem)
|
||||
router.Get("/rest/errors", restGetErrors)
|
||||
|
||||
router.Post("/rest/config", restPostConfig)
|
||||
router.Post("/rest/restart", restPostRestart)
|
||||
router.Post("/rest/error", restPostError)
|
||||
|
||||
go func() {
|
||||
mr := martini.New()
|
||||
@@ -44,7 +60,7 @@ func restGetVersion() string {
|
||||
return Version
|
||||
}
|
||||
|
||||
func restGetModel(m *model.Model, w http.ResponseWriter) {
|
||||
func restGetModel(m *Model, w http.ResponseWriter) {
|
||||
var res = make(map[string]interface{})
|
||||
|
||||
globalFiles, globalDeleted, globalBytes := m.GlobalSize()
|
||||
@@ -63,7 +79,7 @@ func restGetModel(m *model.Model, w http.ResponseWriter) {
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func restGetConnections(m *model.Model, w http.ResponseWriter) {
|
||||
func restGetConnections(m *Model, w http.ResponseWriter) {
|
||||
var res = m.ConnectionStats()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
@@ -79,10 +95,19 @@ func restPostConfig(req *http.Request) {
|
||||
log.Println(err)
|
||||
} else {
|
||||
saveConfig()
|
||||
configInSync = false
|
||||
}
|
||||
}
|
||||
|
||||
type guiFile model.File
|
||||
func restGetConfigInSync(w http.ResponseWriter) {
|
||||
json.NewEncoder(w).Encode(map[string]bool{"configInSync": configInSync})
|
||||
}
|
||||
|
||||
func restPostRestart(req *http.Request) {
|
||||
restart()
|
||||
}
|
||||
|
||||
type guiFile File
|
||||
|
||||
func (f guiFile) MarshalJSON() ([]byte, error) {
|
||||
type t struct {
|
||||
@@ -91,11 +116,11 @@ func (f guiFile) MarshalJSON() ([]byte, error) {
|
||||
}
|
||||
return json.Marshal(t{
|
||||
Name: f.Name,
|
||||
Size: model.File(f).Size(),
|
||||
Size: File(f).Size(),
|
||||
})
|
||||
}
|
||||
|
||||
func restGetNeed(m *model.Model, w http.ResponseWriter) {
|
||||
func restGetNeed(m *Model, w http.ResponseWriter) {
|
||||
files, _ := m.NeedFiles()
|
||||
gfs := make([]guiFile, len(files))
|
||||
for i, f := range files {
|
||||
@@ -124,3 +149,24 @@ func restGetSystem(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(res)
|
||||
}
|
||||
|
||||
func restGetErrors(w http.ResponseWriter) {
|
||||
guiErrorsMut.Lock()
|
||||
json.NewEncoder(w).Encode(guiErrors)
|
||||
guiErrorsMut.Unlock()
|
||||
}
|
||||
|
||||
func restPostError(req *http.Request) {
|
||||
bs, _ := ioutil.ReadAll(req.Body)
|
||||
req.Body.Close()
|
||||
showGuiError(string(bs))
|
||||
}
|
||||
|
||||
func showGuiError(err string) {
|
||||
guiErrorsMut.Lock()
|
||||
guiErrors = append(guiErrors, guiError{time.Now(), err})
|
||||
if len(guiErrors) > 5 {
|
||||
guiErrors = guiErrors[len(guiErrors)-5:]
|
||||
}
|
||||
guiErrorsMut.Unlock()
|
||||
}
|
||||
|
||||
299
gui/app.js
299
gui/app.js
@@ -1,29 +1,37 @@
|
||||
/*jslint browser: true, continue: true, plusplus: true */
|
||||
/*global $: false, angular: false */
|
||||
|
||||
'use strict';
|
||||
|
||||
var syncthing = angular.module('syncthing', []);
|
||||
|
||||
syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
var prevDate = 0;
|
||||
var modelGetOK = true;
|
||||
var prevDate = 0,
|
||||
modelGetOK = true;
|
||||
|
||||
$scope.connections = {};
|
||||
$scope.config = {};
|
||||
$scope.myID = "";
|
||||
$scope.myID = '';
|
||||
$scope.nodes = [];
|
||||
$scope.configInSync = true;
|
||||
$scope.errors = [];
|
||||
$scope.seenError = '';
|
||||
|
||||
// Strings before bools look better
|
||||
$scope.settings = [
|
||||
{id: 'ListenStr', descr:"Sync Protocol Listen Addresses", 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: '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: '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},
|
||||
{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() {
|
||||
@@ -40,46 +48,62 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
}
|
||||
}
|
||||
|
||||
$http.get("/rest/version").success(function (data) {
|
||||
function nodeCompare(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;
|
||||
}
|
||||
|
||||
$http.get('/rest/version').success(function (data) {
|
||||
$scope.version = data;
|
||||
});
|
||||
$http.get("/rest/system").success(function (data) {
|
||||
$http.get('/rest/system').success(function (data) {
|
||||
$scope.system = data;
|
||||
$scope.myID = data.myID;
|
||||
|
||||
$http.get("/rest/config").success(function (data) {
|
||||
$http.get('/rest/config').success(function (data) {
|
||||
$scope.config = data;
|
||||
$scope.config.Options.ListenStr = $scope.config.Options.ListenAddress.join(", ")
|
||||
$scope.config.Options.ListenStr = $scope.config.Options.ListenAddress.join(', ');
|
||||
|
||||
var nodes = $scope.config.Repositories[0].Nodes;
|
||||
nodes = nodes.filter(function (x) { return x.NodeID != $scope.myID; });
|
||||
nodes.sort(function (a, b) {
|
||||
if (a.NodeID < b.NodeID)
|
||||
return -1;
|
||||
return a.NodeID > b.NodeID;
|
||||
})
|
||||
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) {
|
||||
$http.get('/rest/system').success(function (data) {
|
||||
$scope.system = data;
|
||||
});
|
||||
$http.get("/rest/model").success(function (data) {
|
||||
$http.get('/rest/model').success(function (data) {
|
||||
$scope.model = data;
|
||||
modelGetSucceeded();
|
||||
}).error(function () {
|
||||
modelGetFailed();
|
||||
});
|
||||
$http.get("/rest/connections").success(function (data) {
|
||||
var now = Date.now();
|
||||
var td = (now - prevDate) / 1000;
|
||||
prevDate = now;
|
||||
$http.get('/rest/connections').success(function (data) {
|
||||
var now = Date.now(),
|
||||
td = (now - prevDate) / 1000,
|
||||
id;
|
||||
|
||||
$scope.inbps = 0
|
||||
$scope.outbps = 0
|
||||
for (var id in data) {
|
||||
prevDate = now;
|
||||
$scope.inbps = 0;
|
||||
$scope.outbps = 0;
|
||||
|
||||
for (id in data) {
|
||||
if (!data.hasOwnProperty(id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
data[id].inbps = Math.max(0, 8 * (data[id].InBytesTotal - $scope.connections[id].InBytesTotal) / td);
|
||||
data[id].outbps = Math.max(0, 8 * (data[id].OutBytesTotal - $scope.connections[id].OutBytesTotal) / td);
|
||||
@@ -87,16 +111,16 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
data[id].inbps = 0;
|
||||
data[id].outbps = 0;
|
||||
}
|
||||
$scope.inbps += data[id].outbps;
|
||||
$scope.outbps += data[id].inbps;
|
||||
$scope.inbps += data[id].inbps;
|
||||
$scope.outbps += data[id].outbps;
|
||||
}
|
||||
$scope.connections = data;
|
||||
});
|
||||
$http.get("/rest/need").success(function (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];
|
||||
name = data[i].Name.split('/');
|
||||
data[i].ShortName = name[name.length - 1];
|
||||
}
|
||||
data.sort(function (a, b) {
|
||||
if (a.ShortName < b.ShortName) {
|
||||
@@ -109,39 +133,48 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
});
|
||||
$scope.need = data;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.nodeIcon = function (nodeCfg) {
|
||||
if ($scope.connections[nodeCfg.NodeID]) {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
return "minus";
|
||||
$http.get('/rest/errors').success(function (data) {
|
||||
$scope.errors = data;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.nodeStatus = function (nodeCfg) {
|
||||
if ($scope.connections[nodeCfg.NodeID]) {
|
||||
return "Connected";
|
||||
var conn = $scope.connections[nodeCfg.NodeID];
|
||||
if (conn) {
|
||||
if (conn.Completion === 100) {
|
||||
return 'In Sync';
|
||||
} else {
|
||||
return 'Syncing (' + conn.Completion + '%)';
|
||||
}
|
||||
}
|
||||
|
||||
return "Disconnected";
|
||||
return 'Disconnected';
|
||||
};
|
||||
|
||||
$scope.nodeIcon = function (nodeCfg) {
|
||||
if ($scope.connections[nodeCfg.NodeID]) {
|
||||
return "ok";
|
||||
var conn = $scope.connections[nodeCfg.NodeID];
|
||||
if (conn) {
|
||||
if (conn.Completion === 100) {
|
||||
return 'ok';
|
||||
} else {
|
||||
return 'refresh';
|
||||
}
|
||||
}
|
||||
|
||||
return "minus";
|
||||
return 'minus';
|
||||
};
|
||||
|
||||
$scope.nodeClass = function (nodeCfg) {
|
||||
var conn = $scope.connections[nodeCfg.NodeID];
|
||||
if (conn) {
|
||||
return "success";
|
||||
if (conn.Completion === 100) {
|
||||
return 'success';
|
||||
} else {
|
||||
return 'primary';
|
||||
}
|
||||
}
|
||||
|
||||
return "info";
|
||||
return 'info';
|
||||
};
|
||||
|
||||
$scope.nodeAddr = function (nodeCfg) {
|
||||
@@ -149,7 +182,15 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
if (conn) {
|
||||
return conn.Address;
|
||||
}
|
||||
return nodeCfg.Addresses.join(", ");
|
||||
return '(unknown address)';
|
||||
};
|
||||
|
||||
$scope.nodeCompletion = function (nodeCfg) {
|
||||
var conn = $scope.connections[nodeCfg.NodeID];
|
||||
if (conn) {
|
||||
return conn.Completion + '%';
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
$scope.nodeVer = function (nodeCfg) {
|
||||
@@ -160,7 +201,7 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
if (conn) {
|
||||
return conn.ClientVersion;
|
||||
}
|
||||
return "";
|
||||
return '(unknown version)';
|
||||
};
|
||||
|
||||
$scope.nodeName = function (nodeCfg) {
|
||||
@@ -171,69 +212,122 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
};
|
||||
|
||||
$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');
|
||||
};
|
||||
|
||||
$scope.restart = function () {
|
||||
$http.post('/rest/restart');
|
||||
$scope.configInSync = true;
|
||||
};
|
||||
|
||||
$scope.editNode = function (nodeCfg) {
|
||||
$scope.currentNode = nodeCfg;
|
||||
$scope.editingExisting = true;
|
||||
$scope.currentNode.AddressesStr = nodeCfg.Addresses.join(", ")
|
||||
$scope.currentNode.AddressesStr = nodeCfg.Addresses.join(', ');
|
||||
$('#editNode').modal({backdrop: 'static', keyboard: false});
|
||||
};
|
||||
|
||||
$scope.addNode = function () {
|
||||
$scope.currentNode = {NodeID: "", AddressesStr: "dynamic"};
|
||||
$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 = [], i;
|
||||
|
||||
var newNodes = [];
|
||||
for (var i = 0; i < $scope.nodes.length; 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 = newNodes;
|
||||
$scope.config.Repositories[0].Nodes = newNodes;
|
||||
|
||||
$http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}})
|
||||
}
|
||||
$scope.configInSync = false;
|
||||
$http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}});
|
||||
};
|
||||
|
||||
$scope.saveNode = function () {
|
||||
var nodeCfg, done, i;
|
||||
|
||||
$scope.configInSync = false;
|
||||
$('#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++) {
|
||||
done = false;
|
||||
for (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 < b.NodeID)
|
||||
return -1;
|
||||
return a.NodeID > b.NodeID;
|
||||
})
|
||||
|
||||
$scope.nodes.sort(nodeCompare);
|
||||
$scope.config.Repositories[0].Nodes = $scope.nodes;
|
||||
|
||||
$http.post('/rest/config', JSON.stringify($scope.config), {headers: {'Content-Type': 'application/json'}})
|
||||
$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;
|
||||
};
|
||||
|
||||
$scope.thisNode = function () {
|
||||
var i, n;
|
||||
|
||||
for (i = 0; i < $scope.nodes.length; i++) {
|
||||
n = $scope.nodes[i];
|
||||
if (n.NodeID === $scope.myID) {
|
||||
return [n];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
$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;
|
||||
};
|
||||
|
||||
$scope.clearErrors = function () {
|
||||
$scope.seenError = $scope.errors[$scope.errors.length - 1].Time;
|
||||
};
|
||||
|
||||
$scope.friendlyNodes = function (str) {
|
||||
for (var i = 0; i < $scope.nodes.length; i++) {
|
||||
var cfg = $scope.nodes[i];
|
||||
str = str.replace(cfg.NodeID, $scope.nodeName(cfg));
|
||||
}
|
||||
return str;
|
||||
};
|
||||
|
||||
$scope.refresh();
|
||||
@@ -241,22 +335,27 @@ syncthing.controller('SyncthingCtrl', function ($scope, $http) {
|
||||
});
|
||||
|
||||
function decimals(val, num) {
|
||||
if (val === 0) { return 0; }
|
||||
var digits = Math.floor(Math.log(Math.abs(val))/Math.log(10));
|
||||
var decimals = Math.max(0, num - digits);
|
||||
return decimals;
|
||||
var digits, decs;
|
||||
|
||||
if (val === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
digits = Math.floor(Math.log(Math.abs(val)) / Math.log(10));
|
||||
decs = Math.max(0, num - digits);
|
||||
return decs;
|
||||
}
|
||||
|
||||
syncthing.filter('natural', function() {
|
||||
return function(input, valid) {
|
||||
syncthing.filter('natural', function () {
|
||||
return function (input, valid) {
|
||||
return input.toFixed(decimals(input, valid));
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
syncthing.filter('binary', function() {
|
||||
return function(input) {
|
||||
syncthing.filter('binary', function () {
|
||||
return function (input) {
|
||||
if (input === undefined) {
|
||||
return '0 '
|
||||
return '0 ';
|
||||
}
|
||||
if (input > 1024 * 1024 * 1024) {
|
||||
input /= 1024 * 1024 * 1024;
|
||||
@@ -271,13 +370,13 @@ syncthing.filter('binary', function() {
|
||||
return input.toFixed(decimals(input, 2)) + ' Ki';
|
||||
}
|
||||
return Math.round(input) + ' ';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
syncthing.filter('metric', function() {
|
||||
return function(input) {
|
||||
syncthing.filter('metric', function () {
|
||||
return function (input) {
|
||||
if (input === undefined) {
|
||||
return '0 '
|
||||
return '0 ';
|
||||
}
|
||||
if (input > 1000 * 1000 * 1000) {
|
||||
input /= 1000 * 1000 * 1000;
|
||||
@@ -292,25 +391,25 @@ syncthing.filter('metric', function() {
|
||||
return input.toFixed(decimals(input, 2)) + ' k';
|
||||
}
|
||||
return Math.round(input) + ' ';
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
syncthing.filter('short', function() {
|
||||
return function(input) {
|
||||
syncthing.filter('short', function () {
|
||||
return function (input) {
|
||||
return input.substr(0, 6);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
syncthing.filter('alwaysNumber', function() {
|
||||
return function(input) {
|
||||
syncthing.filter('alwaysNumber', function () {
|
||||
return function (input) {
|
||||
if (input === undefined) {
|
||||
return 0;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
syncthing.directive('optionEditor', function() {
|
||||
syncthing.directive('optionEditor', function () {
|
||||
return {
|
||||
restrict: 'C',
|
||||
replace: true,
|
||||
@@ -320,4 +419,4 @@ syncthing.directive('optionEditor', function() {
|
||||
},
|
||||
template: '<input type="text" ng-model="config.Options[setting.id]"></input>',
|
||||
};
|
||||
})
|
||||
});
|
||||
|
||||
322
gui/index.html
322
gui/index.html
@@ -11,25 +11,10 @@
|
||||
<title>syncthing</title>
|
||||
<link href="bootstrap/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style type="text/css">
|
||||
html, body {
|
||||
height: 100%;
|
||||
}
|
||||
#wrap{
|
||||
padding-top: 20px;
|
||||
min-height: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto -50px;
|
||||
padding: 20px 0 50px 0;
|
||||
}
|
||||
#footer {
|
||||
height: 50px;
|
||||
padding: 12px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
padding-bottom: 10px;
|
||||
body {
|
||||
padding-top: 70px;
|
||||
padding-bottom: 70px;
|
||||
}
|
||||
|
||||
.text-monospace {
|
||||
@@ -40,128 +25,146 @@ html, body {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
thead tr th {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: -5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body ng-controller="SyncthingCtrl">
|
||||
<div id="wrap">
|
||||
<div class="navbar navbar-fixed-top navbar-default">
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1 class="text-muted"><img width="64" height="64" src="st-logo-128.png"> syncthing</h1>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<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}"
|
||||
style="width: {{100 * model.inSyncBytes / model.globalBytes | number:2}}%;">
|
||||
{{100 * model.inSyncBytes / model.globalBytes | alwaysNumber | number:0}}%
|
||||
</div>
|
||||
<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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}"
|
||||
style="width: {{100 * model.inSyncBytes / model.globalBytes | number:2}}%;">
|
||||
{{100 * model.inSyncBytes / model.globalBytes | alwaysNumber | number:0}}%
|
||||
</div>
|
||||
<p ng-show="model.needBytes > 0">Need {{model.needFiles | alwaysNumber}} files, {{model.needBytes | binary}}B</p>
|
||||
</div>
|
||||
<p ng-show="model.needBytes > 0">Need {{model.needFiles | alwaysNumber}} files, {{model.needBytes | binary}}B</p>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<!-- myself -->
|
||||
<tr class="text-muted">
|
||||
<td style="width:13%">
|
||||
<span class="label label-default">
|
||||
<span class="glyphicon glyphicon-ok"></span> This node
|
||||
</span>
|
||||
</td>
|
||||
<td style="width:12%">
|
||||
<span class="text-monospace">{{myID | short}}</span>
|
||||
</td>
|
||||
<td style="width:20%">{{version}}</td>
|
||||
<td style="width:25%"></td>
|
||||
<td style="width:10%" class="text-right">
|
||||
<span ng-show="nodeCfg.NodeID != myID">
|
||||
{{inbps | metric}}bps
|
||||
<span class="text-muted glyphicon glyphicon-chevron-down"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td style="width:10%" class="text-right">
|
||||
<span ng-show="nodeCfg.NodeID != myID">
|
||||
{{outbps | metric}}bps
|
||||
<span class="text-muted glyphicon glyphicon-chevron-up"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td style="width:10%" class="text-right"></td>
|
||||
</tr>
|
||||
<!-- all other nodes -->
|
||||
<tr ng-repeat="nodeCfg in nodes">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 panel-info">
|
||||
<div class="panel-heading"><h3 class="panel-title">System</h3></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>
|
||||
@@ -169,43 +172,46 @@ html, body {
|
||||
</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="#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 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>
|
||||
</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 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>
|
||||
</div>
|
||||
<div id="footer" class="text-center">
|
||||
syncthing {{version}}
|
||||
| <a href="https://github.com/calmh/syncthing/releases">Latest Release</a>
|
||||
| <a href="https://github.com/calmh/syncthing/wiki">Documentation</a>
|
||||
| <a href="https://github.com/calmh/syncthing/issues">Bugs</a>
|
||||
| <a href="https://github.com/calmh/syncthing">Source Code</a>
|
||||
|
||||
<div class="navbar navbar-default navbar-fixed-bottom">
|
||||
<div class="container">
|
||||
<p class="navbar-text">{{version}}</p>
|
||||
<ul class="nav navbar-nav navbar-right">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="networkError" class="modal fade">
|
||||
@@ -248,7 +254,7 @@ html, body {
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -6,9 +6,13 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// set in main()
|
||||
var logger *log.Logger
|
||||
|
||||
func init() {
|
||||
log.SetOutput(os.Stderr)
|
||||
logger = log.New(os.Stderr, "", log.Flags())
|
||||
}
|
||||
|
||||
func debugln(vals ...interface{}) {
|
||||
s := fmt.Sprintln(vals...)
|
||||
logger.Output(2, "DEBUG: "+s)
|
||||
@@ -41,11 +45,13 @@ func okf(format string, vals ...interface{}) {
|
||||
|
||||
func warnln(vals ...interface{}) {
|
||||
s := fmt.Sprintln(vals...)
|
||||
showGuiError(s)
|
||||
logger.Output(2, "WARNING: "+s)
|
||||
}
|
||||
|
||||
func warnf(format string, vals ...interface{}) {
|
||||
s := fmt.Sprintf(format, vals...)
|
||||
showGuiError(s)
|
||||
logger.Output(2, "WARNING: "+s)
|
||||
}
|
||||
|
||||
|
||||
91
main.go
91
main.go
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
_ "net/http/pprof"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
@@ -19,7 +20,6 @@ import (
|
||||
|
||||
"github.com/calmh/ini"
|
||||
"github.com/calmh/syncthing/discover"
|
||||
"github.com/calmh/syncthing/model"
|
||||
"github.com/calmh/syncthing/protocol"
|
||||
)
|
||||
|
||||
@@ -32,25 +32,28 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
showVersion bool
|
||||
confDir string
|
||||
trace string
|
||||
profiler string
|
||||
verbose bool
|
||||
showVersion bool
|
||||
confDir string
|
||||
trace string
|
||||
profiler string
|
||||
verbose bool
|
||||
startupDelay int
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetOutput(os.Stderr)
|
||||
logger = log.New(os.Stderr, "", log.Flags())
|
||||
|
||||
flag.StringVar(&confDir, "home", "~/.syncthing", "Set configuration directory")
|
||||
flag.StringVar(&trace, "debug.trace", "", "(connect,net,idx,file,pull)")
|
||||
flag.StringVar(&profiler, "debug.profiler", "", "(addr)")
|
||||
flag.BoolVar(&showVersion, "version", false, "Show version")
|
||||
flag.BoolVar(&verbose, "v", false, "Be more verbose")
|
||||
flag.IntVar(&startupDelay, "delay", 0, "Startup delay (s)")
|
||||
flag.Usage = usageFor(flag.CommandLine, "syncthing [options]")
|
||||
flag.Parse()
|
||||
|
||||
if startupDelay > 0 {
|
||||
time.Sleep(time.Duration(startupDelay) * time.Second)
|
||||
}
|
||||
|
||||
if showVersion {
|
||||
fmt.Println(Version)
|
||||
os.Exit(0)
|
||||
@@ -147,6 +150,9 @@ func main() {
|
||||
infof("Edit %s to taste or use the GUI\n", cfgFile)
|
||||
}
|
||||
|
||||
// Make sure the local node is in the node list.
|
||||
cfg.Repositories[0].Nodes = cleanNodeList(cfg.Repositories[0].Nodes, myID)
|
||||
|
||||
var dir = expandTilde(cfg.Repositories[0].Directory)
|
||||
|
||||
if len(profiler) > 0 {
|
||||
@@ -172,7 +178,7 @@ func main() {
|
||||
}
|
||||
|
||||
ensureDir(dir, -1)
|
||||
m := model.NewModel(dir, cfg.Options.MaxChangeKbps*1000)
|
||||
m := NewModel(dir, cfg.Options.MaxChangeKbps*1000)
|
||||
for _, t := range strings.Split(trace, ",") {
|
||||
m.Trace(t)
|
||||
}
|
||||
@@ -204,12 +210,18 @@ func main() {
|
||||
loadIndex(m)
|
||||
updateLocalModel(m)
|
||||
|
||||
connOpts := map[string]string{
|
||||
"clientId": "syncthing",
|
||||
"clientVersion": Version,
|
||||
"clusterHash": clusterHash(cfg.Repositories[0].Nodes),
|
||||
}
|
||||
|
||||
// Routine to listen for incoming connections
|
||||
if verbose {
|
||||
infoln("Listening for incoming connections")
|
||||
}
|
||||
for _, addr := range cfg.Options.ListenAddress {
|
||||
go listen(myID, addr, m, tlsCfg)
|
||||
go listen(myID, addr, m, tlsCfg, connOpts)
|
||||
}
|
||||
|
||||
// Routine to connect out to configured nodes
|
||||
@@ -217,7 +229,7 @@ func main() {
|
||||
infoln("Attempting to connect to other nodes")
|
||||
}
|
||||
disc := discovery(cfg.Options.ListenAddress[0])
|
||||
go connect(myID, disc, m, tlsCfg)
|
||||
go connect(myID, disc, m, tlsCfg, connOpts)
|
||||
|
||||
// Routine to pull blocks from other nodes to synchronize the local
|
||||
// repository. Does not run when we are in read only (publish only) mode.
|
||||
@@ -235,7 +247,7 @@ func main() {
|
||||
okln("Ready to synchronize (read only; no external updates accepted)")
|
||||
}
|
||||
|
||||
// Periodically scan the repository and update the local model.
|
||||
// Periodically scan the repository and update the local
|
||||
// XXX: Should use some fsnotify mechanism.
|
||||
go func() {
|
||||
td := time.Duration(cfg.Options.RescanIntervalS) * time.Second
|
||||
@@ -255,6 +267,35 @@ func main() {
|
||||
select {}
|
||||
}
|
||||
|
||||
func restart() {
|
||||
infoln("Restarting")
|
||||
args := os.Args
|
||||
doAppend := true
|
||||
for _, arg := range args {
|
||||
if arg == "-delay" {
|
||||
doAppend = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if doAppend {
|
||||
args = append(args, "-delay", "2")
|
||||
}
|
||||
pgm, err := exec.LookPath(os.Args[0])
|
||||
if err != nil {
|
||||
warnln(err)
|
||||
return
|
||||
}
|
||||
proc, err := os.StartProcess(pgm, args, &os.ProcAttr{
|
||||
Env: os.Environ(),
|
||||
Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
|
||||
})
|
||||
if err != nil {
|
||||
fatalln(err)
|
||||
}
|
||||
proc.Release()
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
var saveConfigCh = make(chan struct{})
|
||||
|
||||
func saveConfigLoop(cfgFile string) {
|
||||
@@ -289,9 +330,9 @@ func saveConfig() {
|
||||
saveConfigCh <- struct{}{}
|
||||
}
|
||||
|
||||
func printStatsLoop(m *model.Model) {
|
||||
func printStatsLoop(m *Model) {
|
||||
var lastUpdated int64
|
||||
var lastStats = make(map[string]model.ConnectionInfo)
|
||||
var lastStats = make(map[string]ConnectionInfo)
|
||||
|
||||
for {
|
||||
time.Sleep(60 * time.Second)
|
||||
@@ -320,18 +361,13 @@ func printStatsLoop(m *model.Model) {
|
||||
}
|
||||
}
|
||||
|
||||
func listen(myID string, addr string, m *model.Model, tlsCfg *tls.Config) {
|
||||
func listen(myID string, addr string, m *Model, tlsCfg *tls.Config, connOpts map[string]string) {
|
||||
if strings.Contains(trace, "connect") {
|
||||
debugln("NET: Listening on", addr)
|
||||
}
|
||||
l, err := tls.Listen("tcp", addr, tlsCfg)
|
||||
fatalErr(err)
|
||||
|
||||
connOpts := map[string]string{
|
||||
"clientId": "syncthing",
|
||||
"clientVersion": Version,
|
||||
}
|
||||
|
||||
listen:
|
||||
for {
|
||||
conn, err := l.Accept()
|
||||
@@ -401,12 +437,7 @@ func discovery(addr string) *discover.Discoverer {
|
||||
return disc
|
||||
}
|
||||
|
||||
func connect(myID string, disc *discover.Discoverer, m *model.Model, tlsCfg *tls.Config) {
|
||||
connOpts := map[string]string{
|
||||
"clientId": "syncthing",
|
||||
"clientVersion": Version,
|
||||
}
|
||||
|
||||
func connect(myID string, disc *discover.Discoverer, m *Model, tlsCfg *tls.Config, connOpts map[string]string) {
|
||||
for {
|
||||
nextNode:
|
||||
for _, nodeCfg := range cfg.Repositories[0].Nodes {
|
||||
@@ -455,13 +486,13 @@ func connect(myID string, disc *discover.Discoverer, m *model.Model, tlsCfg *tls
|
||||
}
|
||||
}
|
||||
|
||||
func updateLocalModel(m *model.Model) {
|
||||
func updateLocalModel(m *Model) {
|
||||
files, _ := m.Walk(cfg.Options.FollowSymlinks)
|
||||
m.ReplaceLocal(files)
|
||||
saveIndex(m)
|
||||
}
|
||||
|
||||
func saveIndex(m *model.Model) {
|
||||
func saveIndex(m *Model) {
|
||||
name := m.RepoID() + ".idx.gz"
|
||||
fullName := path.Join(confDir, name)
|
||||
idxf, err := os.Create(fullName + ".tmp")
|
||||
@@ -477,7 +508,7 @@ func saveIndex(m *model.Model) {
|
||||
os.Rename(fullName+".tmp", fullName)
|
||||
}
|
||||
|
||||
func loadIndex(m *model.Model) {
|
||||
func loadIndex(m *Model) {
|
||||
name := m.RepoID() + ".idx.gz"
|
||||
idxf, err := os.Open(path.Join(confDir, name))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
@@ -159,6 +158,7 @@ type ConnectionInfo struct {
|
||||
Address string
|
||||
ClientID string
|
||||
ClientVersion string
|
||||
Completion int
|
||||
}
|
||||
|
||||
// ConnectionStats returns a map with connection statistics for each connected node.
|
||||
@@ -167,7 +167,14 @@ func (m *Model) ConnectionStats() map[string]ConnectionInfo {
|
||||
RemoteAddr() net.Addr
|
||||
}
|
||||
|
||||
m.gmut.RLock()
|
||||
m.pmut.RLock()
|
||||
m.rmut.RLock()
|
||||
|
||||
var tot int
|
||||
for _, f := range m.global {
|
||||
tot += f.Size()
|
||||
}
|
||||
|
||||
var res = make(map[string]ConnectionInfo)
|
||||
for node, conn := range m.protoConn {
|
||||
@@ -179,10 +186,22 @@ func (m *Model) ConnectionStats() map[string]ConnectionInfo {
|
||||
if nc, ok := m.rawConn[node].(remoteAddrer); ok {
|
||||
ci.Address = nc.RemoteAddr().String()
|
||||
}
|
||||
|
||||
var have int
|
||||
for _, f := range m.remote[node] {
|
||||
if f.Equals(m.global[f.Name]) {
|
||||
have += f.Size()
|
||||
}
|
||||
}
|
||||
|
||||
ci.Completion = 100 * have / tot
|
||||
|
||||
res[node] = ci
|
||||
}
|
||||
|
||||
m.rmut.RUnlock()
|
||||
m.pmut.RUnlock()
|
||||
m.gmut.RUnlock()
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -268,7 +287,7 @@ func (m *Model) Index(nodeID string, fs []protocol.FileInfo) {
|
||||
defer m.imut.Unlock()
|
||||
|
||||
if m.trace["net"] {
|
||||
log.Printf("NET IDX(in): %s: %d files", nodeID, len(fs))
|
||||
debugf("NET IDX(in): %s: %d files", nodeID, len(fs))
|
||||
}
|
||||
|
||||
repo := make(map[string]File)
|
||||
@@ -296,13 +315,13 @@ func (m *Model) IndexUpdate(nodeID string, fs []protocol.FileInfo) {
|
||||
defer m.imut.Unlock()
|
||||
|
||||
if m.trace["net"] {
|
||||
log.Printf("NET IDXUP(in): %s: %d files", nodeID, len(files))
|
||||
debugf("NET IDXUP(in): %s: %d files", nodeID, len(files))
|
||||
}
|
||||
|
||||
m.rmut.Lock()
|
||||
repo, ok := m.remote[nodeID]
|
||||
if !ok {
|
||||
log.Printf("WARNING: Index update from node %s that does not have an index", nodeID)
|
||||
warnf("Index update from node %s that does not have an index", nodeID)
|
||||
m.rmut.Unlock()
|
||||
return
|
||||
}
|
||||
@@ -322,11 +341,11 @@ func (m *Model) indexUpdate(repo map[string]File, f File) {
|
||||
if f.Flags&protocol.FlagDeleted != 0 {
|
||||
flagComment = " (deleted)"
|
||||
}
|
||||
log.Printf("IDX(in): %q m=%d f=%o%s v=%d (%d blocks)", f.Name, f.Modified, f.Flags, flagComment, f.Version, len(f.Blocks))
|
||||
debugf("IDX(in): %q m=%d f=%o%s v=%d (%d blocks)", f.Name, f.Modified, f.Flags, flagComment, f.Version, len(f.Blocks))
|
||||
}
|
||||
|
||||
if extraFlags := f.Flags &^ (protocol.FlagInvalid | protocol.FlagDeleted | 0xfff); extraFlags != 0 {
|
||||
log.Printf("WARNING: IDX(in): Unknown flags 0x%x in index record %+v", extraFlags, f)
|
||||
warnf("IDX(in): Unknown flags 0x%x in index record %+v", extraFlags, f)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -336,6 +355,13 @@ func (m *Model) indexUpdate(repo map[string]File, f File) {
|
||||
// Close removes the peer from the model and closes the underlying connection if possible.
|
||||
// Implements the protocol.Model interface.
|
||||
func (m *Model) Close(node string, err error) {
|
||||
if m.trace["net"] {
|
||||
debugf("NET: %s: %v", node, err)
|
||||
}
|
||||
if err == protocol.ErrClusterHash {
|
||||
warnf("Connection to %s closed due to mismatched cluster hash. Ensure that the configured cluster members are identical on both nodes.", node)
|
||||
}
|
||||
|
||||
m.fq.RemoveAvailable(node)
|
||||
|
||||
m.pmut.Lock()
|
||||
@@ -370,7 +396,7 @@ func (m *Model) Request(nodeID, name string, offset int64, size uint32, hash []b
|
||||
m.gmut.RUnlock()
|
||||
|
||||
if !localOk || !globalOk {
|
||||
log.Printf("SECURITY (nonexistent file) REQ(in): %s: %q o=%d s=%d h=%x", nodeID, name, offset, size, hash)
|
||||
warnf("SECURITY (nonexistent file) REQ(in): %s: %q o=%d s=%d h=%x", nodeID, name, offset, size, hash)
|
||||
return nil, ErrNoSuchFile
|
||||
}
|
||||
if lf.Flags&protocol.FlagInvalid != 0 {
|
||||
@@ -378,7 +404,7 @@ func (m *Model) Request(nodeID, name string, offset int64, size uint32, hash []b
|
||||
}
|
||||
|
||||
if m.trace["net"] && nodeID != "<local>" {
|
||||
log.Printf("NET REQ(in): %s: %q o=%d s=%d h=%x", nodeID, name, offset, size, hash)
|
||||
debugf("NET REQ(in): %s: %q o=%d s=%d h=%x", nodeID, name, offset, size, hash)
|
||||
}
|
||||
fn := path.Join(m.dir, name)
|
||||
fd, err := os.Open(fn) // XXX: Inefficient, should cache fd?
|
||||
@@ -495,13 +521,13 @@ func (m *Model) AddConnection(rawConn io.Closer, protoConn Connection) {
|
||||
i := i
|
||||
go func() {
|
||||
if m.trace["pull"] {
|
||||
log.Println("PULL: Starting", nodeID, i)
|
||||
debugln("PULL: Starting", nodeID, i)
|
||||
}
|
||||
for {
|
||||
m.pmut.RLock()
|
||||
if _, ok := m.protoConn[nodeID]; !ok {
|
||||
if m.trace["pull"] {
|
||||
log.Println("PULL: Exiting", nodeID, i)
|
||||
debugln("PULL: Exiting", nodeID, i)
|
||||
}
|
||||
m.pmut.RUnlock()
|
||||
return
|
||||
@@ -511,7 +537,7 @@ func (m *Model) AddConnection(rawConn io.Closer, protoConn Connection) {
|
||||
qb, ok := m.fq.Get(nodeID)
|
||||
if ok {
|
||||
if m.trace["pull"] {
|
||||
log.Println("PULL: Request", nodeID, i, qb.name, qb.block.Offset)
|
||||
debugln("PULL: Request", nodeID, i, qb.name, qb.block.Offset)
|
||||
}
|
||||
data, _ := protoConn.Request(qb.name, qb.block.Offset, qb.block.Size, qb.block.Hash)
|
||||
m.fq.Done(qb.name, qb.block.Offset, data)
|
||||
@@ -537,7 +563,7 @@ func (m *Model) ProtocolIndex() []protocol.FileInfo {
|
||||
if mf.Flags&protocol.FlagDeleted != 0 {
|
||||
flagComment = " (deleted)"
|
||||
}
|
||||
log.Printf("IDX(out): %q m=%d f=%o%s v=%d (%d blocks)", mf.Name, mf.Modified, mf.Flags, flagComment, mf.Version, len(mf.Blocks))
|
||||
debugf("IDX(out): %q m=%d f=%o%s v=%d (%d blocks)", mf.Name, mf.Modified, mf.Flags, flagComment, mf.Version, len(mf.Blocks))
|
||||
}
|
||||
index = append(index, mf)
|
||||
}
|
||||
@@ -556,7 +582,7 @@ func (m *Model) requestGlobal(nodeID, name string, offset int64, size uint32, ha
|
||||
}
|
||||
|
||||
if m.trace["net"] {
|
||||
log.Printf("NET REQ(out): %s: %q o=%d s=%d h=%x", nodeID, name, offset, size, hash)
|
||||
debugf("NET REQ(out): %s: %q o=%d s=%d h=%x", nodeID, name, offset, size, hash)
|
||||
}
|
||||
|
||||
return nc.Request(name, offset, size, hash)
|
||||
@@ -584,7 +610,7 @@ func (m *Model) broadcastIndexLoop() {
|
||||
for _, node := range m.protoConn {
|
||||
node := node
|
||||
if m.trace["net"] {
|
||||
log.Printf("NET IDX(out/loop): %s: %d files", node.ID(), len(idx))
|
||||
debugf("NET IDX(out/loop): %s: %d files", node.ID(), len(idx))
|
||||
}
|
||||
go func() {
|
||||
node.Index(idx)
|
||||
@@ -796,7 +822,7 @@ func (m *Model) recomputeNeedForFile(gf File, toAdd []addOrder, toDelete []File)
|
||||
return toAdd, toDelete
|
||||
}
|
||||
if m.trace["need"] {
|
||||
log.Printf("NEED: lf:%v gf:%v", lf, gf)
|
||||
debugf("NEED: lf:%v gf:%v", lf, gf)
|
||||
}
|
||||
|
||||
if gf.Flags&protocol.FlagDeleted != 0 {
|
||||
@@ -838,12 +864,12 @@ func (m *Model) WhoHas(name string) []string {
|
||||
func (m *Model) deleteLoop() {
|
||||
for file := range m.dq {
|
||||
if m.trace["file"] {
|
||||
log.Println("FILE: Delete", file.Name)
|
||||
debugln("FILE: Delete", file.Name)
|
||||
}
|
||||
path := path.Clean(path.Join(m.dir, file.Name))
|
||||
err := os.Remove(path)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: %s: %v", file.Name, err)
|
||||
warnf("%s: %v", file.Name, err)
|
||||
}
|
||||
|
||||
m.updateLocal(file)
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -27,6 +27,10 @@ const (
|
||||
FlagInvalid = 1 << 13
|
||||
)
|
||||
|
||||
var (
|
||||
ErrClusterHash = fmt.Errorf("Configuration error: mismatched cluster hash")
|
||||
)
|
||||
|
||||
type FileInfo struct {
|
||||
Name string
|
||||
Flags uint32
|
||||
@@ -64,7 +68,8 @@ type Connection struct {
|
||||
awaiting map[int]chan asyncResult
|
||||
nextId int
|
||||
indexSent map[string][2]int64
|
||||
options map[string]string
|
||||
peerOptions map[string]string
|
||||
myOptions map[string]string
|
||||
optionsLock sync.Mutex
|
||||
|
||||
hasSentIndex bool
|
||||
@@ -106,6 +111,7 @@ func NewConnection(nodeID string, reader io.Reader, writer io.Writer, receiver M
|
||||
go c.pingerLoop()
|
||||
|
||||
if options != nil {
|
||||
c.myOptions = options
|
||||
go func() {
|
||||
c.Lock()
|
||||
c.mwriter.writeHeader(header{0, c.nextId, messageTypeOptions})
|
||||
@@ -348,9 +354,14 @@ loop:
|
||||
|
||||
case messageTypeOptions:
|
||||
c.optionsLock.Lock()
|
||||
c.options = c.mreader.readOptions()
|
||||
c.peerOptions = c.mreader.readOptions()
|
||||
c.optionsLock.Unlock()
|
||||
|
||||
if mh, rh := c.myOptions["clusterHash"], c.peerOptions["clusterHash"]; len(mh) > 0 && len(rh) > 0 && mh != rh {
|
||||
c.close(ErrClusterHash)
|
||||
break loop
|
||||
}
|
||||
|
||||
default:
|
||||
c.close(fmt.Errorf("Protocol error: %s: unknown message type %#x", c.ID, hdr.msgType))
|
||||
break loop
|
||||
@@ -423,5 +434,5 @@ func (c *Connection) Statistics() Statistics {
|
||||
func (c *Connection) Option(key string) string {
|
||||
c.optionsLock.Lock()
|
||||
defer c.optionsLock.Unlock()
|
||||
return c.options[key]
|
||||
return c.peerOptions[key]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
0
model/testdata/bar → testdata/bar
vendored
0
model/testdata/bar → testdata/bar
vendored
0
model/testdata/empty → testdata/empty
vendored
0
model/testdata/empty → testdata/empty
vendored
0
model/testdata/foo → testdata/foo
vendored
0
model/testdata/foo → testdata/foo
vendored
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package model
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
Reference in New Issue
Block a user