Restart, cancel and delete actions working, UI sync and performance improvements

pull/43/head
Piero Toffanin 2016-11-04 14:19:18 -04:00
rodzic 31e46bbfb9
commit 145103ce03
19 zmienionych plików z 243 dodań i 69 usunięć

Wyświetl plik

@ -1,5 +1,5 @@
from django.contrib.auth.models import User
from rest_framework import serializers, viewsets
from rest_framework import serializers, viewsets, filters
from app import models
from .tasks import TaskIDsSerializer
@ -26,3 +26,4 @@ class ProjectViewSet(viewsets.ModelViewSet):
filter_fields = ('id', 'owner', 'name')
serializer_class = ProjectSerializer
queryset = models.Project.objects.all()
ordering_fields = '__all__'

Wyświetl plik

@ -50,15 +50,14 @@ class TaskViewSet(viewsets.ViewSet):
raise exceptions.NotFound()
return project
@detail_route(methods=['post'])
def cancel(self, request, pk=None, project_pk=None):
def set_pending_action(self, pending_action, request, pk=None, project_pk=None):
self.get_and_check_project(request, project_pk, ('change_project',))
try:
task = self.queryset.get(pk=pk, project=project_pk)
except ObjectDoesNotExist:
raise exceptions.NotFound()
task.pending_action = task.PendingActions.CANCEL
task.pending_action = pending_action
task.last_error = None
task.save()
@ -67,6 +66,19 @@ class TaskViewSet(viewsets.ViewSet):
return Response({'success': True})
@detail_route(methods=['post'])
def cancel(self, *args, **kwargs):
return self.set_pending_action(models.Task.PendingActions.CANCEL, *args, **kwargs)
@detail_route(methods=['post'])
def restart(self, *args, **kwargs):
return self.set_pending_action(models.Task.PendingActions.RESTART, *args, **kwargs)
@detail_route(methods=['post'])
def remove(self, *args, **kwargs):
# TODO: this should check for delete_project perms
return self.set_pending_action(models.Task.PendingActions.REMOVE, *args, **kwargs)
@detail_route(methods=['get'])
def output(self, request, pk=None, project_pk=None):
"""

Wyświetl plik

@ -78,7 +78,8 @@ def validate_task_options(value):
class Task(models.Model):
class PendingActions:
CANCEL = 1
DELETE = 2
REMOVE = 2
RESTART = 3
STATUS_CODES = (
(status_codes.QUEUED, 'QUEUED'),
@ -90,7 +91,8 @@ class Task(models.Model):
PENDING_ACTIONS = (
(PendingActions.CANCEL, 'CANCEL'),
(PendingActions.DELETE, 'DELETE'),
(PendingActions.REMOVE, 'REMOVE'),
(PendingActions.RESTART, 'RESTART'),
)
uuid = models.CharField(max_length=255, db_index=True, default='', blank=True, help_text="Identifier of the task (as returned by OpenDroneMap's REST API)")
@ -176,11 +178,62 @@ class Task(models.Model):
if self.processing_node and self.uuid:
self.processing_node.cancel_task(self.uuid)
self.pending_action = None
self.save()
else:
raise ProcessingException("Cannot cancel a task that has no processing node or UUID assigned")
raise ProcessingException("Cannot cancel a task that has no processing node or UUID")
elif self.pending_action == self.PendingActions.RESTART:
logger.info("Restarting task {}".format(self))
if self.processing_node and self.uuid:
# Check if the UUID is still valid, as processing nodes purge
# results after a set amount of time, the UUID might have eliminated.
try:
info = self.processing_node.get_task_info(self.uuid)
uuid_still_exists = info['uuid'] == self.uuid
except ProcessingException:
uuid_still_exists = False
if uuid_still_exists:
# Good to go
self.processing_node.restart_task(self.uuid)
else:
# Task has been purged (or processing node is offline)
# TODO: what if processing node went offline?
# Process this as a new task
# Removing its UUID will cause the scheduler
# to process this the next tick
self.uuid = None
self.console_output = ""
self.processing_time = -1
self.status = None
self.last_error = None
self.pending_action = None
self.save()
else:
raise ProcessingException("Cannot restart a task that has no processing node or UUID")
elif self.pending_action == self.PendingActions.REMOVE:
logger.info("Removing task {}".format(self))
if self.processing_node and self.uuid:
# Attempt to delete the resources on the processing node
# We don't care if this fails, as resources on processing nodes
# Are expected to be purged on their own after a set amount of time anyway
try:
self.processing_node.remove_task(self.uuid)
except ProcessingException:
pass
# What's more important is that we delete our task properly here
self.delete()
# Stop right here!
return
except ProcessingException as e:
self.last_error = e.message
finally:
self.save()
@ -224,6 +277,7 @@ class Task(models.Model):
self.status = status_codes.FAILED
self.save()
class Meta:
permissions = (
('view_task', 'Can view task'),

Wyświetl plik

@ -75,6 +75,9 @@ def process_pending_tasks():
def process(task):
task.process()
# Might have been deleted
if task.pk is not None:
task.processing_lock = False
task.save()

Wyświetl plik

@ -26,14 +26,14 @@ class Console extends React.Component {
componentDidMount(){
this.checkAutoscroll();
this.setupDynamicSource();
}
// Dynamic source?
setupDynamicSource(){
if (this.props.source !== undefined){
let currentLineNumber = 0;
const updateFromSource = () => {
let sourceUrl = typeof this.props.source === 'function' ?
this.props.source(currentLineNumber) :
this.props.source(this.state.lines.length) :
this.props.source;
// Fetch
@ -41,7 +41,6 @@ class Console extends React.Component {
if (text !== ""){
let lines = text.split("\n");
this.addLines(lines);
currentLineNumber += (lines.length - 1);
}
})
.always((_, textStatus) => {
@ -56,11 +55,21 @@ class Console extends React.Component {
}
}
componentWillUnmount(){
clear(){
this.tearDownDynamicSource();
this.setState({lines: []});
this.setupDynamicSource();
}
tearDownDynamicSource(){
if (this.sourceTimeout) clearTimeout(this.sourceTimeout);
if (this.sourceRequest) this.sourceRequest.abort();
}
componentWillUnmount(){
this.tearDownDynamicSource();
}
setRef(domNode){
if (domNode != null){
this.$console = $(domNode);
@ -91,7 +100,6 @@ class Console extends React.Component {
render() {
const prettyLine = (line) => {
// TODO Escape
return {__html: prettyPrintOne(Utils.escapeHtml(line), this.props.lang, this.props.lines)};
}
let i = 0;

Wyświetl plik

@ -5,7 +5,7 @@ import ProjectList from './components/ProjectList';
class Dashboard extends React.Component {
render() {
return (
<ProjectList source="/api/projects/"/>
<ProjectList source="/api/projects/?ordering=-created_at"/>
);
}
}

Wyświetl plik

@ -1,18 +1,23 @@
const CANCEL = 1,
DELETE = 2;
REMOVE = 2,
RESTART = 3;
let pendingActions = {
[CANCEL]: {
descr: "Canceling..."
},
[DELETE]: {
[REMOVE]: {
descr: "Deleting..."
},
[RESTART]: {
descr: "Restarting..."
}
};
export default {
CANCEL: CANCEL,
DELETE: DELETE,
REMOVE: REMOVE,
RESTART: RESTART,
description: function(pendingAction) {
if (pendingActions[pendingAction]) return pendingActions[pendingAction].descr;

Wyświetl plik

@ -15,7 +15,7 @@ let statusCodes = {
},
[FAILED]: {
descr: "Failed",
icon: "fa fa-remove"
icon: "fa fa-frown-o"
},
[COMPLETED]: {
descr: "Completed",

Wyświetl plik

@ -167,7 +167,6 @@ class ProjectListItem extends React.Component {
this.setState({
showTaskList: !this.state.showTaskList
});
console.log(this.props);
}
closeUploadError(){
@ -264,7 +263,7 @@ class ProjectListItem extends React.Component {
<span>Updating task information... <i className="fa fa-refresh fa-spin fa-fw"></i></span>
: ""}
{this.state.showTaskList ? <TaskList ref={this.setRef("taskList")} source={`/api/projects/${this.props.data.id}/tasks/?ordering=-id`}/> : ""}
{this.state.showTaskList ? <TaskList ref={this.setRef("taskList")} source={`/api/projects/${this.props.data.id}/tasks/?ordering=-created_at`}/> : ""}
</div>
</li>

Wyświetl plik

@ -13,6 +13,7 @@ class TaskList extends React.Component {
};
this.refresh = this.refresh.bind(this);
this.deleteTask = this.deleteTask.bind(this);
}
componentDidMount(){
@ -46,6 +47,12 @@ class TaskList extends React.Component {
this.taskListRequest.abort();
}
deleteTask(id){
this.setState({
tasks: this.state.tasks.filter(t => t.id !== id)
});
}
render() {
let message = "";
if (this.state.loading){
@ -61,7 +68,7 @@ class TaskList extends React.Component {
{message}
{this.state.tasks.map(task => (
<TaskListItem data={task} key={task.id} refreshInterval={3000} />
<TaskListItem data={task} key={task.id} refreshInterval={3000} onDelete={this.deleteTask} />
))}
</div>
);

Wyświetl plik

@ -27,9 +27,9 @@ class TaskListItem extends React.Component {
shouldRefresh(){
// If a task is completed, or failed, etc. we don't expect it to change
return (this.state.task.status === statusCodes.QUEUED ||
this.state.task.status === statusCodes.RUNNING ||
(!this.state.task.uuid && this.state.task.processing_node && !this.state.task.last_error));
return (([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(this.state.task.status) !== -1 && this.state.task.processing_node) ||
(!this.state.task.uuid && this.state.task.processing_node && !this.state.task.last_error) ||
this.state.task.pending_action !== null);
}
loadTimer(startTime){
@ -65,6 +65,7 @@ class TaskListItem extends React.Component {
// Update timer if we switched to running
if (oldStatus !== this.state.task.status){
if (this.state.task.status === statusCodes.RUNNING){
this.console.clear();
this.loadTimer(this.state.task.processing_time);
}else{
this.setState({time: this.state.task.processing_time});
@ -74,18 +75,21 @@ class TaskListItem extends React.Component {
}else{
console.warn("Cannot refresh task: " + json);
}
})
.always((_, textStatus) => {
if (textStatus !== "abort"){
if (this.shouldRefresh()) this.refreshTimeout = setTimeout(() => this.refresh(), this.props.refreshInterval || 3000);
})
.fail(( _, __, errorThrown) => {
if (errorThrown === "Not Found"){ // Don't translate this one
// Assume this has been deleted
if (this.props.onDelete) this.props.onDelete(this.state.task.id);
}
});
}
componentWillUnmount(){
this.unloadTimer();
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
if (this.refreshRequest) this.refreshRequest.abort();
if (this.refreshTimeout) clearTimeout(this.refreshTimeout);
}
toggleExpanded(){
@ -118,8 +122,9 @@ class TaskListItem extends React.Component {
return [pad(h), pad(m), pad(s)].join(':');
}
genActionApiCall(action){
genActionApiCall(action, options = {}){
return () => {
const doAction = () => {
this.setState({actionButtonsDisabled: true});
$.post(`/api/projects/${this.state.task.project}/tasks/${this.state.task.id}/${action}/`,
@ -129,6 +134,7 @@ class TaskListItem extends React.Component {
).done(json => {
if (json.success){
this.refresh();
if (options.success !== undefined) options.success();
}else{
this.setState({
actionError: json.error,
@ -142,14 +148,32 @@ class TaskListItem extends React.Component {
actionButtonsDisabled: false
});
});
}
if (options.confirm){
if (window.confirm(options.confirm)){
doAction();
}
}else{
doAction();
}
};
}
optionsToList(options){
if (!Array.isArray(options)) return "";
else if (options.length === 0) return "Default";
else {
return options.map(opt => `${opt.name}: ${opt.value}`).join(", ");
}
}
render() {
let name = this.state.task.name !== null ? this.state.task.name : `Task #${this.state.task.id}`;
let status = statusCodes.description(this.state.task.status);
if (status === "") status = "Uploading images";
if (!this.state.task.processing_node) status = "";
if (this.state.task.pending_action !== null) status = pendingActions.description(this.state.task.pending_action);
let expanded = "";
@ -161,19 +185,30 @@ class TaskListItem extends React.Component {
});
};
if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(this.state.task.status) !== -1){
if ([statusCodes.QUEUED, statusCodes.RUNNING, null].indexOf(this.state.task.status) !== -1 &&
this.state.task.processing_node){
addActionButton("Cancel", "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("cancel"));
}
// addActionButton("Restart", "btn-primary", "glyphicon glyphicon-play", genActionApiCall("cancel"));
if ([statusCodes.FAILED, statusCodes.COMPLETED, statusCodes.CANCELED].indexOf(this.state.task.status) !== -1 &&
this.state.task.processing_node){
addActionButton("Restart", "btn-primary", "glyphicon glyphicon-remove-circle", this.genActionApiCall("restart", {
success: () => {
this.console.clear();
this.setState({time: -1});
}
}
));
}
// TODO: ability to change options
// addActionButton("Edit", "btn-primary", "glyphicon glyphicon-pencil", () => {
// console.log("edit call");
// });
// addActionButton("Delete", "btn-danger", "glyphicon glyphicon-trash", () => {
// console.log("Delete call");
// });
addActionButton("Delete", "btn-danger", "glyphicon glyphicon-trash", this.genActionApiCall("remove", {
confirm: "All information related to this task, including images, maps and models will be deleted. Continue?"
}));
actionButtons = (<div className="action-buttons">
{actionButtons.map(button => {
@ -195,6 +230,9 @@ class TaskListItem extends React.Component {
</div>
<div className="labels">
<strong>Status: </strong> {status}<br/>
</div>
<div className="labels">
<strong>Options: </strong> {this.optionsToList(this.state.task.options)}<br/>
</div>
{/* TODO: List of images? */}
</div>
@ -203,7 +241,9 @@ class TaskListItem extends React.Component {
source={this.consoleOutputUrl}
refreshInterval={this.shouldRefresh() ? 3000 : undefined}
autoscroll={true}
height={200} />
height={200}
ref={domNode => this.console = domNode}
/>
</div>
</div>
<div className="row">

Wyświetl plik

@ -16,7 +16,6 @@ class UploadProgressBar extends React.Component {
let percentage = (this.props.progress !== undefined ?
this.props.progress :
0).toFixed(2);
let bytes = this.props.totalBytesSent !== undefined && this.props.totalBytes !== undefined ?
`, remaining to upload: ${this.bytesToSize(this.props.totalBytes - this.props.totalBytesSent)}` :
"";

Wyświetl plik

@ -586,11 +586,13 @@
eventName = _ref1[_i];
this.on(eventName, this.options[eventName]);
}
this.on("uploadprogress", (function(_this) {
return function() {
return _this.updateTotalUploadProgress();
};
})(this));
this.on("removedfile", (function(_this) {
return function() {
return _this.updateTotalUploadProgress();
@ -703,8 +705,9 @@
_ref = this.getActiveFiles();
for (_i = 0, _len = _ref.length; _i < _len; _i++) {
file = _ref[_i];
totalBytesSent += file.upload.bytesSent;
totalBytes += file.upload.total;
totalBytesSent = file.upload.bytesSent;
totalBytes = file.upload.total;
break;
}
totalUploadProgress = 100 * totalBytesSent / totalBytes;
} else {
@ -1288,6 +1291,7 @@
for (_l = 0, _len3 = files.length; _l < _len3; _l++) {
file = files[_l];
_results.push(_this.emit("uploadprogress", file, progress, file.upload.bytesSent));
break;
}
return _results;
};

Wyświetl plik

@ -37,6 +37,11 @@ class TestApi(BootTestCase):
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertTrue(len(res.data["results"]) > 0)
# Can sort
res = client.get('/api/projects/?ordering=-created_at')
last_project = Project.objects.filter(owner=user).latest('created_at')
self.assertTrue(res.data["results"][0]['id'] == last_project.id)
res = client.get('/api/projects/{}/'.format(project.id))
self.assertEqual(res.status_code, status.HTTP_200_OK)
@ -77,11 +82,11 @@ class TestApi(BootTestCase):
self.assertTrue(len(res.data) == 2)
# Can sort
res = client.get('/api/projects/{}/tasks/?ordering=id'.format(project.id))
res = client.get('/api/projects/{}/tasks/?ordering=created_at'.format(project.id))
self.assertTrue(res.data[0]['id'] == task.id)
self.assertTrue(res.data[1]['id'] == task2.id)
res = client.get('/api/projects/{}/tasks/?ordering=-id'.format(project.id))
res = client.get('/api/projects/{}/tasks/?ordering=-created_at'.format(project.id))
self.assertTrue(res.data[0]['id'] == task2.id)
self.assertTrue(res.data[1]['id'] == task.id)
@ -159,6 +164,8 @@ class TestApi(BootTestCase):
res = client.post('/api/projects/{}/tasks/{}/cancel/'.format(other_project.id, other_task.id))
self.assertEqual(res.status_code, status.HTTP_404_NOT_FOUND)
# TODO: test restart and delete operations
def test_processingnodes(self):
client = APIClient()

Wyświetl plik

@ -34,6 +34,11 @@ class ApiClient:
def task_cancel(self, uuid):
return requests.post(self.url('/task/cancel'), data={'uuid': uuid}).json()
def task_remove(self, uuid):
return requests.post(self.url('/task/remove'), data={'uuid': uuid}).json()
def task_restart(self, uuid):
return requests.post(self.url('/task/restart'), data={'uuid': uuid}).json()
def new_task(self, images, name=None, options=[]):
"""

@ -1 +1 @@
Subproject commit fdc55291525c03dbdf922724f1a4af8d1a89d2af
Subproject commit 038940860d043439b184c4fbd990f8a71b60c324

Wyświetl plik

@ -80,10 +80,12 @@ class ProcessingNode(models.Model):
"""
api_client = self.api_client()
result = api_client.task_info(uuid)
if result['uuid']:
if isinstance(result, dict) and 'uuid' in result:
return result
elif result['error']:
elif isinstance(result, dict) and 'error' in result:
raise ProcessingException(result['error'])
else:
raise ProcessingException("Unknown result from task info: {}".format(result))
def get_task_console_output(self, uuid, line):
"""
@ -106,6 +108,20 @@ class ProcessingNode(models.Model):
api_client = self.api_client()
return self.handle_generic_post_response(api_client.task_cancel(uuid))
def remove_task(self, uuid):
"""
Removes a task and deletes all of its assets
"""
api_client = self.api_client()
return self.handle_generic_post_response(api_client.task_remove(uuid))
def restart_task(self, uuid):
"""
Restarts a task that was previously canceled or that had failed to process
"""
api_client = self.api_client()
return self.handle_generic_post_response(api_client.task_restart(uuid))
@staticmethod
def handle_generic_post_response(result):
"""

Wyświetl plik

@ -97,7 +97,20 @@ class TestClientApi(TestCase):
self.assertRaises(ProcessingException, online_node.get_task_console_output, "wrong-uuid", 0)
# Can restart task
self.assertTrue(online_node.restart_task(uuid))
self.assertRaises(ProcessingException, online_node.restart_task, "wrong-uuid")
# Can cancel task
self.assertTrue(online_node.cancel_task(uuid))
self.assertRaises(ProcessingException, online_node.cancel_task, "wrong-uuid")
# Can delete task
self.assertTrue(online_node.remove_task(uuid))
self.assertRaises(ProcessingException, online_node.remove_task, "wrong-uuid")
# Cannot delete task again
self.assertRaises(ProcessingException, online_node.remove_task, uuid)
# Task has been deleted
self.assertRaises(ProcessingException, online_node.get_task_info, uuid)

Wyświetl plik

@ -212,6 +212,7 @@ REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': [
'rest_framework.filters.DjangoObjectPermissionsFilter',
'rest_framework.filters.DjangoFilterBackend',
'rest_framework.filters.OrderingFilter',
],
'PAGE_SIZE': 10,
}