2 * Copyright 2010 Google Inc.
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
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.
25 butter.removeClass('info').addClass('error').text(message);
27 butter.removeClass('error').addClass('info').text(message);
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() {
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));
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">');
60 for (var i = 0; i < endValue.length; i += SPLIT_LENGTH) {
61 moreSpan.append(endValue.substr(i, SPLIT_LENGTH));
62 moreSpan.append('<wbr/>');
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">')
70 toggle.click(function(e) {
72 if (moreSpan.is(':hidden')) {
73 betweenSpan.text(' ');
74 toggle.text('Collapse');
76 betweenSpan.text(betweenMoreText);
77 toggle.text('Expand');
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) {
94 response = $.parseJSON(data);
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.';
104 setButter(error, true);
110 // Retrieve the list of configs.
111 function listConfigs(resultFunc) {
114 url: 'command/list_configs',
116 error: function(request, textStatus) {
117 getResponseDataJson(textStatus);
119 success: function(data, textStatus, request) {
120 var response = getResponseDataJson(null, data);
122 resultFunc(response.configs);
128 // Return the list of job records and notifies the user the content
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');
138 url: 'command/list_jobs?cursor=' + cursor,
140 error: function(request, textStatus) {
141 getResponseDataJson(textStatus);
143 success: function(data, textStatus, request) {
144 var response = getResponseDataJson(null, data);
146 resultFunc(response.jobs, response.cursor);
148 window.scrollTo(0, 0);
150 hideButter(); // Hide the loading message.
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 + '"?')) {
166 url: 'command/cleanup_job',
167 data: {'mapreduce_id': mapreduce_id},
169 error: function(request, textStatus) {
170 getResponseDataJson(textStatus);
172 success: function(data, textStatus, request) {
173 var response = getResponseDataJson(null, data);
175 setButter(response.status);
176 if (!response.status.error) {
177 $('#row-' + mapreduce_id).remove();
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 + '"?')) {
193 url: 'command/abort_job',
194 data: {'mapreduce_id': mapreduce_id},
196 error: function(request, textStatus) {
197 getResponseDataJson(textStatus);
199 success: function(data, textStatus, request) {
200 var response = getResponseDataJson(null, data);
202 setButter(response.status);
208 // Retrieve the detail for a job.
209 function getJobDetail(jobId, resultFunc) {
212 url: 'command/get_job_detail',
214 data: {'mapreduce_id': jobId},
217 setButter('job ' + jobId + ' was not found.', true);
220 error: function(request, textStatus) {
221 getResponseDataJson(textStatus);
223 success: function(data, textStatus, request) {
224 var response = getResponseDataJson(null, data);
226 resultFunc(jobId, response);
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.
238 // Returns an array of the keys of an object in sorted order.
239 function getSortedKeys(obj) {
241 $.each(obj, function(key, value) {
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);
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;
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, ';
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) {
309 var body = $('#running-list > tbody');
312 if (!jobs || (jobs && jobs.length == 0)) {
313 $('<td colspan="8">').text('No job records found.').appendTo(body);
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));
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.
345 var control = $('<a href="">').text('Abort')
346 .click(function(event) {
347 abortJob(job.name, job.mapreduce_id);
348 event.stopPropagation();
351 row.append($('<td>').append(control));
353 var control = $('<a href="">').text('Cleanup')
354 .click(function(event) {
355 cleanUpJob(job.name, job.mapreduce_id);
356 event.stopPropagation();
359 row.append($('<td>').append(control));
364 // Set up the next/first page links.
365 $('#running-first-page')
369 listJobs(null, initJobOverview);
372 $('#running-next-page').unbind('click');
374 $('#running-next-page')
377 listJobs(cursor, initJobOverview);
381 $('#running-next-page').hide();
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;
409 $(matchedForm).show();
412 function runJobDone(name, error, data) {
413 var jobForm = getJobForm(name);
414 var response = getResponseDataJson(error, data);
416 setButter('Successfully started job "' + response['mapreduce_id'] + '"');
417 listJobs(null, initJobOverview);
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');
427 url: 'command/start_job',
428 data: jobForm.serialize(),
430 error: function(request, textStatus) {
431 runJobDone(name, textStatus);
433 success: function(data, textStatus, request) {
434 runJobDone(name, null, data);
439 function initJobLaunching(configs) {
440 $('#launch-control').empty();
441 if (!configs || (configs && configs.length == 0)) {
442 $('#launch-control').append('No job configurations found.');
446 // Set up job config forms.
447 $.each(configs, function(index, config) {
448 var jobForm = $('<form class="run-job">')
454 .appendTo('#launch-container');
456 // Fixed job config values.
457 $.each(FIXED_JOB_PARAMS, function(unused, key) {
458 var value = config[key];
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))
468 $('<input type="hidden">')
470 .attr('value', value)
474 // Add parameter values to the job form.
475 function addParameters(params, prefix) {
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.
489 if (value && value['human_name']) {
490 prettyKey = value['human_name'];
493 if (value && value['default_value']) {
494 value = value['default_value'];
498 .attr('for', paramId)
501 $('<span>').text(': ').appendTo(paramP);
502 $('<input type="text">')
504 .attr('name', prefix + key)
505 .attr('value', value)
507 paramP.appendTo(jobForm);
511 addParameters(config.params, 'params.');
512 addParameters(config.mapper_params, 'mapper_params.');
514 $('<input type="submit">')
515 .attr('value', 'Run')
519 // Setup job name drop-down.
520 var jobSelector = $('<select>')
521 .change(function(event) {
522 showRunJobConfig($(event.target).val());
524 .appendTo('#launch-control');
525 $.each(configs, function(index, config) {
527 .attr('name', config.name)
529 .appendTo(jobSelector);
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 *********/
543 function refreshJobDetail(jobId, detail) {
544 // Overview parameters.
545 var jobParams = $('#detail-params');
548 var status = (detail.active ? 'running' : detail.result_status) || 'unknown';
549 $('<li class="status-text">').text(status).appendTo(jobParams);
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);
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];
571 .append($('<span class="param-key">').text(getNiceParamKey(key)))
572 .append($('<span>').text(': '))
573 .append($('<span class="param-value">').text('' + value))
574 .appendTo(jobParams);
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);
585 .append($('<span class="user-param-key">').text(key))
586 .append($('<span>').text(': '))
588 .appendTo(jobParams);
593 var detailGraph = $('#detail-graph');
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]]);
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;
620 width: 80 + chartWidth,
621 height: 80 + chartHeight
623 var chart = new google.visualization.ColumnChart(detailGraph[0]);
624 chart.draw(data, options);
626 $('<div>').text(chartTitle).appendTo(detailGraph);
628 .attr('src', detail.chart_url)
629 .attr('width', detail.chart_width || 300)
631 .appendTo(detailGraph);
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;
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);
652 // Set up the mapper detail.
653 var mapperBody = $('#mapper-shard-status');
656 $.each(detail.shards, function(index, shard) {
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);
676 function initJobDetail(jobId, detail) {
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.
685 $('#overview-link').hide();
688 var control = $('<a href="">')
690 .click(function(event) {
691 abortJob(detail.name, jobId);
692 event.stopPropagation();
695 $('#job-control').append(control);
697 var control = $('<a href="">')
699 .click(function(event) {
700 cleanUpJob(detail.name, jobId);
701 event.stopPropagation();
704 $('#job-control').append(control);
707 refreshJobDetail(jobId, detail);
710 //////// Detail page entry point.
711 function initDetail() {
712 var jobId = getJobId();
714 setButter('Could not find job ID in query string.', true);
717 getJobDetail(jobId, initJobDetail);