diff --git a/cmd/syncthing/gui.go b/cmd/syncthing/gui.go index a0deca97..aaf9c2ed 100644 --- a/cmd/syncthing/gui.go +++ b/cmd/syncthing/gui.go @@ -90,6 +90,7 @@ type modelIntf interface { Revert(folder string) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfoTruncated, []db.FileInfoTruncated, []db.FileInfoTruncated) RemoteNeedFolderFiles(device protocol.DeviceID, folder string, page, perpage int) ([]db.FileInfoTruncated, error) + LocalChangedFiles(folder string, page, perpage int) []db.FileInfoTruncated NeedSize(folder string) db.Counts ConnectionStats() map[string]interface{} DeviceStatistics() map[string]stats.DeviceStatistics @@ -258,6 +259,7 @@ func (s *apiService) Serve() { getRestMux.HandleFunc("/rest/db/ignores", s.getDBIgnores) // folder getRestMux.HandleFunc("/rest/db/need", s.getDBNeed) // folder [perpage] [page] getRestMux.HandleFunc("/rest/db/remoteneed", s.getDBRemoteNeed) // device folder [perpage] [page] + getRestMux.HandleFunc("/rest/db/localchanged", s.getDBLocalChanged) // folder getRestMux.HandleFunc("/rest/db/status", s.getDBStatus) // folder getRestMux.HandleFunc("/rest/db/browse", s.getDBBrowse) // folder [prefix] [dirsonly] [levels] getRestMux.HandleFunc("/rest/folder/versions", s.getFolderVersions) // folder @@ -707,13 +709,13 @@ func folderSummary(cfg configIntf, m modelIntf, folder string) (map[string]inter res["invalid"] = "" // Deprecated, retains external API for now global := m.GlobalSize(folder) - res["globalFiles"], res["globalDirectories"], res["globalSymlinks"], res["globalDeleted"], res["globalBytes"] = global.Files, global.Directories, global.Symlinks, global.Deleted, global.Bytes + res["globalFiles"], res["globalDirectories"], res["globalSymlinks"], res["globalDeleted"], res["globalBytes"], res["globalTotalItems"] = global.Files, global.Directories, global.Symlinks, global.Deleted, global.Bytes, global.TotalItems() local := m.LocalSize(folder) - res["localFiles"], res["localDirectories"], res["localSymlinks"], res["localDeleted"], res["localBytes"] = local.Files, local.Directories, local.Symlinks, local.Deleted, local.Bytes + res["localFiles"], res["localDirectories"], res["localSymlinks"], res["localDeleted"], res["localBytes"], res["localTotalItems"] = local.Files, local.Directories, local.Symlinks, local.Deleted, local.Bytes, local.TotalItems() need := m.NeedSize(folder) - res["needFiles"], res["needDirectories"], res["needSymlinks"], res["needDeletes"], res["needBytes"] = need.Files, need.Directories, need.Symlinks, need.Deleted, need.Bytes + res["needFiles"], res["needDirectories"], res["needSymlinks"], res["needDeletes"], res["needBytes"], res["needTotalItems"] = need.Files, need.Directories, need.Symlinks, need.Deleted, need.Bytes, need.TotalItems() if cfg.Folders()[folder].Type == config.FolderTypeReceiveOnly { // Add statistics for things that have changed locally in a receive @@ -724,6 +726,7 @@ func folderSummary(cfg configIntf, m modelIntf, folder string) (map[string]inter res["receiveOnlyChangedSymlinks"] = ro.Symlinks res["receiveOnlyChangedDeletes"] = ro.Deleted res["receiveOnlyChangedBytes"] = ro.Bytes + res["receiveOnlyTotalItems"] = ro.TotalItems() } res["inSyncFiles"], res["inSyncBytes"] = global.Files-need.Files, global.Bytes-need.Bytes @@ -791,9 +794,9 @@ func (s *apiService) getDBNeed(w http.ResponseWriter, r *http.Request) { // Convert the struct to a more loose structure, and inject the size. sendJSON(w, map[string]interface{}{ - "progress": toNeedSlice(progress), - "queued": toNeedSlice(queued), - "rest": toNeedSlice(rest), + "progress": toJsonFileInfoSlice(progress), + "queued": toJsonFileInfoSlice(queued), + "rest": toJsonFileInfoSlice(rest), "page": page, "perpage": perpage, }) @@ -816,13 +819,29 @@ func (s *apiService) getDBRemoteNeed(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusNotFound) } else { sendJSON(w, map[string]interface{}{ - "files": toNeedSlice(files), + "files": toJsonFileInfoSlice(files), "page": page, "perpage": perpage, }) } } +func (s *apiService) getDBLocalChanged(w http.ResponseWriter, r *http.Request) { + qs := r.URL.Query() + + folder := qs.Get("folder") + + page, perpage := getPagingParams(qs) + + files := s.model.LocalChangedFiles(folder, page, perpage) + + sendJSON(w, map[string]interface{}{ + "files": toJsonFileInfoSlice(files), + "page": page, + "perpage": perpage, + }) +} + func (s *apiService) getSystemConnections(w http.ResponseWriter, r *http.Request) { sendJSON(w, s.model.ConnectionStats()) } @@ -1638,7 +1657,7 @@ func (s *apiService) getHeapProf(w http.ResponseWriter, r *http.Request) { pprof.WriteHeapProfile(w) } -func toNeedSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo { +func toJsonFileInfoSlice(fs []db.FileInfoTruncated) []jsonDBFileInfo { res := make([]jsonDBFileInfo, len(fs)) for i, f := range fs { res[i] = jsonDBFileInfo(f) diff --git a/cmd/syncthing/mocked_model_test.go b/cmd/syncthing/mocked_model_test.go index 2df049bb..6cb18ce0 100644 --- a/cmd/syncthing/mocked_model_test.go +++ b/cmd/syncthing/mocked_model_test.go @@ -146,3 +146,7 @@ func (m *mockedModel) FolderErrors(folder string) ([]model.FileError, error) { func (m *mockedModel) WatchError(folder string) error { return nil } + +func (m *mockedModel) LocalChangedFiles(folder string, page, perpage int) []db.FileInfoTruncated { + return nil +} diff --git a/gui/default/index.html b/gui/default/index.html index b35755ba..c36a2c0e 100644 --- a/gui/default/index.html +++ b/gui/default/index.html @@ -373,10 +373,10 @@ - +  Out of Sync Items - {{neededItems(folder.id) | alwaysNumber}} items, ~{{model[folder.id].needBytes | binary}}B + {{model[folder.id].needTotalItems | alwaysNumber}} items, ~{{model[folder.id].needBytes | binary}}B @@ -392,6 +392,12 @@ {{model[folder.id].pullErrors | alwaysNumber | localeNumber}} items + +  Locally Changed Items + + {{model[folder.id].receiveOnlyTotalItems | alwaysNumber}} items, ~{{model[folder.id].receiveOnlyBytes | binary}}B + +  Folder Type @@ -822,6 +828,7 @@ + diff --git a/gui/default/syncthing/core/syncthingController.js b/gui/default/syncthing/core/syncthingController.js index 50fc0ccc..b98e288e 100755 --- a/gui/default/syncthing/core/syncthingController.js +++ b/gui/default/syncthing/core/syncthingController.js @@ -46,6 +46,7 @@ angular.module('syncthing.core') $scope.neededCurrentPage = 1; $scope.neededPageSize = 10; $scope.failed = {}; + $scope.localChanged = {}; $scope.scanProgress = {}; $scope.themes = []; $scope.globalChangeEvents = {}; @@ -672,6 +673,15 @@ angular.module('syncthing.core') }); }; + $scope.refreshLocalChanged = function (page, perpage) { + var url = urlbase + '/db/localchanged?folder='; + url += encodeURIComponent($scope.localChanged.folder); + url += "&page=" + page + "&perpage=" + perpage; + $http.get(url).success(function (data) { + $scope.localChanged = data; + }).error($scope.emitHTTPError); + }; + var refreshDeviceStats = debounce(function () { $http.get(urlbase + "/stats/device").success(function (data) { $scope.deviceStats = data; @@ -737,7 +747,7 @@ angular.module('syncthing.core') if (state === 'error') { return 'stopped'; // legacy, the state is called "stopped" in the GUI } - if (state === 'idle' && $scope.neededItems(folderCfg.id) > 0) { + if (state === 'idle' && $scope.model[folderCfg.id].needTotalItems > 0) { return 'outofsync'; } if (state === 'scanning') { @@ -776,15 +786,6 @@ angular.module('syncthing.core') return 'info'; }; - $scope.neededItems = function (folderID) { - if (!$scope.model[folderID]) { - return 0 - } - - return $scope.model[folderID].needFiles + $scope.model[folderID].needDirectories + - $scope.model[folderID].needSymlinks + $scope.model[folderID].needDeletes; - }; - $scope.syncPercentage = function (folder) { if (typeof $scope.model[folder] === 'undefined') { return 100; @@ -2194,6 +2195,14 @@ angular.module('syncthing.core') $http.post(urlbase + "/db/override?folder=" + encodeURIComponent(folder)); }; + $scope.showLocalChanged = function (folder) { + $scope.localChanged.folder = folder; + $scope.localChanged = $scope.refreshLocalChanged(1, 10); + $('#localChanged').modal().one('hidden.bs.modal', function () { + $scope.localChanged = {}; + }); + }; + $scope.revert = function (folder) { $http.post(urlbase + "/db/revert?folder=" + encodeURIComponent(folder)); }; @@ -2203,11 +2212,7 @@ angular.module('syncthing.core') if (!f) { return false; } - return f.receiveOnlyChangedBytes > 0 || - f.receiveOnlyChangedDeletes > 0 || - f.receiveOnlyChangedDirectories > 0 || - f.receiveOnlyChangedFiles > 0 || - f.receiveOnlyChangedSymlinks > 0; + return $scope.model[folder].receiveOnlyTotalItems > 0; }; $scope.advanced = function () { diff --git a/gui/default/syncthing/transfer/localChangedFilesModalView.html b/gui/default/syncthing/transfer/localChangedFilesModalView.html new file mode 100644 index 00000000..48af20ca --- /dev/null +++ b/gui/default/syncthing/transfer/localChangedFilesModalView.html @@ -0,0 +1,31 @@ + + + + diff --git a/gui/default/syncthing/transfer/neededFilesModalView.html b/gui/default/syncthing/transfer/neededFilesModalView.html index 00053974..2a041cd4 100644 --- a/gui/default/syncthing/transfer/neededFilesModalView.html +++ b/gui/default/syncthing/transfer/neededFilesModalView.html @@ -14,7 +14,7 @@ - +
diff --git a/lib/db/set.go b/lib/db/set.go index a7c8824c..2889eeff 100644 --- a/lib/db/set.go +++ b/lib/db/set.go @@ -45,6 +45,7 @@ type FileIntf interface { IsIgnored() bool IsUnsupported() bool MustRescan() bool + IsReceiveOnlyChanged() bool IsDirectory() bool IsSymlink() bool ShouldConflict() bool diff --git a/lib/db/structs.go b/lib/db/structs.go index 0b2b236e..671337d9 100644 --- a/lib/db/structs.go +++ b/lib/db/structs.go @@ -142,6 +142,10 @@ func (c Counts) Add(other Counts) Counts { } } +func (c Counts) TotalItems() int32 { + return c.Files + c.Directories + c.Symlinks + c.Deleted +} + func (vl VersionList) String() string { var b bytes.Buffer var id protocol.DeviceID diff --git a/lib/model/model.go b/lib/model/model.go index 689b9cb6..d2c84357 100644 --- a/lib/model/model.go +++ b/lib/model/model.go @@ -797,12 +797,10 @@ func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfo skip-- return true } - if get > 0 { - ft := f.(db.FileInfoTruncated) - if _, ok := seen[ft.Name]; !ok { - rest = append(rest, ft) - get-- - } + ft := f.(db.FileInfoTruncated) + if _, ok := seen[ft.Name]; !ok { + rest = append(rest, ft) + get-- } return get > 0 }) @@ -810,6 +808,47 @@ func (m *Model) NeedFolderFiles(folder string, page, perpage int) ([]db.FileInfo return progress, queued, rest } +// LocalChangedFiles returns a paginated list of currently needed files in +// progress, queued, and to be queued on next puller iteration, as well as the +// total number of files currently needed. +func (m *Model) LocalChangedFiles(folder string, page, perpage int) []db.FileInfoTruncated { + m.fmut.RLock() + defer m.fmut.RUnlock() + + rf, ok := m.folderFiles[folder] + if !ok { + return nil + } + fcfg := m.folderCfgs[folder] + if fcfg.Type != config.FolderTypeReceiveOnly { + return nil + } + if rf.ReceiveOnlyChangedSize().TotalItems() == 0 { + return nil + } + + files := make([]db.FileInfoTruncated, 0, perpage) + + skip := (page - 1) * perpage + get := perpage + + rf.WithHaveTruncated(protocol.LocalDeviceID, func(f db.FileIntf) bool { + if !f.IsReceiveOnlyChanged() { + return true + } + if skip > 0 { + skip-- + return true + } + ft := f.(db.FileInfoTruncated) + files = append(files, ft) + get-- + return get > 0 + }) + + return files +} + // RemoteNeedFolderFiles returns paginated list of currently needed files in // progress, queued, and to be queued on next puller iteration, as well as the // total number of files currently needed. @@ -833,10 +872,8 @@ func (m *Model) RemoteNeedFolderFiles(device protocol.DeviceID, folder string, p skip-- return true } - if get > 0 { - files = append(files, f.(db.FileInfoTruncated)) - get-- - } + files = append(files, f.(db.FileInfoTruncated)) + get-- return get > 0 })