提交 fbd73b21 编写于 作者: L Lucas Meneghel Rodrigues

Avocado: Add htmlresult plugin

Add a plugin to write a job summary HTML page.
The plugin takes results and creates a JSON context, that
is used by the pystache library, to render an HTML page
using a mustache template.

This commit contains the base plugin code, and the resource
files used by the plugin (bootstrap for some of the js, base
css, datatables for the test results table functionality).
Signed-off-by: NLucas Meneghel Rodrigues <lmr@redhat.com>
上级 8217d78b
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
#
# See LICENSE for more details.
#
# Copyright: Red Hat Inc. 2014
# Author: Lucas Meneghel Rodrigues <lmr@redhat.com>
"""
HTML output module.
"""
import os
import time
import shutil
import sys
import webbrowser
import pystache
from avocado.core import output
from avocado.core import error_codes
from avocado.plugins import plugin
from avocado.result import TestResult
class ReportModel(object):
"""
Prepares JSON that can be passed up to mustache for rendering.
"""
def __init__(self, json_input, html_output, relative_links):
"""
Base JSON that comes from test results.
"""
self.json = json_input
self.relative_links = relative_links
self.html_output = html_output
def job_id(self):
return self.json['job_id']
def execution_time(self):
return "%.2f" % self.json['time']
def _results_dir(self, relative_links=True):
debuglog_abspath = os.path.abspath(os.path.dirname(self.json['debuglog']))
html_output_abspath = os.path.abspath(os.path.dirname(self.html_output))
if relative_links:
return os.path.relpath(debuglog_abspath, html_output_abspath)
else:
return debuglog_abspath
def results_dir(self):
return self._results_dir(relative_links=self.relative_links)
def results_dir_basename(self):
return os.path.basename(self._results_dir(relative_links=False))
def total(self):
return self.json['total']
def passed(self):
return self.json['pass']
def pass_rate(self):
pr = 100 * (float(self.json['pass']) / float(self.json['total']))
return "%.2f" % pr
def _get_sysinfo(self, sysinfo_file):
sysinfo_path = os.path.join(self._results_dir(relative_links=False), 'sysinfo', 'pre', sysinfo_file)
try:
with open(sysinfo_path, 'r') as sysinfo_file:
sysinfo_contents = sysinfo_file.read()
except OSError, details:
sysinfo_contents = "Error reading %s: %s" % (sysinfo_path, details)
return sysinfo_contents
def hostname(self):
return self._get_sysinfo('hostname')
@property
def tests(self):
mapping = {"TEST_NA": "warning",
"ABORT": "danger",
"ERROR": "danger",
"NOT_FOUND": "warning",
"FAIL": "danger",
"WARN": "warning",
"PASS": "success",
"START": "info",
"ALERT": "danger",
"RUNNING": "info",
"NOSTATUS": "info",
"INTERRUPTED": "danger"}
test_info = self.json['tests']
for t in test_info:
t['link'] = os.path.join(self._results_dir(relative_links=self.relative_links), 'test-results', t['url'], 'debug.log')
t['link_basename'] = os.path.basename(t['link'])
t['dir_link'] = os.path.join(self._results_dir(relative_links=self.relative_links), 'test-results', t['url'])
t['time'] = "%.2f" % t['time']
t['time_start'] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(t['time_start']))
t['row_class'] = mapping[t['status']]
exibition_limit = 40
if len(t['fail_reason']) > exibition_limit:
t['fail_reason'] = ('<a data-container="body" data-toggle="popover" '
'data-placement="top" title="Error Details" data-content="%s">%s...</a>' %
(t['fail_reason'], t['fail_reason'][:exibition_limit]))
return test_info
def sysinfo(self):
base_path = os.path.join(self._results_dir(relative_links=False), 'sysinfo', 'pre')
sysinfo_files = os.listdir(base_path)
sysinfo_files.sort()
sysinfo_list = []
s_id = 1
for s_f in sysinfo_files:
sysinfo_dict = {}
sysinfo_path = os.path.join(base_path, s_f)
try:
with open(sysinfo_path, 'r') as sysinfo_file:
sysinfo_dict['file'] = " ".join(s_f.split("_"))
sysinfo_dict['contents'] = sysinfo_file.read()
sysinfo_dict['element_id'] = 'heading_%s' % s_id
sysinfo_dict['collapse_id'] = 'collapse_%s' % s_id
except OSError:
sysinfo_dict[s_f] = 'Error reading sysinfo file %s' % sysinfo_path
sysinfo_list.append(sysinfo_dict)
s_id += 1
return sysinfo_list
class HTMLTestResult(TestResult):
"""
HTML Test Result class.
"""
command_line_arg_name = '--html'
def __init__(self, stream=None, args=None):
TestResult.__init__(self, stream, args)
self.output = getattr(self.args, 'html_output')
self.args = args
self.view = output.View(app_args=args)
self.json = None
def start_tests(self):
"""
Called once before any tests are executed.
"""
TestResult.start_tests(self)
self.json = {'debuglog': self.stream.logfile,
'tests': []}
def end_test(self, state):
"""
Called when the given test has been run.
:param state: result of :class:`avocado.test.Test.get_state`.
:type state: dict
"""
TestResult.end_test(self, state)
if 'job_id' not in self.json:
self.json['job_id'] = state['job_unique_id']
if state['fail_reason'] is None:
state['fail_reason'] = ''
else:
state['fail_reason'] = str(state['fail_reason'])
t = {'test': state['tagged_name'],
'url': state['name'],
'time_start': state['time_start'],
'time_end': state['time_end'],
'time': state['time_elapsed'],
'status': state['status'],
'fail_reason': state['fail_reason'],
'whiteboard': state['whiteboard'],
}
self.json['tests'].append(t)
def end_tests(self):
"""
Called once after all tests are executed.
"""
TestResult.end_tests(self)
self.json.update({
'total': len(self.json['tests']),
'pass': len(self.passed),
'errors': len(self.errors),
'not_found': len(self.not_found),
'failures': len(self.failed),
'skip': len(self.skipped),
'time': self.total_time
})
self._render_report()
def _render_report(self):
if self.args is not None:
relative_links = getattr(self.args, 'relative_links')
else:
relative_links = False
context = ReportModel(json_input=self.json, html_output=self.output, relative_links=relative_links)
renderer = pystache.Renderer()
html = HTML()
template = html.get_resource_path('templates', 'report.mustache')
report_contents = renderer.render(open(template, 'r').read(), context)
static_basedir = html.get_resource_path('static')
if self.output == '-':
self.view.notify(event='error', msg="HTML to stdout not supported "
"(not all HTML resources can be embedded to a single file)")
sys.exit(error_codes.numeric_status['AVOCADO_JOB_FAIL'])
else:
output_dir = os.path.dirname(os.path.abspath(self.output))
if not os.path.exists(output_dir):
os.makedirs(output_dir)
for resource_dir in os.listdir(static_basedir):
res_dir = os.path.join(static_basedir, resource_dir)
out_dir = os.path.join(output_dir, resource_dir)
if os.path.exists(out_dir):
shutil.rmtree(out_dir)
shutil.copytree(res_dir, out_dir)
with open(self.output, 'w') as report_file:
report_file.write(report_contents)
if self.args is not None:
if getattr(self.args, 'open_browser'):
webbrowser.open(self.output)
class HTML(plugin.Plugin):
"""
HTML job report.
"""
name = 'htmlresult'
enabled = True
parser = None
def configure(self, parser):
self.parser = parser
self.parser.runner.add_argument(
'--html', type=str,
dest='html_output',
help=('Enable HTML output to the file where the result should be written. '
'The option - is not supported since not all HTML resources can be '
'embedded into a single file (page resources will be copied to the '
'output file dir)'))
self.parser.runner.add_argument(
'--relative-links',
dest='relative_links',
action='store_true',
default=False,
help=('On the HTML report, generate anchor links with relative instead of absolute paths. Default: %s' %
False))
self.parser.runner.add_argument(
'--open-browser',
dest='open_browser',
action='store_true',
default=False,
help='Open the generated report on your preferred browser. '
'This works even if --html was not explicitly passed, since an HTML '
'report is always generated on the job results dir. Default: %s' % False)
self.configured = True
def activate(self, app_args):
try:
if app_args.html_output:
self.parser.application.set_defaults(html_result=HTMLTestResult)
except AttributeError:
pass
div.dataTables_length label {
font-weight: normal;
text-align: left;
white-space: nowrap;
}
div.dataTables_length select {
width: 75px;
display: inline-block;
}
div.dataTables_filter {
text-align: right;
}
div.dataTables_filter label {
font-weight: normal;
white-space: nowrap;
text-align: left;
}
div.dataTables_filter input {
margin-left: 0.5em;
display: inline-block;
}
div.dataTables_info {
padding-top: 8px;
white-space: nowrap;
}
div.dataTables_paginate {
margin: 0;
white-space: nowrap;
text-align: right;
}
div.dataTables_paginate ul.pagination {
margin: 2px 0;
white-space: nowrap;
}
@media screen and (max-width: 767px) {
div.dataTables_length,
div.dataTables_filter,
div.dataTables_info,
div.dataTables_paginate {
text-align: center;
}
}
table.dataTable td,
table.dataTable th {
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
table.dataTable {
clear: both;
margin-top: 6px !important;
margin-bottom: 6px !important;
max-width: none !important;
}
table.dataTable thead .sorting,
table.dataTable thead .sorting_asc,
table.dataTable thead .sorting_desc,
table.dataTable thead .sorting_asc_disabled,
table.dataTable thead .sorting_desc_disabled {
cursor: pointer;
}
table.dataTable thead .sorting { background: url('../images/sort_both.png') no-repeat center right; }
table.dataTable thead .sorting_asc { background: url('../images/sort_asc.png') no-repeat center right; }
table.dataTable thead .sorting_desc { background: url('../images/sort_desc.png') no-repeat center right; }
table.dataTable thead .sorting_asc_disabled { background: url('../images/sort_asc_disabled.png') no-repeat center right; }
table.dataTable thead .sorting_desc_disabled { background: url('../images/sort_desc_disabled.png') no-repeat center right; }
table.dataTable thead > tr > th {
padding-left: 18px;
padding-right: 18px;
}
table.dataTable th:active {
outline: none;
}
/* Scrolling */
div.dataTables_scrollHead table {
margin-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
div.dataTables_scrollHead table thead tr:last-child th:first-child,
div.dataTables_scrollHead table thead tr:last-child td:first-child {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
div.dataTables_scrollBody table {
border-top: none;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
div.dataTables_scrollBody tbody tr:first-child th,
div.dataTables_scrollBody tbody tr:first-child td {
border-top: none;
}
div.dataTables_scrollFoot table {
margin-top: 0 !important;
border-top: none;
}
/* Frustratingly the border-collapse:collapse used by Bootstrap makes the column
width calculations when using scrolling impossible to align columns. We have
to use separate
*/
table.table-bordered.dataTable {
border-collapse: separate !important;
}
table.table-bordered thead th,
table.table-bordered thead td {
border-left-width: 0;
border-top-width: 0;
}
table.table-bordered tbody th,
table.table-bordered tbody td {
border-left-width: 0;
border-bottom-width: 0;
}
table.table-bordered th:last-child,
table.table-bordered td:last-child {
border-right-width: 0;
}
div.dataTables_scrollHead table.table-bordered {
border-bottom-width: 0;
}
/*
* TableTools styles
*/
.table.dataTable tbody tr.active td,
.table.dataTable tbody tr.active th {
background-color: #08C;
color: white;
}
.table.dataTable tbody tr.active:hover td,
.table.dataTable tbody tr.active:hover th {
background-color: #0075b0 !important;
}
.table.dataTable tbody tr.active th > a,
.table.dataTable tbody tr.active td > a {
color: white;
}
.table-striped.dataTable tbody tr.active:nth-child(odd) td,
.table-striped.dataTable tbody tr.active:nth-child(odd) th {
background-color: #017ebc;
}
table.DTTT_selectable tbody tr {
cursor: pointer;
}
div.DTTT .btn {
color: #333 !important;
font-size: 12px;
}
div.DTTT .btn:hover {
text-decoration: none !important;
}
ul.DTTT_dropdown.dropdown-menu {
z-index: 2003;
}
ul.DTTT_dropdown.dropdown-menu a {
color: #333 !important; /* needed only when demo_page.css is included */
}
ul.DTTT_dropdown.dropdown-menu li {
position: relative;
}
ul.DTTT_dropdown.dropdown-menu li:hover a {
background-color: #0088cc;
color: white !important;
}
div.DTTT_collection_background {
z-index: 2002;
}
/* TableTools information display */
div.DTTT_print_info {
position: fixed;
top: 50%;
left: 50%;
width: 400px;
height: 150px;
margin-left: -200px;
margin-top: -75px;
text-align: center;
color: #333;
padding: 10px 30px;
opacity: 0.95;
background-color: white;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 6px;
-webkit-box-shadow: 0 3px 7px rgba(0, 0, 0, 0.5);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.5);
}
div.DTTT_print_info h6 {
font-weight: normal;
font-size: 28px;
line-height: 28px;
margin: 1em;
}
div.DTTT_print_info p {
font-size: 14px;
line-height: 20px;
}
div.dataTables_processing {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 60px;
margin-left: -50%;
margin-top: -25px;
padding-top: 20px;
padding-bottom: 20px;
text-align: center;
font-size: 1.2em;
background-color: white;
background: -webkit-gradient(linear, left top, right top, color-stop(0%, rgba(255,255,255,0)), color-stop(25%, rgba(255,255,255,0.9)), color-stop(75%, rgba(255,255,255,0.9)), color-stop(100%, rgba(255,255,255,0)));
background: -webkit-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);
background: -moz-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);
background: -ms-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);
background: -o-linear-gradient(left, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);
background: linear-gradient(to right, rgba(255,255,255,0) 0%, rgba(255,255,255,0.9) 25%, rgba(255,255,255,0.9) 75%, rgba(255,255,255,0) 100%);
}
/*
* FixedColumns styles
*/
div.DTFC_LeftHeadWrapper table,
div.DTFC_LeftFootWrapper table,
div.DTFC_RightHeadWrapper table,
div.DTFC_RightFootWrapper table,
table.DTFC_Cloned tr.even {
background-color: white;
margin-bottom: 0;
}
div.DTFC_RightHeadWrapper table ,
div.DTFC_LeftHeadWrapper table {
border-bottom: none !important;
margin-bottom: 0 !important;
border-top-right-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
div.DTFC_RightHeadWrapper table thead tr:last-child th:first-child,
div.DTFC_RightHeadWrapper table thead tr:last-child td:first-child,
div.DTFC_LeftHeadWrapper table thead tr:last-child th:first-child,
div.DTFC_LeftHeadWrapper table thead tr:last-child td:first-child {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
div.DTFC_RightBodyWrapper table,
div.DTFC_LeftBodyWrapper table {
border-top: none;
margin: 0 !important;
}
div.DTFC_RightBodyWrapper tbody tr:first-child th,
div.DTFC_RightBodyWrapper tbody tr:first-child td,
div.DTFC_LeftBodyWrapper tbody tr:first-child th,
div.DTFC_LeftBodyWrapper tbody tr:first-child td {
border-top: none;
}
div.DTFC_RightFootWrapper table,
div.DTFC_LeftFootWrapper table {
border-top: none;
margin-top: 0 !important;
}
/*
* FixedHeader styles
*/
div.FixedHeader_Cloned table {
margin: 0 !important
}
/*! DataTables Bootstrap 3 integration
* ©2011-2014 SpryMedia Ltd - datatables.net/license
*/
/**
* DataTables integration for Bootstrap 3. This requires Bootstrap 3 and
* DataTables 1.10 or newer.
*
* This file sets the defaults and adds options to DataTables to style its
* controls using Bootstrap. See http://datatables.net/manual/styling/bootstrap
* for further information.
*/
(function(window, document, undefined){
var factory = function( $, DataTable ) {
"use strict";
/* Set the defaults for DataTables initialisation */
$.extend( true, DataTable.defaults, {
dom:
"<'row'<'col-sm-6'l><'col-sm-6'f>>" +
"<'row'<'col-sm-12'tr>>" +
"<'row'<'col-sm-6'i><'col-sm-6'p>>",
renderer: 'bootstrap'
} );
/* Default class modification */
$.extend( DataTable.ext.classes, {
sWrapper: "dataTables_wrapper form-inline dt-bootstrap",
sFilterInput: "form-control input-sm",
sLengthSelect: "form-control input-sm"
} );
/* Bootstrap paging button renderer */
DataTable.ext.renderer.pageButton.bootstrap = function ( settings, host, idx, buttons, page, pages ) {
var api = new DataTable.Api( settings );
var classes = settings.oClasses;
var lang = settings.oLanguage.oPaginate;
var btnDisplay, btnClass;
var attach = function( container, buttons ) {
var i, ien, node, button;
var clickHandler = function ( e ) {
e.preventDefault();
if ( !$(e.currentTarget).hasClass('disabled') ) {
api.page( e.data.action ).draw( false );
}
};
for ( i=0, ien=buttons.length ; i<ien ; i++ ) {
button = buttons[i];
if ( $.isArray( button ) ) {
attach( container, button );
}
else {
btnDisplay = '';
btnClass = '';
switch ( button ) {
case 'ellipsis':
btnDisplay = '&hellip;';
btnClass = 'disabled';
break;
case 'first':
btnDisplay = lang.sFirst;
btnClass = button + (page > 0 ?
'' : ' disabled');
break;
case 'previous':
btnDisplay = lang.sPrevious;
btnClass = button + (page > 0 ?
'' : ' disabled');
break;
case 'next':
btnDisplay = lang.sNext;
btnClass = button + (page < pages-1 ?
'' : ' disabled');
break;
case 'last':
btnDisplay = lang.sLast;
btnClass = button + (page < pages-1 ?
'' : ' disabled');
break;
default:
btnDisplay = button + 1;
btnClass = page === button ?
'active' : '';
break;
}
if ( btnDisplay ) {
node = $('<li>', {
'class': classes.sPageButton+' '+btnClass,
'aria-controls': settings.sTableId,
'tabindex': settings.iTabIndex,
'id': idx === 0 && typeof button === 'string' ?
settings.sTableId +'_'+ button :
null
} )
.append( $('<a>', {
'href': '#'
} )
.html( btnDisplay )
)
.appendTo( container );
settings.oApi._fnBindAction(
node, {action: button}, clickHandler
);
}
}
}
};
attach(
$(host).empty().html('<ul class="pagination"/>').children('ul'),
buttons
);
};
/*
* TableTools Bootstrap compatibility
* Required TableTools 2.1+
*/
if ( DataTable.TableTools ) {
// Set the classes that TableTools uses to something suitable for Bootstrap
$.extend( true, DataTable.TableTools.classes, {
"container": "DTTT btn-group",
"buttons": {
"normal": "btn btn-default",
"disabled": "disabled"
},
"collection": {
"container": "DTTT_dropdown dropdown-menu",
"buttons": {
"normal": "",
"disabled": "disabled"
}
},
"print": {
"info": "DTTT_print_info"
},
"select": {
"row": "active"
}
} );
// Have the collection use a bootstrap compatible drop down
$.extend( true, DataTable.TableTools.DEFAULTS.oTags, {
"collection": {
"container": "ul",
"button": "li",
"liner": "a"
}
} );
}
}; // /factory
// Define as an AMD module if possible
if ( typeof define === 'function' && define.amd ) {
define( ['jquery', 'datatables'], factory );
}
else if ( typeof exports === 'object' ) {
// Node/CommonJS
factory( require('jquery'), require('datatables') );
}
else if ( jQuery ) {
// Otherwise simply initialise as normal, stopping multiple evaluation
factory( jQuery, jQuery.fn.dataTable );
}
})(window, document);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Avocado Job Report</title>
<link href="css/bootstrap.min.css" rel="stylesheet">
<link href="css/dataTables.bootstrap.css" rel="stylesheet">
<script src="js/jquery.min.js"></script>
<script src="js/bootstrap.min.js"></script>
<script src="js/jquery.dataTables.min.js"></script>
<script src="js/dataTables.bootstrap.js"></script>
<script type="text/javascript" charset="utf-8">
$(document).ready(function() {
$('#results').dataTable();
$(function () {$('[data-toggle="popover"]').popover()})
} );
</script>
</head>
<body>
<div class="container">
<div class="page-header">
<h3>Avocado Job Report</h3>
</div>
<div class="panel-group" role="tablist">
<div class="panel panel-default">
<!-- Default panel contents -->
<div class="panel-heading">
<h4 class="panel-title">Summary</h4>
</div>
<!-- Table -->
<table class="table table-bordered">
<tr>
<td>ID</td><td><tt>{{job_id}}</tt></td>
</tr>
<tr>
<td>Host</td><td><tt>{{hostname}}</tt></td>
</tr>
<tr>
<td>Results Dir</td><td><a href="{{results_dir}}"><tt>{{results_dir_basename}}</tt></a></td>
</tr>
<tr>
<td>Execution time</td><td>{{execution_time}} s</td>
</tr>
<tr>
<td>Stats</td><td>From {{total}} tests executed, {{passed}} passed (pass rate of {{pass_rate}}%)</td>
</tr>
</table>
</div>
</div>
<table id="results" class="display" cellspacing="0" width="100%"><thead>
<tr>
<th>Start Time</th>
<th>Test ID</th>
<th>Status</th>
<th>Time (sec)</th>
<th>Info</th>
<th>Debug Log</th>
</tr>
</thead>
{{#tests}}
<tr class="{{row_class}}">
<td>{{time_start}}</td>
<td><a href="{{dir_link}}">{{url}}</a></td>
<td>{{status}}</td>
<td>{{time}}</td>
<td>{{& fail_reason}}</td>
<td><a href="{{link}}">{{link_basename}}</a></td>
</tr>
{{/tests}}
</table>
<div class="panel-group" id="accordion" role="tablist" aria-multiselectable="true">
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="headingOne">
<h4 class="panel-title"><a data-toggle="collapse" data-parent="#accordion" href="#collapseOne" aria-expanded="false" aria-controls="collapseOne"> Sysinfo (pre job, click to expand) </a></h4>
</div>
<div id="collapseOne" class="panel-collapse collapse" role="tabpanel" aria-labelledby="headingOne">
<div class="panel-body">
<div class="panel-group" id="accordion2" role="tablist" aria-multiselectable="true">
{{#sysinfo}}
<div class="panel panel-default">
<div class="panel-heading" role="tab" id="{{element_id}}">
<h4 class="panel-title"><a data-toggle="collapse" data-parent="#accordion2" href="#{{collapse_id}}" aria-expanded="false" aria-controls="{{collapse_id}}"><tt>{{file}}</tt></a></h4>
</div>
<div id="{{collapse_id}}" class="panel-collapse collapse" role="tabpanel" aria-labelledby="{{element_id}}">
<div class="panel-body">
<pre>{{contents}}</pre>
</div>
</div>
</div>
{{/sysinfo}}
</div>
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
$('#results')
.removeClass( 'display' )
.addClass('table table-striped table-bordered');
</script>
</body>
</html>
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册