blob: 21a942be42d8a168377b17bb8303d41613b4d944 [file] [log] [blame]
/*
* Copyright 2010 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/********* Common functions *********/
// Sets the status butter, optionally indicating if it's an error message.
function setButter(message, error) {
var butter = getButterBar();
// Prevent flicker on butter update by hiding it first.
butter.hide();
if (error) {
butter.removeClass('info').addClass('error').text(message);
} else {
butter.removeClass('error').addClass('info').text(message);
}
butter.show();
$(document).scrollTop(0);
}
// Hides the butter bar.
function hideButter() {
getButterBar().hide();
}
// Fetches the butter bar dom element.
function getButterBar() {
return $('#butter');
}
// Renders a value with a collapsable twisty.
function renderCollapsableValue(value, container) {
var stringValue = $.toJSON(value);
var SPLIT_LENGTH = 200;
if (stringValue.length < SPLIT_LENGTH) {
container.append($('<span>').text(stringValue));
return;
}
var startValue = stringValue.substr(0, SPLIT_LENGTH);
var endValue = stringValue.substr(SPLIT_LENGTH);
// Split the end value with <wbr> tags so it looks nice; forced
// word wrapping never works right.
var moreSpan = $('<span class="value-disclosure-more">');
moreSpan.hide();
for (var i = 0; i < endValue.length; i += SPLIT_LENGTH) {
moreSpan.append(endValue.substr(i, SPLIT_LENGTH));
moreSpan.append('<wbr/>');
}
var betweenMoreText = '...(' + endValue.length + ' more) ';
var betweenSpan = $('<span class="value-disclosure-between">')
.text(betweenMoreText);
var toggle = $('<a class="value-disclosure-toggle">')
.text('Expand')
.attr('href', '');
toggle.click(function(e) {
e.preventDefault();
if (moreSpan.is(':hidden')) {
betweenSpan.text(' ');
toggle.text('Collapse');
} else {
betweenSpan.text(betweenMoreText);
toggle.text('Expand');
}
moreSpan.toggle();
});
container.append($('<span>').text(startValue));
container.append(moreSpan);
container.append(betweenSpan);
container.append(toggle);
}
// Given an AJAX error message (which is empty or null on success) and a
// data payload containing JSON, parses the data payload and returns the object.
// Server-side errors and AJAX errors will be brought to the user's attention
// if present in the response object
function getResponseDataJson(error, data) {
var response = null;
try {
response = $.parseJSON(data);
} catch (e) {
error = '' + e;
}
if (response && response.error_class) {
error = response.error_class + ': ' + response.error_message;
} else if (!response) {
error = 'Could not parse response JSON data.';
}
if (error) {
setButter(error, true);
return null;
}
return response;
}
// Retrieve the list of configs.
function listConfigs(resultFunc) {
$.ajax({
type: 'GET',
url: 'command/list_configs',
dataType: 'text',
error: function(request, textStatus) {
getResponseDataJson(textStatus);
},
success: function(data, textStatus, request) {
var response = getResponseDataJson(null, data);
if (response) {
resultFunc(response.configs);
}
}
});
}
// Return the list of job records and notifies the user the content
// is being fetched.
function listJobs(cursor, resultFunc) {
// If the user is paging then they scrolled down so let's
// help them by scrolling the window back to the top.
var jumpToTop = !!cursor;
cursor = cursor ? cursor : '';
setButter('Loading');
$.ajax({
type: 'GET',
url: 'command/list_jobs?cursor=' + cursor,
dataType: 'text',
error: function(request, textStatus) {
getResponseDataJson(textStatus);
},
success: function(data, textStatus, request) {
var response = getResponseDataJson(null, data);
if (response) {
resultFunc(response.jobs, response.cursor);
if (jumpToTop) {
window.scrollTo(0, 0);
}
hideButter(); // Hide the loading message.
}
}
});
}
// Cleans up a job with the given name and ID, updates butter with status.
function cleanUpJob(name, mapreduce_id) {
if (!confirm('Clean up job "' + name +
'" with ID "' + mapreduce_id + '"?')) {
return;
}
$.ajax({
async: false,
type: 'POST',
url: 'command/cleanup_job',
data: {'mapreduce_id': mapreduce_id},
dataType: 'text',
error: function(request, textStatus) {
getResponseDataJson(textStatus);
},
success: function(data, textStatus, request) {
var response = getResponseDataJson(null, data);
if (response) {
setButter(response.status);
if (!response.status.error) {
$('#row-' + mapreduce_id).remove();
}
}
}
});
}
// Aborts the job with the given ID, updates butter with status.
function abortJob(name, mapreduce_id) {
if (!confirm('Abort job "' + name + '" with ID "' + mapreduce_id + '"?')) {
return;
}
$.ajax({
async: false,
type: 'POST',
url: 'command/abort_job',
data: {'mapreduce_id': mapreduce_id},
dataType: 'text',
error: function(request, textStatus) {
getResponseDataJson(textStatus);
},
success: function(data, textStatus, request) {
var response = getResponseDataJson(null, data);
if (response) {
setButter(response.status);
}
}
});
}
// Retrieve the detail for a job.
function getJobDetail(jobId, resultFunc) {
$.ajax({
type: 'GET',
url: 'command/get_job_detail',
dataType: 'text',
data: {'mapreduce_id': jobId},
statusCode: {
404: function() {
setButter('job ' + jobId + ' was not found.', true);
}
},
error: function(request, textStatus) {
getResponseDataJson(textStatus);
},
success: function(data, textStatus, request) {
var response = getResponseDataJson(null, data);
if (response) {
resultFunc(jobId, response);
}
}
});
}
// Turns a key into a nicely scrubbed parameter name.
function getNiceParamKey(key) {
// TODO: Figure out if we want to do this at all.
return key;
}
// Returns an array of the keys of an object in sorted order.
function getSortedKeys(obj) {
var keys = [];
$.each(obj, function(key, value) {
keys.push(key);
});
keys.sort();
return keys;
}
// Gets a local datestring from a UNIX timestamp in milliseconds.
function getLocalTimestring(timestamp_ms) {
var when = new Date();
when.setTime(timestamp_ms);
return when.toLocaleString();
}
function leftPadNumber(number, minSize, paddingChar) {
var stringified = '' + number;
if (stringified.length < minSize) {
for (var i = 0; i < (minSize - stringified.length); ++i) {
stringified = paddingChar + stringified;
}
}
return stringified;
}
// Get locale time string for time portion of job runtime. Specially
// handle number of days running as a prefix.
function getElapsedTimeString(start_timestamp_ms, updated_timestamp_ms) {
var updatedDiff = updated_timestamp_ms - start_timestamp_ms;
var updatedDays = Math.floor(updatedDiff / 86400000.0);
updatedDiff -= (updatedDays * 86400000.0);
var updatedHours = Math.floor(updatedDiff / 3600000.0);
updatedDiff -= (updatedHours * 3600000.0);
var updatedMinutes = Math.floor(updatedDiff / 60000.0);
updatedDiff -= (updatedMinutes * 60000.0);
var updatedSeconds = Math.floor(updatedDiff / 1000.0);
var updatedString = '';
if (updatedDays == 1) {
updatedString = '1 day, ';
} else if (updatedDays > 1) {
updatedString = '' + updatedDays + ' days, ';
}
updatedString +=
leftPadNumber(updatedHours, 2, '0') + ':' +
leftPadNumber(updatedMinutes, 2, '0') + ':' +
leftPadNumber(updatedSeconds, 2, '0');
return updatedString;
}
// Retrieves the mapreduce_id from the query string.
function getJobId() {
var jobId = $.url().param('mapreduce_id');
return jobId == null ? '' : jobId;
}
/********* Specific to overview status page *********/
//////// Running jobs overview.
function initJobOverview(jobs, cursor) {
// Empty body.
var body = $('#running-list > tbody');
body.empty();
if (!jobs || (jobs && jobs.length == 0)) {
$('<td colspan="8">').text('No job records found.').appendTo(body);
return;
}
// Show header.
$('#running-list > thead').show();
// Populate the table.
$.each(jobs, function(index, job) {
var row = $('<tr id="row-' + job.mapreduce_id + '">');
var status = (job.active ? 'running' : job.result_status) || 'unknown';
row.append($('<td class="status-text">').text(status));
$('<td>').append(
$('<a>')
.attr('href', 'detail?mapreduce_id=' + job.mapreduce_id)
.text('Detail')).appendTo(row);
row.append($('<td>').text(job.mapreduce_id))
.append($('<td>').text(job.name));
var activity = '' + job.active_shards + ' / ' + job.shards + ' shards';
row.append($('<td>').text(activity))
row.append($('<td>').text(getLocalTimestring(job.start_timestamp_ms)));
row.append($('<td>').text(getElapsedTimeString(
job.start_timestamp_ms, job.updated_timestamp_ms)));
// Controller links for abort, cleanup, etc.
if (job.active) {
var control = $('<a href="">').text('Abort')
.click(function(event) {
abortJob(job.name, job.mapreduce_id);
event.stopPropagation();
return false;
});
row.append($('<td>').append(control));
} else {
var control = $('<a href="">').text('Cleanup')
.click(function(event) {
cleanUpJob(job.name, job.mapreduce_id);
event.stopPropagation();
return false;
});
row.append($('<td>').append(control));
}
row.appendTo(body);
});
// Set up the next/first page links.
$('#running-first-page')
.show()
.unbind('click')
.click(function() {
listJobs(null, initJobOverview);
return false;
});
$('#running-next-page').unbind('click');
if (cursor) {
$('#running-next-page')
.show()
.click(function() {
listJobs(cursor, initJobOverview);
return false;
});
} else {
$('#running-next-page').hide();
}
$('#running-list > tfoot').show();
}
//////// Launching jobs.
// TODO(aizatsky): new job parameters shouldn't be hidden by default.
var FIXED_JOB_PARAMS = [
'name', 'mapper_input_reader', 'mapper_handler', 'mapper_params_validator',
'mapper_output_writer'
];
var EDITABLE_JOB_PARAMS = ['shard_count', 'processing_rate', 'queue_name'];
function getJobForm(name) {
return $('form.run-job > input[name="name"][value="' + name + '"]').parent();
}
function showRunJobConfig(name) {
var matchedForm = null;
$.each($('form.run-job'), function(index, jobForm) {
if ($(jobForm).find('input[name="name"]').val() == name) {
matchedForm = jobForm;
} else {
$(jobForm).hide();
}
});
$(matchedForm).show();
}
function runJobDone(name, error, data) {
var jobForm = getJobForm(name);
var response = getResponseDataJson(error, data);
if (response) {
setButter('Successfully started job "' + response['mapreduce_id'] + '"');
listJobs(null, initJobOverview);
}
jobForm.find('input[type="submit"]').attr('disabled', null);
}
function runJob(name) {
var jobForm = getJobForm(name);
jobForm.find('input[type="submit"]').attr('disabled', 'disabled');
$.ajax({
type: 'POST',
url: 'command/start_job',
data: jobForm.serialize(),
dataType: 'text',
error: function(request, textStatus) {
runJobDone(name, textStatus);
},
success: function(data, textStatus, request) {
runJobDone(name, null, data);
}
});
}
function initJobLaunching(configs) {
$('#launch-control').empty();
if (!configs || (configs && configs.length == 0)) {
$('#launch-control').append('No job configurations found.');
return;
}
// Set up job config forms.
$.each(configs, function(index, config) {
var jobForm = $('<form class="run-job">')
.submit(function() {
runJob(config.name);
return false;
})
.hide()
.appendTo('#launch-container');
// Fixed job config values.
$.each(FIXED_JOB_PARAMS, function(unused, key) {
var value = config[key];
if (!value) return;
if (key != 'name') {
// Name is up in the page title so doesn't need to be shown again.
$('<p class="job-static-param">')
.append($('<span class="param-key">').text(getNiceParamKey(key)))
.append($('<span>').text(': '))
.append($('<span class="param-value">').text(value))
.appendTo(jobForm);
}
$('<input type="hidden">')
.attr('name', key)
.attr('value', value)
.appendTo(jobForm);
});
// Add parameter values to the job form.
function addParameters(params, prefix) {
if (!params) {
return;
}
var sortedParams = getSortedKeys(params);
$.each(sortedParams, function(index, key) {
var value = params[key];
var paramId = 'job-' + prefix + key + '-param';
var paramP = $('<p class="editable-input">');
// Deal with the case in which the value is an object rather than
// just the default value string.
var prettyKey = key;
if (value && value['human_name']) {
prettyKey = value['human_name'];
}
if (value && value['default_value']) {
value = value['default_value'];
}
$('<label>')
.attr('for', paramId)
.text(prettyKey)
.appendTo(paramP);
$('<span>').text(': ').appendTo(paramP);
$('<input type="text">')
.attr('id', paramId)
.attr('name', prefix + key)
.attr('value', value)
.appendTo(paramP);
paramP.appendTo(jobForm);
});
}
addParameters(config.params, 'params.');
addParameters(config.mapper_params, 'mapper_params.');
$('<input type="submit">')
.attr('value', 'Run')
.appendTo(jobForm);
});
// Setup job name drop-down.
var jobSelector = $('<select>')
.change(function(event) {
showRunJobConfig($(event.target).val());
})
.appendTo('#launch-control');
$.each(configs, function(index, config) {
$('<option>')
.attr('name', config.name)
.text(config.name)
.appendTo(jobSelector);
});
showRunJobConfig(jobSelector.val());
}
//////// Status page entry point.
function initStatus() {
listConfigs(initJobLaunching);
listJobs(null, initJobOverview);
}
/********* Specific to detail status page *********/
//////// Job detail.
function refreshJobDetail(jobId, detail) {
// Overview parameters.
var jobParams = $('#detail-params');
jobParams.empty();
var status = (detail.active ? 'running' : detail.result_status) || 'unknown';
$('<li class="status-text">').text(status).appendTo(jobParams);
$('<li>')
.append($('<span class="param-key">').text('Elapsed time'))
.append($('<span>').text(': '))
.append($('<span class="param-value">').text(getElapsedTimeString(
detail.start_timestamp_ms, detail.updated_timestamp_ms)))
.appendTo(jobParams);
$('<li>')
.append($('<span class="param-key">').text('Start time'))
.append($('<span>').text(': '))
.append($('<span class="param-value">').text(getLocalTimestring(
detail.start_timestamp_ms)))
.appendTo(jobParams);
$.each(FIXED_JOB_PARAMS, function(index, key) {
// Skip some parameters or those with no values.
if (key == 'name') return;
var value = detail[key];
if (!value) return;
$('<li>')
.append($('<span class="param-key">').text(getNiceParamKey(key)))
.append($('<span>').text(': '))
.append($('<span class="param-value">').text('' + value))
.appendTo(jobParams);
});
// User-supplied parameters.
if (detail.mapper_spec.mapper_params) {
var sortedKeys = getSortedKeys(detail.mapper_spec.mapper_params);
$.each(sortedKeys, function(index, key) {
var value = detail.mapper_spec.mapper_params[key];
var valueSpan = $('<span class="param-value">');
renderCollapsableValue(value, valueSpan);
$('<li>')
.append($('<span class="user-param-key">').text(key))
.append($('<span>').text(': '))
.append(valueSpan)
.appendTo(jobParams);
});
}
// Graph image.
var detailGraph = $('#detail-graph');
detailGraph.empty();
$('<div>').text('Processed items per shard').appendTo(detailGraph);
$('<img>')
.attr('src', detail.chart_url)
.attr('width', detail.chart_width || 300)
.attr('height', 200)
.appendTo(detailGraph);
// Aggregated counters.
var aggregatedCounters = $('#aggregated-counters');
aggregatedCounters.empty();
var runtimeMs = detail.updated_timestamp_ms - detail.start_timestamp_ms;
var sortedCounters = getSortedKeys(detail.counters);
$.each(sortedCounters, function(index, key) {
var value = detail.counters[key];
// Round to 2 decimal places.
var avgRate = Math.round(100.0 * value / (runtimeMs / 1000.0)) / 100.0;
$('<li>')
.append($('<span class="param-key">').html(getNiceParamKey(key)))
.append($('<span>').text(': '))
.append($('<span class="param-value">').html(value))
.append($('<span>').text(' '))
.append($('<span class="param-aux">').text('(' + avgRate + '/sec avg.)'))
.appendTo(aggregatedCounters);
});
// Set up the mapper detail.
var mapperBody = $('#mapper-shard-status');
mapperBody.empty();
$.each(detail.shards, function(index, shard) {
var row = $('<tr>');
row.append($('<td>').text(shard.shard_number));
var status = (shard.active ? 'running' : shard.result_status) || 'unknown';
row.append($('<td>').text(status));
// TODO: Set colgroup width for shard description.
row.append($('<td>').text(shard.shard_description));
row.append($('<td>').text(shard.last_work_item || 'Unknown'));
row.append($('<td>').text(getElapsedTimeString(
detail.start_timestamp_ms, shard.updated_timestamp_ms)));
row.appendTo(mapperBody);
});
}
function initJobDetail(jobId, detail) {
// Set titles.
var title = 'Status for "' + detail.name + '"-- Job #' + jobId;
$('head > title').text(title);
$('#detail-page-title').text(detail.name);
$('#detail-page-undertext').text('Job #' + jobId);
// Set control buttons.
if (self != top) {
$('#overview-link').hide();
}
if (detail.active) {
var control = $('<a href="">')
.text('Abort Job')
.click(function(event) {
abortJob(detail.name, jobId);
event.stopPropagation();
return false;
});
$('#job-control').append(control);
} else {
var control = $('<a href="">')
.text('Cleanup Job')
.click(function(event) {
cleanUpJob(detail.name, jobId);
event.stopPropagation();
return false;
});
$('#job-control').append(control);
}
refreshJobDetail(jobId, detail);
}
//////// Detail page entry point.
function initDetail() {
var jobId = getJobId();
if (!jobId) {
setButter('Could not find job ID in query string.', true);
return;
}
getJobDetail(jobId, initJobDetail);
}