cmd/syncthing: UI for version restoration (fixes #2599) (#4602)

cmd/syncthing: Add UI for version restoration (fixes #2599)
This commit is contained in:
Audrius Butkevicius
2018-01-01 14:39:23 +00:00
committed by Jakob Borg
parent c7f136c2b8
commit b0e2050cdb
33 changed files with 20045 additions and 65 deletions

View File

@@ -134,3 +134,75 @@ function debounce(func, wait) {
return result;
};
}
function buildTree(children) {
/* Converts
*
* {
* 'foo/bar': [...],
* 'foo/baz': [...]
* }
*
* to
*
* [
* {
* title: 'foo',
* children: [
* {
* title: 'bar',
* versions: [...],
* ...
* },
* {
* title: 'baz',
* versions: [...],
* ...
* }
* ],
* }
* ]
*/
var root = {
children: []
}
$.each(children, function(path, data) {
var parts = path.split('/');
var name = parts.splice(-1)[0];
var keySoFar = [];
var parent = root;
while (parts.length > 0) {
var part = parts.shift();
keySoFar.push(part);
var found = false;
for (var i = 0; i < parent.children.length; i++) {
if (parent.children[i].title == part) {
parent = parent.children[i];
found = true;
break;
}
}
if (!found) {
var child = {
title: part,
key: keySoFar.join('/'),
folder: true,
children: []
}
parent.children.push(child);
parent = child;
}
}
parent.children.push({
title: name,
key: path,
folder: false,
versions: data,
});
});
return root.children;
}

View File

@@ -2,7 +2,7 @@ angular.module('syncthing.core')
.config(function($locationProvider) {
$locationProvider.html5Mode({enabled: true, requireBase: false}).hashPrefix('!');
})
.controller('SyncthingController', function ($scope, $http, $location, LocaleService, Events, $filter, $q, $interval) {
.controller('SyncthingController', function ($scope, $http, $location, LocaleService, Events, $filter, $q, $compile, $timeout, $rootScope) {
'use strict';
// private/helper definitions
@@ -1107,9 +1107,9 @@ angular.module('syncthing.core')
},
show: function() {
$scope.logging.refreshFacilities();
$scope.logging.timer = $interval($scope.logging.fetch, 0, 1);
$scope.logging.timer = $timeout($scope.logging.fetch);
$('#logViewer').modal().on('hidden.bs.modal', function () {
$interval.cancel($scope.logging.timer);
$timeout.cancel($scope.logging.timer);
$scope.logging.timer = null;
$scope.logging.entries = [];
});
@@ -1138,7 +1138,7 @@ angular.module('syncthing.core')
var textArea = $('#logViewerText');
if (textArea.is(":focus")) {
if (!$scope.logging.timer) return;
$scope.logging.timer = $interval($scope.logging.fetch, 500, 1);
$scope.logging.timer = $timeout($scope.logging.fetch, 500);
return;
}
@@ -1149,7 +1149,7 @@ angular.module('syncthing.core')
$http.get(urlbase + '/system/log' + (last ? '?since=' + encodeURIComponent(last) : '')).success(function (data) {
if (!$scope.logging.timer) return;
$scope.logging.timer = $interval($scope.logging.fetch, 2000, 1);
$scope.logging.timer = $timeout($scope.logging.fetch, 2000);
if (!textArea.is(":focus")) {
if (data.messages) {
$scope.logging.entries.push.apply($scope.logging.entries, data.messages);
@@ -1767,6 +1767,233 @@ angular.module('syncthing.core')
});
};
function resetRestoreVersions() {
$scope.restoreVersions = {
folder: null,
selections: {},
versions: null,
tree: null,
errors: null,
filters: {},
massAction: function (name, action) {
$.each($scope.restoreVersions.versions, function(key) {
if (key.startsWith(name + '/') && (!$scope.restoreVersions.filters.text || key.indexOf($scope.restoreVersions.filters.text) > -1)) {
if (action == 'unset') {
delete $scope.restoreVersions.selections[key];
return;
}
var availableVersions = [];
$.each($scope.restoreVersions.filterVersions($scope.restoreVersions.versions[key]), function(idx, version) {
availableVersions.push(version.versionTime);
})
if (availableVersions.length) {
availableVersions.sort(function (a, b) { return a - b; });
if (action == 'latest') {
$scope.restoreVersions.selections[key] = availableVersions.pop();
} else if (action == 'oldest') {
$scope.restoreVersions.selections[key] = availableVersions.shift();
}
}
}
});
},
filterVersions: function(versions) {
var filteredVersions = [];
$.each(versions, function (idx, version) {
if (moment(version.versionTime).isBetween($scope.restoreVersions.filters['start'], $scope.restoreVersions.filters['end'], null, '[]')) {
filteredVersions.push(version);
}
});
return filteredVersions;
},
selectionCount: function() {
var count = 0;
$.each($scope.restoreVersions.selections, function(key, value) {
if (value) {
count++;
}
});
return count;
},
restore: function() {
$scope.restoreVersions.tree.clear();
$scope.restoreVersions.tree = null;
$scope.restoreVersions.versions = null;
var selections = {};
$.each($scope.restoreVersions.selections, function(key, value) {
if (value) {
selections[key] = value;
}
});
$scope.restoreVersions.selections = {};
$http.post(urlbase + '/folder/versions?folder=' + encodeURIComponent($scope.restoreVersions.folder), selections).success(function (data) {
if (Object.keys(data).length == 0) {
$('#restoreVersions').modal('hide');
} else {
$scope.restoreVersions.errors = data;
}
});
},
show: function(folder) {
$scope.restoreVersions.folder = folder;
var closed = false;
var modalShown = $q.defer();
$('#restoreVersions').modal().on('hidden.bs.modal', function () {
closed = true;
resetRestoreVersions();
}).on('shown.bs.modal', function() {
modalShown.resolve();
});
var dataReceived = $http.get(urlbase + '/folder/versions?folder=' + encodeURIComponent($scope.restoreVersions.folder))
.success(function (data) {
$.each(data, function(key, values) {
$.each(values, function(idx, value) {
value.modTime = new Date(value.modTime);
value.versionTime = new Date(value.versionTime);
});
});
if (closed) return;
$scope.restoreVersions.versions = data;
});
$q.all([dataReceived, modalShown.promise]).then(function() {
if (closed) {
resetRestoreVersions();
return;
}
$scope.restoreVersions.tree = $("#restoreTree").fancytree({
extensions: ["table", "filter"],
quicksearch: true,
filter: {
autoApply: true,
counter: true,
hideExpandedCounter: true,
hideExpanders: true,
highlight: true,
leavesOnly: false,
nodata: true,
mode: "hide"
},
table: {
indentation: 20,
nodeColumnIdx: 0,
},
debugLevel: 2,
source: buildTree($scope.restoreVersions.versions),
renderColumns: function(event, data) {
var node = data.node,
$tdList = $(node.tr).find(">td"),
template;
if (node.folder) {
template = '<div ng-include="\'syncthing/folder/restoreVersionsMassActions.html\'" class="pull-right"/>';
} else {
template = '<div ng-include="\'syncthing/folder/restoreVersionsVersionSelector.html\'" class="pull-right"/>';
}
var scope = $rootScope.$new(true);
scope.key = node.key;
scope.restoreVersions = $scope.restoreVersions;
$tdList.eq(1).html(
$compile(template)(scope)
);
// Force angular to redraw.
$timeout(function() {
$scope.$apply();
});
}
}).fancytree("getTree");
var minDate = moment(),
maxDate = moment(0, 'X'),
date;
// Find version window.
$.each($scope.restoreVersions.versions, function(key) {
$.each($scope.restoreVersions.versions[key], function(idx, version) {
date = moment(version.versionTime);
if (date.isBefore(minDate)) {
minDate = date;
}
if (date.isAfter(maxDate)) {
maxDate = date;
}
});
});
$scope.restoreVersions.filters['start'] = minDate;
$scope.restoreVersions.filters['end'] = maxDate;
var ranges = {
'All time': [minDate, maxDate],
'Today': [moment(), moment()],
'Yesterday': [moment().subtract(1, 'days'), moment().subtract(1, 'days')],
'Last 7 Days': [moment().subtract(6, 'days'), moment()],
'Last 30 Days': [moment().subtract(29, 'days'), moment()],
'This Month': [moment().startOf('month'), moment().endOf('month')],
'Last Month': [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')]
};
// Filter out invalid ranges.
$.each(ranges, function(key, range) {
if (!range[0].isBetween(minDate, maxDate, null, '[]') && !range[1].isBetween(minDate, maxDate, null, '[]')) {
delete ranges[key];
}
});
$("#restoreVersionDateRange").daterangepicker({
timePicker: true,
timePicker24Hour: true,
timePickerSeconds: true,
autoUpdateInput: true,
opens: "left",
drops: "up",
startDate: minDate,
endDate: maxDate,
minDate: minDate,
maxDate: maxDate,
ranges: ranges,
locale: {
format: 'YYYY/MM/DD HH:mm:ss',
}
}).on('apply.daterangepicker', function(ev, picker) {
$scope.restoreVersions.filters['start'] = picker.startDate;
$scope.restoreVersions.filters['end'] = picker.endDate;
// Events for this UI element are not managed by angular.
// Force angular to wake up.
$timeout(function() {
$scope.$apply();
});
});
});
}
};
}
resetRestoreVersions();
$scope.$watchCollection('restoreVersions.filters', function() {
if (!$scope.restoreVersions.tree) return;
$scope.restoreVersions.tree.filterNodes(function (node) {
if (node.folder) return false;
if ($scope.restoreVersions.filters.text && node.key.indexOf($scope.restoreVersions.filters.text) < 0) {
return false;
}
if ($scope.restoreVersions.filterVersions(node.data.versions).length == 0) {
return false;
}
return true;
});
});
$scope.editIgnoresOnAddingFolder = function () {
if ($scope.editingExisting) {
return;

View File

@@ -1,15 +1,15 @@
<modal id="remove-device-confirmation" status="warning" icon="exclamation-circle" heading="{{'Remove Device' | translate}}" large="no" closeable="yes">
<div class="modal-body">
<p ng-model="currentDevice.name" style=" overflow : hidden; text-overflow: ellipsis; white-space: nowrap;">
<span translate translate-value-name="{{currentDevice.name}}">Are you sure you want to remove device {%name%}?</span>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning pull-left btn-sm" data-dismiss="modal" ng-click="deleteDevice()">
<span class="fa fa-minus-circle"></span>&nbsp;<span translate>Yes</span>
</button>
<button type="button" class="btn btn-default btn-sm" data-dismiss="modal">
<span class="fa fa-times"></span>&nbsp;<span translate>No</span>
</button>
</div>
<div class="modal-body">
<p ng-model="currentDevice.name" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<span translate translate-value-name="{{currentDevice.name}}">Are you sure you want to remove device {%name%}?</span>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning pull-left btn-sm" data-dismiss="modal" ng-click="deleteDevice()">
<span class="fa fa-minus-circle"></span>&nbsp;<span translate>Yes</span>
</button>
<button type="button" class="btn btn-default btn-sm" data-dismiss="modal">
<span class="fa fa-times"></span>&nbsp;<span translate>No</span>
</button>
</div>
</modal>

View File

@@ -1,18 +1,18 @@
<modal id="remove-folder-confirmation" status="warning" icon="exclamation-circle" heading="{{'Remove Folder' | translate}}" large="no" closeable="yes">
<div class="modal-body">
<p ng-model="currentFolder.label" style=" overflow : hidden; text-overflow: ellipsis; white-space: nowrap;">
<span translate translate-value-label="{{currentFolder.label}}">Are you sure you want to remove folder {%label%}?</span>
</p>
<p translate>
No files will be deleted as a result of this operation.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning pull-left btn-sm" data-dismiss="modal" ng-click="deleteFolder(currentFolder.id)">
<span class="fa fa-minus-circle"></span>&nbsp;<span translate>Yes</span>
</button>
<button type="button" class="btn btn-default btn-sm" data-dismiss="modal">
<span class="fa fa-times"></span>&nbsp;<span translate>No</span>
</button>
</div>
<div class="modal-body">
<p style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<span translate translate-value-label="{{currentFolder.label}}">Are you sure you want to remove folder {%label%}?</span>
</p>
<p translate>
No files will be deleted as a result of this operation.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning pull-left btn-sm" data-dismiss="modal" ng-click="deleteFolder(currentFolder.id)">
<span class="fa fa-minus-circle"></span>&nbsp;<span translate>Yes</span>
</button>
<button type="button" class="btn btn-default btn-sm" data-dismiss="modal">
<span class="fa fa-times"></span>&nbsp;<span translate>No</span>
</button>
</div>
</modal>

View File

@@ -0,0 +1,15 @@
<modal id="restore-versions-confirmation" status="warning" icon="exclamation-circle" heading="{{'Restore Versions' | translate}}" large="no" closeable="yes">
<div class="modal-body">
<p style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<span translate-value-count="{{restoreVersions.selectionCount()}}" translate>Are you sure you want to restore {%count%} files?</span>
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning pull-left btn-sm" data-dismiss="modal" ng-click="restoreVersions.restore()">
<span class="fa fa-check"></span>&nbsp;<span translate>Yes</span>
</button>
<button type="button" class="btn btn-default btn-sm" data-dismiss="modal">
<span class="fa fa-times"></span>&nbsp;<span translate>No</span>
</button>
</div>
</modal>

View File

@@ -0,0 +1,11 @@
<div class="dropdown">
<button class="btn btn-default btn-xs dropdown-toggle" type="button" data-toggle="dropdown">
<span translate>Mass actions</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li><a href="#" ng-click="restoreVersions.massAction(key, 'unset')" translate>Do not restore all</a></li>
<li><a href="#" ng-click="restoreVersions.massAction(key, 'latest')" translate>Select latest version</a></li>
<li><a href="#" ng-click="restoreVersions.massAction(key, 'oldest')" translate>Select oldest version</a></li>
</ul>
</div>

View File

@@ -0,0 +1,51 @@
<modal id="restoreVersions" status="default" heading="{{'Restore Versions' | translate}}" large="yes" closeable="yes">
<div class="modal-body">
<span translate ng-if="!restoreVersions.versions && !restoreVersions.errors">Loading data...</span>
<div ng-if="restoreVersions.versions">
<table id="restoreTree">
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<hr/>
<div class="row form-inline">
<div class="col-md-6">
<div class="form-group">
<label translate for="restoreVersionSearch">Filter by name</label>:&nbsp
<input id="restoreVersionSearch" class="form-control" type="text" ng-model="restoreVersions.filters.text">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label translate for="restoreVersionDate">Filter by date</label>:&nbsp
<input id="restoreVersionDateRange" class="form-control">
</div>
</div>
</div>
</div>
<div ng-if="restoreVersions.errors">
<label><span translate>Some items could not be restored:</span></label>
<table class="table table-condensed table-striped">
<tbody>
<tr ng-repeat="(file, error) in restoreVersions.errors">
<td>{{ file }}</td>
<td>{{ error }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary btn-sm" data-toggle="modal" data-target="#restore-versions-confirmation" ng-if="restoreVersions.versions" ng-disabled="restoreVersions.selectionCount() < 1">
<span class="fa fa-check"></span>&nbsp;<span translate>Restore</span>
</button>
<button type="button" class="btn btn-default btn-sm" data-dismiss="modal">
<span class="fa fa-times"></span>&nbsp;<span translate>Close</span>
</button>
</div>
</modal>

View File

@@ -0,0 +1,17 @@
<div class="dropdown">
<button class="btn btn-default btn-xs dropdown-toggle" type="button" data-toggle="dropdown">
<span ng-if="!restoreVersions.selections[key]" translate>Do not restore</span>
<span ng-if="restoreVersions.selections[key]">{{ restoreVersions.selections[key] | date:"yyyy/MM/dd HH:mm:ss" }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li>
<a href="#" ng-click="restoreVersions.selections[key] = undefined" translate>Do not restore</a>
</li>
<li ng-repeat="version in restoreVersions.filterVersions(restoreVersions.versions[key])">
<a href="#" ng-click="restoreVersions.selections[key] = version.versionTime">
{{ version.versionTime | date:"yyyy/MM/dd HH:mm:ss" }} {{ version.size | binary }}B
</a>
</li>
</ul>
</div>