Browse Source

all: Allow dismissing pending devices / folders without ignoring (fixes #7700) (#7712)

André Colomb 4 years ago
parent
commit
45edad867c

+ 3 - 0
gui/default/assets/lang/lang-en.json

@@ -94,6 +94,8 @@
    "Discovered": "Discovered",
    "Discovery": "Discovery",
    "Discovery Failures": "Discovery Failures",
+   "Dismiss": "Dismiss",
+   "Do not add it to the ignore list, so this notification may recurr.": "Do not add it to the ignore list, so this notification may recurr.",
    "Do not restore": "Do not restore",
    "Do not restore all": "Do not restore all",
    "Do you want to enable watching for changes for all your folders?": "Do you want to enable watching for changes for all your folders?",
@@ -227,6 +229,7 @@
    "Periodic scanning at given interval and disabled watching for changes": "Periodic scanning at given interval and disabled watching for changes",
    "Periodic scanning at given interval and enabled watching for changes": "Periodic scanning at given interval and enabled watching for changes",
    "Periodic scanning at given interval and failed setting up watching for changes, retrying every 1m:": "Periodic scanning at given interval and failed setting up watching for changes, retrying every 1m:",
+   "Permanently add it to the ignore list, suppressing further notifications.": "Permanently add it to the ignore list, suppressing further notifications.",
    "Permissions": "Permissions",
    "Please consult the release notes before performing a major upgrade.": "Please consult the release notes before performing a major upgrade.",
    "Please set a GUI Authentication User and Password in the Settings dialog.": "Please set a GUI Authentication User and Password in the Settings dialog.",

+ 8 - 2
gui/default/index.html

@@ -209,10 +209,13 @@
               </div>
               <div class="panel-footer clearfix">
                 <div class="pull-right">
+                  <button type="button" class="btn btn-sm btn-default" ng-click="dismissPendingDevice(deviceID)" tooltip data-original-title="{{'Do not add it to the ignore list, so this notification may recurr.' | translate}}">
+                    <span class="far fa-clock"></span>&nbsp;<span translate>Dismiss</span>
+                  </button>
                   <button type="button" class="btn btn-sm btn-success" ng-click="addDevice(deviceID, pendingDevice.name)">
                     <span class="fas fa-plus"></span>&nbsp;<span translate>Add Device</span>
                   </button>
-                  <button type="button" class="btn btn-sm btn-danger" ng-click="ignoreDevice(deviceID, pendingDevice)">
+                  <button type="button" class="btn btn-sm btn-danger" ng-click="ignoreDevice(deviceID, pendingDevice)" tooltip data-original-title="{{'Permanently add it to the ignore list, suppressing further notifications.' | translate}}">
                     <span class="fas fa-times"></span>&nbsp;<span translate>Ignore</span>
                   </button>
                 </div>
@@ -250,13 +253,16 @@
                 </div>
                 <div class="panel-footer clearfix">
                   <div class="pull-right">
+                    <button type="button" class="btn btn-sm btn-default" ng-click="dismissPendingFolder(folderID, deviceID)" tooltip data-original-title="{{'Do not add it to the ignore list, so this notification may recurr.' | translate}}">
+                      <span class="far fa-clock"></span>&nbsp;<span translate>Dismiss</span>
+                    </button>
                     <button type="button" class="btn btn-sm btn-success" ng-click="addFolderAndShare(folderID, pendingFolder, deviceID)" ng-if="!folders[folderID]">
                       <span class="fas fa-check"></span>&nbsp;<span translate>Add</span>
                     </button>
                     <button type="button" class="btn btn-sm btn-success" ng-click="shareFolderWithDevice(folderID, deviceID)" ng-if="folders[folderID]">
                       <span class="fas fa-check"></span>&nbsp;<span translate>Share</span>
                     </button>
-                    <button type="button" class="btn btn-sm btn-danger" ng-click="ignoreFolder(deviceID, folderID, offeringDevice)">
+                    <button type="button" class="btn btn-sm btn-danger" ng-click="ignoreFolder(deviceID, folderID, offeringDevice)" tooltip data-original-title="{{'Permanently add it to the ignore list, suppressing further notifications.' | translate}}">
                       <span class="fas fa-times"></span>&nbsp;<span translate>Ignore</span>
                     </button>
                   </div>

+ 9 - 0
gui/default/syncthing/core/syncthingController.js

@@ -1749,6 +1749,10 @@ angular.module('syncthing.core')
             $scope.saveConfig();
         };
 
+        $scope.dismissPendingDevice = function (deviceID) {
+            $http.delete(urlbase + '/cluster/pending/devices?device=' + encodeURIComponent(deviceID));
+        };
+
         $scope.unignoreDeviceFromTemporaryConfig = function (ignoredDevice) {
             $scope.tmpRemoteIgnoredDevices = $scope.tmpRemoteIgnoredDevices.filter(function (existingIgnoredDevice) {
                 return ignoredDevice.deviceID !== existingIgnoredDevice.deviceID;
@@ -2220,6 +2224,11 @@ angular.module('syncthing.core')
             }
         };
 
+        $scope.dismissPendingFolder = function (folderID, deviceID) {
+            $http.delete(urlbase + '/cluster/pending/folders?folder=' + encodeURIComponent(folderID)
+                         + '&device=' + encodeURIComponent(deviceID));
+        };
+
         $scope.sharesFolder = function (folderCfg) {
             var names = [];
             folderCfg.devices.forEach(function (device) {

+ 35 - 0
lib/api/api.go

@@ -291,6 +291,10 @@ func (s *service) Serve(ctx context.Context) error {
 	restMux.HandlerFunc(http.MethodPost, "/rest/system/resume", s.makeDevicePauseHandler(false)) // [device]
 	restMux.HandlerFunc(http.MethodPost, "/rest/system/debug", s.postSystemDebug)                // [enable] [disable]
 
+	// The DELETE handlers
+	restMux.HandlerFunc(http.MethodDelete, "/rest/cluster/pending/devices", s.deletePendingDevices) // device
+	restMux.HandlerFunc(http.MethodDelete, "/rest/cluster/pending/folders", s.deletePendingFolders) // folder [device]
+
 	// Config endpoints
 
 	configBuilder := &configMuxBuilder{
@@ -632,6 +636,21 @@ func (s *service) getPendingDevices(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, devices)
 }
 
+func (s *service) deletePendingDevices(w http.ResponseWriter, r *http.Request) {
+	qs := r.URL.Query()
+
+	device := qs.Get("device")
+	deviceID, err := protocol.DeviceIDFromString(device)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+
+	if err := s.model.DismissPendingDevice(deviceID); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
 func (s *service) getPendingFolders(w http.ResponseWriter, r *http.Request) {
 	qs := r.URL.Query()
 
@@ -650,6 +669,22 @@ func (s *service) getPendingFolders(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, folders)
 }
 
+func (s *service) deletePendingFolders(w http.ResponseWriter, r *http.Request) {
+	qs := r.URL.Query()
+
+	device := qs.Get("device")
+	deviceID, err := protocol.DeviceIDFromString(device)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	folderID := qs.Get("folder")
+
+	if err := s.model.DismissPendingFolder(deviceID, folderID); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+	}
+}
+
 func (s *service) restPing(w http.ResponseWriter, r *http.Request) {
 	sendJSON(w, map[string]string{"ping": "pong"})
 }

+ 11 - 6
lib/db/observed.go

@@ -26,11 +26,13 @@ func (db *Lowlevel) AddOrUpdatePendingDevice(device protocol.DeviceID, name, add
 	return db.Put(key, bs)
 }
 
-func (db *Lowlevel) RemovePendingDevice(device protocol.DeviceID) {
+func (db *Lowlevel) RemovePendingDevice(device protocol.DeviceID) error {
 	key := db.keyer.GeneratePendingDeviceKey(nil, device[:])
 	if err := db.Delete(key); err != nil {
 		l.Warnf("Failed to remove pending device entry: %v", err)
+		return err
 	}
+	return nil
 }
 
 // PendingDevices enumerates all entries.  Invalid ones are dropped from the database
@@ -79,32 +81,35 @@ func (db *Lowlevel) AddOrUpdatePendingFolder(id string, of ObservedFolder, devic
 }
 
 // RemovePendingFolderForDevice removes entries for specific folder / device combinations.
-func (db *Lowlevel) RemovePendingFolderForDevice(id string, device protocol.DeviceID) {
+func (db *Lowlevel) RemovePendingFolderForDevice(id string, device protocol.DeviceID) error {
 	key, err := db.keyer.GeneratePendingFolderKey(nil, device[:], []byte(id))
 	if err != nil {
-		return
+		return err
 	}
 	if err := db.Delete(key); err != nil {
 		l.Warnf("Failed to remove pending folder entry: %v", err)
+		return err
 	}
+	return nil
 }
 
 // RemovePendingFolder removes all entries matching a specific folder ID.
-func (db *Lowlevel) RemovePendingFolder(id string) {
+func (db *Lowlevel) RemovePendingFolder(id string) error {
 	iter, err := db.NewPrefixIterator([]byte{KeyTypePendingFolder})
 	if err != nil {
 		l.Infof("Could not iterate through pending folder entries: %v", err)
-		return
+		return err
 	}
 	defer iter.Release()
 	for iter.Next() {
 		if id != string(db.keyer.FolderFromPendingFolderKey(iter.Key())) {
 			continue
 		}
-		if err := db.Delete(iter.Key()); err != nil {
+		if err = db.Delete(iter.Key()); err != nil {
 			l.Warnf("Failed to remove pending folder entry: %v", err)
 		}
 	}
+	return err
 }
 
 // Consolidated information about a pending folder

+ 150 - 0
lib/model/mocks/model.go

@@ -177,6 +177,29 @@ type Model struct {
 		result1 map[protocol.DeviceID]stats.DeviceStatistics
 		result2 error
 	}
+	DismissPendingDeviceStub        func(protocol.DeviceID) error
+	dismissPendingDeviceMutex       sync.RWMutex
+	dismissPendingDeviceArgsForCall []struct {
+		arg1 protocol.DeviceID
+	}
+	dismissPendingDeviceReturns struct {
+		result1 error
+	}
+	dismissPendingDeviceReturnsOnCall map[int]struct {
+		result1 error
+	}
+	DismissPendingFolderStub        func(protocol.DeviceID, string) error
+	dismissPendingFolderMutex       sync.RWMutex
+	dismissPendingFolderArgsForCall []struct {
+		arg1 protocol.DeviceID
+		arg2 string
+	}
+	dismissPendingFolderReturns struct {
+		result1 error
+	}
+	dismissPendingFolderReturnsOnCall map[int]struct {
+		result1 error
+	}
 	DownloadProgressStub        func(protocol.DeviceID, string, []protocol.FileDownloadProgressUpdate) error
 	downloadProgressMutex       sync.RWMutex
 	downloadProgressArgsForCall []struct {
@@ -1332,6 +1355,129 @@ func (fake *Model) DeviceStatisticsReturnsOnCall(i int, result1 map[protocol.Dev
 	}{result1, result2}
 }
 
+func (fake *Model) DismissPendingDevice(arg1 protocol.DeviceID) error {
+	fake.dismissPendingDeviceMutex.Lock()
+	ret, specificReturn := fake.dismissPendingDeviceReturnsOnCall[len(fake.dismissPendingDeviceArgsForCall)]
+	fake.dismissPendingDeviceArgsForCall = append(fake.dismissPendingDeviceArgsForCall, struct {
+		arg1 protocol.DeviceID
+	}{arg1})
+	stub := fake.DismissPendingDeviceStub
+	fakeReturns := fake.dismissPendingDeviceReturns
+	fake.recordInvocation("DismissPendingDevice", []interface{}{arg1})
+	fake.dismissPendingDeviceMutex.Unlock()
+	if stub != nil {
+		return stub(arg1)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *Model) DismissPendingDeviceCallCount() int {
+	fake.dismissPendingDeviceMutex.RLock()
+	defer fake.dismissPendingDeviceMutex.RUnlock()
+	return len(fake.dismissPendingDeviceArgsForCall)
+}
+
+func (fake *Model) DismissPendingDeviceCalls(stub func(protocol.DeviceID) error) {
+	fake.dismissPendingDeviceMutex.Lock()
+	defer fake.dismissPendingDeviceMutex.Unlock()
+	fake.DismissPendingDeviceStub = stub
+}
+
+func (fake *Model) DismissPendingDeviceArgsForCall(i int) protocol.DeviceID {
+	fake.dismissPendingDeviceMutex.RLock()
+	defer fake.dismissPendingDeviceMutex.RUnlock()
+	argsForCall := fake.dismissPendingDeviceArgsForCall[i]
+	return argsForCall.arg1
+}
+
+func (fake *Model) DismissPendingDeviceReturns(result1 error) {
+	fake.dismissPendingDeviceMutex.Lock()
+	defer fake.dismissPendingDeviceMutex.Unlock()
+	fake.DismissPendingDeviceStub = nil
+	fake.dismissPendingDeviceReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *Model) DismissPendingDeviceReturnsOnCall(i int, result1 error) {
+	fake.dismissPendingDeviceMutex.Lock()
+	defer fake.dismissPendingDeviceMutex.Unlock()
+	fake.DismissPendingDeviceStub = nil
+	if fake.dismissPendingDeviceReturnsOnCall == nil {
+		fake.dismissPendingDeviceReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.dismissPendingDeviceReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *Model) DismissPendingFolder(arg1 protocol.DeviceID, arg2 string) error {
+	fake.dismissPendingFolderMutex.Lock()
+	ret, specificReturn := fake.dismissPendingFolderReturnsOnCall[len(fake.dismissPendingFolderArgsForCall)]
+	fake.dismissPendingFolderArgsForCall = append(fake.dismissPendingFolderArgsForCall, struct {
+		arg1 protocol.DeviceID
+		arg2 string
+	}{arg1, arg2})
+	stub := fake.DismissPendingFolderStub
+	fakeReturns := fake.dismissPendingFolderReturns
+	fake.recordInvocation("DismissPendingFolder", []interface{}{arg1, arg2})
+	fake.dismissPendingFolderMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *Model) DismissPendingFolderCallCount() int {
+	fake.dismissPendingFolderMutex.RLock()
+	defer fake.dismissPendingFolderMutex.RUnlock()
+	return len(fake.dismissPendingFolderArgsForCall)
+}
+
+func (fake *Model) DismissPendingFolderCalls(stub func(protocol.DeviceID, string) error) {
+	fake.dismissPendingFolderMutex.Lock()
+	defer fake.dismissPendingFolderMutex.Unlock()
+	fake.DismissPendingFolderStub = stub
+}
+
+func (fake *Model) DismissPendingFolderArgsForCall(i int) (protocol.DeviceID, string) {
+	fake.dismissPendingFolderMutex.RLock()
+	defer fake.dismissPendingFolderMutex.RUnlock()
+	argsForCall := fake.dismissPendingFolderArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2
+}
+
+func (fake *Model) DismissPendingFolderReturns(result1 error) {
+	fake.dismissPendingFolderMutex.Lock()
+	defer fake.dismissPendingFolderMutex.Unlock()
+	fake.DismissPendingFolderStub = nil
+	fake.dismissPendingFolderReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *Model) DismissPendingFolderReturnsOnCall(i int, result1 error) {
+	fake.dismissPendingFolderMutex.Lock()
+	defer fake.dismissPendingFolderMutex.Unlock()
+	fake.DismissPendingFolderStub = nil
+	if fake.dismissPendingFolderReturnsOnCall == nil {
+		fake.dismissPendingFolderReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.dismissPendingFolderReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
 func (fake *Model) DownloadProgress(arg1 protocol.DeviceID, arg2 string, arg3 []protocol.FileDownloadProgressUpdate) error {
 	var arg3Copy []protocol.FileDownloadProgressUpdate
 	if arg3 != nil {
@@ -3254,6 +3400,10 @@ func (fake *Model) Invocations() map[string][][]interface{} {
 	defer fake.delayScanMutex.RUnlock()
 	fake.deviceStatisticsMutex.RLock()
 	defer fake.deviceStatisticsMutex.RUnlock()
+	fake.dismissPendingDeviceMutex.RLock()
+	defer fake.dismissPendingDeviceMutex.RUnlock()
+	fake.dismissPendingFolderMutex.RLock()
+	defer fake.dismissPendingFolderMutex.RUnlock()
 	fake.downloadProgressMutex.RLock()
 	defer fake.downloadProgressMutex.RUnlock()
 	fake.folderErrorsMutex.RLock()

+ 78 - 15
lib/model/model.go

@@ -107,6 +107,8 @@ type Model interface {
 
 	PendingDevices() (map[protocol.DeviceID]db.ObservedDevice, error)
 	PendingFolders(device protocol.DeviceID) (map[string]db.PendingFolder, error)
+	DismissPendingDevice(device protocol.DeviceID) error
+	DismissPendingFolder(device protocol.DeviceID, folder string) error
 
 	StartDeadlockDetector(timeout time.Duration)
 	GlobalDirectoryTree(folder, prefix string, levels int, dirsOnly bool) ([]*TreeEntry, error)
@@ -1374,17 +1376,18 @@ func (m *model) ccHandleFolders(folders []protocol.Folder, deviceCfg config.Devi
 	}
 
 	indexHandlers.RemoveAllExcept(seenFolders)
+	expiredPendingList := make([]map[string]string, 0, len(expiredPending))
 	for folder := range expiredPending {
-		m.db.RemovePendingFolderForDevice(folder, deviceID)
-	}
-	if len(updatedPending) > 0 || len(expiredPending) > 0 {
-		expiredPendingList := make([]map[string]string, 0, len(expiredPending))
-		for folderID := range expiredPending {
-			expiredPendingList = append(expiredPendingList, map[string]string{
-				"folderID": folderID,
-				"deviceID": deviceID.String(),
-			})
+		if err = m.db.RemovePendingFolderForDevice(folder, deviceID); err != nil {
+			// Nothing we can fix; logged from DB already
+			continue
 		}
+		expiredPendingList = append(expiredPendingList, map[string]string{
+			"folderID": folder,
+			"deviceID": deviceID.String(),
+		})
+	}
+	if len(updatedPending) > 0 || len(expiredPendingList) > 0 {
 		m.evLogger.Log(events.PendingFoldersChanged, map[string]interface{}{
 			"added":   updatedPending,
 			"removed": expiredPendingList,
@@ -2947,10 +2950,13 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 			// folders as well, assuming the folder is no longer of interest
 			// at all (but might become pending again).
 			l.Debugf("Discarding pending removed folder %v from all devices", folderID)
-			m.db.RemovePendingFolder(folderID)
-			removedPendingFolders = append(removedPendingFolders, map[string]string{
-				"folderID": folderID,
-			})
+			if err := m.db.RemovePendingFolder(folderID); err != nil {
+				// Nothing we can fix; logged from DB already
+			} else {
+				removedPendingFolders = append(removedPendingFolders, map[string]string{
+					"folderID": folderID,
+				})
+			}
 			continue
 		}
 		for deviceID := range pf.OfferedBy {
@@ -2969,7 +2975,10 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 			}
 			continue
 		removeFolderForDevice:
-			m.db.RemovePendingFolderForDevice(folderID, deviceID)
+			if err := m.db.RemovePendingFolderForDevice(folderID, deviceID); err != nil {
+				// Nothing we can fix; logged from DB already
+				continue
+			}
 			removedPendingFolders = append(removedPendingFolders, map[string]string{
 				"folderID": folderID,
 				"deviceID": deviceID.String(),
@@ -2999,7 +3008,10 @@ func (m *model) cleanPending(existingDevices map[protocol.DeviceID]config.Device
 		}
 		continue
 	removeDevice:
-		m.db.RemovePendingDevice(deviceID)
+		if err := m.db.RemovePendingDevice(deviceID); err != nil {
+			// Nothing we can fix; logged from DB already
+			continue
+		}
 		removedPendingDevices = append(removedPendingDevices, map[string]string{
 			"deviceID": deviceID.String(),
 		})
@@ -3041,6 +3053,57 @@ func (m *model) PendingFolders(device protocol.DeviceID) (map[string]db.PendingF
 	return m.db.PendingFoldersForDevice(device)
 }
 
+// DismissPendingDevices removes the record of a specific pending device.
+func (m *model) DismissPendingDevice(device protocol.DeviceID) error {
+	l.Debugf("Discarding pending device %v", device)
+	err := m.db.RemovePendingDevice(device)
+	if err != nil {
+		return err
+	}
+	removedPendingDevices := []map[string]string{
+		{"deviceID": device.String()},
+	}
+	m.evLogger.Log(events.PendingDevicesChanged, map[string]interface{}{
+		"removed": removedPendingDevices,
+	})
+	return nil
+}
+
+// DismissPendingFolders removes records of pending folders.  Either a specific folder /
+// device combination, or all matching a specific folder ID if the device argument is
+// specified as EmptyDeviceID.
+func (m *model) DismissPendingFolder(device protocol.DeviceID, folder string) error {
+	var removedPendingFolders []map[string]string
+	if device == protocol.EmptyDeviceID {
+		l.Debugf("Discarding pending removed folder %s from all devices", folder)
+		err := m.db.RemovePendingFolder(folder)
+		if err != nil {
+			return err
+		}
+		removedPendingFolders = []map[string]string{
+			{"folderID": folder},
+		}
+	} else {
+		l.Debugf("Discarding pending folder %s from device %v", folder, device)
+		err := m.db.RemovePendingFolderForDevice(folder, device)
+		if err != nil {
+			return err
+		}
+		removedPendingFolders = []map[string]string{
+			{
+				"folderID": folder,
+				"deviceID": device.String(),
+			},
+		}
+	}
+	if len(removedPendingFolders) > 0 {
+		m.evLogger.Log(events.PendingFoldersChanged, map[string]interface{}{
+			"removed": removedPendingFolders,
+		})
+	}
+	return nil
+}
+
 // mapFolders returns a map of folder ID to folder configuration for the given
 // slice of folder configurations.
 func mapFolders(folders []config.FolderConfiguration) map[string]config.FolderConfiguration {