App Engine Python SDK version 1.9.9
[gae.git] / python / google / appengine / ext / mapreduce / static / status.js
blob1b288c0636de78b41c8755dce5d07f951605bfa5
1 /*
2  * Copyright 2010 Google Inc.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
17 /********* Common functions *********/
19 // Sets the status butter, optionally indicating if it's an error message.
20 function setButter(message, error) {
21   var butter = getButterBar();
22   // Prevent flicker on butter update by hiding it first.
23   butter.hide();
24   if (error) {
25     butter.removeClass('info').addClass('error').text(message);
26   } else {
27     butter.removeClass('error').addClass('info').text(message);
28   }
29   butter.show();
30   $(document).scrollTop(0);
33 // Hides the butter bar.
34 function hideButter() {
35   getButterBar().hide();
38 // Fetches the butter bar dom element.
39 function getButterBar() {
40   return $('#butter');
44 // Renders a value with a collapsable twisty.
45 function renderCollapsableValue(value, container) {
46   var stringValue = $.toJSON(value);
47   var SPLIT_LENGTH = 200;
48   if (stringValue.length < SPLIT_LENGTH) {
49     container.append($('<span>').text(stringValue));
50     return;
51   }
53   var startValue = stringValue.substr(0, SPLIT_LENGTH);
54   var endValue = stringValue.substr(SPLIT_LENGTH);
56   // Split the end value with <wbr> tags so it looks nice; forced
57   // word wrapping never works right.
58   var moreSpan = $('<span class="value-disclosure-more">');
59   moreSpan.hide();
60   for (var i = 0; i < endValue.length; i += SPLIT_LENGTH) {
61     moreSpan.append(endValue.substr(i, SPLIT_LENGTH));
62     moreSpan.append('<wbr/>');
63   }
64   var betweenMoreText = '...(' + endValue.length + ' more) ';
65   var betweenSpan = $('<span class="value-disclosure-between">')
66       .text(betweenMoreText);
67   var toggle = $('<a class="value-disclosure-toggle">')
68       .text('Expand')
69       .attr('href', '');
70   toggle.click(function(e) {
71       e.preventDefault();
72       if (moreSpan.is(':hidden')) {
73         betweenSpan.text(' ');
74         toggle.text('Collapse');
75       } else {
76         betweenSpan.text(betweenMoreText);
77         toggle.text('Expand');
78       }
79       moreSpan.toggle();
80   });
81   container.append($('<span>').text(startValue));
82   container.append(moreSpan);
83   container.append(betweenSpan);
84   container.append(toggle);
87 // Given an AJAX error message (which is empty or null on success) and a
88 // data payload containing JSON, parses the data payload and returns the object.
89 // Server-side errors and AJAX errors will be brought to the user's attention
90 // if present in the response object
91 function getResponseDataJson(error, data) {
92   var response = null;
93   try {
94     response = $.parseJSON(data);
95   } catch (e) {
96     error = '' + e;
97   }
98   if (response && response.error_class) {
99     error = response.error_class + ': ' + response.error_message;
100   } else if (!response) {
101     error = 'Could not parse response JSON data.';
102   }
103   if (error) {
104     setButter(error, true);
105     return null;
106   }
107   return response;
110 // Retrieve the list of configs.
111 function listConfigs(resultFunc) {
112   $.ajax({
113     type: 'GET',
114     url: 'command/list_configs',
115     dataType: 'text',
116     error: function(request, textStatus) {
117       getResponseDataJson(textStatus);
118     },
119     success: function(data, textStatus, request) {
120       var response = getResponseDataJson(null, data);
121       if (response) {
122         resultFunc(response.configs);
123       }
124     }
125   });
128 // Return the list of job records and notifies the user the content
129 // is being fetched.
130 function listJobs(cursor, resultFunc) {
131   // If the user is paging then they scrolled down so let's
132   // help them by scrolling the window back to the top.
133   var jumpToTop = !!cursor;
134   cursor = cursor ? cursor : '';
135   setButter('Loading');
136   $.ajax({
137     type: 'GET',
138     url: 'command/list_jobs?cursor=' + cursor,
139     dataType: 'text',
140     error: function(request, textStatus) {
141       getResponseDataJson(textStatus);
142     },
143     success: function(data, textStatus, request) {
144       var response = getResponseDataJson(null, data);
145       if (response) {
146         resultFunc(response.jobs, response.cursor);
147         if (jumpToTop) {
148           window.scrollTo(0, 0);
149         }
150         hideButter();  // Hide the loading message.
151       }
152     }
153   });
156 // Cleans up a job with the given name and ID, updates butter with status.
157 function cleanUpJob(name, mapreduce_id) {
158   if (!confirm('Clean up job "' + name +
159                '" with ID "' + mapreduce_id + '"?')) {
160     return;
161   }
163   $.ajax({
164     async: false,
165     type: 'POST',
166     url: 'command/cleanup_job',
167     data: {'mapreduce_id': mapreduce_id},
168     dataType: 'text',
169     error: function(request, textStatus) {
170       getResponseDataJson(textStatus);
171     },
172     success: function(data, textStatus, request) {
173       var response = getResponseDataJson(null, data);
174       if (response) {
175         setButter(response.status);
176         if (!response.status.error) {
177           $('#row-' + mapreduce_id).remove();
178         }
179       }
180     }
181   });
184 // Aborts the job with the given ID, updates butter with status.
185 function abortJob(name, mapreduce_id) {
186   if (!confirm('Abort job "' + name + '" with ID "' + mapreduce_id + '"?')) {
187     return;
188   }
190   $.ajax({
191     async: false,
192     type: 'POST',
193     url: 'command/abort_job',
194     data: {'mapreduce_id': mapreduce_id},
195     dataType: 'text',
196     error: function(request, textStatus) {
197       getResponseDataJson(textStatus);
198     },
199     success: function(data, textStatus, request) {
200       var response = getResponseDataJson(null, data);
201       if (response) {
202         setButter(response.status);
203       }
204     }
205   });
208 // Retrieve the detail for a job.
209 function getJobDetail(jobId, resultFunc) {
210   $.ajax({
211     type: 'GET',
212     url: 'command/get_job_detail',
213     dataType: 'text',
214     data: {'mapreduce_id': jobId},
215     statusCode: {
216       404: function() {
217         setButter('job ' + jobId + ' was not found.', true);
218       }
219     },
220     error: function(request, textStatus) {
221       getResponseDataJson(textStatus);
222     },
223     success: function(data, textStatus, request) {
224       var response = getResponseDataJson(null, data);
225       if (response) {
226         resultFunc(jobId, response);
227       }
228     }
229   });
232 // Turns a key into a nicely scrubbed parameter name.
233 function getNiceParamKey(key) {
234   // TODO: Figure out if we want to do this at all.
235   return key;
238 // Returns an array of the keys of an object in sorted order.
239 function getSortedKeys(obj) {
240   var keys = [];
241   $.each(obj, function(key, value) {
242     keys.push(key);
243   });
244   keys.sort();
245   return keys;
248 // Convert milliseconds since the epoch to an ISO8601 datestring.
249 // Consider using new Date().toISOString() instead (J.S 1.8+)
250 function getIso8601String(timestamp_ms) {
251   var time = new Date();
252   time.setTime(timestamp_ms);
253   return '' +
254       time.getUTCFullYear() + '-' +
255       leftPadNumber(time.getUTCMonth() + 1, 2, '0') + '-' +
256       leftPadNumber(time.getUTCDate(), 2, '0') + 'T' +
257       leftPadNumber(time.getUTCHours(), 2, '0') + ':' +
258       leftPadNumber(time.getUTCMinutes(), 2, '0') + ':' +
259       leftPadNumber(time.getUTCSeconds(), 2, '0') + 'Z';
262 function leftPadNumber(number, minSize, paddingChar) {
263   var stringified = '' + number;
264   if (stringified.length < minSize) {
265     for (var i = 0; i < (minSize - stringified.length); ++i) {
266       stringified = paddingChar + stringified;
267     }
268   }
269   return stringified;
272 // Get locale time string for time portion of job runtime. Specially
273 // handle number of days running as a prefix.
274 function getElapsedTimeString(start_timestamp_ms, updated_timestamp_ms) {
275   var updatedDiff = updated_timestamp_ms - start_timestamp_ms;
276   var updatedDays = Math.floor(updatedDiff / 86400000.0);
277   updatedDiff -= (updatedDays * 86400000.0);
278   var updatedHours = Math.floor(updatedDiff / 3600000.0);
279   updatedDiff -= (updatedHours * 3600000.0);
280   var updatedMinutes = Math.floor(updatedDiff / 60000.0);
281   updatedDiff -= (updatedMinutes * 60000.0);
282   var updatedSeconds = Math.floor(updatedDiff / 1000.0);
284   var updatedString = '';
285   if (updatedDays == 1) {
286     updatedString = '1 day, ';
287   } else if (updatedDays > 1) {
288     updatedString = '' + updatedDays + ' days, ';
289   }
290   updatedString +=
291       leftPadNumber(updatedHours, 2, '0') + ':' +
292       leftPadNumber(updatedMinutes, 2, '0') + ':' +
293       leftPadNumber(updatedSeconds, 2, '0');
295   return updatedString;
298 // Retrieves the mapreduce_id from the query string.
299 function getJobId() {
300   var jobId = $.url().param('mapreduce_id');
301   return jobId == null ? '' : jobId;
304 /********* Specific to overview status page *********/
306 //////// Running jobs overview.
307 function initJobOverview(jobs, cursor) {
308   // Empty body.
309   var body = $('#running-list > tbody');
310   body.empty();
312   if (!jobs || (jobs && jobs.length == 0)) {
313     $('<td colspan="8">').text('No job records found.').appendTo(body);
314     return;
315   }
317   // Show header.
318   $('#running-list > thead').show();
320   // Populate the table.
321   $.each(jobs, function(index, job) {
322     var row = $('<tr id="row-' + job.mapreduce_id + '">');
324     var status = (job.active ? 'running' : job.result_status) || 'unknown';
325     row.append($('<td class="status-text">').text(status));
327     $('<td>').append(
328       $('<a>')
329         .attr('href', 'detail?mapreduce_id=' + job.mapreduce_id)
330         .text('Detail')).appendTo(row);
332     row.append($('<td>').text(job.mapreduce_id))
333       .append($('<td>').text(job.name));
335     var activity = '' + job.active_shards + ' / ' + job.shards + ' shards';
336     row.append($('<td>').text(activity))
338     row.append($('<td>').text(getIso8601String(job.start_timestamp_ms)));
340     row.append($('<td>').text(getElapsedTimeString(
341         job.start_timestamp_ms, job.updated_timestamp_ms)));
343     // Controller links for abort, cleanup, etc.
344     if (job.active) {
345       var control = $('<a href="">').text('Abort')
346         .click(function(event) {
347           abortJob(job.name, job.mapreduce_id);
348           event.stopPropagation();
349           return false;
350         });
351       row.append($('<td>').append(control));
352     } else {
353       var control = $('<a href="">').text('Cleanup')
354         .click(function(event) {
355           cleanUpJob(job.name, job.mapreduce_id);
356           event.stopPropagation();
357           return false;
358         });
359       row.append($('<td>').append(control));
360     }
361     row.appendTo(body);
362   });
364   // Set up the next/first page links.
365   $('#running-first-page')
366     .show()
367     .unbind('click')
368     .click(function() {
369     listJobs(null, initJobOverview);
370     return false;
371   });
372   $('#running-next-page').unbind('click');
373   if (cursor) {
374     $('#running-next-page')
375       .show()
376       .click(function() {
377         listJobs(cursor, initJobOverview);
378         return false;
379       });
380   } else {
381     $('#running-next-page').hide();
382   }
383   $('#running-list > tfoot').show();
386 //////// Launching jobs.
388 // TODO(aizatsky): new job parameters shouldn't be hidden by default.
389 var FIXED_JOB_PARAMS = [
390     'name', 'mapper_input_reader', 'mapper_handler', 'mapper_params_validator',
391     'mapper_output_writer'
394 var EDITABLE_JOB_PARAMS = ['shard_count', 'processing_rate', 'queue_name'];
396 function getJobForm(name) {
397   return $('form.run-job > input[name="name"][value="' + name + '"]').parent();
400 function showRunJobConfig(name) {
401   var matchedForm = null;
402   $.each($('form.run-job'), function(index, jobForm) {
403     if ($(jobForm).find('input[name="name"]').val() == name) {
404       matchedForm = jobForm;
405     } else {
406       $(jobForm).hide();
407     }
408   });
409   $(matchedForm).show();
412 function runJobDone(name, error, data) {
413   var jobForm = getJobForm(name);
414   var response = getResponseDataJson(error, data);
415   if (response) {
416     setButter('Successfully started job "' + response['mapreduce_id'] + '"');
417     listJobs(null, initJobOverview);
418   }
419   jobForm.find('input[type="submit"]').attr('disabled', null);
422 function runJob(name) {
423   var jobForm = getJobForm(name);
424   jobForm.find('input[type="submit"]').attr('disabled', 'disabled');
425   $.ajax({
426     type: 'POST',
427     url: 'command/start_job',
428     data: jobForm.serialize(),
429     dataType: 'text',
430     error: function(request, textStatus) {
431       runJobDone(name, textStatus);
432     },
433     success: function(data, textStatus, request) {
434       runJobDone(name, null, data);
435     }
436   });
439 function initJobLaunching(configs) {
440   $('#launch-control').empty();
441   if (!configs || (configs && configs.length == 0)) {
442     $('#launch-control').append('No job configurations found.');
443     return;
444   }
446   // Set up job config forms.
447   $.each(configs, function(index, config) {
448     var jobForm = $('<form class="run-job">')
449       .submit(function() {
450         runJob(config.name);
451         return false;
452       })
453       .hide()
454       .appendTo('#launch-container');
456     // Fixed job config values.
457     $.each(FIXED_JOB_PARAMS, function(unused, key) {
458       var value = config[key];
459       if (!value) return;
460       if (key != 'name') {
461         // Name is up in the page title so doesn't need to be shown again.
462         $('<p class="job-static-param">')
463           .append($('<span class="param-key">').text(getNiceParamKey(key)))
464           .append($('<span>').text(': '))
465           .append($('<span class="param-value">').text(value))
466           .appendTo(jobForm);
467       }
468       $('<input type="hidden">')
469         .attr('name', key)
470         .attr('value', value)
471         .appendTo(jobForm);
472     });
474     // Add parameter values to the job form.
475     function addParameters(params, prefix) {
476       if (!params) {
477         return;
478       }
480       var sortedParams = getSortedKeys(params);
481       $.each(sortedParams, function(index, key) {
482         var value = params[key];
483         var paramId = 'job-' + prefix + key + '-param';
484         var paramP = $('<p class="editable-input">');
486         // Deal with the case in which the value is an object rather than
487         // just the default value string.
488         var prettyKey = key;
489         if (value && value['human_name']) {
490           prettyKey = value['human_name'];
491         }
493         if (value && value['default_value']) {
494           value = value['default_value'];
495         }
497         $('<label>')
498           .attr('for', paramId)
499           .text(prettyKey)
500           .appendTo(paramP);
501         $('<span>').text(': ').appendTo(paramP);
502         $('<input type="text">')
503           .attr('id', paramId)
504           .attr('name', prefix + key)
505           .attr('value', value)
506           .appendTo(paramP);
507         paramP.appendTo(jobForm);
508       });
509     }
511     addParameters(config.params, 'params.');
512     addParameters(config.mapper_params, 'mapper_params.');
514     $('<input type="submit">')
515       .attr('value', 'Run')
516       .appendTo(jobForm);
517   });
519   // Setup job name drop-down.
520   var jobSelector = $('<select>')
521       .change(function(event) {
522         showRunJobConfig($(event.target).val());
523       })
524       .appendTo('#launch-control');
525   $.each(configs, function(index, config) {
526     $('<option>')
527       .attr('name', config.name)
528       .text(config.name)
529       .appendTo(jobSelector);
530   });
531   showRunJobConfig(jobSelector.val());
534 //////// Status page entry point.
535 function initStatus() {
536   listConfigs(initJobLaunching);
537   listJobs(null, initJobOverview);
540 /********* Specific to detail status page *********/
542 //////// Job detail.
543 function refreshJobDetail(jobId, detail) {
544   // Overview parameters.
545   var jobParams = $('#detail-params');
546   jobParams.empty();
548   var status = (detail.active ? 'running' : detail.result_status) || 'unknown';
549   $('<li class="status-text">').text(status).appendTo(jobParams);
551   $('<li>')
552     .append($('<span class="param-key">').text('Elapsed time'))
553     .append($('<span>').text(': '))
554     .append($('<span class="param-value">').text(getElapsedTimeString(
555           detail.start_timestamp_ms, detail.updated_timestamp_ms)))
556     .appendTo(jobParams);
557   $('<li>')
558     .append($('<span class="param-key">').text('Start time'))
559     .append($('<span>').text(': '))
560     .append($('<span class="param-value">').text(getIso8601String(
561           detail.start_timestamp_ms)))
562     .appendTo(jobParams);
564   $.each(FIXED_JOB_PARAMS, function(index, key) {
565     // Skip some parameters or those with no values.
566     if (key == 'name') return;
567     var value = detail[key];
568     if (!value) return;
570     $('<li>')
571       .append($('<span class="param-key">').text(getNiceParamKey(key)))
572       .append($('<span>').text(': '))
573       .append($('<span class="param-value">').text('' + value))
574       .appendTo(jobParams);
575   });
577   // User-supplied parameters.
578   if (detail.mapper_spec.mapper_params) {
579     var sortedKeys = getSortedKeys(detail.mapper_spec.mapper_params);
580     $.each(sortedKeys, function(index, key) {
581       var value = detail.mapper_spec.mapper_params[key];
582       var valueSpan = $('<span class="param-value">');
583       renderCollapsableValue(value, valueSpan);
584       $('<li>')
585         .append($('<span class="user-param-key">').text(key))
586         .append($('<span>').text(': '))
587         .append(valueSpan)
588         .appendTo(jobParams);
589     });
590   }
592   // Graph image.
593   var detailGraph = $('#detail-graph');
594   detailGraph.empty();
595   var chartTitle = 'Processed items per shard';
596   if (detail.chart_data) {
597     var data = new google.visualization.DataTable();
598     data.addColumn('string', 'Shard');
599     data.addColumn('number', 'Count');
600     var shards = detail.chart_data.length;
601     for (var i = 0; i < shards; i++) {
602       data.addRow([i.toString(), detail.chart_data[i]]);
603     }
604     var log2Shards = Math.log(shards) / Math.log(2);
605     var chartWidth = Math.max(Math.max(300, shards * 2), 100 * log2Shards);
606     var chartHeight = 200;
607     var options = {
608         legend: 'none',
609         bar: {
610             groupWidth: '100%'
611         },
612         vAxis: {
613           minValue: 0
614         },
615         title: chartTitle,
616         chartArea: {
617           width: chartWidth,
618           height: chartHeight
619         },
620         width: 80 + chartWidth,
621         height: 80 + chartHeight
622     };
623     var chart = new google.visualization.ColumnChart(detailGraph[0]);
624     chart.draw(data, options);
625   } else {
626     $('<div>').text(chartTitle).appendTo(detailGraph);
627     $('<img>')
628       .attr('src', detail.chart_url)
629       .attr('width', detail.chart_width || 300)
630       .attr('height', 200)
631       .appendTo(detailGraph);
632   }
634   // Aggregated counters.
635   var aggregatedCounters = $('#aggregated-counters');
636   aggregatedCounters.empty();
637   var runtimeMs = detail.updated_timestamp_ms - detail.start_timestamp_ms;
638   var sortedCounters = getSortedKeys(detail.counters);
639   $.each(sortedCounters, function(index, key) {
640     var value = detail.counters[key];
641     // Round to 2 decimal places.
642     var avgRate = Math.round(100.0 * value / (runtimeMs / 1000.0)) / 100.0;
643     $('<li>')
644       .append($('<span class="param-key">').html(getNiceParamKey(key)))
645       .append($('<span>').text(': '))
646       .append($('<span class="param-value">').html(value))
647       .append($('<span>').text(' '))
648       .append($('<span class="param-aux">').text('(' + avgRate + '/sec avg.)'))
649       .appendTo(aggregatedCounters);
650   });
652   // Set up the mapper detail.
653   var mapperBody = $('#mapper-shard-status');
654   mapperBody.empty();
656   $.each(detail.shards, function(index, shard) {
657     var row = $('<tr>');
659     row.append($('<td>').text(shard.shard_number));
661     var status = (shard.active ? 'running' : shard.result_status) || 'unknown';
662     row.append($('<td>').text(status));
664     // TODO: Set colgroup width for shard description.
665     row.append($('<td>').text(shard.shard_description));
667     row.append($('<td>').text(shard.last_work_item || 'Unknown'));
669     row.append($('<td>').text(getElapsedTimeString(
670         detail.start_timestamp_ms, shard.updated_timestamp_ms)));
672     row.appendTo(mapperBody);
673   });
676 function initJobDetail(jobId, detail) {
677   // Set titles.
678   var title = 'Status for "' + detail.name + '"-- Job #' + jobId;
679   $('head > title').text(title);
680   $('#detail-page-title').text(detail.name);
681   $('#detail-page-undertext').text('Job #' + jobId);
683   // Set control buttons.
684   if (self != top) {
685     $('#overview-link').hide();
686   }
687   if (detail.active) {
688     var control = $('<a href="">')
689       .text('Abort Job')
690       .click(function(event) {
691         abortJob(detail.name, jobId);
692         event.stopPropagation();
693         return false;
694       });
695     $('#job-control').append(control);
696   } else {
697     var control = $('<a href="">')
698       .text('Cleanup Job')
699       .click(function(event) {
700         cleanUpJob(detail.name, jobId);
701         event.stopPropagation();
702         return false;
703       });
704     $('#job-control').append(control);
705   }
707   refreshJobDetail(jobId, detail);
710 //////// Detail page entry point.
711 function initDetail() {
712   var jobId = getJobId();
713   if (!jobId) {
714     setButter('Could not find job ID in query string.', true);
715     return;
716   }
717   getJobDetail(jobId, initJobDetail);