Started rewriting task item UI, added upload tracking progress

pull/571/head
Piero Toffanin 2018-12-05 19:18:54 -05:00
rodzic 683dbd6bb3
commit 8cff925b44
12 zmienionych plików z 452 dodań i 83 usunięć

Wyświetl plik

@ -0,0 +1,27 @@
# Generated by Django 2.0.3 on 2018-12-05 16:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0021_auto_20180726_1746'),
]
operations = [
migrations.RemoveField(
model_name='task',
name='ground_control_points',
),
migrations.AddField(
model_name='task',
name='resize_progress',
field=models.FloatField(blank=True, default=0.0, help_text="Value between 0 and 1 indicating the resize progress of this task's images."),
),
migrations.AddField(
model_name='task',
name='upload_progress',
field=models.FloatField(blank=True, default=0.0, help_text="Value between 0 and 1 indicating the upload progress of this task's files to the processing node."),
),
]

Wyświetl plik

@ -2,6 +2,7 @@ import logging
import os
import shutil
import zipfile
import time
import uuid as uuid_module
import json
@ -155,7 +156,6 @@ class Task(models.Model):
options = fields.JSONField(default=dict(), blank=True, help_text="Options that are being used to process this task", validators=[validate_task_options])
available_assets = fields.ArrayField(models.CharField(max_length=80), default=list(), blank=True, help_text="List of available assets to download")
console_output = models.TextField(null=False, default="", blank=True, help_text="Console output of the OpenDroneMap's process")
ground_control_points = models.FileField(null=True, blank=True, upload_to=gcp_directory_path, help_text="Optional Ground Control Points file to use for processing")
orthophoto_extent = GeometryField(null=True, blank=True, srid=4326, help_text="Extent of the orthophoto created by OpenDroneMap")
dsm_extent = GeometryField(null=True, blank=True, srid=4326, help_text="Extent of the DSM created by OpenDroneMap")
@ -168,6 +168,12 @@ class Task(models.Model):
public = models.BooleanField(default=False, help_text="A flag indicating whether this task is available to the public")
resize_to = models.IntegerField(default=-1, help_text="When set to a value different than -1, indicates that the images for this task have been / will be resized to the size specified here before processing.")
upload_progress = models.FloatField(default=0.0,
help_text="Value between 0 and 1 indicating the upload progress of this task's files to the processing node.",
blank=True)
resize_progress = models.FloatField(default=0.0,
help_text="Value between 0 and 1 indicating the resize progress of this task's images.",
blank=True)
def __init__(self, *args, **kwargs):
super(Task, self).__init__(*args, **kwargs)
@ -333,10 +339,14 @@ class Task(models.Model):
images = [image.path() for image in self.imageupload_set.all()]
# Track upload progress, but limit the number of DB updates
# to every 2 seconds (and always record the 100% progress)
last_update = 0
def callback(progress):
# TODO: add step_progress field in task to track progress
pass
nonlocal last_update
if time.time() - last_update >= 2 or (progress >= 1.0 - 1e-6 and progress <= 1.0 + 1e-6):
Task.objects.filter(pk=self.id).update(upload_progress=progress)
last_update = time.time()
# This takes a while
uuid = self.processing_node.process_new_task(images, self.name, self.options, callback)

Wyświetl plik

@ -264,4 +264,8 @@ footer{
.full-height{
height: calc(100vh - 110px);
padding-bottom: 12px;
}
.clearfix{
clear: both;
}

Wyświetl plik

@ -24,6 +24,8 @@ class Console extends React.Component {
this.setRef = this.setRef.bind(this);
this.handleMouseOver = this.handleMouseOver.bind(this);
this.handleMouseOut = this.handleMouseOut.bind(this);
this.downloadTxt = this.downloadTxt.bind(this);
this.copyTxt = this.copyTxt.bind(this);
}
componentDidMount(){
@ -64,6 +66,7 @@ class Console extends React.Component {
}
downloadTxt(filename="console.txt"){
console.log(filename);
function saveAs(uri, filename) {
let link = document.createElement('a');
if (typeof link.download === 'string') {
@ -93,7 +96,7 @@ class Console extends React.Component {
el.select();
document.execCommand('copy');
document.body.removeChild(el);
console.log("Task output copied to clipboard");
console.log("Output copied to clipboard");
}
tearDownDynamicSource(){
@ -140,22 +143,41 @@ class Console extends React.Component {
}
let i = 0;
return (
<pre className={`console prettyprint
${this.props.lang ? `lang-${this.props.lang}` : ""}
${this.props.lines ? "linenums" : ""}`}
style={{height: (this.props.height ? this.props.height : "auto")}}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
ref={this.setRef}
>
{this.state.lines.map(line => {
if (this.props.lang) return (<div key={i++} dangerouslySetInnerHTML={prettyLine(line)}></div>);
else return line + "\n";
})}
{"\n"}
</pre>
);
let lines = this.state.lines;
if (this.props.maximumLines && lines.length > this.props.maximumLines){
lines = lines.slice(-this.props.maximumLines);
lines.unshift(`... output truncated at ${this.props.maximumLines} lines ...`);
}
const items = [
<pre key="console" className={`console prettyprint
${this.props.lang ? `lang-${this.props.lang}` : ""}
${this.props.lines ? "linenums" : ""}
${this.props.className || ""}`}
style={{height: (this.props.height ? this.props.height : "auto")}}
onMouseOver={this.handleMouseOver}
onMouseOut={this.handleMouseOut}
ref={this.setRef}
>
{lines.map(line => {
if (this.props.lang) return (<div key={i++} dangerouslySetInnerHTML={prettyLine(line)}></div>);
else return line + "\n";
})}
{"\n"}
</pre>];
if (this.props.showConsoleButtons){
items.push(<div key="buttons" className="console-buttons">
<a href="javascript:void(0);" onClick={() => this.downloadTxt()} className="btn btn-sm btn-primary" title="Download To File">
<i className="fa fa-download"></i>
</a>
<a href="javascript:void(0);" onClick={this.copyTxt} className="btn btn-sm btn-primary" title="Copy To Clipboard">
<i className="fa fa-clipboard"></i>
</a>
</div>);
}
return items;
}
}

Wyświetl plik

@ -0,0 +1,54 @@
export default {
get: function(){
return [{
action: "dataset",
label: "Load Dataset",
icon: "fa fa-database",
beginsWith: "Running ODM Load Dataset Cell",
endsWith: "Running ODM Load Dataset Cell - Finished"
},
{
action: "opensfm",
label: "Structure From Motion",
icon: "fa fa-camera",
beginsWith: "Running ODM OpenSfM Cell",
endsWith: "Running ODM Meshing Cell"
},
{
action: "odm_meshing",
label: "Meshing",
icon: "fa fa-cube",
beginsWith: "Running ODM Meshing Cell",
endsWith: "Running ODM Meshing Cell - Finished"
},
{
action: "mvs_texturing",
label: "Texturing",
icon: "fa fa-connectdevelop",
beginsWith: "Running MVS Texturing Cell",
endsWith: "Running ODM Texturing Cell - Finished"
},
{
action: "odm_georeferencing",
label: "Georeferencing",
icon: "fa fa-globe",
beginsWith: "Running ODM Georeferencing Cell",
endsWith: "Running ODM Georeferencing Cell - Finished"
},
{
action: "odm_dem",
label: "DEM",
icon: "fa fa-area-chart",
beginsWith: "Running ODM DEM Cell",
endsWith: "Running ODM DEM Cell - Finished"
},
{
action: "odm_orthophoto",
label: "Orthophoto",
icon: "fa fa-map-o",
beginsWith: "Running ODM Orthophoto Cell",
endsWith: "Running ODM OrthoPhoto Cell - Finished"
}
];
}
};

Wyświetl plik

@ -45,7 +45,7 @@ class AssetDownloadButtons extends React.Component {
{assetDownloads.map((asset, i) => {
if (!asset.separator){
return (<li key={i}>
<a href="javascript:void(0);" onClick={this.downloadAsset(asset)}><i className={asset.icon}></i> {asset.label}</a>
<a href="javascript:void(0);" onClick={this.downloadAsset(asset)}><i className={asset.icon + " fa-fw"}></i> {asset.label}</a>
</li>);
}else{
return (<li key={i} className="divider"></li>);

Wyświetl plik

@ -0,0 +1,198 @@
import React from 'react';
import PropTypes from 'prop-types';
import '../css/BasicTaskView.scss';
import update from 'immutability-helper';
import RerunFromParams from '../classes/RerunFromParams';
import StatusCodes from '../classes/StatusCodes';
import $ from 'jquery';
class BasicTaskView extends React.Component {
static defaultProps = {};
static propTypes = {
source: PropTypes.oneOfType([
PropTypes.string.isRequired,
PropTypes.func.isRequired
]),
taskStatus: PropTypes.number,
onAddLines: PropTypes.func
};
constructor(props){
super();
this.state = {
lines: [],
currentRf: 0,
rf: RerunFromParams.get()
};
// Add a post processing step
this.state.rf.push({
action: "postprocessing",
label: "Post Processing",
icon: "fa fa-file-archive-o",
beginsWith: "Running ODM OrthoPhoto Cell - Finished",
endsWith: null
});
this.addLines = this.addLines.bind(this);
this.setupDynamicSource = this.setupDynamicSource.bind(this);
this.reset = this.reset.bind(this);
this.tearDownDynamicSource = this.tearDownDynamicSource.bind(this);
this.getRfEndStatus = this.getRfEndStatus.bind(this);
this.getRfRunningStatus = this.getRfRunningStatus.bind(this);
this.suffixFor = this.suffixFor.bind(this);
this.updateRfState = this.updateRfState.bind(this);
this.getInitialStatus = this.getInitialStatus.bind(this);
}
componentDidMount(){
this.reset();
}
setupDynamicSource(){
const updateFromSource = () => {
let sourceUrl = typeof this.props.source === 'function' ?
this.props.source(this.state.lines.length) :
this.props.source;
// Fetch
this.sourceRequest = $.get(sourceUrl, text => {
if (text !== ""){
let lines = text.split("\n");
this.addLines(lines);
}
})
.always((_, textStatus) => {
if (textStatus !== "abort" && this.props.refreshInterval !== undefined){
this.sourceTimeout = setTimeout(updateFromSource, this.props.refreshInterval);
}
});
};
updateFromSource();
}
getRfEndStatus(){
return this.props.taskStatus === StatusCodes.COMPLETED ?
'completed' :
'errored';
}
getRfRunningStatus(){
return [StatusCodes.RUNNING, StatusCodes.QUEUED].indexOf(this.props.taskStatus) !== -1 ?
'running' :
'errored';
}
getInitialStatus(){
if ([StatusCodes.QUEUED, StatusCodes.RUNNING].indexOf(this.props.taskStatus) !== -1){
return 'queued';
}else{
return this.getRfEndStatus();
}
}
reset(){
this.state.rf.forEach(p => {
p.state = this.getInitialStatus();
});
this.tearDownDynamicSource();
this.setState({lines: [], currentRf: 0});
this.setupDynamicSource();
}
tearDownDynamicSource(){
if (this.sourceTimeout) clearTimeout(this.sourceTimeout);
if (this.sourceRequest) this.sourceRequest.abort();
}
componentDidUpdate(prevProps){
let taskFailed = [StatusCodes.RUNNING, StatusCodes.QUEUED].indexOf(prevProps.taskStatus) !== -1 &&
[StatusCodes.FAILED, StatusCodes.CANCELED].indexOf(this.props.taskStatus) !== -1;
this.updateRfState(taskFailed);
}
componentWillUnmount(){
this.tearDownDynamicSource();
}
addLines(lines){
if (!Array.isArray(lines)) lines = [lines];
let currentRf = this.state.currentRf;
const updateRf = (rfIndex, line) => {
const current = this.state.rf[rfIndex];
if (!current) return;
if (current.beginsWith && line.endsWith(current.beginsWith)){
current.state = this.getRfRunningStatus();
// Set previous as done
if (this.state.rf[rfIndex - 1]){
this.state.rf[rfIndex - 1].state = 'completed';
}
}else if (current.endsWith && line.endsWith(current.endsWith)){
current.state = this.getRfEndStatus();
// Check next
updateRf(rfIndex + 1, line);
currentRf = rfIndex + 1;
}
};
lines.forEach(line => {
updateRf(currentRf, line);
});
this.setState(update(this.state, {
lines: {$push: lines}
}));
this.setState({
rf: this.state.rf,
currentRf
});
this.updateRfState();
if (this.props.onAddLines) this.props.onAddLines(lines);
}
updateRfState(taskFailed){
// If the task has just failed, update all items that were either running or in queued state
if (taskFailed){
this.state.rf.forEach(p => {
if (p.state === 'queued' || p.state === 'running') p.state = this.getInitialStatus();
});
}
// The last is always dependent on the task status
this.state.rf[this.state.rf.length - 1].state = this.getInitialStatus();
}
suffixFor(state){
if (state === 'running'){
return (<span>...</span>);
}else if (state === 'completed'){
return (<i className="fa fa-check"></i>);
}else if (state === 'errored'){
return (<i className="fa fa-times"></i>);
}
}
render() {
return (<div className="basic-task-view">
{this.state.rf.map(p => {
return (<div key={p.action} className={p.state + " processing-step"}>
<i className={p.icon + " fa-fw"}></i> {p.label} {this.suffixFor(p.state)}
</div>);
})}
</div>);
}
}
export default BasicTaskView;

Wyświetl plik

@ -9,6 +9,8 @@ import AssetDownloadButtons from './AssetDownloadButtons';
import HistoryNav from '../classes/HistoryNav';
import PropTypes from 'prop-types';
import TaskPluginActionButtons from './TaskPluginActionButtons';
import RerunFromParams from '../classes/RerunFromParams';
import BasicTaskView from './BasicTaskView';
class TaskListItem extends React.Component {
static propTypes = {
@ -32,7 +34,8 @@ class TaskListItem extends React.Component {
editing: false,
memoryError: false,
friendlyTaskError: "",
pluginActionButtons: []
pluginActionButtons: [],
view: "basic"
}
for (let k in props.data){
@ -44,9 +47,8 @@ class TaskListItem extends React.Component {
this.stopEditing = this.stopEditing.bind(this);
this.startEditing = this.startEditing.bind(this);
this.checkForCommonErrors = this.checkForCommonErrors.bind(this);
this.downloadTaskOutput = this.downloadTaskOutput.bind(this);
this.copyTaskOutput = this.copyTaskOutput.bind(this);
this.handleEditTaskSave = this.handleEditTaskSave.bind(this);
this.setView = this.setView.bind(this);
}
shouldRefresh(){
@ -69,6 +71,12 @@ class TaskListItem extends React.Component {
}
}
setView(type){
return () => {
this.setState({view: type});
}
}
unloadTimer(){
if (this.processingTimeInterval){
clearInterval(this.processingTimeInterval);
@ -96,6 +104,7 @@ class TaskListItem extends React.Component {
if (oldStatus !== this.state.task.status){
if (this.state.task.status === statusCodes.RUNNING){
if (this.console) this.console.clear();
if (this.basicView) this.basicView.reset();
this.loadTimer(this.state.task.processing_time);
}else{
this.setState({time: this.state.task.processing_time});
@ -205,14 +214,6 @@ class TaskListItem extends React.Component {
};
}
downloadTaskOutput(){
this.console.downloadTxt("task_output.txt");
}
copyTaskOutput(){
this.console.copyTxt();
}
optionsToList(options){
if (!Array.isArray(options)) return "";
else if (options.length === 0) return "Default";
@ -269,35 +270,13 @@ class TaskListItem extends React.Component {
const { task } = this.state;
// Map rerun-from parameters to display items
const rfMap = {
"odm_meshing": {
label: "From Meshing",
icon: "fa fa-cube"
},
"mvs_texturing": {
label: "From Texturing",
icon: "fa fa-connectdevelop"
},
"odm_georeferencing": {
label: "From Georeferencing",
icon: "fa fa-globe"
},
"odm_dem": {
label: "From DEM",
icon: "fa fa-area-chart"
},
"odm_orthophoto": {
label: "From Orthophoto",
icon: "fa fa-map-o"
}
};
// (remove the first item so that 'dataset' is not displayed)
const rfMap = {};
RerunFromParams.get().slice(1).forEach(rf => rfMap[rf.action] = rf);
// Create onClick handlers
for (let rfParam in rfMap){
rfMap[rfParam].label = "From " + rfMap[rfParam].label;
rfMap[rfParam].onClick = this.genRestartAction(rfParam);
}
@ -468,7 +447,7 @@ class TaskListItem extends React.Component {
data-toggle="dropdown"><span className="caret"></span></button>,
<ul key="dropdown-menu" className="dropdown-menu">
{subItems.map(subItem => <li key={subItem.label}>
<a href="javascript:void(0);" onClick={subItem.onClick}><i className={subItem.icon}></i>{subItem.label}</a>
<a href="javascript:void(0);" onClick={subItem.onClick}><i className={subItem.icon + ' fa-fw '}></i>{subItem.label}</a>
</li>)}
</ul>]}
</div>);
@ -501,23 +480,35 @@ class TaskListItem extends React.Component {
</div>
<div className="col-md-8">
<Console
source={this.consoleOutputUrl}
refreshInterval={this.shouldRefresh() ? 3000 : undefined}
autoscroll={true}
height={200}
ref={domNode => this.console = domNode}
onAddLines={this.checkForCommonErrors}
/>
<div className="console-buttons">
<a href="javascript:void(0);" onClick={this.downloadTaskOutput} className="btn btn-sm btn-primary" title="Download Task Output">
<i className="fa fa-download"></i>
</a>
<a href="javascript:void(0);" onClick={this.copyTaskOutput} className="btn btn-sm btn-primary" title="Copy Task Output">
<i className="fa fa-clipboard"></i>
</a>
<div className="switch-view text-right pull-right">
<i className="fa fa-list-ul"></i> <a href="javascript:void(0);" onClick={this.setView("basic")}
className={this.state.view === 'basic' ? "selected" : ""}>Basic</a>
|
<i className="fa fa-desktop"></i> <a href="javascript:void(0);" onClick={this.setView("console")}
className={this.state.view === 'console' ? "selected" : ""}>Console</a>
</div>
{this.state.view === 'console' ?
<Console
className="clearfix"
source={this.consoleOutputUrl}
refreshInterval={this.shouldRefresh() ? 3000 : undefined}
autoscroll={true}
height={200}
ref={domNode => this.console = domNode}
onAddLines={this.checkForCommonErrors}
showConsoleButtons={true}
maximumLines={500}
/> : ""}
{this.state.view === 'basic' ?
<BasicTaskView
source={this.consoleOutputUrl}
ref={domNode => this.basicView = domNode}
refreshInterval={this.shouldRefresh() ? 3000 : undefined}
onAddLines={this.checkForCommonErrors}
taskStatus={task.status}
/> : ""}
{showMemoryErrorWarning ?
<div className="task-warning"><i className="fa fa-support"></i> <span>It looks like your processing node ran out of memory. If you are using docker, make sure that your docker environment has <a href={memoryErrorLink} target="_blank">enough RAM allocated</a>. Alternatively, make sure you have enough physical RAM, reduce the number of images, make your images smaller, or reduce the max-concurrency parameter from the task's <a href="javascript:void(0);" onClick={this.startEditing}>options</a>.</span></div> : ""}
@ -532,7 +523,7 @@ class TaskListItem extends React.Component {
<li>Increase the <b>min-num-features</b> option, especially if your images have lots of vegetation</li>
</ul>
Still not working? Upload your images somewhere like <a href="https://www.dropbox.com/" target="_blank">Dropbox</a> or <a href="https://drive.google.com/drive/u/0/" target="_blank">Google Drive</a> and <a href="http://community.opendronemap.org/c/webodm" target="_blank">open a topic</a> on our community forum, making
sure to include a <a href="javascript:void(0);" onClick={this.downloadTaskOutput}>copy of your task's output</a> (the one you see above <i className="fa fa-arrow-up"></i>, click to <a href="javascript:void(0);" onClick={this.downloadTaskOutput}>download</a> it). Our awesome contributors will try to help you! <i className="fa fa-smile-o"></i>
sure to include a <a href="javascript:void(0);" onClick={this.setView("console")}>copy of your task's output</a>. Our awesome contributors will try to help you! <i className="fa fa-smile-o"></i>
</div>
</div>
: ""}

Wyświetl plik

@ -0,0 +1,10 @@
import React from 'react';
import { mount } from 'enzyme';
import BasicTaskView from '../BasicTaskView';
describe('<BasicTaskView />', () => {
it('renders without exploding', () => {
const wrapper = mount(<BasicTaskView source="http://localhost/output" taskStatus={40} />);
expect(wrapper.exists()).toBe(true);
})
});

Wyświetl plik

@ -0,0 +1,34 @@
.basic-task-view{
margin-bottom: 8px;
.processing-step{
opacity: 0.7;
&.completed{
opacity: 1;
/* font-weight: bold; */
}
&.running{
opacity: 1;
animation: pulse ease-in-out 1.4833s infinite;
}
&.queued{
}
&.errored{
opacity: 1;
}
}
@keyframes pulse {
0% {
opacity: 0.7;
}
50% {
opacity: 1;
}
100% {
opacity: 0.7;
}
}
}

Wyświetl plik

@ -0,0 +1,9 @@
.console-buttons{
margin-left: 16px;
margin-bottom: 16px;
float: right;
text-align: right;
a{
margin-left: 4px;
}
}

Wyświetl plik

@ -97,13 +97,23 @@
display: inline;
}
.console-buttons{
margin-left: 16px;
margin-bottom: 16px;
float: right;
text-align: right;
.switch-view{
margin-bottom: 8px;
font-size: 90%;
a{
margin-left: 4px;
margin-right: 8px;
&.selected{
font-weight: bold;
}
&:last-child{
margin-right: 0;
}
}
i{
margin-left: 8px;
}
}
}