puppetboard: Commit the current code.

This is all the code after a week and a bit hacking on this project.
It's in a rather experimental state but should work with a little
effort.
This commit is contained in:
Daniele Sluijters
2013-08-07 08:58:44 +02:00
parent 26de6d2979
commit f41dd99f60
30 changed files with 2377 additions and 0 deletions

8
dev.py Normal file
View File

@@ -0,0 +1,8 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from puppetboard.app import app
if __name__ == '__main__':
app.debug=True
app.run('127.0.0.1')

0
puppetboard/__init__.py Normal file
View File

198
puppetboard/app.py Normal file
View File

@@ -0,0 +1,198 @@
from __future__ import unicode_literals
from __future__ import absolute_import
import os
import logging
import collections
from flask import (
Flask, render_template, abort, url_for,
Response, stream_with_context,
)
from pypuppetdb import connect
from pypuppetdb.errors import ExperimentalDisabledError
from puppetboard.forms import QueryForm
from puppetboard.utils import (
get_or_abort, yield_or_stop,
ten_reports,
)
app = Flask(__name__)
app.config.from_object('puppetboard.default_settings')
app.config.from_envvar('PUPPETBOARD_SETTINGS', silent=True)
app.secret_key = os.urandom(24)
puppetdb = connect(
host=app.config['PUPPETDB_HOST'],
port=app.config['PUPPETDB_PORT'],
ssl=app.config['PUPPETDB_SSL'],
ssl_key=app.config['PUPPETDB_KEY'],
ssl_cert=app.config['PUPPETDB_CERT'],
timeout=app.config['PUPPETDB_TIMEOUT'],
experimental=app.config['PUPPETDB_EXPERIMENTAL'])
numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None)
if not isinstance(numeric_level, int):
raise ValueError('Invalid log level: %s' % loglevel)
logging.basicConfig(level=numeric_level)
log = logging.getLogger(__name__)
def stream_template(template_name, **context):
app.update_template_context(context)
t = app.jinja_env.get_template(template_name)
rv = t.stream(context)
rv.enable_buffering(5)
return rv
@app.errorhandler(400)
def bad_request(e):
return render_template('400.html'), 400
@app.errorhandler(404)
def not_found(e):
return render_template('404.html'), 404
@app.errorhandler(412)
def precond_failed(e):
"""We're slightly abusing 412 to handle ExperimentalDisabled errors."""
return render_template('412.html'), 412
@app.errorhandler(500)
def server_error(e):
return render_template('500.html'), 500
@app.route('/')
def index():
"""This view generates the index page and displays a set of metrics fetched
from PuppetDB."""
# TODO: Would be great if we could parallelize this somehow, doing these
# requests in sequence is rather pointless.
num_nodes = get_or_abort(puppetdb.metric,
'com.puppetlabs.puppetdb.query.population:type=default,name=num-nodes')
num_resources = get_or_abort(puppetdb.metric,
'com.puppetlabs.puppetdb.query.population:type=default,name=num-resources')
avg_resources_node = get_or_abort(puppetdb.metric,
'com.puppetlabs.puppetdb.query.population:type=default,name=avg-resources-per-node')
mean_failed_commands = get_or_abort(puppetdb.metric,
'com.puppetlabs.puppetdb.command:type=global,name=fatal')
mean_command_time = get_or_abort(puppetdb.metric,
'com.puppetlabs.puppetdb.command:type=global,name=processing-time')
metrics = {
'num_nodes': num_nodes['Value'],
'num_resources': num_resources['Value'],
'avg_resources_node': "{:10.6f}".format(avg_resources_node['Value']),
'mean_failed_commands': mean_failed_commands['MeanRate'],
'mean_command_time': "{:10.6f}".format(mean_command_time['MeanRate']),
}
return render_template('index.html', metrics=metrics)
@app.route('/nodes')
def nodes():
"""Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes.
Downside of the streaming aproach is that since we've already sent our
headers we can't abort the request if we detect an error. Because of this
we'll end up with an empty table instead because of how yield_or_stop
works. Once pagination is in place we can change this but we'll need to
provide a search feature instead.
"""
return Response(stream_with_context(stream_template('nodes.html',
nodes=yield_or_stop(puppetdb.nodes()))))
@app.route('/node/<node_name>')
def node(node_name):
"""Display a dashboard for a node showing as much data as we have on that
node. This includes facts and reports but not Resources as that is too
heavy to do within a single request.
"""
node = get_or_abort(puppetdb.node, node_name)
facts = node.facts()
if app.config['PUPPETDB_EXPERIMENTAL']:
reports = ten_reports(node.reports())
else:
reports = iter([])
return render_template('node.html', node=node, facts=yield_or_stop(facts),
reports=yield_or_stop(reports))
@app.route('/reports')
def reports():
"""Doesn't do much yet but is meant to show something like the reports of
the last half our, something like that."""
if app.config['PUPPETDB_EXPERIMENTAL']:
return render_template('reports.html')
else:
log.warn('Access to experimental endpoint not allowed.')
abort(412)
@app.route('/reports/<node>')
def reports_node(node):
"""Fetches all reports for a node and processes them eventually rendering
a table displaying those reports."""
if app.config['PUPPETDB_EXPERIMENTAL']:
reports = ten_reports(yield_or_stop(
puppetdb.reports('["=", "certname", "{0}"]'.format(node))))
else:
log.warn('Access to experimental endpoint not allowed.')
abort(412)
return render_template('reports_node.html', reports=reports,
nodename=node)
@app.route('/report/<node>/<report_id>')
def report(node, report_id):
"""Displays a single report including all the events associated with that
report and their status."""
if app.config['PUPPETDB_EXPERIMENTAL']:
reports = puppetdb.reports('["=", "certname", "{0}"]'.format(node))
else:
log.warn('Access to experimental endpoint not allowed.')
abort(412)
for report in reports:
if report.hash_ == report_id:
events = puppetdb.events('["=", "report", "{0}"]'.format(
report.hash_))
return render_template('report.html', report=report,
events=yield_or_stop(events))
else:
abort(404)
@app.route('/facts')
def facts():
"""Displays an alphabetical list of all facts currently known to
PuppetDB."""
facts_dict = collections.defaultdict(list)
facts = get_or_abort(puppetdb.fact_names)
for fact in facts:
letter = fact[0].upper()
letter_list = facts_dict[letter]
letter_list.append(fact)
facts_dict[letter] = letter_list
sorted_facts_dict = sorted(facts_dict.items())
return render_template('facts.html', facts_dict=sorted_facts_dict)
@app.route('/fact/<fact>')
def fact(fact):
"""Fetches the specific fact from PuppetDB and displays its value per
node for which this fact is known."""
return Response(stream_with_context(stream_template('fact.html',
name=fact,
facts=yield_or_stop(puppetdb.facts(name=fact)))))
@app.route('/query', methods=('GET', 'POST'))
def query():
"""Allows to execute raw, user created querries against PuppetDB. This is
currently highly experimental and explodes in interesting ways since none
of the possible exceptions are being handled just yet. This will return
the JSON of the response or a message telling you what whent wrong /
why nothing was returned."""
form = QueryForm()
if form.validate_on_submit():
result = get_or_abort(puppetdb._query, form.endpoints.data,
query='[{0}]'.format(form.query.data))
return render_template('query.html', form=form, result=result)
return render_template('query.html', form=form)

View File

@@ -0,0 +1,8 @@
PUPPETDB_HOST='localhost'
PUPPETDB_PORT=8080
PUPPETDB_SSL=False
PUPPETDB_KEY=None
PUPPETDB_CERT=None
PUPPETDB_TIMEOUT=20
PUPPETDB_EXPERIMENTAL=False
LOGLEVEL='info'

20
puppetboard/forms.py Normal file
View File

@@ -0,0 +1,20 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from flask.ext.wtf import Form
from wtforms import RadioField, TextAreaField, validators
class QueryForm(Form):
"""The form used to allow freeform queries to be executed against
PuppetDB."""
query = TextAreaField('Query', [validators.Required(
message='A query is required.')])
endpoints = RadioField('API endpoint', choices = [
('nodes', 'Nodes'),
('resources', 'Resources'),
('facts', 'Facts'),
('fact-names', 'Fact Names'),
('reports', 'Reports'),
('events', 'Events'),
])

View File

@@ -0,0 +1,17 @@
$ = jQuery
$ ->
$('input.filter-list').parent('div').removeClass('hide')
$("input.filter-list").on "keyup", (e) ->
rex = new RegExp($(this).val(), "i")
$(".searchable li").hide()
$(".searchable li").filter( ->
rex.test $(this).text()
).show()
if e.keyCode is 27
$(e.currentTarget).val ""
ev = $.Event("keyup")
ev.keyCode = 13
$(e.currentTarget).trigger(ev)
e.currentTarget.blur()

View File

@@ -0,0 +1,28 @@
$ = jQuery
$ ->
$('.nodes').tablesorter(
headers:
3:
sorter: false
sortList: [[0,0]]
)
$('.facts').tablesorter(
sortList: [[0,0]]
)
$('input.filter-table').parent('div').removeClass('hide')
$("input.filter-table").on "keyup", (e) ->
rex = new RegExp($(this).val(), "i")
$(".searchable tr").hide()
$(".searchable tr").filter( ->
rex.test $(this).text()
).show()
if e.keyCode is 27
$(e.currentTarget).val ""
ev = $.Event("keyup")
ev.keyCode = 13
$(e.currentTarget).trigger(ev)
e.currentTarget.blur()

View File

@@ -0,0 +1,51 @@
body {
padding-top: 60px;
}
th.headerSortUp {
position: relative
}
th.headerSortDown {
position: relative
}
th.header {
position: relative
}
th.header:after {
content: "\f0dc";
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
text-decoration: inherit;
color: #000;
font-size: 18px;
padding-right: 0.5em;
float:right;
}
th.headerSortUp:after {
content: "\f0de";
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
text-decoration: inherit;
color: #000;
font-size: 18px;
padding-right: 0.5em;
float:right;
}
th.headerSortDown:after {
content: "\f0dd";
font-family: FontAwesome;
font-style: normal;
font-weight: normal;
text-decoration: inherit;
color: #000;
font-size: 18px;
padding-right: 0.5em;
float:right;
}
.stat {
margin-bottom: 40px;
}
.navbar .brand:hover {
color: #fff;
}

View File

@@ -0,0 +1,27 @@
// Generated by CoffeeScript 1.6.3
(function() {
var $;
$ = jQuery;
$(function() {});
$('input.filter-list').parent('div').removeClass('hide');
$("input.filter-list").on("keyup", function(e) {
var ev, rex;
rex = new RegExp($(this).val(), "i");
$(".searchable li").hide();
$(".searchable li").filter(function() {
return rex.test($(this).text());
}).show();
if (e.keyCode === 27) {
$(e.currentTarget).val("");
ev = $.Event("keyup");
ev.keyCode = 13;
$(e.currentTarget).trigger(ev);
return e.currentTarget.blur();
}
});
}).call(this);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
// Generated by CoffeeScript 1.6.3
(function() {
var $;
$ = jQuery;
$(function() {});
$('.nodes').tablesorter({
headers: {
3: {
sorter: false
}
},
sortList: [[0, 0]]
});
$('.facts').tablesorter({
sortList: [[0, 0]]
});
$('input.filter-table').parent('div').removeClass('hide');
$("input.filter-table").on("keyup", function(e) {
var ev, rex;
rex = new RegExp($(this).val(), "i");
$(".searchable tr").hide();
$(".searchable tr").filter(function() {
return rex.test($(this).text());
}).show();
if (e.keyCode === 27) {
$(e.currentTarget).val("");
ev = $.Event("keyup");
ev.keyCode = 13;
$(e.currentTarget).trigger(ev);
return e.currentTarget.blur();
}
});
}).call(this);

View File

@@ -0,0 +1,23 @@
jQuery(function ($) {
var localise_timestamp = function(timestamp){
if (timestamp === "None"){
return '';
};
d = moment.utc(timestamp);
d.local();
return d;
};
$("[rel=utctimestamp]").each(
function(index, timestamp){
var tstamp = $(timestamp);
var tstring = tstamp.text().trim();
var result = localise_timestamp(tstring);
if (result == '') {
tstamp.text('Unknown');
} else {
tstamp.text(localise_timestamp(tstring).format('LLLL'));
};
});
});

View File

@@ -0,0 +1,11 @@
{% extends 'layout.html' %}
{% block row_fluid %}
<div class="container" style="margin-bottom:55px;">
<div class="row">
<div class="span12">
<h2>Bad Request</h2>
<p>The request sent to PuppetDB was invalid. This is usually caused by using an unsupported operator.</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'layout.html' %}
{% block row_fluid %}
<div class="container" style="margin-bottom:55px;">
<div class="row">
<div class="span12">
<h2>Not Found</h2>
<p>What you were looking for could not be found in PuppetDB.</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,11 @@
{% extends 'layout.html' %}
{% block row_fluid %}
<div class="container" style="margin-bottom:55px;">
<div class="row">
<div class="span12">
<h2>Experimental Disabled</h2>
<p>You're trying to access a feature restricted to PuppetDB's Experimental API but haven't configured Puppetboard to allow this.</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,16 @@
{% extends 'layout.html' %}
{% block row_fluid %}
<div class="container" style="margin-bottom:55px;">
<div class="row">
<div class="span12">
<h2>Internal Server Error</h2>
<p>This error usually occurs because:
<ul>
<li>We were unable to reach PuppetDB;</li>
<Li>The query to be executed was malformed resulting in an incorrectly encoded request.</li>
</ul></p>
<p>Please have a look at the log output for further information.</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% macro facts_table(facts, autofocus=False, condensed=False, show_node=False, margin_top=20, margin_bottom=20) -%}
<div class="filter" style="margin-bottom:{{margin_bottom}}px;margin-top:{{margin_top}}px;">
<input {% if autofocus %} autofocus="autofocus" {% endif %} style="width:100%" type="text" class="filter-table input-medium search-query" placeholder="Type here to filter">
</div>
<table class="filter-table table table-striped {% if condensed %}table-condensed{% endif%}" style="table-layout:fixed">
<thead>
<tr>
<th>Fact</th>
<th>Value</th>
</tr>
</thead>
<tbody class="searchable">
{% for fact in facts %}
<tr>
{% if show_node %}
<td>{{fact.node}}</td>
{% else %}
<td>{{fact.name}}</td>
{% endif %}
<td style="word-wrap:break-word">{{fact.value}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{%- endmacro %}
{% macro reports_table(reports, nodename, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True) -%}
<div class="alert alert-info">
Only showing the last ten reports.
</div>
<table class='table table-striped {% if condensed %}table-condensed{% endif %}'>
<thead>
<tr>
<th>Start time</th>
<th>Run time</th>
<th>Full report</th>
{% if show_conf_col %}
<th>Configuration version</th>
{% endif %}
{% if show_agent_col %}
<th>Agent version</th>
{% endif %}
{% if show_host_col %}
<th>Hostname</th>
{% endif %}
<tr>
</thead>
<tbody>
{% for report in reports %}
{% if hash_truncate %}
{% set rep_hash = "%s&hellip;"|format(report.hash_[0:6])|safe %}
{% else %}
{% set rep_hash = report.hash_ %}
{% endif %}
{% if report.failed %}
<tr class="error">
{% else %}
<tr>
{% endif %}
<td rel="utctimestamp">{{report.start}}</td>
<td>{{report.run_time}}</td>
<td><a href="{{url_for('report', node=nodename, report_id=report.hash_)}}">{{rep_hash}}</a></td>
{% if show_conf_col %}
<td>{{report.version}}</td>
{% endif %}
{% if show_agent_col %}
<td>{{report.agent_version}}</td>
{% endif %}
{% if show_host_col %}
<td>{{nodename}}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{%- endmacro %}

View File

@@ -0,0 +1,6 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %}
<h1>{{name}}</h1>
{{macros.facts_table(facts, autofocus=True, show_node=True, margin_bottom=10)}}
{% endblock content %}

View File

@@ -0,0 +1,16 @@
{% extends 'layout.html' %}
{% block content %}
<div class="hide" style="margin-bottom:20px">
<input autofocus="autofocus" style="width:100%" type="text" class="filter-list input-medium search-query" placeholder="Type here to filter">
</div>
<div style="-moz-column-count:4; -webkit-column-count:4; column-count:4;">
{%- for key,facts_list in facts_dict %}
<span class='label label-success'>{{key}}</span>
<ul class="searchable">
{%- for fact in facts_list %}
<li><a href="{{url_for('fact', fact=fact)}}">{{fact}}</a></li>
{%- endfor %}
</ul>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,38 @@
{% extends 'layout.html' %}
{% block row_fluid %}
<div class="span12">
<div class='alert alert-info'>
We need something fancy here.
</div>
</div>
<div class="container" style="margin-bottom:55px;">
<div class="row">
<div class="span12">
<div class="span4 stat">
<h1>{{metrics['num_nodes']}}</h1>
<span>Population</span>
</div>
<div class="span4 stat">
<h1>{{metrics['num_resources']}}</h1>
<span>Resources managed</span>
</div>
<div class="span4 stat">
<h1>{{metrics['avg_resources_node']}}</h1>
<span>Avg. resources/node</span>
</div>
</div>
</div>
<div class="row">
<div class="span12">
<div class="span4 stat">
<h1>{{metrics['mean_failed_commands']}}</h1>
<span>Mean command failures</span>
</div>
<div class="span4 stat offset4">
<h1>{{metrics['mean_command_time']}}s</h1>
<span>Mean command execution time</span>
</div>
</div>
</div>
</div>
{% endblock row_fluid %}

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Puppet&#7427;oard</title>
<link href="//netdna.bootstrapcdn.com/bootswatch/2.3.2/flatly/bootstrap.min.css" rel="stylesheet">
<link href="//netdna.bootstrapcdn.com/font-awesome/3.2.1/css/font-awesome.min.css" rel="stylesheet">
<link href="{{url_for('static', filename='css/puppetboard.css')}}" rel="stylesheet">
</head>
<body>
<div style="font-variant:small-caps" class="navbar navbar-fixed-top">
<div class="navbar-inner">
<div class="container">
<a class="btn btn-navbar" data-toggle="collapse" data-target=".nav-collapse">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</a>
<span style="margin-top:-2px;" class="brand">Puppetboard</span>
<div class="nav-collapse collapse">
<ul class="nav">
{%- for endpoint, caption in [
('index', 'Overview'),
('nodes', 'Nodes'),
('facts', 'Facts'),
('reports', 'Reports'),
('query', 'Query'),
] %}
<li{% if endpoint == request.endpoint %} class=active{% endif
%}><a href="{{ url_for(endpoint) }}">{{ caption }}</a></li>
{%- endfor %}
</ul>
</div>
</div>
</div>
</div>
{% block container %}
<div class="container-fluid" style="margin-bottom:55px;">
<div class="row-fluid">
{% block row_fluid %}
<div class="span12">
{% block content %} {% endblock content %}
</div>
{% endblock row_fluid %}
</div>
</div>
{% endblock container %}
<div class="navbar navbar-fixed-bottom" style="background-color:rgb(249, 249, 249);">
<div class="navbar" style="height:55px;margin-bottom:0;">
<div class="container-fluid" style="line-height:55px;">
Copyright © 2013 <a href="https://github.com/daenney">Daniele Sluijters</a>. <span style="float:right">Live from PuppetDB.</span>
</div>
</div>
</div>
<script src="http://code.jquery.com/jquery-1.10.0.min.js"></script>
<script src="//netdna.bootstrapcdn.com/twitter-bootstrap/2.3.2/js/bootstrap.min.js"></script>
<script src="//cdn.jsdelivr.net/tablesorter/2.0.3/jquery.tablesorter.min.js"></script>
<script src="{{ url_for('static', filename='js/moment.js')}}"></script>
<script src="{{ url_for('static', filename='js/timestamps.js')}}"></script>
<script src="{{url_for('static', filename='js/tables.js')}}"></script>
<script src="{{url_for('static', filename='js/lists.js')}}"></script>
{% block script %} {% endblock script %}
</body>
</html>

View File

@@ -0,0 +1,46 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %}
<div class="row-fluid">
<div class="span4">
<h1>Details</h1>
<table class="table table-striped table-condensed" style="table-layout:fixed">
<tbody>
<tr>
<td style="width:140px">Hostname</td>
<td style="word-wrap:break-word"><b>{{node.name}}</b></td>
</tr>
<tr>
<td>Catalog compiled at</td>
<td rel="utctimestamp">{{node.catalog_timestamp}}</td>
</tr>
<tr>
<td>Facts retrieved at</td>
<td rel="utctimestamp">{{node.facts_timestamp}}</td>
</tr>
{% if config.PUPPETDB_EXPERIMENTAL %}
<tr>
<td>Report uploaded at</td>
<td rel="utctimestamp">{{node.report_timestamp}}</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
{% if config.PUPPETDB_EXPERIMENTAL %}
<div class="span4">
<h1>Facts</h1>
{{macros.facts_table(facts, condensed=True, margin_top=10)}}
</div>
<div class="span4">
<h1>Reports</h1>
{{ macros.reports_table(reports, node.name, condensed=True, hash_truncate=True, show_conf_col=False, show_agent_col=False, show_host_col=False)}}
</div>
{% else %}
<div class="span8">
<h1>Facts</h1>
{{macros.facts_table(facts, condensed=True, margin_top=10)}}
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,50 @@
{% extends 'layout.html' %}
{% block content %}
<div class="alert alert-info">
PuppetDB currently only returns active nodes.
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{category}}">
{{message}}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="hide" style="margin-bottom:20px">
<input autofocus="autofocus" style="width:100%" type="text" class="filter-table input-medium search-query" placeholder="Type here to filter">
</div>
<table class='nodes table table-striped table-condensed'>
<thead>
<tr>
<th>Hostname</th>
<th>Catalog compiled at</th>
{% if config.PUPPETDB_EXPERIMENTAL %}
<th>Last report</th>
<th>&nbsp;</th>
{% endif %}
</tr>
</thead>
<tbody class="searchable">
{% for node in nodes %}
<tr>
<td><a href="{{url_for('node', node_name=node.name)}}">{{node.name}}</a></td>
<td rel="utctimestamp">{{node.catalog_timestamp}}</td>
{% if config.PUPPETDB_EXPERIMENTAL %}
<td>
{% if node.report_timestamp %}
<span rel="utctimestamp">{{ node.report_timestamp }}</span>
{% else %}
<i class="icon icon-ban-circle"></i>
{% endif %}
</td>
<td>
<a class="btn btn-small btn-primary" href="{{url_for('reports_node', node=node.name)}}">Reports</a>
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}

View File

@@ -0,0 +1,61 @@
{% extends 'layout.html' %}
{% block row_fluid %}
<div class="span12">
<div class="alert">
This is highly exeprimental and will likely set your server on fire.
</div>
</div>
<div class="container" style="margin-bottom:55px;">
<div class="row">
<div class="span12">
<h2>Compose</h2>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{category}}">
{{message}}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<form class="form-horizontal" method="POST" action="{{ url_for('query')}}">
{{ form.csrf_token }}
<div class="control-group {% if form.query.errors %} error {% endif %}">
{{form.query.label(class_="control-label")}}
<div class="controls">
{{form.query(class_="input-block-level", autofocus="autofocus", rows=5, placeholder="\"=\", \"name\" \"hostname\"")}}
{% if form.query.errors %}
<span class="help-inline">{% for error in form.query.errors %}{{error}}{% endfor %}</span>
{% endif %}
</div>
</div>
<div class="control-group {% if form.endpoints.errors %} error {% endif %}">
{{form.endpoints.label(class_="control-label")}}
<div class="controls">
{% for subfield in form.endpoints %}
{{subfield.label(class_="radio inline")}}
{{subfield }}
{% endfor %}
{% if form.endpoints.errors %}
<span class="help-inline">{% for error in form.endpoints.errors %}{{error}}{% endfor %}</span>
{% endif %}
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Yes I'm sure</button>
<button type="button" class="btn">No thanks</button>
</div>
{{ form.hidden_tag() }}
</form>
</div>
</div>
{% if result %}
<div class="row">
<div class="span12">
<h2>Result</h2>
<pre><code>{{ result|tojson|replace(", ", ",\n") }}</code></pre>
</div>
</div>
{% endif %}
</div>
{% endblock row_fluid %}

View File

@@ -0,0 +1,62 @@
{% extends 'layout.html' %}
{% block content %}
<h1>Summary</h1>
<table class='table table-striped'>
<thead>
<tr>
<th>Hostname</th>
<th>Configuration version</th>
<th>Start time</th>
<th>End time</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{report.node}}</td>
<td>
{{report.version}}
</td>
<td rel="utctimestamp">
{{report.start}}
</td>
<td rel="utctimestamp">
{{report.end}}
</td>
</tr>
</tbody>
</table>
<h1>Events</h1>
<table class='table table-striped table-condensed'>
<thead>
<tr>
<th>Resource</th>
<th>Status</th>
<th>Changed From</th>
<th>Changed To</th>
</tr>
</thead>
<tbody>
{% for event in events %}
{% if not event.failed and event.item['old'] != event.item['new'] %}
<tr class='success'>
{% elif event.failed %}
<tr class='error'>
{% endif %}
<td>{{event.item['type']}}[{{event.item['title']}}]</td>
<td>{{event.status}}</td>
<td>{{event.item['old']}}</td>
<td>{{event.item['new']}}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}
{% block script %}
<script type='text/javascript'>
jQuery(function ($) {
$("[rel=tooltip]").tooltip();
});
</script>
{% endblock script %}

View File

@@ -0,0 +1,6 @@
{% extends 'layout.html' %}
{% block content %}
<div class="alert">
Pending <a href="http://projects.puppetlabs.com/issues/21600">#21600</a>. You can access reports for a node or individual reports through the <a href="{{url_for('nodes')}}">Nodes</a> tab.
</div>
{% endblock content %}

View File

@@ -0,0 +1,5 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %}
{{ macros.reports_table(reports, nodename, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True)}}
{% endblock content %}

61
puppetboard/utils.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import absolute_import
from __future__ import unicode_literals
from requests.exceptions import HTTPError, ConnectionError
from pypuppetdb.errors import EmptyResponseError, ExperimentalDisabledError
from flask import abort, flash
def get_or_abort(func, *args, **kwargs):
"""Execute the function with its arguments and handle the possible
errors that might occur.
In this case, if we get an exception we simply abort the request.
"""
try:
return func(*args, **kwargs)
except HTTPError, e:
abort(e.response.status_code)
except ConnectionError:
abort(500)
except ExperimentalDisabledError:
abort(412)
except EmptyResponseError:
abort(204)
def ten_reports(reports):
"""Helper to yield the first then reports from the reports generator.
This is an ugly solution at best...
"""
for count, report in enumerate(reports):
if count == 10:
raise StopIteration
yield report
def yield_or_stop(generator):
"""Similar in intent to get_or_abort this helper will iterate over our
generators and handle certain errors.
Since this is also used in streaming responses where we can't just abort
a request we always yield empty and then raise StopIteration.
"""
while True:
try:
yield next(generator)
except StopIteration:
raise
except ExperimentalDisabledError:
yield
raise StopIteration
except EmptyResponseError:
yield
raise StopIteration
except ConnectionError:
yield
raise StopIteration
except HTTPError:
yield
raise StopIteration

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
Flask==0.10.1
Flask-WTF==0.8.4
pypuppetdb=0.0.1

11
wsgi.py Normal file
View File

@@ -0,0 +1,11 @@
from __future__ import absolute_import
import os
import sys
me = os.path.dirname(os.path.abspath(__file__))
# Add us to the PYTHONPATH/sys.path if we're not on it
if not me in sys.path:
sys.path.insert(0, me)
from puppetboard.app import app as application