| /* |
| * 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; |
| } |
| |
| // Convert milliseconds since the epoch to an ISO8601 datestring. |
| // Consider using new Date().toISOString() instead (J.S 1.8+) |
| function getIso8601String(timestamp_ms) { |
| var time = new Date(); |
| time.setTime(timestamp_ms); |
| return '' + |
| time.getUTCFullYear() + '-' + |
| leftPadNumber(time.getUTCMonth() + 1, 2, '0') + '-' + |
| leftPadNumber(time.getUTCDate(), 2, '0') + 'T' + |
| leftPadNumber(time.getUTCHours(), 2, '0') + ':' + |
| leftPadNumber(time.getUTCMinutes(), 2, '0') + ':' + |
| leftPadNumber(time.getUTCSeconds(), 2, '0') + 'Z'; |
| } |
| |
| 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(getIso8601String(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(user): 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"]').disabled = false; |
| } |
| |
| function runJob(name) { |
| var jobForm = getJobForm(name); |
| jobForm.find('input[type="submit"]').disabled = true; |
| $.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(getIso8601String( |
| 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(); |
| var chartTitle = 'Processed items per shard'; |
| if (detail.chart_data) { |
| var data = new google.visualization.DataTable(); |
| data.addColumn('string', 'Shard'); |
| data.addColumn('number', 'Count'); |
| var shards = detail.chart_data.length; |
| for (var i = 0; i < shards; i++) { |
| data.addRow([i.toString(), detail.chart_data[i]]); |
| } |
| var log2Shards = Math.log(shards) / Math.log(2); |
| var chartWidth = Math.max(Math.max(300, shards * 2), 100 * log2Shards); |
| var chartHeight = 200; |
| var options = { |
| legend: 'none', |
| bar: { |
| groupWidth: '100%' |
| }, |
| vAxis: { |
| minValue: 0 |
| }, |
| title: chartTitle, |
| chartArea: { |
| width: chartWidth, |
| height: chartHeight |
| }, |
| width: 80 + chartWidth, |
| height: 80 + chartHeight |
| }; |
| var chart = new google.visualization.ColumnChart(detailGraph[0]); |
| chart.draw(data, options); |
| } else { |
| $('<div>').text(chartTitle).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); |
| } |