1
0
mirror of https://github.com/ansible/awx.git synced 2024-10-31 23:51:09 +03:00

Merge pull request #1311 from leigh-johnson/HostEventModal

Refactor HostViewer into job-details/host-event module
This commit is contained in:
Leigh 2016-03-30 21:37:36 -04:00
commit 1095ecc392
23 changed files with 463 additions and 687 deletions

View File

@ -180,7 +180,6 @@ var tower = angular.module('Tower', [
'LogViewerStatusDefinition', 'LogViewerStatusDefinition',
'StandardOutHelper', 'StandardOutHelper',
'LogViewerOptionsDefinition', 'LogViewerOptionsDefinition',
'EventViewerHelper',
'JobDetailHelper', 'JobDetailHelper',
'SocketIO', 'SocketIO',
'lrInfiniteScroll', 'lrInfiniteScroll',
@ -211,6 +210,8 @@ var tower = angular.module('Tower', [
templateUrl: urlPrefix + 'partials/breadcrumb.html' templateUrl: urlPrefix + 'partials/breadcrumb.html'
}); });
// route to the details pane of /job/:id/host-event/:eventId if no other child specified
$urlRouterProvider.when('/jobs/*/host-event/*', '/jobs/*/host-event/*/details')
// $urlRouterProvider.otherwise("/home"); // $urlRouterProvider.otherwise("/home");
$urlRouterProvider.otherwise(function($injector){ $urlRouterProvider.otherwise(function($injector){
var $state = $injector.get("$state"); var $state = $injector.get("$state");

View File

@ -9,7 +9,6 @@ import './lists';
import Children from "./helpers/Children"; import Children from "./helpers/Children";
import Credentials from "./helpers/Credentials"; import Credentials from "./helpers/Credentials";
import EventViewer from "./helpers/EventViewer";
import Events from "./helpers/Events"; import Events from "./helpers/Events";
import Groups from "./helpers/Groups"; import Groups from "./helpers/Groups";
import Hosts from "./helpers/Hosts"; import Hosts from "./helpers/Hosts";
@ -42,7 +41,6 @@ import ActivityStreamHelper from "./helpers/ActivityStream";
export export
{ Children, { Children,
Credentials, Credentials,
EventViewer,
Events, Events,
Groups, Groups,
Hosts, Hosts,

View File

@ -1,568 +0,0 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name helpers.function:EventViewer
* @description eventviewerhelper
*/
export default
angular.module('EventViewerHelper', ['ModalDialog', 'Utilities', 'EventsViewerFormDefinition', 'HostsHelper'])
.factory('EventViewer', ['$compile', 'CreateDialog', 'GetEvent', 'Wait', 'EventAddTable', 'GetBasePath', 'Empty', 'EventAddPreFormattedText',
function($compile, CreateDialog, GetEvent, Wait, EventAddTable, GetBasePath, Empty, EventAddPreFormattedText) {
return function(params) {
var parent_scope = params.scope,
url = params.url,
event_id = params.event_id,
parent_id = params.parent_id,
title = params.title, //optional
scope = parent_scope.$new(true),
index = params.index,
page,
current_event;
if (scope.removeShowNextEvent) {
scope.removeShowNextEvent();
}
scope.removeShowNextEvent = scope.$on('ShowNextEvent', function(e, data, show_event) {
scope.events = data;
$('#event-next-spinner').slideUp(200);
if (show_event === 'prev') {
showEvent(scope.events.length - 1);
}
else if (show_event === 'next') {
showEvent(0);
}
});
// show scope.events[idx]
function showEvent(idx) {
var show_tabs = false, elem, data;
if (idx > scope.events.length - 1) {
GetEvent({
scope: scope,
url: scope.next_event_set,
show_event: 'next'
});
return;
}
if (idx < 0) {
GetEvent({
scope: scope,
url: scope.prev_event_set,
show_event: 'prev'
});
return;
}
data = scope.events[idx];
current_event = idx;
$('#status-form-container').empty();
$('#results-form-container').empty();
$('#timing-form-container').empty();
$('#stdout-form-container').empty();
$('#stderr-form-container').empty();
$('#traceback-form-container').empty();
$('#json-form-container').empty();
$('#eventview-tabs li:eq(1)').hide();
$('#eventview-tabs li:eq(2)').hide();
$('#eventview-tabs li:eq(3)').hide();
$('#eventview-tabs li:eq(4)').hide();
$('#eventview-tabs li:eq(5)').hide();
$('#eventview-tabs li:eq(6)').hide();
EventAddTable({ scope: scope, id: 'status-form-container', event: data, section: 'Event' });
if (EventAddTable({ scope: scope, id: 'results-form-container', event: data, section: 'Results'})) {
show_tabs = true;
$('#eventview-tabs li:eq(1)').show();
}
if (EventAddTable({ scope: scope, id: 'timing-form-container', event: data, section: 'Timing' })) {
show_tabs = true;
$('#eventview-tabs li:eq(2)').show();
}
if (data.stdout) {
show_tabs = true;
$('#eventview-tabs li:eq(3)').show();
EventAddPreFormattedText({
id: 'stdout-form-container',
val: data.stdout
});
}
if (data.stderr) {
show_tabs = true;
$('#eventview-tabs li:eq(4)').show();
EventAddPreFormattedText({
id: 'stderr-form-container',
val: data.stderr
});
}
if (data.traceback) {
show_tabs = true;
$('#eventview-tabs li:eq(5)').show();
EventAddPreFormattedText({
id: 'traceback-form-container',
val: data.traceback
});
}
show_tabs = true;
$('#eventview-tabs li:eq(6)').show();
EventAddPreFormattedText({
id: 'json-form-container',
val: JSON.stringify(data, null, 2)
});
if (!show_tabs) {
$('#eventview-tabs').hide();
}
elem = angular.element(document.getElementById('eventviewer-modal-dialog'));
$compile(elem)(scope);
}
function setButtonMargin() {
var width = ($('.ui-dialog[aria-describedby="eventviewer-modal-dialog"] .ui-dialog-buttonpane').innerWidth() / 2) - $('#events-next-button').outerWidth() - 73;
$('#events-next-button').css({'margin-right': width + 'px'});
}
function addSpinner() {
var position;
if ($('#event-next-spinner').length > 0) {
$('#event-next-spinner').remove();
}
position = $('#events-next-button').position();
$('#events-next-button').after('<i class="fa fa-cog fa-spin" id="event-next-spinner" style="display:none; position:absolute; top:' + (position.top + 15) + 'px; left:' + (position.left + 75) + 'px;"></i>');
}
if (scope.removeModalReady) {
scope.removeModalReady();
}
scope.removeModalReady = scope.$on('ModalReady', function() {
Wait('stop');
$('#eventviewer-modal-dialog').dialog('open');
});
if (scope.removeJobReady) {
scope.removeJobReady();
}
scope.removeEventReady = scope.$on('EventReady', function(e, data) {
var btns;
scope.events = data;
if (event_id) {
// find and show the selected event
data.every(function(row, idx) {
if (parseInt(row.id,10) === parseInt(event_id,10)) {
current_event = idx;
return false;
}
return true;
});
}
else {
current_event = 0;
}
showEvent(current_event);
btns = [];
if (scope.events.length > 1) {
btns.push({
label: "Prev",
onClick: function () {
if (current_event - 1 === 0 && !scope.prev_event_set) {
$('#events-prev-button').prop('disabled', true);
}
if (current_event - 1 < scope.events.length - 1) {
$('#events-next-button').prop('disabled', false);
}
showEvent(current_event - 1);
},
icon: "fa-chevron-left",
"class": "btn btn-primary",
id: "events-prev-button"
});
btns.push({
label: "Next",
onClick: function() {
if (current_event + 1 > 0) {
$('#events-prev-button').prop('disabled', false);
}
if (current_event + 1 >= scope.events.length - 1 && !scope.next_event_set) {
$('#events-next-button').prop('disabled', true);
}
showEvent(current_event + 1);
},
icon: "fa-chevron-right",
"class": "btn btn-primary",
id: "events-next-button"
});
}
btns.push({
label: "OK",
onClick: function() {
scope.modalOK();
},
icon: "",
"class": "btn btn-primary",
id: "dialog-ok-button"
});
CreateDialog({
scope: scope,
width: 675,
height: 600,
minWidth: 450,
callback: 'ModalReady',
id: 'eventviewer-modal-dialog',
// onResizeStop: resizeText,
title: ( (title) ? title : 'Host Event' ),
buttons: btns,
closeOnEscape: true,
onResizeStop: function() {
setButtonMargin();
addSpinner();
},
onClose: function() {
try {
scope.$destroy();
}
catch(e) {
//ignore
}
},
onOpen: function() {
$('#eventview-tabs a:first').tab('show');
$('#dialog-ok-button').focus();
if (scope.events.length > 1 && current_event === 0 && !scope.prev_event_set) {
$('#events-prev-button').prop('disabled', true);
}
if ((current_event === scope.events.length - 1) && !scope.next_event_set) {
$('#events-next-button').prop('disabled', true);
}
if (scope.events.length > 1) {
setButtonMargin();
addSpinner();
}
}
});
});
page = (index) ? Math.ceil((index+1)/50) : 1;
url += (/\/$/.test(url)) ? '?' : '&';
url += (parent_id) ? 'page='+page +'&parent=' + parent_id + '&page_size=50&order=host_name,counter' : 'page_size=50&order=host_name,counter';
GetEvent({
url: url,
scope: scope
});
scope.modalOK = function() {
$('#eventviewer-modal-dialog').dialog('close');
scope.$destroy();
};
};
}])
.factory('GetEvent', ['Wait', 'Rest', 'ProcessErrors',
function(Wait, Rest, ProcessErrors) {
return function(params) {
var url = params.url,
scope = params.scope,
show_event = params.show_event,
results= [];
if (show_event) {
$('#event-next-spinner').show();
}
else {
Wait('start');
}
function getStatus(e) {
return (e.event === "runner_on_unreachable") ? "unreachable" : (e.event === "runner_on_skipped") ? 'skipped' : (e.failed) ? 'failed' :
(e.changed) ? 'changed' : 'ok';
}
Rest.setUrl(url);
Rest.get()
.success( function(data) {
if(jQuery.isEmptyObject(data)) {
Wait('stop');
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to get event ' + url + '. ' });
}
else {
scope.next_event_set = data.next;
scope.prev_event_set = data.previous;
data.results.forEach(function(event) {
var msg, key, event_data = {};
if (event.event_data.res) {
if (typeof event.event_data.res !== 'object') {
// turn event_data.res into an object
msg = event.event_data.res;
event.event_data.res = {};
event.event_data.res.msg = msg;
}
for (key in event.event_data) {
if (key !== "res") {
event.event_data.res[key] = event.event_data[key];
}
}
if (event.event_data.res.ansible_facts) {
// don't show fact gathering results
event.event_data.res.task = "Gathering Facts";
delete event.event_data.res.ansible_facts;
}
event.event_data.res.status = getStatus(event);
event_data = event.event_data.res;
}
else {
event.event_data.status = getStatus(event);
event_data = event.event_data;
}
// convert results to stdout
if (event_data.results && typeof event_data.results === "object" && Array.isArray(event_data.results)) {
event_data.stdout = "";
event_data.results.forEach(function(row) {
event_data.stdout += row + "\n";
});
delete event_data.results;
}
if (event_data.invocation) {
for (key in event_data.invocation) {
event_data[key] = event_data.invocation[key];
}
delete event_data.invocation;
}
event_data.play = event.play;
if (event.task) {
event_data.task = event.task;
}
event_data.created = event.created;
event_data.role = event.role;
event_data.host_id = event.host;
event_data.host_name = event.host_name;
if (event_data.host) {
delete event_data.host;
}
event_data.id = event.id;
event_data.parent = event.parent;
event_data.event = (event.event_display) ? event.event_display : event.event;
results.push(event_data);
});
if (show_event) {
scope.$emit('ShowNextEvent', results, show_event);
}
else {
scope.$emit('EventReady', results);
}
} //else statement
})
.error(function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to get event ' + url + '. GET returned: ' + status });
});
};
}])
.factory('EventAddTable', ['$compile', '$filter', 'Empty', 'EventsViewerForm', function($compile, $filter, Empty, EventsViewerForm) {
return function(params) {
var scope = params.scope,
id = params.id,
event = params.event,
section = params.section,
html = '', e;
function parseObject(obj) {
// parse nested JSON objects. a mini version of parseJSON without references to the event form object.
var i, key, html = '';
for (key in obj) {
if (typeof obj[key] === "boolean" || typeof obj[key] === "number" || typeof obj[key] === "string") {
html += "<tr><td class=\"key\">" + key + ":</td><td class=\"value\">" + obj[key] + "</td></tr>";
}
else if (typeof obj[key] === "object" && Array.isArray(obj[key])) {
html += "<tr><td class=\"key\">" + key + ":</td><td class=\"value\">[";
for (i = 0; i < obj[key].length; i++) {
html += obj[key][i] + ",";
}
html = html.replace(/,$/,'');
html += "]</td></tr>\n";
}
else if (typeof obj[key] === "object") {
html += "<tr><td class=\"key\">" + key + ":</td><td class=\"nested-table\"><table>\n<tbody>\n" + parseObject(obj[key]) + "</tbody>\n</table>\n</td></tr>\n";
}
}
return html;
}
function parseItem(itm, key, label) {
var i, html = '';
if (Empty(itm)) {
// exclude empty items
}
else if (typeof itm === "boolean" || typeof itm === "number" || typeof itm === "string") {
html += "<tr><td class=\"key\">" + label + ":</td><td class=\"value\">";
if (key === "status") {
html += "<i class=\"fa icon-job-" + itm + "\"></i> " + itm;
}
else if (key === "start" || key === "end" || key === "created") {
if (!/Z$/.test(itm)) {
itm = itm.replace(/\ /,'T') + 'Z';
html += $filter('longDate')(itm);
}
else {
html += $filter('longDate')(itm);
}
}
else if (key === "host_name" && event.host_id) {
html += "<a href=\"/#/home/hosts/?id=" + event.host_id + "\" target=\"_blank\" " +
"aw-tool-tip=\"View host. Opens in new tab or window.\" data-placement=\"top\" " +
">" + itm + "</a>";
}
else {
if( typeof itm === "string"){
if(itm.indexOf('<') > -1 || itm.indexOf('>') > -1){
itm = $filter('sanitize')(itm);
}
}
html += "<span ng-non-bindable>" + itm + "</span>";
}
html += "</td></tr>\n";
}
else if (typeof itm === "object" && Array.isArray(itm)) {
html += "<tr><td class=\"key\">" + label + ":</td><td class=\"value\">[";
for (i = 0; i < itm.length; i++) {
html += itm[i] + ",";
}
html = html.replace(/,$/,'');
html += "]</td></tr>\n";
}
else if (typeof itm === "object") {
html += "<tr><td class=\"key\">" + label + ":</td><td class=\"nested-table\"><table>\n<tbody>\n" + parseObject(itm) + "</tbody>\n</table>\n</td></tr>\n";
}
return html;
}
function parseJSON(obj) {
var h, html = '', key, keys, found = false, string_warnings = "", string_cmd = "";
if (typeof obj === "object") {
html += "<table class=\"table eventviewer-status\">\n";
html += "<tbody>\n";
keys = [];
for (key in EventsViewerForm.fields) {
if (EventsViewerForm.fields[key].section === section) {
keys.push(key);
}
}
keys.forEach(function(key) {
var h, label;
label = EventsViewerForm.fields[key].label;
h = parseItem(obj[key], key, label);
if (h) {
html += h;
found = true;
}
});
if (section === 'Results') {
// Add to result fields that might not be found in the form object.
for (key in obj) {
h = '';
if (key !== 'host_id' && key !== 'parent' && key !== 'event' && key !== 'src' && key !== 'md5sum' &&
key !== 'stdout' && key !== 'traceback' && key !== 'stderr' && key !== 'cmd' && key !=='changed' && key !== "verbose_override" &&
key !== 'feature_result' && key !== 'warnings') {
if (!EventsViewerForm.fields[key]) {
h = parseItem(obj[key], key, key);
if (h) {
html += h;
found = true;
}
}
} else if (key === 'cmd') {
// only show cmd if it's a cmd that was run
if (!EventsViewerForm.fields[key] && obj[key].length > 0) {
// include the label head Shell Command instead of CMD in the modal
if(typeof(obj[key]) === 'string'){
obj[key] = [obj[key]];
}
string_cmd += obj[key].join(" ");
h = parseItem(string_cmd, key, "Shell Command");
if (h) {
html += h;
found = true;
}
}
} else if (key === 'warnings') {
if (!EventsViewerForm.fields[key] && obj[key].length > 0) {
if(typeof(obj[key]) === 'string'){
obj[key] = [obj[key]];
}
string_warnings += obj[key].join(" ");
h = parseItem(string_warnings, key, "Warnings");
if (h) {
html += h;
found = true;
}
}
}
}
}
html += "</tbody>\n";
html += "</table>\n";
}
return (found) ? html : '';
}
html = parseJSON(event);
e = angular.element(document.getElementById(id));
e.empty();
if (html) {
e.html(html);
$compile(e)(scope);
}
return (html) ? true : false;
};
}])
.factory('EventAddTextarea', [ function() {
return function(params) {
var container_id = params.container_id,
val = params.val,
fld_id = params.fld_id,
html;
html = "<div class=\"form-group\">\n" +
"<textarea ng-non-bindable id=\"" + fld_id + "\" class=\"form-control mono-space\" rows=\"12\" readonly>" + val + "</textarea>" +
"</div>\n";
$('#' + container_id).empty().html(html);
};
}])
.factory('EventAddPreFormattedText', ['$filter', function($filter) {
return function(params) {
var id = params.id,
val = params.val,
html;
if( typeof val === "string"){
if(val.indexOf('<') > -1 || val.indexOf('>') > -1){
val = $filter('sanitize')(val);
}
}
html = "<pre ng-non-bindable>" + val + "</pre>\n";
$('#' + id).empty().html(html);
};
}]);

View File

@ -0,0 +1,49 @@
<div class="HostEvent-details--left">
<div class="HostEvent-field">
<div class="HostEvent-title">EVENT</div>
<span class="HostEvent-field--content"></span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">HOST</span>
<span class="HostEvent-field--content">
<a ui-sref="jobDetail.host-events({hostName: event.host_name})">{{event.host_name || "No result found"}}</a></span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">STATUS</span>
<span class="HostEvent-field--content">
<a class="HostEvents-status">
<i class="fa fa-circle" ng-class="processEventStatus"></i>
</a>
{{event.status || "No result found"}}
</span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">ID</span>
<span class="HostEvent-field--content">{{event.id || "No result found"}}</span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">CREATED</span>
<span class="HostEvent-field--content">{{event.created || "No result found"}}</span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">PLAY</span>
<span class="HostEvent-field--content">{{event.play || "No result found"}}</span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">TASK</span>
<span class="HostEvent-field--content">{{event.task || "No result found"}}</span>
</div>
<div class="HostEvent-field">
<span class="HostEvent-field--label">MODULE</span>
<span class="HostEvent-field--content">{{event.event_data.res.invocation.module_name || "No result found"}}</span>
</div>
</div>
<div class="HostEvent-details--right" ng-show="event.event_data.res">
<div class="HostEvent-title">RESULTS</div>
<!-- discard any objects in the ansible response until we decide to flatten them -->
<div class="HostEvent-field" ng-repeat="(key, value) in results = event.event_data.res track by $index" ng-if="processResults(value)">
<span class="HostEvent-field--label">{{key}}</span>
<span class="HostEvent-field--content">{{value}}</span>
</div>
</div>

View File

@ -0,0 +1,2 @@
<textarea id="HostEvent-json" class="HostEvent-json">
</textarea>

View File

@ -0,0 +1,36 @@
<div id="HostEvent" class="HostEvent modal fade" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<!-- modal body -->
<div class="modal-body">
<div class="HostEvent-header">
<span class="HostEvent-title">HOST EVENT</span>
<!-- close -->
<button ui-sref="jobDetail" type="button" class="close">
<i class="fa fa-times-circle"></i>
</button>
</div>
<div class="HostEvent-nav">
<!-- view navigation buttons -->
<button ui-sref="jobDetail.host-event.details" type="button" class="btn btn-sm btn-default" >Details</button>
<button ui-sref="jobDetail.host-event.json" type="button" class="btn btn-sm btn-default ">JSON</button>
<button ng-show="event.stdout" ui-sref="jobDetail.host-event.stdout" type="button" class="btn btn-sm btn-default ">Standard Out</button>
<button ng-show="event.timing" ui-sref="jobDetail.host-event.timing" type="button" class="btn btn-sm btn-default ">Timing</button>
</div>
<div class="HostEvent-body">
<!-- views -->
<div ui-view></div>
</div>
<!-- controls -->
<div class="HostEvent-controls">
<button ng-show="showPrev()" ng-click="goPrev()"
class="btn btn-sm btn-default">Prev</button>
<button ng-show="showNext()"ng-click="goNext()" class="btn btn-sm btn-default">Next</button>
<button ui-sref="jobDetail" class="btn btn-sm btn-default" ng-show="true" >Close</button>
</div>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,13 @@
<div class="EventHost-stdoutPanel Panel">
<div class="StandardOut-panelHeader">
<div class="StandardOut-panelHeaderText">STANDARD OUT</div>
<div class="StandardOut-panelHeaderActions">
<a href="/api/v1/jobs/{{ job.id }}/stdout?format=txt_download&token={{ token }}">
<button class="StandardOut-actionButton" aw-tool-tip="Download Output" data-placement="top">
<i class="fa fa-download"></i>
</button>
</a>
</div>
</div>
<standard-out-log stdout-endpoint="event._stdout"></standard-out-log>
</div>

View File

@ -0,0 +1 @@
<div>timing</div>

View File

@ -0,0 +1,69 @@
@import "awx/ui/client/src/shared/branding/colors.less";
@import "awx/ui/client/src/shared/branding/colors.default.less";
@import "awx/ui/client/src/shared/layouts/one-plus-two.less";
.HostEvent .modal-footer{
border: 0;
margin-top: 0px;
padding-top: 5px;
}
.HostEvent-controls{
float: right;
button {
margin-left: 10px;
}
}
.HostEvent-status--ok{
color: @green;
}
.HostEvent-status--unreachable{
color: @unreachable;
}
.HostEvent-status--changed{
color: @changed;
}
.HostEvent-status--failed{
color: @default-err;
}
.HostEvent-status--skipped{
color: @skipped;
}
.HostEvent-title{
color: @default-interface-txt;
font-weight: 600;
}
.HostEvent .modal-body{
max-height: 500px;
overflow-y: auto;
padding: 20px;
}
.HostEvent-nav{
padding-top: 12px;
padding-bottom: 12px;
}
.HostEvent-field{
margin-bottom: 8px;
}
.HostEvent-field--label{
.OnePlusTwo-left--detailsLabel;
width: 80px;
margin-right: 20px;
font-size: 12px;
}
.HostEvent-field{
.OnePlusTwo-left--detailsRow;
}
.HostEvent-field--content{
.OnePlusTwo-left--detailsContent;
}
.HostEvent-details--left, .HostEvent-details--right{
vertical-align:top;
width:270px;
display: inline-block;
}
.HostEvent-details--right{
.HostEvent-field--label{
width: 170px;
}
}

View File

@ -0,0 +1,71 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
['$stateParams', '$scope', '$state', 'Wait', 'JobDetailService', 'moment', 'event',
function($stateParams, $scope, $state, Wait, JobDetailService, moment, event){
// Avoid rendering objects in the details fieldset
// ng-if="processResults(value)" via host-event-details.partial.html
$scope.processResults = function(value){
if (typeof value == 'object'){return false}
else {return true}
};
var codeMirror = function(){
var el = $('#HostEvent-json')[0];
var editor = CodeMirror.fromTextArea(el, {
lineNumbers: true,
mode: {name: "javascript", json: true}
});
editor.getDoc().setValue(JSON.stringify($scope.json, null, 4));
};
$scope.getActiveHostIndex = function(){
var result = $scope.hostResults.filter(function( obj ) {
return obj.id == $scope.event.id;
});
return $scope.hostResults.indexOf(result[0])
};
$scope.showPrev = function(){
return $scope.getActiveHostIndex() != 0
};
$scope.showNext = function(){
return $scope.getActiveHostIndex() < $scope.hostResults.indexOf($scope.hostResults[$scope.hostResults.length - 1])
};
$scope.goNext = function(){
var index = $scope.getActiveHostIndex() + 1;
var id = $scope.hostResults[index].id;
$state.go('jobDetail.host-event.details', {eventId: id})
};
$scope.goPrev = function(){
var index = $scope.getActiveHostIndex() - 1;
var id = $scope.hostResults[index].id;
$state.go('jobDetail.host-event.details', {eventId: id})
};
var init = function(){
$scope.event = event.data.results[0];
$scope.event.created = moment($scope.event.created).format();
$scope.processEventStatus = JobDetailService.processEventStatus($scope.event);
$scope.hostResults = $stateParams.hostResults;
$scope.json = JobDetailService.processJson($scope.event);
if ($state.current.name == 'jobDetail.host-event.json'){
codeMirror();
}
try {
$scope.stdout = $scope.event.event_data.res.stdout
}
catch(err){
$scope.sdout = null;
}
$('#HostEvent').modal('show');
};
init();
}];

View File

@ -0,0 +1,86 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {templateUrl} from '../../shared/template-url/template-url.factory';
var hostEventModal = {
name: 'jobDetail.host-event',
url: '/host-event/:eventId',
controller: 'HostEventController',
params:{
hostResults: {
value: null,
squash: false,
}
},
templateUrl: templateUrl('job-detail/host-event/host-event-modal'),
resolve: {
features: ['FeaturesService', function(FeaturesService){
return FeaturesService.get();
}],
event: ['JobDetailService','$stateParams', function(JobDetailService, $stateParams) {
return JobDetailService.getRelatedJobEvents($stateParams.id, {
id: $stateParams.eventId
}).success(function(res){ return res.results[0]})
}]
},
onExit: function($state){
// close the modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
$('#HostEvent').modal('hide');
// hacky way to handle user browsing away via URL bar
$('.modal-backdrop').remove();
$('body').removeClass('modal-open');
}
}
var hostEventDetails = {
name: 'jobDetail.host-event.details',
url: '/details',
controller: 'HostEventController',
templateUrl: templateUrl('job-detail/host-event/host-event-details'),
resolve: {
features: ['FeaturesService', function(FeaturesService){
return FeaturesService.get();
}]
}
}
var hostEventJson = {
name: 'jobDetail.host-event.json',
url: '/json',
controller: 'HostEventController',
templateUrl: templateUrl('job-detail/host-event/host-event-json'),
resolve: {
features: ['FeaturesService', function(FeaturesService){
return FeaturesService.get();
}]
}
};
var hostEventTiming = {
name: 'jobDetail.host-event.timing',
url: '/timing',
controller: 'HostEventController',
templateUrl: templateUrl('job-detail/host-event/host-event-timing'),
resolve: {
features: ['FeaturesService', function(FeaturesService){
return FeaturesService.get();
}]
}
};
var hostEventStdout = {
name: 'jobDetail.host-event.stdout',
url: '/stdout',
controller: 'HostEventController',
templateUrl: templateUrl('job-detail/host-event/host-event-stdout'),
resolve: {
features: ['FeaturesService', function(FeaturesService){
return FeaturesService.get();
}]
}
};
export {hostEventDetails, hostEventJson, hostEventTiming, hostEventStdout, hostEventModal}

View File

@ -0,0 +1,21 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import {hostEventModal, hostEventDetails, hostEventTiming,
hostEventJson, hostEventStdout} from './host-event.route';
import controller from './host-event.controller';
export default
angular.module('jobDetail.hostEvent', [])
.controller('HostEventController', controller)
.run(['$stateExtender', function($stateExtender){
$stateExtender.addState(hostEventModal);
$stateExtender.addState(hostEventDetails);
$stateExtender.addState(hostEventTiming);
$stateExtender.addState(hostEventJson);
$stateExtender.addState(hostEventStdout);
}]);

View File

@ -16,7 +16,7 @@
color: @changed; color: @changed;
} }
.HostEvents-status--failed{ .HostEvents-status--failed{
color: @warning; color: @default-err;
} }
.HostEvents-status--skipped{ .HostEvents-status--skipped{
color: @skipped; color: @skipped;
@ -47,6 +47,7 @@
padding-bottom: 15px; padding-bottom: 15px;
} }
.HostEvents-title{ .HostEvents-title{
text-transform: uppercase;
color: @default-interface-txt; color: @default-interface-txt;
font-weight: 600; font-weight: 600;
} }

View File

@ -6,13 +6,14 @@
export default export default
['$stateParams', '$scope', '$rootScope', '$state', 'Wait', ['$stateParams', '$scope', '$rootScope', '$state', 'Wait',
'JobDetailService', 'CreateSelect2', 'JobDetailService', 'CreateSelect2', 'hosts',
function($stateParams, $scope, $rootScope, $state, Wait, function($stateParams, $scope, $rootScope, $state, Wait,
JobDetailService, CreateSelect2){ JobDetailService, CreateSelect2, hosts){
// pagination not implemented yet, but it'll depend on this // pagination not implemented yet, but it'll depend on this
$scope.page_size = $stateParams.page_size; $scope.page_size = $stateParams.page_size;
$scope.processEventStatus = JobDetailService.processEventStatus;
$scope.activeFilter = $stateParams.filter || null; $scope.activeFilter = $stateParams.filter || null;
$scope.search = function(){ $scope.search = function(){
@ -39,6 +40,7 @@
var filter = function(filter){ var filter = function(filter){
Wait('start'); Wait('start');
if (filter == 'all'){ if (filter == 'all'){
return JobDetailService.getRelatedJobEvents($stateParams.id, { return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName, host_name: $stateParams.hostName,
@ -104,47 +106,17 @@
filter($('.HostEvents-select').val()); filter($('.HostEvents-select').val());
}); });
$scope.processStatus = function(event, $index){
// the stack for which status to display is
// unreachable > failed > changed > ok
// uses the API's runner events and convenience properties .failed .changed to determine status.
// see: job_event_callback.py
if (event.event == 'runner_on_unreachable'){
$scope.results[$index].status = 'Unreachable';
return 'HostEvents-status--unreachable'
}
// equiv to 'runner_on_error' && 'runner on failed'
if (event.failed){
$scope.results[$index].status = 'Failed';
return 'HostEvents-status--failed'
}
// catch the changed case before ok, because both can be true
if (event.changed){
$scope.results[$index].status = 'Changed';
return 'HostEvents-status--changed'
}
if (event.event == 'runner_on_ok'){
$scope.results[$index].status = 'OK';
return 'HostEvents-status--ok'
}
if (event.event == 'runner_on_skipped'){
$scope.results[$index].status = 'Skipped';
return 'HostEvents-status--skipped'
}
else{
// study a case where none of these apply
}
};
var init = function(){ var init = function(){
$scope.hostName = $stateParams.hostName;
// create filter dropdown // create filter dropdown
console.log($stateParams)
CreateSelect2({ CreateSelect2({
element: '.HostEvents-select', element: '.HostEvents-select',
multiple: false multiple: false
}); });
// process the filter if one was passed // process the filter if one was passed
if ($stateParams.filter){ if ($stateParams.filter){
Wait('start');
filter($stateParams.filter).success(function(res){ filter($stateParams.filter).success(function(res){
$scope.results = res.results; $scope.results = res.results;
Wait('stop'); Wait('stop');
@ -152,25 +124,11 @@
});; });;
} }
else{ else{
Wait('start'); $scope.results = hosts.data.results;
JobDetailService.getRelatedJobEvents($stateParams.id, { $('#HostEvents').modal('show');
host_name: $stateParams.hostName,
page_size: $stateParams.page_size})
.success(function(res){
$scope.pagination = res;
$scope.results = res.results;
Wait('stop');
$('#HostEvents').modal('show');
});
} }
}; };
$scope.goBack = function(){
// go back to the job details state
// we're leaning on $stateProvider's onExit to close the modal
$state.go('jobDetail');
};
init(); init();

View File

@ -3,9 +3,9 @@
<div class="modal-content"> <div class="modal-content">
<div class="modal-body"> <div class="modal-body">
<div class="HostEvents-header"> <div class="HostEvents-header">
<span class="HostEvents-title">HOST EVENTS</span> <span class="HostEvents-title">HOST EVENTS | {{hostName}}</span>
<!-- Close --> <!-- Close -->
<button ng-click="goBack()" type="button" class="close"> <button ui-sref="jobDetail" type="button" class="close">
<i class="fa fa fa-times-circle"></i> <i class="fa fa fa-times-circle"></i>
</button> </button>
</div> </div>
@ -29,7 +29,6 @@
<table class="table"> <table class="table">
<!-- column labels --> <!-- column labels -->
<th ng-hide="results.length == 0" class="HostEvents-table--header">STATUS</th> <th ng-hide="results.length == 0" class="HostEvents-table--header">STATUS</th>
<th ng-hide="results.length == 0" class="HostEvents-table--header">HOST</th>
<th ng-hide="results.length == 0" class="HostEvents-table--header">PLAY</th> <th ng-hide="results.length == 0" class="HostEvents-table--header">PLAY</th>
<th ng-hide="results.length == 0" class="HostEvents-table--header">TASK</th> <th ng-hide="results.length == 0" class="HostEvents-table--header">TASK</th>
<!-- result rows --> <!-- result rows -->
@ -37,11 +36,10 @@
<td class=HostEvents-table--cell> <td class=HostEvents-table--cell>
<!-- status circles --> <!-- status circles -->
<a class="HostEvents-status"> <a class="HostEvents-status">
<i class="fa fa-circle" ng-class="processStatus(event, $index)"></i> <i class="fa fa-circle" ng-class="processEventStatus(event)"></i>
</a> </a>
{{event.status}} {{event.status}}
</td> </td>
<td class=HostEvents-table--cell>{{event.host_name}}</td>
<td class=HostEvents-table--cell>{{event.play}}</td> <td class=HostEvents-table--cell>{{event.play}}</td>
<td class=HostEvents-table--cell>{{event.task}}</td> <td class=HostEvents-table--cell>{{event.task}}</td>
</tr> </tr>
@ -56,7 +54,7 @@
<div class="modal-footer"> <div class="modal-footer">
<!-- pagination --> <!-- pagination -->
<!-- close --> <!-- close -->
<button ng-click="goBack()" class="btn btn-default pull-right HostEvents-close">OK</button> <button ui-sref="jobDetail" class="btn btn-default pull-right HostEvents-close">OK</button>
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
/************************************************* /*************************************************
* Copyright (c) 2015 Ansible, Inc. * Copyright (c) 2016 Ansible, Inc.
* *
* All Rights Reserved * All Rights Reserved
*************************************************/ *************************************************/
@ -25,6 +25,11 @@ export default {
resolve: { resolve: {
features: ['FeaturesService', function(FeaturesService) { features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get(); return FeaturesService.get();
}] }],
hosts: ['JobDetailService','$stateParams', function(JobDetailService, $stateParams) {
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName
}).success(function(res){ return res.results[0]})
}]
} }
}; };

View File

@ -18,7 +18,7 @@ export default
'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun', 'JobIsFinished', 'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'PlaybookRun',
'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit', 'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit',
'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels',
'EditSchedule', 'ParseTypeChange', 'JobDetailService', 'EventViewer', 'EditSchedule', 'ParseTypeChange', 'JobDetailService',
function( function(
$location, $rootScope, $filter, $scope, $compile, $stateParams, $location, $rootScope, $filter, $scope, $compile, $stateParams,
$log, ClearScope, GetBasePath, Wait, ProcessErrors, $log, ClearScope, GetBasePath, Wait, ProcessErrors,
@ -27,7 +27,7 @@ export default
SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob,
PlaybookRun, LoadPlays, LoadTasks, LoadHosts, PlaybookRun, LoadPlays, LoadTasks, LoadHosts,
HostsEdit, ParseVariableString, GetChoices, fieldChoices, HostsEdit, ParseVariableString, GetChoices, fieldChoices,
fieldLabels, EditSchedule, ParseTypeChange, JobDetailService, EventViewer fieldLabels, EditSchedule, ParseTypeChange, JobDetailService
) { ) {
ClearScope(); ClearScope();
@ -1119,17 +1119,6 @@ export default
} }
}; };
scope.viewHostResults = function(id) {
EventViewer({
scope: scope,
url: scope.job.related.job_events,
parent_id: scope.selectedTask,
event_id: id,
index: this.$index,
title: 'Host Event'
});
};
if (scope.removeDeleteFinished) { if (scope.removeDeleteFinished) {
scope.removeDeleteFinished(); scope.removeDeleteFinished();
} }

View File

@ -343,7 +343,9 @@
<table class="table"> <table class="table">
<tbody> <tbody>
<tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="result in results = (hostResults) track by $index"> <tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="result in results = (hostResults) track by $index">
<td class="List-tableCell col-lg-4 col-md-3 col-sm-3 col-xs-3 status-column"><a ng-click="viewHostResults(result.id)" aw-tool-tip="Event ID: {{ result.id }}<br \>Status: {{ result.status_text }}. Click for details" data-placement="top"><i ng-show="result.status_text != 'Unreachable'" class="JobDetail-statusIcon fa icon-job-{{ result.status }}"></i><span ng-show="result.status_text != 'Unreachable'">{{ result.name }}</span><i ng-show="result.status_text == 'Unreachable'" class="JobDetail-statusIcon fa icon-job-unreachable"></i><span ng-show="result.status_text == 'Unreachable'">{{ result.name }}</span></a></td> <td class="List-tableCell col-lg-4 col-md-3 col-sm-3 col-xs-3 status-column">
<a ui-sref="jobDetail.host-event.details({eventId: result.id, hostResults: hostResults})" aw-tool-tip="Event ID: {{ result.id }}<br \>Status: {{ result.status_text }}. Click for details" data-placement="top"><i ng-show="result.status_text != 'Unreachable'" class="JobDetail-statusIcon fa icon-job-{{ result.status }}"></i><span ng-show="result.status_text != 'Unreachable'">{{ result.name }}</span><i ng-show="result.status_text == 'Unreachable'" class="JobDetail-statusIcon fa icon-job-unreachable"></i><span ng-show="result.status_text == 'Unreachable'">{{ result.name }}</span></a>
</td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3 item-column">{{ result.item }}</td> <td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3 item-column">{{ result.item }}</td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3">{{ result.msg }}</td> <td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3">{{ result.msg }}</td>
</tr> </tr>
@ -422,7 +424,7 @@
<tbody> <tbody>
<tr class="List-tableRow" ng-repeat="host in summaryList = (hosts) track by $index" id="{{ host.id }}" ng-class-even="'List-tableRow--evenRow'" ng-class-odd="'List-tableRow--oddRow'"> <tr class="List-tableRow" ng-repeat="host in summaryList = (hosts) track by $index" id="{{ host.id }}" ng-class-even="'List-tableRow--evenRow'" ng-class-odd="'List-tableRow--oddRow'">
<td class="List-tableCell name col-lg-6 col-md-6 col-sm-6 col-xs-6"> <td class="List-tableCell name col-lg-6 col-md-6 col-sm-6 col-xs-6">
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id})" aw-tool-tip="View events" data-placement="top">{{ host.name }}</a> <a ui-sref="jobDetail.host-events({hostName: host.name})" aw-tool-tip="View events" data-placement="top">{{ host.name }}</a>
</td> </td>
<td class="List-tableCell col-lg-6 col-md-5 col-sm-5 col-xs-5 badge-column"> <td class="List-tableCell col-lg-6 col-md-5 col-sm-5 col-xs-5 badge-column">
<a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id, filter: 'ok'})" aw-tool-tip="{{ host.okTip }}" data-tip-watch="host.okTip" data-placement="top" ng-hide="host.ok == 0"><span class="badge successful-hosts">{{ host.ok }}</span></a> <a ui-sref="jobDetail.host-events({hostName: host.name, hostId: host.id, filter: 'ok'})" aw-tool-tip="{{ host.okTip }}" data-tip-watch="host.okTip" data-placement="top" ng-hide="host.ok == 0"><span class="badge successful-hosts">{{ host.ok }}</span></a>
@ -480,8 +482,6 @@
</div> </div>
</div> </div>
<div ng-include="'/static/partials/eventviewer.html'"></div>
<div id="host-modal-dialog" style="display: none;" class="dialog-content"></div> <div id="host-modal-dialog" style="display: none;" class="dialog-content"></div>
<div ng-include="'/static/partials/schedule_dialog.html'"></div> <div ng-include="'/static/partials/schedule_dialog.html'"></div>

View File

@ -2,13 +2,85 @@ export default
['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', function($rootScope, Rest, GetBasePath, ProcessErrors){ ['$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', function($rootScope, Rest, GetBasePath, ProcessErrors){
return { return {
/* /*
For ES6 For ES6
it might be useful to set some default params here, e.g. it might be useful to set some default params here, e.g.
getJobHostSummaries: function(id, page_size=200, order='host_name'){} getJobHostSummaries: function(id, page_size=200, order='host_name'){}
without ES6, we'd have to supply defaults like this: without ES6, we'd have to supply defaults like this:
this.page_size = params.page_size ? params.page_size : 200; this.page_size = params.page_size ? params.page_size : 200;
*/ */
// the the API passes through Ansible's event_data response
// we need to massage away the verbose and redundant properties
processJson: function(data){
// a deep copy
var result = $.extend(true, {}, data);
// configure fields to ignore
var ignored = [
'event_data',
'related',
'summary_fields',
'url',
'ansible_facts',
];
// remove ignored properties
Object.keys(result).forEach(function(key, index){
if (ignored.indexOf(key) > -1) {
delete result[key]
}
});
// flatten Ansible's passed-through response
try{
result.event_data = {};
Object.keys(data.event_data.res).forEach(function(key, index){
if (ignored.indexOf(key) > -1) {
return
}
else{
result.event_data[key] = data.event_data.res[key];
}
});
}
catch(err){result.event_data = null;}
return result
},
processEventStatus: function(event){
// Generate a helper class for job_event statuses
// the stack for which status to display is
// unreachable > failed > changed > ok
// uses the API's runner events and convenience properties .failed .changed to determine status.
// see: job_event_callback.py
if (event.event == 'runner_on_unreachable'){
event.status = 'Unreachable';
return 'HostEvents-status--unreachable'
}
// equiv to 'runner_on_error' && 'runner on failed'
if (event.failed){
event.status = 'Failed';
return 'HostEvents-status--failed'
}
// catch the changed case before ok, because both can be true
if (event.changed){
event.status = 'Changed';
return 'HostEvents-status--changed'
}
if (event.event == 'runner_on_ok'){
event.status = 'OK';
return 'HostEvents-status--ok'
}
if (event.event == 'runner_on_skipped'){
event.status = 'Skipped';
return 'HostEvents-status--skipped'
}
else{
// study a case where none of these apply
}
},
// GET events related to a job run // GET events related to a job run
// e.g. // e.g.

View File

@ -8,10 +8,12 @@ import route from './job-detail.route';
import controller from './job-detail.controller'; import controller from './job-detail.controller';
import service from './job-detail.service'; import service from './job-detail.service';
import hostEvents from './host-events/main'; import hostEvents from './host-events/main';
import hostEvent from './host-event/main';
export default export default
angular.module('jobDetail', [ angular.module('jobDetail', [
hostEvents.name hostEvents.name,
hostEvent.name
]) ])
.controller('JobDetailController', controller) .controller('JobDetailController', controller)
.service('JobDetailService', service) .service('JobDetailService', service)

View File

@ -23,7 +23,8 @@
set: function(data){ set: function(data){
var defaultUrl = GetBasePath('job_templates'); var defaultUrl = GetBasePath('job_templates');
Rest.setUrl(defaultUrl); Rest.setUrl(defaultUrl);
data.results[0].name = data.results[0].name + ' ' + moment().format('h:mm:ss a'); // 2:49:11 pm var name = this.buildName(data.results[0].name)
data.results[0].name = name + ' @ ' + moment().format('h:mm:ss a'); // 2:49:11 pm
return Rest.post(data.results[0]) return Rest.post(data.results[0])
.success(function(res){ .success(function(res){
return res return res
@ -32,6 +33,10 @@
ProcessErrors($rootScope, res, status, null, {hdr: 'Error!', ProcessErrors($rootScope, res, status, null, {hdr: 'Error!',
msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status}); msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
}); });
},
buildName: function(name){
var result = name.split('@')[0];
return result
} }
} }
} }

View File

@ -1,34 +0,0 @@
<div id="eventviewer-modal-dialog" style="display: none;">
<ul id="eventview-tabs" class="nav nav-tabs">
<li class="active"><a href="#status" id="status-link" data-toggle="tab" ng-click="toggleTab($event, 'status-link', 'eventview-tabs')">Event</a></li>
<li><a href="#results" id="results-link" data-toggle="tab" ng-click="toggleTab($event, 'results-link', 'eventview-tabs')">Results</a></li>
<li><a href="#timing" id="timing-link" data-toggle="tab" ng-click="toggleTab($event, 'timing-link', 'eventview-tabs')">Timing</a></li>
<li><a href="#stdout" id="stdout-link" data-toggle="tab" ng-click="toggleTab($event, 'stdout-link', 'eventview-tabs')">Standard Out</a></li>
<li><a href="#stderr" id="stderr-link" data-toggle="tab" ng-click="toggleTab($event, 'stderr-link', 'eventview-tabs')">Standard Error</a></li>
<li><a href="#traceback" id="traceback-link" data-toggle="tab" ng-click="toggleTab($event, 'traceback-link', 'eventview-tabs')">Traceback</a></li>
<li><a href="#json" id="json-link" data-toggle="tab" ng-click="toggleTab($event, 'json-link', 'eventview-tabs')">JSON</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="status">
<div id="status-form-container"></div>
</div>
<div class="tab-pane" id="results">
<div id="results-form-container"></div>
</div>
<div class="tab-pane" id="timing">
<div id="timing-form-container"></div>
</div>
<div class="tab-pane" id="stdout">
<div id="stdout-form-container"></div>
</div>
<div class="tab-pane" id="stderr">
<div id="stderr-form-container"></div>
</div>
<div class="tab-pane" id="traceback">
<div id="traceback-form-container"></div>
</div>
<div class="tab-pane" id="json">
<div id="json-form-container"></div>
</div>
</div>
</div>

View File

@ -61,7 +61,8 @@
} }
.OnePlusTwo-left--detailsLabel { .OnePlusTwo-left--detailsLabel {
width: 140px; word-wrap: break-word;
width: 170px;
display: inline-block; display: inline-block;
color: @default-interface-txt; color: @default-interface-txt;
text-transform: uppercase; text-transform: uppercase;