Skip to content

Commit 67d4ea8

Browse files
authored
Admin jobs UI and tests (#5)
* WIP: Add first version of jobs table Does not autoupdate and the UI could be better. * ENH: Working version of the UI * ENH: Better working version of the table * ERR: Undo changes to nginx file * ERR: More ctrl-z * ENH: Improve column widths * TST: Add first round of tests * TST: Add more tests * TST: Add a check for the correct payload * ENH: Fix minor issues * ENH: Address @antgonza's review comments
1 parent 035b4d6 commit 67d4ea8

File tree

3 files changed

+226
-51
lines changed

3 files changed

+226
-51
lines changed

qiita_pet/handlers/admin_processing_job.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,29 @@ class AJAXAdminProcessingJobListing(AdminProcessingJobBaseClass):
5151
def get(self):
5252
self._check_access()
5353
echo = self.get_argument('sEcho')
54+
command_id = int(self.get_argument('commandId'))
5455

5556
jobs = []
5657
for ps in self._get_private_software():
5758
for cmd in ps.commands:
59+
if cmd.id != command_id:
60+
continue
61+
5862
for job in cmd.processing_jobs:
5963
msg = '' if job.status != 'error' else job.log.msg
64+
msg = msg.replace('\n', '</br>')
6065
outputs = []
6166
if job.status == 'success':
6267
outputs = [[k, v.id] for k, v in job.outputs.items()]
6368
validator_jobs = [v.id for v in job.validator_jobs]
69+
70+
if job.heartbeat is not None:
71+
heartbeat = job.heartbeat.strftime('%Y-%m-%d %H:%M:%S')
72+
else:
73+
heartbeat = 'N/A'
74+
6475
jobs.append([job.id, job.command.name, job.status, msg,
65-
outputs, validator_jobs, job.heartbeat,
76+
outputs, validator_jobs, heartbeat,
6677
job.parameters.values])
6778
results = {
6879
"sEcho": echo,

qiita_pet/templates/admin_processing_job.html

+165-50
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,131 @@
22

33
{% block head %}
44
<script type="text/javascript">
5+
var jobsTable;
6+
7+
// This modal view is used to display job details when a job has errored or
8+
// succeeded. Needs to be in the main scope because this function is called
9+
// from the "onclick" attribute of some elements.
10+
var populate_modal = function(title, main, artifact_id) {
11+
$('#job-output-modal').find('[name="modal-title"]').html(title);
12+
var $main = $('#job-output-modal').find('[name="modal-main-text"]');
13+
14+
if (artifact_id === undefined) {
15+
$main.html(unescape(decodeURIComponent(main)));
16+
}
17+
else {
18+
$.get('/artifact/' + artifact_id + '/summary/', function(data){
19+
$main.html(data);
20+
})
21+
.fail(function(object, status, error_msg) {
22+
$main.html("Error loading artifact information: " + status + " " + object.statusText);
23+
}
24+
);
25+
}
26+
}
27+
28+
// Initialize the previous jobs table once the document is ready
29+
$(function() {
30+
var renderJobArguments = function(data, type, row, meta) {
31+
// select how you want to render the data
32+
var lines = [], container;
33+
34+
for (key in data) {
35+
lines.push('<b>' + key + '</b>:');
36+
if (typeof data[key] === 'string'){
37+
lines.push(data[key]);
38+
}
39+
else if (typeof data[key] === 'object') {
40+
container = data[key];
41+
42+
// if we have all these attributes then render a named hyperlink to
43+
//download the data
44+
if (container['body'] !== undefined &&
45+
container['filename'] !== undefined &&
46+
container['content_type'] !== undefined) {
47+
lines.push('<a download="' + container['filename'] +
48+
'" href="data:text/plain;charset=utf-8,' +
49+
encodeURIComponent(container['body']) + '">' +
50+
container['filename'] + '</a>');
51+
}
52+
else {
53+
lines.push('Unsupported JavaScript Object')
54+
}
55+
}
56+
else {
57+
lines.push(data[key]);
58+
}
59+
}
60+
61+
return lines.join('<br>');
62+
}
63+
64+
var renderRichDescription = function(data, type, row, meta) {
65+
var out = [], status = row[2];
66+
var statusToClass = {
67+
'error': 'text-danger',
68+
'success': 'text-success',
69+
'queued': 'text-muted',
70+
'running': 'text-info'
71+
};
72+
73+
out.push('<b class="' + statusToClass[status] + '">' + status +
74+
'</b>: ');
75+
76+
if (status === 'running' || status === 'queued') {
77+
out.push(row[0] + ' <i>' + row[3] + '</i>')
78+
}
79+
else {
80+
// We write a callback attribute on the link to be able to display a
81+
// modal window with the additional success/error details. Note, the
82+
// quotation is a bit hard to follow but all in all it's ensuring any
83+
// text we get back from the server is properly formatted for
84+
// inclusion in an HTML attribute.
85+
var callback = 'populate_modal("' + row[0] + " completed on " +
86+
row[6] + '",';
87+
out.push('<a href="#" data-toggle="modal" data-target="#job-output-modal" onclick=\'' + callback)
88+
89+
if (status === 'success') {
90+
// job output is in the fourth element
91+
// We only expect one output with the artifact id in the 2 element
92+
out.push('undefined, ' + row[4][0][1] +')\'');
93+
out.push('>View results</a>')
94+
95+
}
96+
else if (status === 'error') {
97+
out.push('"' + escape(encodeURIComponent(row[3])) + '")\'');
98+
out.push('>View error log summary</a>')
99+
}
100+
}
101+
return out.join('');
102+
}
103+
104+
jobsTable = $('#private-jobs-table').DataTable({
105+
"order": [[2, "desc"]],
106+
"oLanguage": {
107+
"sZeroRecords": "There are no jobs available",
108+
"search": "Filter results",
109+
"loadingRecords": "Please wait - loading previous jobs ...",
110+
},
111+
"destroy": true,
112+
"autoWidth": false,
113+
"columnDefs": [
114+
{'width': '80%', 'targets': 0, 'data': 0, 'render': renderRichDescription},
115+
{'width': '10%', 'targets': 1, 'data': 7, 'render': renderJobArguments},
116+
{'width': '10%', 'targets': 2, 'data': 6},
117+
],
118+
});
119+
120+
// update the table every 10 seconds
121+
setInterval(function () {
122+
if (jobsTable.ajax.url()) {
123+
// user paging is not reset on reload
124+
jobsTable.ajax.reload(null, false);
125+
}
126+
}, 10000);
127+
128+
});
129+
5130
/**
6131
* This is a modified version of loadParameterGUI in networkVue.js
7132
**/
@@ -148,6 +273,9 @@
148273
})
149274
.always(function() {
150275
_helper_clean_objects();
276+
277+
// trigger a courtesy reload of the table to show the new job
278+
jobsTable.ajax.reload(null, false);
151279
});
152280
},
153281
error: function (object, status, error_msg) {
@@ -160,6 +288,7 @@
160288

161289
function generate_summary(option_selected){
162290
var command_id = option_selected.attr('value');
291+
var command_name = option_selected.text();
163292
$('#cmd-description').html(option_selected.attr('description'));
164293
$("#parameters").empty();
165294

@@ -189,57 +318,15 @@
189318
this.disabled = false;
190319
});
191320

192-
});
193-
}
194-
195-
// $('#private-jobs-table').dataTable({
196-
// "deferRender": true,
197-
// "iDisplayLength": 50,
198-
// "oLanguage": {
199-
// "sZeroRecords": "No studies belong to selected portal"
200-
// },
201-
// "columns": [
202-
// {"className": 'select', "data": null},
203-
// {"data": "x, y, z"},
204-
// {"data": "y, z"},
205-
// ],
206-
// // 'aoColumnDefs': [
207-
// // { 'mRender': render_checkbox, 'mData': 2, 'aTargets': [ 0 ] }
208-
// // ],
209-
// "ajax": {
210-
// "url": "/admin/processing_jobs/list?sEcho=" + Math.floor(Math.random()*1001),
211-
// "aoColumns": [
212-
// {"sSortDataType": "dom-checkbox", "bSearchable": false, "bSortable": false},
213-
// {"data": "x, y, z"},
214-
// {"data": "y, z"},
215-
// ],
216-
// "error": function(jqXHR, textStatus, ex) {
217-
// $("#add-button").prop('disabled', true);
218-
// $("#remove-button").prop('disabled', true);
219-
// $("#errors").append("<li id='comm-error'>Internal Server Error, please try again later</li>");
220-
// }
221-
// }
222-
// });
223-
224-
$('#private-jobs-table').dataTable( {
225-
"ajax": {
226-
"url": "/admin/processing_jobs/list?sEcho=" + Math.floor(Math.random()*1001),
227-
"type": "POST"
228-
}
229-
} );
321+
// load the jobs table for the selected command
322+
$('#previous-jobs').show();
323+
$('#previous-jobs-tin').html('Previous "' + command_name + '" jobs');
230324

325+
// update the table
326+
jobsTable.ajax.url("/admin/processing_jobs/list?sEcho=" + Math.floor(Math.random()*10001) + "&commandId=" + command_id).load();
231327

232-
function display_artifact_info(artifactId){
233-
$.get('/artifact/' + artifactId + '/summary/', function(data){
234-
$("#modal-body").html(data);
235-
})
236-
.fail(function(object, status, error_msg) {
237-
$("#modal-body").html("Error loading artifact information: " + status + " " + object.statusText);
238-
}
239-
);
328+
});
240329
}
241-
242-
243330
</script>
244331

245332
{% end %}
@@ -287,8 +374,36 @@ <h5 class="modal-title" id="title">Artifact Summary</h5>
287374
</div>
288375
</div>
289376

290-
<h3 class="gray-msg">Previous Jobs</h3>
291-
<table id="private-jobs-table" class="table table-bordered gray-msg"></table>
377+
<!-- Job Output Modal -->
378+
<div class="modal fade" tabindex="-1" role="dialog" aria-hidden="true" id="job-output-modal">
379+
<div class="modal-dialog modal-med">
380+
<div class="modal-content">
381+
<div class="modal-header">
382+
<h3 name="modal-title"></h3>
383+
</div>
384+
<div class="modal-body" name="modal-main-text">
385+
</div>
386+
<div class="modal-footer">
387+
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
388+
</div>
389+
</div>
390+
</div>
391+
</div>
292392

393+
<div id="previous-jobs" style="display: none;" class="row">
394+
<div class="col-sm-12">
395+
<h3 id="previous-jobs-tin" class="gray-msg">Previous Jobs</h3>
396+
<table id="private-jobs-table" class="table table-bordered gray-msg" style="width: 30px!">
397+
<thead>
398+
<tr>
399+
<th class="sorting sorting_asc" tabindex="0" aria-controls="private-jobs-table" rowspan="1" colspan="1" aria-sort="ascending" aria-label="Last Updated">Summary</th>
400+
<th class="sorting sorting_asc" tabindex="0" aria-controls="private-jobs-table" rowspan="1" colspan="1" aria-sort="ascending" aria-label="Last Updated">Arguments</th>
401+
<th class="sorting sorting_asc" tabindex="0" aria-controls="private-jobs-table" rowspan="1" colspan="1" aria-sort="ascending" aria-label="Last Updated">Last Updated</th>
402+
</thead>
403+
<tbody>
404+
</tbody>
405+
</table>
406+
</div>
407+
</div>
293408

294409
{% end %}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# -----------------------------------------------------------------------------
2+
# Copyright (c) 2014--, The Qiita Development Team.
3+
#
4+
# Distributed under the terms of the BSD 3-clause License.
5+
#
6+
# The full license is in the file LICENSE, distributed with this software.
7+
# -----------------------------------------------------------------------------
8+
9+
from unittest import main
10+
from json import loads
11+
12+
from mock import Mock
13+
14+
from qiita_db.user import User
15+
from qiita_pet.handlers.base_handlers import BaseHandler
16+
from qiita_pet.test.tornado_test_base import TestHandlerBase
17+
18+
19+
class BaseAdminTests(TestHandlerBase):
20+
def setUp(self):
21+
super().setUp()
22+
BaseHandler.get_current_user = Mock(return_value=User("admin@foo.bar"))
23+
24+
25+
class TestAdminProcessingJob(BaseAdminTests):
26+
def test_get(self):
27+
response = self.get('/admin/processing_jobs/')
28+
self.assertEqual(response.code, 200)
29+
self.assertIn("Available Commands", response.body.decode('ascii'))
30+
31+
32+
class TestAJAXAdminProcessingJobListing(BaseAdminTests):
33+
def test_get(self):
34+
response = self.get('/admin/processing_jobs/list?sEcho=3&commandId=1')
35+
self.assertEqual(response.code, 200)
36+
37+
exp = {'sEcho': '3', 'recordsTotal': 0, 'recordsFiltered': 0,
38+
'data': []}
39+
self.assertEqual(loads(response.body), exp)
40+
41+
def test_get_missing_argument(self):
42+
response = self.get('/admin/processing_jobs/list?sEcho=1')
43+
self.assertEqual(response.code, 400)
44+
self.assertIn("Missing argument commandId",
45+
response.body.decode('ascii'))
46+
47+
48+
if __name__ == "__main__":
49+
main()

0 commit comments

Comments
 (0)