feature (upload): upload queue that show progress with abort and retry - #267

This commit is contained in:
Sergei Azarkin
2020-05-23 08:43:39 +02:00
committed by GitHub
parent b58cb5ebd2
commit 865ba7ded6
13 changed files with 522 additions and 208 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/><path d="M0 0h24v24H0z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 6h12v12H6z"/></svg>

After

Width:  |  Height:  |  Size: 151 B

View File

@ -39,6 +39,8 @@ import img_info from '../assets/img/info.svg';
import img_fullscreen from '../assets/img/fullscreen.svg'; import img_fullscreen from '../assets/img/fullscreen.svg';
import img_camera from '../assets/img/camera.svg'; import img_camera from '../assets/img/camera.svg';
import img_location from '../assets/img/location.svg'; import img_location from '../assets/img/location.svg';
import img_stop from '../assets/img/stop.svg';
import img_refresh from '../assets/img/refresh.svg';
export const img_placeholder = "/assets/icons/placeholder.png"; export const img_placeholder = "/assets/icons/placeholder.png";
export const Icon = (props) => { export const Icon = (props) => {
@ -126,6 +128,10 @@ export const Icon = (props) => {
img = img_camera; img = img_camera;
}else if(props.name === 'location'){ }else if(props.name === 'location'){
img = img_location; img = img_location;
} else if (props.name === 'stop') {
img = img_stop;
} else if (props.name === 'refresh') {
img = img_refresh;
}else{ }else{
throw('unknown icon: "'+props.name+"'"); throw('unknown icon: "'+props.name+"'");
} }

View File

@ -21,3 +21,4 @@ export { Dropdown, DropdownButton, DropdownList, DropdownItem } from './dropdown
export { MapShot } from './mapshot'; export { MapShot } from './mapshot';
export { LoggedInOnly, ErrorPage, LoadingPage } from './decorator'; export { LoggedInOnly, ErrorPage, LoadingPage } from './decorator';
export { FormBuilder } from './formbuilder'; export { FormBuilder } from './formbuilder';
export { UploadQueue } from './upload_queue';

View File

@ -0,0 +1,381 @@
import React from 'react';
import Path from 'path';
import { Files } from '../model/';
import { confirm, notify, upload } from '../helpers/';
import { Icon, NgIf } from './';
import './upload_queue.scss';
const MAX_POOL_SIZE = 15;
function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024;
if (Math.abs(bytes) < thresh) {
return bytes.toFixed(1) + ' B';
}
var units = si
? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
: ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
var u = -1;
do {
bytes /= thresh;
++u;
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
return bytes.toFixed(1) + ' ' + units[u];
}
export class UploadQueue extends React.Component {
constructor(props) {
super(props);
this.state = {
timeout: 1,
running: false,
files: [],
processes: [],
currents: [],
failed: [],
finished: [],
prior_status: {},
progress: {},
speed: [],
};
}
componentDidMount() {
if (typeof this.state.timeout === "number") {
this.setState({
timeout: window.setTimeout(() => {
this.componentDidMount();
}, Math.random() * 1000 + 200)
});
}
upload.subscribe((path, files) => this.addFiles(path, files));
}
componentWillUnmount() {
window.clearTimeout(this.state.timeout);
}
reset() {
this.setState({
files: [],
processes: [],
currents: [],
failed: [],
finished: [],
prior_status: {},
progress: {},
speed: [],
});
}
emphasis(path) {
notify.send(path.split("/").join(" / "), "info");
}
runner(id) {
let current_process = null;
let processes = [...this.state.processes];
if (processes.length === 0 || !this.state.running) {
return Promise.resolve();
}
var i;
for (i = 0; i < processes.length; i++) {
if (
// init: getting started with creation of files/folders
processes[i].parent === null ||
// running: make sure we've created the parent folder
this.state.prior_status[processes[i].parent] === true
) {
current_process = this.state.processes[i];
processes.splice(i, 1);
this.setState({
processes,
currents: [...this.state.currents, current_process],
})
break;
}
}
if (current_process) {
return current_process.fn(id)
.then(() => {
if (current_process.id) {
this.setState({
prior_status: {
...this.state.prior_status,
[current_process.id]: true
}
})
}
this.setState({
currents: this.state.currents.filter((c) => c.path != current_process.path),
finished: [...this.state.finished, current_process],
})
return this.runner(id);
})
.catch((err) => {
current_process.err = err;
this.setState({
failed: [...this.state.failed, current_process],
currents: this.state.currents.filter((c) => c.path != current_process.path),
});
let { message } = err;
if (message !== 'aborted') {
notify.send(err, 'error');
}
return this.runner(id);
});
} else {
function waitABit() {
return new Promise((done) => {
window.setTimeout(() => {
requestAnimationFrame(() => {
done();
});
}, 250);
});
}
return waitABit().then(() => this.runner(id));
}
}
updateProgress(path, e) {
if (e.lengthComputable) {
let prev = this.state.progress[path];
this.setState({
progress: {
...this.state.progress,
[path]: {
...prev,
percent: Math.round(100 * e.loaded / e.total),
loaded: e.loaded,
time: Date.now(),
prev: prev ? prev : null,
}
}
});
}
}
updateAbort(path, abort) {
this.setState({
progress: {
...this.state.progress,
[path]: {
...this.state.progress[path],
abort,
},
}
})
}
addFiles(path, files) {
const processes = files.map((file) => {
let original_path = file.path;
file.path = Path.join(path, file.path);
if (file.type === 'file') {
if (files.length < 150) Files.touch(file.path, file.file, 'prepare_only');
return {
path: original_path,
parent: file._prior || null,
fn: Files.touch.bind(
Files, file.path, file.file, 'execute_only',
{
progress: (e) => this.updateProgress(original_path, e),
abort: (x) => this.updateAbort(original_path, x),
}
)
};
} else {
Files.mkdir(file.path, 'prepare_only');
return {
id: file._id || null,
path: original_path,
parent: file._prior || null,
fn: Files.mkdir.bind(Files, file.path, 'execute_only')
};
}
});
this.setState({
processes: [...this.state.processes, ...processes],
files: [...this.state.files, ...files],
});
this.start();
}
retryFiles(process) {
this.setState({
processes: [...this.state.processes, process],
failed: this.state.failed.filter((c) => c.path != process.path),
});
window.setTimeout(() => this.start(), 300);
}
start() {
if (!this.state.running) {
window.setTimeout(() => this.calcSpeed(), 500);
this.setState({
running: true,
});
Promise.all(Array.apply(null, Array(MAX_POOL_SIZE)).map((process, index) => {
return this.runner();
})).then(() => {
window.setTimeout(() => {
notify.send('Upload completed', 'success');
}, 300);
this.setState({ running: false });
}).catch((err) => {
notify.send(err, 'error');
this.setState({ running: false });
});
}
}
abort(p) {
let info = this.state.progress[p.path];
if (info && info.abort) {
info.abort();
}
}
getCurrentPercent(path) {
let info = this.state.progress[path];
if (info && info.percent) {
return this.state.progress[path].percent + "%";
}
return "0%"
}
calcSpeed() {
let now = Date.now();
let curSpeed = [];
for (const [key, value] of Object.entries(this.state.progress)) {
if (value.prev && now - value.time < 5 * 1000) {
let bytes = value.loaded - value.prev.loaded;
let timeMs = value.time - value.prev.time;
curSpeed.push(1000 * bytes / timeMs);
}
}
let avgSpeed = curSpeed.reduce(function (p, c, i) { return p + (c - p) / (i + 1) }, 0);
this.setState({
speed: [...this.state.speed, avgSpeed].slice(-5),
});
if (this.state.running) {
window.setTimeout(() => this.calcSpeed(), 500);
}
}
getState() {
let avgSpeed = this.state.speed.reduce(function (p, c, i) { return p + (c - p) / (i + 1) }, 0);
let speedStr = ""
if (avgSpeed > 0) {
speedStr = " ~ " + humanFileSize(avgSpeed) + "/s";
}
if (this.state.running) {
return "Running..." + speedStr
}
return "Done" + speedStr
}
onClose() {
if(this.state.running) {
confirm.now(
"Abort current uploads?",
() => {
this.setState({
running: false,
});
this.state.currents.map(p => this.abort(p));
window.setTimeout(() => this.reset(), 30);
},
() => {}
);
} else {
this.reset()
}
}
renderRows(arr, state, col_state, action) {
let row_class = state + "_color";
return arr.slice(0, 1000).map((process, i) => {
return (
<div className={"file_row " + row_class} key={i}>
<div
className="file_path"
onClick={() => this.emphasis(process.path)}
>
{process.path.replace(/\//, '')}
</div>
{col_state(process)}
<div className="file_control">
{action ? action(process): (<span></span>)}
</div>
</div>
);
});
}
render() {
let { finished, files, processes, currents, failed } = this.state;
let totalFiles = files.length;
return (
<NgIf cond={totalFiles > 0}>
<div className="component_stats">
<h2>
CURRENT UPLOAD
<div className="count_block">
<span className="completed">{finished.length}</span>
<span className="grandTotal">{totalFiles}</span>
</div>
<Icon name="close" onClick={(e) => this.onClose()} />
</h2>
<h3>{this.getState()}</h3>
<div className="stats_content">
{this.renderRows(
finished,
"done",
(_) => (<div className="file_state file_state_done">Done</div>),
)}
{this.renderRows(
currents,
"current",
(p) => (
<div className="file_state file_state_current">
{this.getCurrentPercent(p.path)}
</div>
),
(p) => (
<Icon name="stop" onClick={(e) => this.abort(p)} ></Icon>
)
)}
{this.renderRows(
processes,
"todo",
(_) => (
<div className="file_state file_state_todo">Waiting</div>
)
)}
{this.renderRows(
failed,
"error",
(p) => (
(p.err && p.err.message == 'aborted')
?
<div className="file_state file_state_error">Aborted</div>
:
<div className="file_state file_state_error">Error</div>
),
(p) => (
<Icon name="refresh" onClick={(e) => this.retryFiles(p)} ></Icon>
)
)}
</div>
</div>
</NgIf>
);
}
}

View File

@ -0,0 +1,99 @@
.component_stats{
position: fixed;
bottom: 20px;
right: 20px;
z-index: 999;
max-width: 300px;
box-shadow: 1px 2px 20px rgba(0, 0, 0, 0.1);
background: white;
padding: 20px;
h2 {
margin: 0 0 5px 0;
font-size: 1.2em;
font-weight: 100;
.percent{color: var(--emphasis-primary);}
.count_block {
display: inline;
margin-left: 10px;
span.grandTotal{
font-size: 0.8em;
color: var(--emphasis-secondary);
&:before { content: "/"; }
}
span.completed{
color: var(--emphasis-secondary);
}
}
.component_icon {
cursor: pointer;
margin-left: 10px;
width: 32px;
float: right;
}
}
h3 {
margin: 0 0 5px 0;
font-size: 1.0em;
font-weight: 100;
}
.stats_content {
clear: both;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
font-size: 0.85em;
.error_color{
color: var(--error);
}
.todo_color{
color: var(--light);
}
.file_row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
width: 100%;
.file_path {
padding: 5px;
display: flex;
flex-direction: column;
flex-basis: 100%;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file_state {
padding: 5px;
display: flex;
flex-direction: column;
width: 40px;
}
&:hover {
.file_control img {
display: block !important;
cursor: pointer;
}
}
.file_control {
padding: 5px;
display: flex;
flex-direction: column;
width: 18px;
height: 18px;
img {
display: none;
}
}
}
}
}

View File

@ -33,7 +33,7 @@ export function http_get(url, type = 'json'){
}); });
} }
export function http_post(url, data, type = 'json'){ export function http_post(url, data, type = 'json', params){
return new Promise((done, err) => { return new Promise((done, err) => {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
xhr.open("POST", url, true); xhr.open("POST", url, true);
@ -43,6 +43,9 @@ export function http_post(url, data, type = 'json'){
data = JSON.stringify(data); data = JSON.stringify(data);
xhr.setRequestHeader('Content-Type', 'application/json'); xhr.setRequestHeader('Content-Type', 'application/json');
} }
if (params !== undefined && params.progress) {
xhr.upload.addEventListener("progress", params.progress, false);
}
xhr.send(data); xhr.send(data);
xhr.onload = function () { xhr.onload = function () {
if (xhr.readyState === XMLHttpRequest.DONE) { if (xhr.readyState === XMLHttpRequest.DONE) {
@ -65,6 +68,12 @@ export function http_post(url, data, type = 'json'){
xhr.onerror = function(){ xhr.onerror = function(){
handle_error_response(xhr, err) handle_error_response(xhr, err)
} }
if (params !== undefined && params.abort) {
params.abort(() => {
xhr.abort();
err({ message: 'aborted' });
})
}
}); });
} }

View File

@ -14,3 +14,4 @@ export { leftPad, format, copyToClipboard } from './common';
export { getMimeType } from './mimetype'; export { getMimeType } from './mimetype';
export { settings_get, settings_put } from './settings'; export { settings_get, settings_put } from './settings';
export { FormObjToJSON, createFormBackend, autocomplete } from './form'; export { FormObjToJSON, createFormBackend, autocomplete } from './form';
export { upload } from './upload';

16
client/helpers/upload.js Normal file
View File

@ -0,0 +1,16 @@
const Upload = function () {
let fn = null;
return {
add: function (path, files) {
if (!fn) { return window.setTimeout(() => this.add(path, files), 50); }
fn(path, files);
return Promise.resolve();
},
subscribe: function (_fn) {
fn = _fn;
}
};
};
export const upload = new Upload();

View File

@ -261,7 +261,7 @@ class FileSystem{
} }
} }
touch(path, file, step){ touch(path, file, step, params){
const origin_path = pathBuilder(this.current_path, basename(path), 'file'), const origin_path = pathBuilder(this.current_path, basename(path), 'file'),
destination_path = path; destination_path = path;
@ -308,7 +308,7 @@ class FileSystem{
const url = appendShareToUrl('/api/files/cat?path='+prepare(path)); const url = appendShareToUrl('/api/files/cat?path='+prepare(path));
let formData = new window.FormData(); let formData = new window.FormData();
formData.append('file', file); formData.append('file', file);
return http_post(url, formData, 'multipart'); return http_post(url, formData, 'multipart', params);
}else{ }else{
const url = appendShareToUrl('/api/files/touch?path='+prepare(path)); const url = appendShareToUrl('/api/files/touch?path='+prepare(path));
return http_get(url); return http_get(url);

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Files } from '../model/'; import { Files } from '../model/';
import { notify, alert, currentShare } from '../helpers/'; import { notify, upload } from '../helpers/';
import Path from 'path'; import Path from 'path';
import { Observable } from "rxjs/Observable"; import { Observable } from "rxjs/Observable";
@ -120,9 +120,6 @@ export const onMultiRename = function(arrOfPath){
* 3. user is coming from a upload form button as he doesn't have drag and drop with files * 3. user is coming from a upload form button as he doesn't have drag and drop with files
*/ */
export const onUpload = function(path, e){ export const onUpload = function(path, e){
const MAX_POOL_SIZE = 15;
let PRIOR_STATUS = {};
let extractFiles = null; let extractFiles = null;
if(e.dataTransfer === undefined){ // case 3 if(e.dataTransfer === undefined){ // case 3
extractFiles = extract_upload_crappy_hack_but_official_way(e.target); extractFiles = extract_upload_crappy_hack_but_official_way(e.target);
@ -141,165 +138,7 @@ export const onUpload = function(path, e){
}) })
} }
extractFiles.then((files) => { extractFiles.then((files) => upload.add(path, files));
var failed = [],
currents = [];
const processes = files.map((file) => {
let original_path = file.path;
file.path = Path.join(path, file.path);
if(file.type === 'file'){
if(files.length < 150) Files.touch(file.path, file.file, 'prepare_only');
return {
path: original_path,
parent: file._prior || null,
fn: Files.touch.bind(Files, file.path, file.file, 'execute_only')
};
}else{
Files.mkdir(file.path, 'prepare_only');
return {
id: file._id || null,
path: original_path,
parent: file._prior || null,
fn: Files.mkdir.bind(Files, file.path, 'execute_only')
};
}
});
class Stats extends React.Component {
constructor(props){
super(props);
this.state = {timeout: 1};
}
componentDidMount(){
if(typeof this.state.timeout === "number"){
this.setState({
timeout: window.setTimeout(() => {
this.componentDidMount();
}, Math.random()*1000+200)
});
}
}
componentWillUnmount(){
window.clearTimeout(this.state.timeout);
}
emphasis(path){
notify.send(path.split("/").join(" / "), "info");
}
render() {
const percent = Math.floor(100 * (files.length - processes.length - currents.length) / files.length);
return (
<div className="component_stats">
<h2>
UPLOADING <span className="percent">({percent}%)</span>
<div>
<span className="completed">{files.length - processes.length - currents.length}</span>
<span className="grandTotal">{files.length}</span>
</div>
</h2>
<div className="stats_content">
{
currents.slice(0, 1000).map((process, i) => {
return (
<div onClick={() => this.emphasis(process.path)} className="current_color" key={i}>{process.path.replace(/\//, '')}</div>
);
})
}
{
processes.slice(0, 1000).map((process, i) => {
return (
<div onClick={() => this.emphasis(process.path)} className="todo_color" key={i}>{process.path.replace(/\//, '')}</div>
);
})
}
{
failed.slice(0, 500).map((process, i) => {
return (
<div onClick={() => this.emphasis(process.path)} className="error_color" key={i}>{process.path}</div>
);
})
}
</div>
</div>
);
}
}
function runner(id){
let current_process = null;
if(processes.length === 0) return Promise.resolve();
var i;
for(i=0; i<processes.length; i++){
if(
// init: getting started with creation of files/folders
processes[i].parent === null ||
// running: make sure we've created the parent folder
PRIOR_STATUS[processes[i].parent] === true
){
current_process = processes[i];
processes.splice(i, 1);
currents.push(current_process);
break;
}
}
if(current_process){
return current_process.fn(id)
.then(() => {
if(current_process.id) PRIOR_STATUS[current_process.id] = true;
currents = currents.filter((c) => c.path != current_process.path);
return runner(id);
})
.catch((err) => {
failed.push(current_process);
currents = currents.filter((c) => c.path != current_process.path);
notify.send(err, 'error');
return runner(id);
});
}else{
return waitABit()
.then(() => runner(id));
function waitABit(){
return new Promise((done) => {
window.setTimeout(() => {
requestAnimationFrame(() => {
done();
});
}, 250);
});
}
}
}
if(files.length >= 5){
alert.now(<Stats/>, () => {});
}
Promise.all(Array.apply(null, Array(MAX_POOL_SIZE)).map((process,index) => {
return runner();
})).then(() => {
// remove the popup
if(failed.length === 0){
var e = new Event("keydown");
e.keyCode = 27;
window.dispatchEvent(e);
}
currents = [];
// display message
window.setTimeout(() => {
notify.send('Upload completed', 'success');
}, 300);
}).catch((err) => {
currents = [];
notify.send(err, 'error');
});
});
// adapted from: https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript // adapted from: https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
function _rand_id(){ function _rand_id(){

View File

@ -42,43 +42,3 @@
height: 100%; height: 100%;
} }
} }
.component_stats{
h2{
margin: 0 0 5px 0;
font-size: 1.2em;
font-weight: 100;
.percent{color: var(--emphasis-primary);}
> div{
float: right;
span.grandTotal{
font-size: 0.8em;
color: var(--emphasis-secondary);
&:before { content: "/"; }
}
span.completed{
color: var(--emphasis-secondary);
}
}
}
.stats_content {
clear: both;
max-height: 150px;
overflow-y: auto;
overflow-x: hidden;
font-size: 0.85em;
div{
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: calc(100% - 10px);
-webkit-overflow-scrolling: touch;
}
.error_color{
color: var(--error);
}
.todo_color{
color: var(--light);
}
}
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import { BrowserRouter, Route, IndexRoute, Switch } from 'react-router-dom'; import { BrowserRouter, Route, IndexRoute, Switch } from 'react-router-dom';
import { NotFoundPage, ConnectPage, HomePage, SharePage, LogoutPage, FilesPage, ViewerPage } from './pages/'; import { NotFoundPage, ConnectPage, HomePage, SharePage, LogoutPage, FilesPage, ViewerPage } from './pages/';
import { URL_HOME, URL_FILES, URL_VIEWER, URL_LOGIN, URL_LOGOUT } from './helpers/'; import { URL_HOME, URL_FILES, URL_VIEWER, URL_LOGIN, URL_LOGOUT } from './helpers/';
import { Bundle, ModalPrompt, ModalAlert, ModalConfirm, Notification, Audio, Video } from './components/'; import { Bundle, ModalPrompt, ModalAlert, ModalConfirm, Notification, Audio, Video, UploadQueue } from './components/';
const AdminPage = (props) => ( const AdminPage = (props) => (
<Bundle loader={import(/* webpackChunkName: "admin" */"./pages/adminpage")} symbol="AdminPage"> <Bundle loader={import(/* webpackChunkName: "admin" */"./pages/adminpage")} symbol="AdminPage">
@ -27,7 +27,7 @@ export default class AppRouter extends React.Component {
</Switch> </Switch>
</BrowserRouter> </BrowserRouter>
<ModalPrompt /> <ModalAlert /> <ModalConfirm /> <ModalPrompt /> <ModalAlert /> <ModalConfirm />
<Notification /> <Notification /> <UploadQueue/>
</div> </div>
); );
} }