Skip to content

Commit

Permalink
WebUI: New Trash Manager page
Browse files Browse the repository at this point in the history
Fixes #245
  • Loading branch information
gboudreau committed Nov 30, 2020
1 parent 2f15acb commit e248903
Show file tree
Hide file tree
Showing 5 changed files with 729 additions and 0 deletions.
1 change: 1 addition & 0 deletions web-app/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
$tabs = [
new Tab('l1_status', 'Status', 'status.php'),
new Tab('l1_spool', 'Storage pool', 'storage_pool.php'),
new Tab('l1_trashman', 'Trash Manager', 'trash.php'),
new Tab('l1_smbshares', 'Samba Shares', 'samba_shares.php'),
new Tab('l1_smbconfig', 'Samba Config', 'samba_config.php'),
new Tab('l1_ghconfig', 'Greyhole Config', 'greyhole_config.php'),
Expand Down
241 changes: 241 additions & 0 deletions web-app/init.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,247 @@

echo json_encode(['result' => 'success', 'sp_stats' => $sp_stats]);
exit();
case 'get_trashman_content':
// Find all files in specified directory, with their size (%s) and last modified date (%T@)
$dir = $_REQUEST['dir'];
foreach (Config::storagePoolDrives() as $sp_drive) {
// Ensure the specified dir doesn't contains .. to try to go outside the trash folder!
$base_dir = "$sp_drive/.gh_trash";
if (substr("$base_dir/$dir",0, strlen($base_dir)) !== $base_dir) {
continue;
}
if (is_dir("$sp_drive/.gh_trash/$dir")) {
chdir("$sp_drive/.gh_trash/$dir");
exec("find . -type f -printf \"%s %T@ %p\\n\"", $output);
}
}
chdir(__DIR__);

// Build $trash_content array that counts and sums the filesize of all files contained in the current directory
$trash_content = [];
$restore_content = [];
foreach ($output as $line) {
$line = explode(" ", trim($line));
$size = array_shift($line);
$last_modified = date('Y-m-d H:i:s', explode('.', array_shift($line))[0]);
$line = implode(" ", $line);
$file_path = explode("/", $line);
array_shift($file_path);
$dir_path = ['.'];
foreach ($file_path as $part) {
$key = implode("/", $dir_path);
if (!isset($trash_content[$key])) {
$trash_content[$key] = [];
}
if (count($dir_path) > 1) {
// No need to calculate stats for subfolders
break;
}
if (!isset($trash_content[$key][$part])) {
$trash_content[$key][$part] = ['size' => 0, 'num_copies' => 0, 'modified' => $last_modified];
}
$trash_content[$key][$part]['size'] += $size;
$trash_content[$key][$part]['num_copies']++;
$dir_path[] = $part;

$restore_content[$part][implode("/", $file_path)] = $size;
}
}

if (!isset($trash_content['.'])) {
// Can happen after someone deleted the last entry using trashman
$data[] = ['path' => 'Empty', 'size' => '', 'copies' => '', 'modified' => '', 'actions' => ''];
} else {
// Display the files/folders found in the current dir (.)
$result = $trash_content['.'];
$num_rows = count($result);
foreach ($result as $path => $stat) {
$files_to_restore = $restore_content[$path];
$data[] = ['path' => $path, 'size' => $stat['size'], 'copies' => $stat['num_copies'], 'modified' => $stat['modified'], 'copies_restore' => count($files_to_restore), 'size_restore' => bytes_to_human(array_sum($files_to_restore), TRUE, TRUE)];
}

// Sort (using user-specified column & direction)
$columns = ['path', 'size', 'copies', 'modified'];
$order_by = [];
foreach ($_GET['order'] as $ob) {
$order_by[] = [$columns[$ob['column']], $ob['dir']];
}
usort($data, function ($d1, $d2) use ($order_by) {
foreach ($order_by as $ob) {
$col = $ob[0];
$dir = $ob[1];
if ($d1[$col] < $d2[$col]) {
if ($dir == 'asc') {
return -1;
}
return 1;
}
if ($d1[$col] > $d2[$col]) {
if ($dir == 'asc') {
return 1;
}
return -1;
}
}
return 0;
});

// Format size values; add link (navigation) for directories, add action buttons (delete, restore)
foreach ($data as &$d) {
$d['raw_path'] = $d['path'];
$key = './' . $d['path'];
$d['size_delete'] = bytes_to_human($d['size'], TRUE, TRUE);
if (isset($trash_content[$key])) {
// Is a folder
$d['path'] = '<a href="#enter" onclick="trashmanEnterFolder(this); return false">' . he($d['path']) . '</a>';
$d['size'] = number_format($d['size']);
} else {
// Is a file
$d['path'] = $key;
if ($d['copies'] > 1) {
$d['size'] = $d['copies'] . ' x ' . bytes_to_human($d['size'] / $d['copies'], TRUE, TRUE) . ' = ' . number_format($d['size']);
} else {
$d['size'] = number_format($d['size']);
}
}
$d['actions'] = '<a class="btn btn-danger" href="#delete" onclick="trashmanDelete(this); return false">Delete forever...</a> <a class="btn btn-success" href="restore" onclick="trashmanRestore(this); return false">Restore...</a>';
}
}

// If we're in a sub-directory, add a row at the top to allow navigating back to the parent directory
if ($dir != '.') {
array_unshift($data, ['path' => '<a href="#parent" onclick="trashmanGoToParent(); return false">&lt; Parent directory</a>', 'size' => '', 'modified' => '', 'copies' => '', 'actions' => '']);
}

echo json_encode(['draw' => $_GET['draw'], 'recordsTotal' => $num_rows, 'recordsFiltered' => $num_rows, 'data' => $data]);
exit();
case 'restore_from_trash':
$folder = $_REQUEST['folder'];
$restore_content = [];
foreach (Config::storagePoolDrives() as $sp_drive) {
// Ensure the specified dir doesn't contains .. to try to go outside the trash folder!
$base_dir = "$sp_drive/.gh_trash";
if (substr("$base_dir/$folder",0, strlen($base_dir)) !== $base_dir) {
continue;
}
if (is_dir("$sp_drive/.gh_trash/$folder")) {
chdir("$sp_drive/.gh_trash/$folder");
$dir = $folder;
unset($output);
exec("find . -type f -printf \"%s %T@ %p\\n\"", $output);
chdir(__DIR__);
} elseif (is_file("$sp_drive/.gh_trash/$folder")) {
$file = "$sp_drive/.gh_trash/$folder";
$size = gh_filesize($file);
$last_modified = filemtime($file);
$file = substr($file, strlen("$sp_drive/.gh_trash/"));
$dir = dirname($file);
$output = ["$size $last_modified ./" . basename($file)];
} else {
continue;
}
foreach ($output as $line) {
$line = explode(" ", trim($line));
$size = array_shift($line);
$last_modified = date('Y-m-d H:i:s', explode('.', array_shift($line))[0]);
$line = implode(" ", $line);
$file_path = explode("/", $line);
array_shift($file_path);
$file_path = implode("/", $file_path);
$restore_content[$file_path][] = ['path' => "$sp_drive/.gh_trash/$dir/$file_path", 'size' => $size, 'last_modified' => $last_modified, 'sp_drive' => $sp_drive];
}
}

$total_size = 0;
$file_copies_to_restore = [];
foreach ($restore_content as $trashed_files) {
$max_last_modified = 0;
$file_to_restore = ['last_modified' => 0, 'size' => 0];
foreach ($trashed_files as $trashed_file) {
if (strtotime($trashed_file['last_modified']) >= $file_to_restore['last_modified']) {
if (strtotime($trashed_file['last_modified']) > $file_to_restore['last_modified'] || $trashed_file['size'] > $file_to_restore['size']) {
$file_to_restore = $trashed_file;
}
}
}
$file_copies_to_restore[] = $file_to_restore;
$total_size += $file_to_restore['size'];
}

error_log("Restoring " . count($file_copies_to_restore) . " files totaling " . bytes_to_human($total_size, FALSE, TRUE) . " from trash into $dir");

foreach ($file_copies_to_restore as $file_copy_to_restore) {
$source = $file_copy_to_restore['path'];
$target = str_replace($file_copy_to_restore['sp_drive'] . "/.gh_trash/", $file_copy_to_restore['sp_drive'] . "/", $file_copy_to_restore['path']);

$link = substr($target, strlen($file_copy_to_restore['sp_drive'])+1);
$link = explode("/", $link);
$share = array_shift($link);
$full_path = implode("/", $link);
$link = get_share_landing_zone($share) . "/$full_path";

$tentative_link = $link;
$i = 0;
while (file_exists($tentative_link)) {
$i++;
$tentative_link = $link . " (restored $i)";
}
$link = $tentative_link;

if ($i > 0) {
$target .= " (restored $i)";
}
error_log("- Moving $source into $target");
gh_mkdir(dirname($target), dirname($source));
rename($source, $target);

error_log("- Creating symlink at $link");
gh_mkdir(dirname($link), dirname($source));
gh_symlink($target, $link);
FsckFileTask::queue($share, dirname($full_path) . '/' . basename($link));
}

echo json_encode(['result' => 'success']);
exit();
case 'delete_from_trash':
foreach (Config::storagePoolDrives() as $sp_drive) {
$base_dir = "$sp_drive/.gh_trash";
$path_to_delete_tr = realpath("$base_dir/" . $_REQUEST['folder']);
if ($path_to_delete_tr && substr($path_to_delete_tr,0, strlen($base_dir)) === $base_dir) {
// First, delete the folders & symlinks from the Trash share, if it exists
$trash_share = SharesConfig::getConfigForShare(CONFIG_TRASH_SHARE);
if ($trash_share) {
$base_dir = $trash_share[CONFIG_LANDING_ZONE];
$path_to_delete = "$base_dir/" . $_REQUEST['folder'];
if (file_exists($path_to_delete)) {
$rm_command = "rm -rf " . escapeshellarg($path_to_delete);
error_log($rm_command);
exec($rm_command);

// Also delete other copies symlinks from the Trash share, if any (those are named "FILENAME copy #")
$i = 1;
while (TRUE) {
$path_to_delete = "$base_dir/" . $_REQUEST['folder'] . " copy $i";
if (file_exists($path_to_delete) && !is_dir($path_to_delete)) {
$rm_command = "rm -rf " . escapeshellarg($path_to_delete);
error_log($rm_command);
exec($rm_command);
$i++;
} else {
break;
}
}
}
}

// Then delete the trashed files from the .gh_trash
$rm_command = "rm -rf " . escapeshellarg($path_to_delete_tr);
error_log($rm_command);
exec($rm_command);
}
}
echo json_encode(['result' => 'success']);
exit();
case 'install':
$step = $_POST['step'];

Expand Down
110 changes: 110 additions & 0 deletions web-app/scripts.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ let tab_changed_functions = {
id_l2_status_fsck_tab: loadStatusFsckReport,
id_l2_status_balance_tab: loadStatusBalanceReport,
id_l1_spool_tab: loadStoragePool,
id_l1_trashman_tab: loadTrashmanContent,
id_l2_actions_trash_tab: loadActionsTrashContent,
};
function topTabChanged() {
Expand Down Expand Up @@ -97,12 +98,117 @@ defer(function() {
],
order: [[0, 'desc']],
pageLength: 10,
responsive: true,
});

$('#trashman-table').DataTable({
processing: true,
serverSide: true,
ajax: './?ajax=get_trashman_content&dir=' + encodeURIComponent($('#trashman-current-dir').val()),
columns: [
{ data: 'path' },
{ data: 'size', orderSequence: ['desc', 'asc'] },
{ data: 'copies', orderSequence: ['desc', 'asc'], width: '110px' },
{ data: 'modified', width: '140px' },
{ data: 'actions', sorting: false }
],
order: [[0, 'asc']],
paging: false,
searching: false,
info: false,
responsive: true,
"fnRowCallback": function(nRow, aData, iDisplayIndex, iDisplayIndexFull) {
$(nRow).data('path', aData.raw_path);
$(nRow).data('copies', aData.copies);
$(nRow).data('size', aData.size);
$(nRow).data('size-delete', aData.size_delete);
$(nRow).data('copies-restore', aData.copies_restore);
$(nRow).data('size-restore', aData.size_restore);
},
}).on('draw', function () {
// Adjust columns size
$('#trashman-table').DataTable().columns.adjust();
});

topTabChanged();
});
});

function trashmanGoToParent() {
let current_dir = $('#trashman-current-dir').val().split('/');
current_dir.pop();
let new_dir = current_dir.join('/');
trashmanDataForDir(new_dir);
}

function trashmanEnterFolder(btn) {
let folder = $(btn).closest('tr').data('path');
let current_dir = $('#trashman-current-dir').val();
let new_dir = current_dir + '/' + folder;
trashmanDataForDir(new_dir);
}

function trashmanDelete(btn) {
let $row = $(btn).closest('tr');
let folder = $('#trashman-current-dir').val() + '/' + $row.data('path');
folder = folder.substring(2);

let $modal = $('#modal-trashman-delete');
$modal.find('.copies').text($row.data('copies'));
$modal.find('.path').text(folder);
$modal.find('.size').html($row.data('size-delete'));
$modal.find('.btn-danger').data('folder', folder);
$modal.modal('show');
}

function trashmanConfirmDelete(btn) {
let folder = $(btn).data('folder');
ajaxCallFromButton(btn, 'delete_from_trash', 'folder=' + encodeURIComponent(folder), 'Deleting...', 'Deleted', 'Delete forever', function (data, $button) {
$(btn).closest('.modal').modal('hide');
let dir = $('#trashman-current-dir').val();
trashmanDataForDir(dir);
}, 0);
}

function trashmanRestore(btn) {
let $row = $(btn).closest('tr');
let folder = $('#trashman-current-dir').val() + '/' + $row.data('path');
folder = folder.substring(2);

let $modal = $('#modal-trashman-restore');
$modal.find('.copies').text($row.data('copies-restore'));
$modal.find('.path').text(folder);
$modal.find('.size').html($row.data('size-restore'));
$modal.find('.btn-success').data('folder', folder);
$modal.modal('show');
}

function trashmanConfirmRestore(btn) {
let folder = $(btn).data('folder');
ajaxCallFromButton(btn, 'restore_from_trash', 'folder=' + encodeURIComponent(folder), 'Restoring...', 'Restored', 'Restore', function (data, $button) {
$(btn).closest('.modal').modal('hide');
let dir = $('#trashman-current-dir').val();
trashmanDataForDir(dir);
}, 0);
}

function trashmanDataForDir(new_dir) {
$('#trashman-current-dir').val(new_dir);

// ajax.url is normally only set when the Datatable is initialized; we need to update it because it needs to use new_dir
$('#trashman-table').DataTable().ajax.url('./?ajax=get_trashman_content&dir=' + encodeURIComponent(new_dir));
// Reload data from server
$('#trashman-table').DataTable().ajax.reload();

// Show/hide current folder, based of if we're showing the root folder or not
if (new_dir === '.') {
$('#trashman-current-dir-header').hide();
} else {
$('#trashman-current-dir-header').show();
$('#trashman-current-dir-header').text(' | ' + new_dir.substring(2));
}
}

function resizeSPDrivesUsageGraphs() {
$('.table-sp-drives').each(function(i, el) {
let $table = $(el);
Expand Down Expand Up @@ -1173,6 +1279,10 @@ function loadStoragePool() {
});
}

function loadTrashmanContent() {
trashmanDataForDir('.');
}

function loadActionsTrashContent() {
var $table = $('#trash-content');
var $loading_row = $table.find('.loading');
Expand Down
Loading

0 comments on commit e248903

Please sign in to comment.