26 Commits

Author SHA1 Message Date
Corey Hammerton
709480a83f Merge pull request #176 from corey-hammerton/0.1.0
puppetboard: Adding PuppetDB 3.x support

Some of the implementations here is:

- Environment Awareness with a dropdown menu to switch environments
- First implementation of pagination in the form of a macro for flexibility
- Use of the PuppetDB 3.x APIs, therefore PuppetDB 2.x compatibility is broken
2015-11-09 20:42:27 -05:00
Corey Hammerton
ba32cdc8a1 Merge pull request #177 from corey-hammerton/footer
puppetboard/templates/layout: Replacing @daenney in the Footer with Puppet Community
2015-11-09 20:37:37 -05:00
Corey Hammerton
cb83144443 Update README.rst
Properly setting the _GoogleGroup macro in the "Getting Help" section
2015-11-09 20:36:17 -05:00
Corey Hammerton
1c72a754d2 puppetboard/templates/layout: Restoring the footer but referencing Puppet Community
@daenney would rather have the footer reference the Puppet Community organization
than completing removing this bar.

Also adding a reference in README.rst about the available Google Group
2015-11-07 20:16:20 -05:00
Corey Hammerton
46439055f8 puppetboard/app: Reducing code redundancy for environment retreival and checking
Moving the envs variable out of the functions scope to the global scope,
this enables each function to reference and use these values.

Adding a new function check_env() to standardize the way to ensure that
the request environment exists, if it doesn't then abort with a 404.

This reduces 16 blocks of code and is in line with @daenney's suggestions
2015-11-07 20:06:48 -05:00
Corey Hammerton
61fc5994fb puppetboard/templates/layout: Removing the footer bar
This bar reduces the available vertical space and other maintainers feel
it better to remove it than to modify it.

Also reducing the specific mention of @daenney from README.rst since he
has steped down as main project maintainer.
2015-11-05 21:37:58 -05:00
Corey Hammerton
b628032c39 Merge branch '0.1.0' of github.com:corey-hammerton/puppetboard into 0.1.0 2015-11-05 21:05:51 -05:00
Corey Hammerton
f2393eabe4 Merge branch 'puppet-community/master' into 0.1.0
Fixing merge conflicts

Conflicts:
	puppetboard/app.py
	puppetboard/forms.py
	puppetboard/templates/catalogs.html
2015-11-05 21:05:31 -05:00
Spencer Krum
6f6bd0585a Merge pull request #161 from corey-hammerton/catalog
puppetboard: Adding a more intuitive catalog view
2015-11-04 17:18:01 -08:00
Corey Hammerton
b04f941e67 Puppetboard/CHANGELOG
Removing the environment switching point in the Known Issues section that has been dealt with.
2015-11-04 20:00:04 -05:00
Corey Hammerton
9486adbd14 puppetboard: Squashed commit of the following:
b4f74e240d
Making catalog tables searchable

7a8ddde6ca
Navbar style/naming simplifications

e8fea997fd
Creating Semantic UI Menu for environment switching instead of select menu
2015-11-03 07:34:30 -05:00
Corey Hammerton
e0866a12ea puppetboard/catalog: Making the catalog tables searchable
Also standardizing the form declarations
2015-10-28 19:54:16 -04:00
Corey Hammerton
7f520af661 Merge pull request #164 from raphink/v4-api
Make Puppetboard work with the PuppetDB V4 api

This PR just enables the basic functionality of PuppetBoard with the new PyPuppetDB version. https://github.com/puppet-community/puppetboard/pull/176 includes this work as well as other work to make better utilization of the new library
2015-10-26 22:11:40 -04:00
Corey Hammerton
af05f67428 puppetboard: Adding PuppetDB 3.x support
Some excerpts from CHANGELOG.rst include:
- Increasing the pypuppetdb requirements from 0.1.x to 0.2.x
- The Reports page now lists reports from the reports endpoint instead of
  a link to a PuppetDB issue with a feature request
- Adding a Catalogs page to view either individual node catalogs or compare
  them against other nodes
- New environment awareness adds a new query parameter to all applicable
  endpoints to filter results based on the current environment. If the
  default environment 'production' is not available, or any other unavailable
  environment, the user is redirected to the first known environment.
- Adding pagination functionality for reports (for now) based on the value of
  the REPORTS_COUNT configuration option (used for the limit and the offset
  calculation). Implementation also makes it possible for other UI enhancements.
- Removing the limit_reports function from puppetboard/utils.py since paging
  parameters are now accepted by the pypuppetdb endpoint functions.
- Bumping the version to 0.1.0
2015-10-26 21:44:33 -04:00
Corey Hammerton
4362f80db6 Merge pull request #170 from rfletcher/skip-color
Make "skips" color consistent
2015-10-26 20:22:24 -04:00
Corey Hammerton
2b5903375e Merge pull request #173 from pyther/master
update query example to use certname

This is just an example text update.
2015-10-26 20:22:00 -04:00
Mickaël Canévet
b539fc9475 Fix dependencies now that pypuppetdb 0.2.0 is released 2015-10-26 13:39:54 +01:00
Raphaël Pinson
6af356a2fd Use pypuppetdb with api version 4 2015-10-26 13:39:54 +01:00
Daniele Sluijters
7e3cf0189b Merge pull request #175 from bastelfreak/update-maintainer
update ArchLinux Package maintainer
2015-10-25 15:37:24 +01:00
Tim Meusel
5142f96b0b update ArchLinux Package maintainer 2015-10-25 12:46:06 +01:00
Matthew Gyurgyik
1aad26a0c8 update query example to use certname 2015-10-23 11:17:02 -04:00
Rick Fletcher
0c5914ff44 Turn down brightness and saturation a bit in the predefined "yellow" 2015-10-13 12:06:08 -04:00
Rick Fletcher
15a9aaaa9f Consistently use yellow for skip stats 2015-10-12 20:20:49 -04:00
Rick Fletcher
f2da1b295a Define a "yellow" 2015-10-12 20:19:00 -04:00
Rick Fletcher
4c13898490 Make "skips" color consistent
"Skips" were highlighted with orange on one page and yellow on another. This change makes them consistent, and switches the color to "black" for accessibility reasons. (The contrast between the shades of orange and red used for skips and errors was *very* low. Indistinguishable on some screens.)
2015-10-12 00:26:16 -04:00
Corey Hammerton
a3473abf61 puppetboard: Adding a more intuitive catalog view
A new endpoint in the header, Catalogs, takes the user to a page with a node
table similar to that in the nodes page. This table shows the node with a
link to the node page, the catalog timestamp with a link to the catalog
page and a small form with a select field to be used to compare the
catalog of this row's node with that of another node.
2015-09-23 11:16:20 -04:00
24 changed files with 946 additions and 239 deletions

View File

@@ -4,6 +4,37 @@ Changelog
This is the changelog for Puppetboard. This is the changelog for Puppetboard.
0.1.0
====
* Requires pypuppetdb >= 0.2.0
* Full support for PuppetDB 3.x
* The first directory location is now a Puppet environment which is filtered
on all supported queries. Users can browse different environments with a
select field in the top NavBar
* Using limit, order_by and offset parameters adding pagaination on the Reports
page (available in the NavBar). Functionality is available to pages that
accept a page attribute.
* The report page now directly queries pypuppetdb to match the report_id
value with the report hash or configuration_version fields.
* Catching and aborting with a 404 if the report and report_latest function
queries do not return a generator object.
* Adding a Catalogs page (similar to the Nodes page) with a form to compare
one node's catalog information with that of another node.
* Updating the Query Endpoints for the Query page.
* Adding to ``templates/_macros.html`` status_counts that shows node/report
status information, like what is avaiable on the index and nodes pages,
available to the reports pages and tables also.
* Showing report logs and metrics in the report page.
* Removing ``limit_reports`` from ``utils.py`` because this helper function
has been replaced by the limit PuppetDB paging function.
**Known Issues**
* fact_value pages rendered from JSON valued facts return no results. A more
sophisticated API is required to make use of JSON valued facts (through the
factsets, fact-paths and/or fact-contents endpoints for example)
0.0.5 0.0.5
===== =====

View File

@@ -99,13 +99,13 @@ Native packages for your operating system will be provided in the near future.
+-------------------+-----------+--------------------------------------------+ +-------------------+-----------+--------------------------------------------+
| `SuSE LE 11 SP3`_ | available | Maintained on `OpenSuSE Build Service`_ | | `SuSE LE 11 SP3`_ | available | Maintained on `OpenSuSE Build Service`_ |
+-------------------+-----------+--------------------------------------------+ +-------------------+-----------+--------------------------------------------+
| `ArchLinux`_ | available | Maintained by `Niels Abspoel`_ | | `ArchLinux`_ | available | Maintained by `Tim Meusel`_ |
+-------------------+-----------+--------------------------------------------+ +-------------------+-----------+--------------------------------------------+
| `OpenBSD`_ | available | Maintained by `Jasper Lievisse Adriaanse`_ | | `OpenBSD`_ | available | Maintained by `Jasper Lievisse Adriaanse`_ |
+-------------------+-----------+--------------------------------------------+ +-------------------+-----------+--------------------------------------------+
.. _ArchLinux: https://aur.archlinux.org/packages/python2-puppetboard/ .. _ArchLinux: https://aur.archlinux.org/packages/python2-puppetboard/
.. _Niels Abspoel: https://github.com/aboe76 .. _Tim Meusel: https://github.com/bastelfreak
.. _Jasper Lievisse Adriaanse: https://github.com/jasperla .. _Jasper Lievisse Adriaanse: https://github.com/jasperla
.. _OpenBSD: http://www.openbsd.org/cgi-bin/cvsweb/ports/www/puppetboard/ .. _OpenBSD: http://www.openbsd.org/cgi-bin/cvsweb/ports/www/puppetboard/
.. _OpenSuSE Build Service: https://build.opensuse.org/package/show/systemsmanagement:puppet/python-puppetboard .. _OpenSuSE Build Service: https://build.opensuse.org/package/show/systemsmanagement:puppet/python-puppetboard
@@ -587,16 +587,19 @@ This project is still very new so it's not inconceivable you'll run into
issues. issues.
For bug reports you can file an `issue`_. If you need help with something For bug reports you can file an `issue`_. If you need help with something
feel free to hit up `@daenney`_ by e-mail or find him on IRC. He can usually feel free to hit up the maintainers by e-mail or on IRC. They can usually
be found on `IRCnet`_ and `Freenode`_ and idles in #puppet. be found on `IRCnet`_ and `Freenode`_ and idles in #puppetboard.
There's now also the #puppetboard channel on `Freenode`_ where we hang out There's now also the #puppetboard channel on `Freenode`_ where we hang out
and answer questions related to pypuppetdb and Puppetboard. and answer questions related to pypuppetdb and Puppetboard.
.. _issue: https://github.com/nedap/puppetboard/issues There is also a `GoogleGroup`_ to exchange questions and discussions. Please
.. _@daenney: https://github.com/daenney note that this group contains discussions of other Puppet Community projects.
.. _issue: https://github.com/puppet-community/puppetboard/issues
.. _IRCnet: http://www.ircnet.org .. _IRCnet: http://www.ircnet.org
.. _Freenode: http://freenode.net .. _Freenode: http://freenode.net
.. _GoogleGroup: https://groups.google.com/forum/?hl=en#!forum/puppet-community
Third party Third party
=========== ===========

View File

@@ -8,6 +8,7 @@ try:
except ImportError: except ImportError:
from urllib.parse import unquote from urllib.parse import unquote
from datetime import datetime from datetime import datetime
from itertools import tee
from flask import ( from flask import (
Flask, render_template, abort, url_for, Flask, render_template, abort, url_for,
@@ -18,10 +19,10 @@ from flask_wtf.csrf import CsrfProtect
from pypuppetdb import connect from pypuppetdb import connect
from puppetboard.forms import QueryForm from puppetboard.forms import (CatalogForm, QueryForm)
from puppetboard.utils import ( from puppetboard.utils import (
get_or_abort, yield_or_stop, get_or_abort, yield_or_stop,
limit_reports, jsonprint jsonprint, Pagination
) )
@@ -37,7 +38,6 @@ app.secret_key = app.config['SECRET_KEY']
app.jinja_env.filters['jsonprint'] = jsonprint app.jinja_env.filters['jsonprint'] = jsonprint
puppetdb = connect( puppetdb = connect(
api_version=3,
host=app.config['PUPPETDB_HOST'], host=app.config['PUPPETDB_HOST'],
port=app.config['PUPPETDB_PORT'], port=app.config['PUPPETDB_PORT'],
ssl_verify=app.config['PUPPETDB_SSL_VERIFY'], ssl_verify=app.config['PUPPETDB_SSL_VERIFY'],
@@ -59,6 +59,33 @@ def stream_template(template_name, **context):
rv.enable_buffering(5) rv.enable_buffering(5)
return rv return rv
def url_for_pagination(page):
args = request.view_args.copy()
args['page'] = page
return url_for(request.endpoint, **args)
def url_for_environments(env):
args = request.view_args.copy()
args['env'] = env
return url_for(request.endpoint, **args)
def environments():
envs = get_or_abort(puppetdb.environments)
x = []
for env in envs:
x.append(env['name'])
return x
def check_env(env):
if env not in envs:
abort(404)
app.jinja_env.globals['url_for_pagination'] = url_for_pagination
app.jinja_env.globals['url_for_environments'] = url_for_environments
envs = environments()
@app.context_processor @app.context_processor
def utility_processor(): def utility_processor():
@@ -70,39 +97,45 @@ def utility_processor():
@app.errorhandler(400) @app.errorhandler(400)
def bad_request(e): def bad_request(e):
return render_template('400.html'), 400 return render_template('400.html', envs=envs), 400
@app.errorhandler(403) @app.errorhandler(403)
def forbidden(e): def forbidden(e):
return render_template('403.html'), 400 return render_template('403.html', envs=envs), 400
@app.errorhandler(404) @app.errorhandler(404)
def not_found(e): def not_found(e):
return render_template('404.html'), 404 return render_template('404.html', envs=envs), 404
@app.errorhandler(412) @app.errorhandler(412)
def precond_failed(e): def precond_failed(e):
"""We're slightly abusing 412 to handle missing features """We're slightly abusing 412 to handle missing features
depending on the API version.""" depending on the API version."""
return render_template('412.html'), 412 return render_template('412.html', envs=envs), 412
@app.errorhandler(500) @app.errorhandler(500)
def server_error(e): def server_error(e):
return render_template('500.html'), 500 return render_template('500.html', envs=envs), 500
@app.route('/') @app.route('/', defaults={'env': 'production'})
def index(): @app.route('/<env>/')
def index(env):
"""This view generates the index page and displays a set of metrics and """This view generates the index page and displays a set of metrics and
latest reports on nodes fetched from PuppetDB. latest reports on nodes fetched from PuppetDB.
:param env: Search for nodes in this (Catalog and Fact) environment
:type env: :obj:`string`
""" """
check_env(env)
# TODO: Would be great if we could parallelize this somehow, doing these # TODO: Would be great if we could parallelize this somehow, doing these
# requests in sequence is rather pointless. # requests in sequence is rather pointless.
prefix = 'com.puppetlabs.puppetdb.query.population' prefix = 'puppetlabs.puppetdb.query.population'
num_nodes = get_or_abort( num_nodes = get_or_abort(
puppetdb.metric, puppetdb.metric,
"{0}{1}".format(prefix, ':type=default,name=num-nodes')) "{0}{1}".format(prefix, ':type=default,name=num-nodes'))
@@ -118,7 +151,10 @@ def index():
'avg_resources_node': "{0:10.0f}".format(avg_resources_node['Value']), 'avg_resources_node': "{0:10.0f}".format(avg_resources_node['Value']),
} }
nodes = puppetdb.nodes( nodes = get_or_abort(puppetdb.nodes,
query='["and", {0}]'.format(
", ".join('["=", "{0}", "{1}"]'.format(field, env)
for field in ['catalog_environment', 'facts_environment'])),
unreported=app.config['UNRESPONSIVE_HOURS'], unreported=app.config['UNRESPONSIVE_HOURS'],
with_status=True) with_status=True)
@@ -150,12 +186,15 @@ def index():
'index.html', 'index.html',
metrics=metrics, metrics=metrics,
nodes=nodes_overview, nodes=nodes_overview,
stats=stats stats=stats,
envs=envs,
current_env=env
) )
@app.route('/nodes') @app.route('/nodes', defaults={'env': 'production'})
def nodes(): @app.route('/<env>/nodes')
def nodes(env):
"""Fetch all (active) nodes from PuppetDB and stream a table displaying """Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes. those nodes.
@@ -164,9 +203,17 @@ def nodes():
we'll end up with an empty table instead because of how yield_or_stop 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 works. Once pagination is in place we can change this but we'll need to
provide a search feature instead. provide a search feature instead.
:param env: Search for nodes in this (Catalog and Fact) environment
:type env: :obj:`string`
""" """
check_env(env)
status_arg = request.args.get('status', '') status_arg = request.args.get('status', '')
nodelist = puppetdb.nodes( nodelist = puppetdb.nodes(
query='["and", {0}]'.format(
", ".join('["=", "{0}", "{1}"]'.format(field, env)
for field in ['catalog_environment', 'facts_environment'])),
unreported=app.config['UNRESPONSIVE_HOURS'], unreported=app.config['UNRESPONSIVE_HOURS'],
with_status=True) with_status=True)
nodes = [] nodes = []
@@ -177,11 +224,15 @@ def nodes():
else: else:
nodes.append(node) nodes.append(node)
return Response(stream_with_context( return Response(stream_with_context(
stream_template('nodes.html', nodes=nodes))) stream_template('nodes.html',
nodes=nodes,
envs=envs,
current_env=env)))
@app.route('/inventory') @app.route('/inventory', defaults={'env': 'production'})
def inventory(): @app.route('/<env>/inventory')
def inventory(env):
"""Fetch all (active) nodes from PuppetDB and stream a table displaying """Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes along with a set of facts about them. those nodes along with a set of facts about them.
@@ -190,7 +241,11 @@ def inventory():
we'll end up with an empty table instead because of how yield_or_stop 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 works. Once pagination is in place we can change this but we'll need to
provide a search feature instead. provide a search feature instead.
:param env: Search for facts in this environment
:type env: :obj:`string`
""" """
check_env(env)
fact_desc = [] # a list of fact descriptions to go fact_desc = [] # a list of fact descriptions to go
# in the table header # in the table header
@@ -217,7 +272,8 @@ def inventory():
fact_desc.append(description) fact_desc.append(description)
fact_names.append(name) fact_names.append(name)
query = '["or", {0}]'.format( query = '["and", ["=", "environment", "{0}"], ["or", {1}]]'.format(
env,
', '.join('["=", "name", "{0}"]'.format(name) ', '.join('["=", "name", "{0}"]'.format(name)
for name in fact_names)) for name in fact_names))
@@ -239,92 +295,252 @@ def inventory():
nodedata[node].append("undef") nodedata[node].append("undef")
return Response(stream_with_context( return Response(stream_with_context(
stream_template('inventory.html', nodedata=nodedata, fact_desc=fact_desc))) stream_template('inventory.html',
nodedata=nodedata,
fact_desc=fact_desc,
envs=envs,
current_env=env)))
@app.route('/node/<node_name>') @app.route('/node/<node_name>', defaults={'env': 'production'})
def node(node_name): @app.route('/<env>/node/<node_name>')
def node(env, node_name):
"""Display a dashboard for a node showing as much data as we have on that """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 node. This includes facts and reports but not Resources as that is too
heavy to do within a single request. heavy to do within a single request.
:param env: Ensure that the node, facts and reports are in this environment
:type env: :obj:`string`
""" """
check_env(env)
node = get_or_abort(puppetdb.node, node_name) node = get_or_abort(puppetdb.node, node_name)
facts = node.facts() facts = node.facts()
reports = limit_reports(node.reports(), app.config['REPORTS_COUNT']) reports = get_or_abort(puppetdb.reports,
query='["and", ["=", "environment", "{0}"],' \
'["=", "certname", "{1}"]]'.format(env, node_name),
limit=app.config['REPORTS_COUNT'],
order_by='[{"field": "start_time", "order": "desc"}]')
reports, reports_events = tee(reports)
report_event_counts = {}
for report in reports_events:
counts = get_or_abort(puppetdb.event_counts,
query='["and", ["=", "environment", "{0}"],' \
'["=", "certname", "{1}"], ["=", "report", "{2}"]]'.format(
env,
node_name,
report.hash_),
summarize_by="certname")
try:
report_event_counts[report.hash_] = counts[0]
except IndexError:
report_event_counts[report.hash_] = {}
return render_template( return render_template(
'node.html', 'node.html',
node=node, node=node,
facts=yield_or_stop(facts), facts=yield_or_stop(facts),
reports=yield_or_stop(reports), reports=yield_or_stop(reports),
reports_count=app.config['REPORTS_COUNT']) reports_count=app.config['REPORTS_COUNT'],
report_event_counts=report_event_counts,
envs=envs,
current_env=env)
@app.route('/reports') @app.route('/reports/', defaults={'env': 'production', 'page': 1})
def reports(): @app.route('/<env>/reports/', defaults={'page': 1})
"""Doesn't do much yet but is meant to show something like the reports of @app.route('/<env>/reports/page/<int:page>')
the last half our, something like that.""" def reports(env, page):
return render_template('reports.html') """Displays a list of reports and status from all nodes, retreived using the
reports endpoint, sorted by start_time.
:param env: Search for all reports in this environment
@app.route('/reports/<node_name>') :type env: :obj:`string`
def reports_node(node_name): :param page: Calculates the offset of the query based on the report count
"""Fetches all reports for a node and processes them eventually rendering and this value
a table displaying those reports.""" :type page: :obj:`int`
reports = limit_reports(
yield_or_stop(
puppetdb.reports('["=", "certname", "{0}"]'.format(node_name))),
app.config['REPORTS_COUNT'])
return render_template(
'reports_node.html',
reports=reports,
nodename=node_name,
reports_count=app.config['REPORTS_COUNT'])
@app.route('/report/latest/<node_name>')
def report_latest(node_name):
"""Redirect to the latest report of a given node. This is a workaround
as long as PuppetDB can't filter reports for latest-report? field. This
feature has been requested: https://tickets.puppetlabs.com/browse/PDB-203
""" """
reports = get_or_abort(puppetdb._query, 'reports', check_env(env)
query='["=","certname","{0}"]'.format(node_name),
limit=1) reports = get_or_abort(puppetdb.reports,
if len(reports) > 0: query='["=", "environment", "{0}"]'.format(env),
report = reports[0]['hash'] limit=app.config['REPORTS_COUNT'],
return redirect( offset=(page-1) * app.config['REPORTS_COUNT'],
url_for('report', node_name=node_name, report_id=report)) order_by='[{"field": "start_time", "order": "desc"}]')
else: total = get_or_abort(puppetdb._query,
'reports',
query='["extract", [["function", "count"]],'\
'["and", ["=", "environment", "{0}"]]]'.format(
env))
total = total[0]['count']
reports, reports_events = tee(reports)
report_event_counts = {}
if total == 0 and page != 1:
abort(404) abort(404)
for report in reports_events:
counts = get_or_abort(puppetdb.event_counts,
query='["and",' \
'["=", "environment", "{0}"],' \
'["=", "certname", "{1}"],' \
'["=", "report", "{2}"]]'.format(
env,
report.node,
report.hash_),
summarize_by="certname")
try:
report_event_counts[report.hash_] = counts[0]
except IndexError:
report_event_counts[report.hash_] = {}
return Response(stream_with_context(stream_template(
'reports.html',
reports=yield_or_stop(reports),
reports_count=app.config['REPORTS_COUNT'],
report_event_counts=report_event_counts,
pagination=Pagination(page, app.config['REPORTS_COUNT'], total),
envs=envs,
current_env=env)))
@app.route('/report/<node_name>/<report_id>')
def report(node_name, report_id): @app.route('/reports/<node_name>/', defaults={'env': 'production', 'page': 1})
@app.route('/<env>/reports/<node_name>', defaults={'page': 1})
@app.route('/<env>/reports/<node_name>/page/<int:page>')
def reports_node(env, node_name, page):
"""Fetches all reports for a node and processes them eventually rendering
a table displaying those reports.
:param env: Search for reports in this environment
:type env: :obj:`string`
:param node_name: Find the reports whose certname match this value
:type node_name: :obj:`string`
:param page: Calculates the offset of the query based on the report count
and this value
:type page: :obj:`int`
"""
check_env(env)
reports = get_or_abort(puppetdb.reports,
query='["and",' \
'["=", "environment", "{0}"],' \
'["=", "certname", "{1}"]]'.format(env, node_name),
limit=app.config['REPORTS_COUNT'],
offset=(page-1) * app.config['REPORTS_COUNT'],
order_by='[{"field": "start_time", "order": "desc"}]')
total = get_or_abort(puppetdb._query,
'reports',
query='["extract", [["function", "count"]],' \
'["and", ["=", "environment", "{0}"], ["=", "certname", "{1}"]]]'.format(
env,
node_name))
total = total[0]['count']
reports, reports_events = tee(reports)
report_event_counts = {}
if total == 0 and page != 1:
abort(404)
for report in reports_events:
counts = get_or_abort(puppetdb.event_counts,
query='["and",' \
'["=", "environment", "{0}"],' \
'["=", "certname", "{1}"],' \
'["=", "report", "{2}"]]'.format(env, report.node, report.hash_),
summarize_by="certname")
try:
report_event_counts[report.hash_] = counts[0]
except IndexError:
report_event_counts[report.hash_] = {}
return render_template(
'reports.html',
reports=reports,
reports_count=app.config['REPORTS_COUNT'],
report_event_counts=report_event_counts,
pagination=Pagination(page, app.config['REPORTS_COUNT'], total),
envs=envs,
current_env=env)
@app.route('/report/latest/<node_name>', defaults={'env': 'production'})
@app.route('/<env>/report/latest/<node_name>')
def report_latest(env, node_name):
"""Redirect to the latest report of a given node.
:param env: Search for reports in this environment
:type env: :obj:`string`
:param node_name: Find the reports whose certname match this value
:type node_name: :obj:`string`
"""
check_env(env)
reports = get_or_abort(puppetdb.reports,
query='["and",' \
'["=", "environment", "{0}"],' \
'["=", "certname", "{1}"],' \
'["=", "latest_report?", true]]'.format(
env,
node_name))
try:
report = next(reports)
except StopIteration:
abort(404)
return redirect(
url_for('report', env=env, node_name=node_name, report_id=report.hash_))
@app.route('/report/<node_name>/<report_id>', defaults={'env': 'production'})
@app.route('/<env>/report/<node_name>/<report_id>')
def report(env, node_name, report_id):
"""Displays a single report including all the events associated with that """Displays a single report including all the events associated with that
report and their status. report and their status.
The report_id may be the puppetdb's report hash or the The report_id may be the puppetdb's report hash or the
configuration_version. This allows for better integration configuration_version. This allows for better integration
into puppet-hipchat. into puppet-hipchat.
"""
reports = puppetdb.reports('["=", "certname", "{0}"]'.format(node_name))
for report in reports: :param env: Search for reports in this environment
if report.hash_ == report_id or report.version == report_id: :type env: :obj:`string`
events = puppetdb.events('["=", "report", "{0}"]'.format( :param node_name: Find the reports whose certname match this value
report.hash_)) :type node_name: :obj:`string`
:param report_id: The hash or the configuration_version of the desired
report
:type report_id: :obj:`string`
"""
check_env(env)
query = '["and", ["=", "environment", "{0}"], ["=", "certname", "{1}"],' \
'["or", ["=", "hash", "{2}"], ["=", "configuration_version", "{2}"]]]'.format(
env, node_name, report_id)
reports = puppetdb.reports(query=query)
try:
report = next(reports)
except StopIteration:
abort(404)
return render_template( return render_template(
'report.html', 'report.html',
report=report, report=report,
events=yield_or_stop(events)) events=yield_or_stop(report.events()),
else: logs=report.logs,
abort(404) metrics=report.metrics,
envs=envs,
current_env=env)
@app.route('/facts') @app.route('/facts', defaults={'env': 'production'})
def facts(): @app.route('/<env>/facts')
def facts(env):
"""Displays an alphabetical list of all facts currently known to """Displays an alphabetical list of all facts currently known to
PuppetDB.""" PuppetDB.
:param env: Serves no purpose for this function, only for consistency's
sake
:type env: :obj:`string`
"""
check_env(env)
facts_dict = collections.defaultdict(list) facts_dict = collections.defaultdict(list)
facts = get_or_abort(puppetdb.fact_names) facts = get_or_abort(puppetdb.fact_names)
for fact in facts: for fact in facts:
@@ -334,45 +550,85 @@ def facts():
facts_dict[letter] = letter_list facts_dict[letter] = letter_list
sorted_facts_dict = sorted(facts_dict.items()) sorted_facts_dict = sorted(facts_dict.items())
return render_template('facts.html', facts_dict=sorted_facts_dict) return render_template('facts.html',
facts_dict=sorted_facts_dict,
envs=envs,
current_env=env)
@app.route('/fact/<fact>') @app.route('/fact/<fact>', defaults={'env': 'production'})
def fact(fact): @app.route('/<env>/fact/<fact>')
def fact(env, fact):
"""Fetches the specific fact from PuppetDB and displays its value per """Fetches the specific fact from PuppetDB and displays its value per
node for which this fact is known.""" node for which this fact is known.
:param env: Searches for facts in this environment
:type env: :obj:`string`
:param fact: Find all facts with this name
:type fact: :obj:`string`
"""
check_env(env)
# we can only consume the generator once, lists can be doubly consumed # we can only consume the generator once, lists can be doubly consumed
# om nom nom # om nom nom
render_graph = False render_graph = False
if fact in graph_facts: if fact in graph_facts:
render_graph = True render_graph = True
localfacts = [f for f in yield_or_stop(puppetdb.facts(name=fact))] localfacts = [f for f in yield_or_stop(puppetdb.facts(
name=fact,
query='["=", "environment", "{0}"]'.format(env)))]
return Response(stream_with_context(stream_template( return Response(stream_with_context(stream_template(
'fact.html', 'fact.html',
name=fact, name=fact,
render_graph=render_graph, render_graph=render_graph,
facts=localfacts))) facts=localfacts,
envs=envs,
current_env=env)))
@app.route('/fact/<fact>/<value>') @app.route('/fact/<fact>/<value>', defaults={'env': 'production'})
def fact_value(fact, value): @app.route('/<env>/fact/<fact>/<value>')
"""On asking for fact/value get all nodes with that fact.""" def fact_value(env, fact, value):
facts = get_or_abort(puppetdb.facts, fact, value) """On asking for fact/value get all nodes with that fact.
:param env: Searches for facts in this environment
:type env: :obj:`string`
:param fact: Find all facts with this name
:type fact: :obj:`string`
:param value: Filter facts whose value is equal to this
:type value: :obj:`string`
"""
check_env(env)
facts = get_or_abort(puppetdb.facts,
name=fact,
value=value,
query='["=", "environment", "{0}"]'.format(env))
localfacts = [f for f in yield_or_stop(facts)] localfacts = [f for f in yield_or_stop(facts)]
return render_template( return render_template(
'fact.html', 'fact.html',
name=fact, name=fact,
value=value, value=value,
facts=localfacts) facts=localfacts,
envs=envs,
current_env=env)
@app.route('/query', methods=('GET', 'POST')) @app.route('/query', methods=('GET', 'POST'), defaults={'env': 'production'})
def query(): @app.route('/<env>/query', methods=('GET', 'POST'))
def query(env):
"""Allows to execute raw, user created querries against PuppetDB. This is """Allows to execute raw, user created querries against PuppetDB. This is
currently highly experimental and explodes in interesting ways since none currently highly experimental and explodes in interesting ways since none
of the possible exceptions are being handled just yet. This will return 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 / the JSON of the response or a message telling you what whent wrong /
why nothing was returned.""" why nothing was returned.
:param env: Serves no purpose for the query data but is required for the
select field in the environment block
:type env: :obj:`string`
"""
check_env(env)
if app.config['ENABLE_QUERY']: if app.config['ENABLE_QUERY']:
form = QueryForm() form = QueryForm()
if form.validate_on_submit(): if form.validate_on_submit():
@@ -384,36 +640,185 @@ def query():
puppetdb._query, puppetdb._query,
form.endpoints.data, form.endpoints.data,
query=query) query=query)
return render_template('query.html', form=form, result=result) return render_template('query.html',
return render_template('query.html', form=form) form=form,
result=result,
envs=envs,
current_env=env)
return render_template('query.html',
form=form,
envs=envs,
current_env=env)
else: else:
log.warn('Access to query interface disabled by administrator..') log.warn('Access to query interface disabled by administrator..')
abort(403) abort(403)
@app.route('/metrics') @app.route('/metrics', defaults={'env': 'production'})
def metrics(): @app.route('/<env>/metrics')
metrics = get_or_abort(puppetdb._query, 'metrics', path='mbeans') def metrics(env):
"""Lists all available metrics that PuppetDB is aware of.
:param env: While this parameter serves no function purpose it is required
for the environments template block
:type env: :obj:`string`
"""
check_env(env)
metrics = get_or_abort(puppetdb._query, 'mbean')
for key, value in metrics.items(): for key, value in metrics.items():
metrics[key] = value.split('/')[3] metrics[key] = value.split('/')[2]
return render_template('metrics.html', metrics=sorted(metrics.items())) return render_template('metrics.html',
metrics=sorted(metrics.items()),
envs=envs,
current_env=env)
@app.route('/metric/<metric>') @app.route('/metric/<metric>', defaults={'env': 'production'})
def metric(metric): @app.route('/<env>/metric/<metric>')
def metric(env, metric):
"""Lists all information about the metric of the given name.
:param env: While this parameter serves no function purpose it is required
for the environments template block
:type env: :obj:`string`
"""
check_env(env)
name = unquote(metric) name = unquote(metric)
metric = puppetdb.metric(metric) metric = puppetdb.metric(metric)
return render_template( return render_template(
'metric.html', 'metric.html',
name=name, name=name,
metric=sorted(metric.items())) metric=sorted(metric.items()),
envs=envs,
current_env=env)
@app.route('/catalogs', defaults={'env': 'production'})
@app.route('/<env>/catalogs')
def catalogs(env):
"""Lists all nodes with a compiled catalog.
:param env: Find the nodes with this catalog_environment value
:type env: :obj:`string`
"""
check_env(env)
@app.route('/catalog/<node_name>')
def catalog_node(node_name):
"""Fetches from PuppetDB the compiled catalog of a given node."""
if app.config['ENABLE_CATALOG']: if app.config['ENABLE_CATALOG']:
catalog = puppetdb.catalog(node=node_name) nodenames = []
return render_template('catalog.html', catalog=catalog) catalog_list = []
nodes = get_or_abort(puppetdb.nodes,
query='["and",' \
'["=", "catalog_environment", "{0}"],' \
'["null?", "catalog_timestamp", false]]'.format(env),
with_status=False,
order_by='[{"field": "certname", "order": "asc"}]')
nodes, temp = tee(nodes)
for node in temp:
nodenames.append(node.name)
for node in nodes:
table_row = {
'name': node.name,
'catalog_timestamp': node.catalog_timestamp
}
if len(nodenames) > 1:
form = CatalogForm()
form.compare.data = node.name
form.against.choices = [(x, x) for x in nodenames
if x != node.name]
table_row['form'] = form
else:
table_row['form'] = None
catalog_list.append(table_row)
return render_template(
'catalogs.html',
nodes=catalog_list,
envs=envs,
current_env=env)
else:
log.warn('Access to catalog interface disabled by administrator')
abort(403)
@app.route('/catalog/<node_name>', defaults={'env': 'production'})
@app.route('/<env>/catalog/<node_name>')
def catalog_node(env, node_name):
"""Fetches from PuppetDB the compiled catalog of a given node.
:param env: Find the catalog with this environment value
:type env: :obj:`string`
"""
check_env(env)
if app.config['ENABLE_CATALOG']:
catalog = get_or_abort(puppetdb.catalog,
node=node_name)
return render_template('catalog.html',
catalog=catalog,
envs=envs,
current_env=env)
else:
log.warn('Access to catalog interface disabled by administrator')
abort(403)
@app.route('/catalog/submit', methods=['POST'], defaults={'env': 'production'})
@app.route('/<env>/catalog/submit', methods=['POST'])
def catalog_submit(env):
"""Receives the submitted form data from the catalogs page and directs
the users to the comparison page. Directs users back to the catalogs
page if no form submission data is found.
:param env: This parameter only directs the response page to the right
environment. If this environment does not exist return the use to the
catalogs page with the right environment.
:type env: :obj:`string`
"""
check_env(env)
if app.config['ENABLE_CATALOG']:
form = CatalogForm(request.form)
form.against.choices = [(form.against.data, form.against.data)]
if form.validate_on_submit():
compare = form.compare.data
against = form.against.data
return redirect(
url_for('catalog_compare',
env=env,
compare=compare,
against=against))
return redirect(url_for('catalogs', env=env))
else:
log.warn('Access to catalog interface disabled by administrator')
abort(403)
@app.route('/catalogs/compare/<compare>...<against>', defaults={'env': 'production'})
@app.route('/<env>/catalogs/compare/<compare>...<against>')
def catalog_compare(env, compare, against):
"""Compares the catalog of one node, parameter compare, with that of
with that of another node, parameter against.
:param env: Ensure that the 2 catalogs are in the same environment
:type env: :obj:`string`
"""
check_env(env)
if app.config['ENABLE_CATALOG']:
compare_cat = get_or_abort(puppetdb.catalog,
node=compare)
against_cat = get_or_abort(puppetdb.catalog,
node=against)
return render_template('catalog_compare.html',
compare=compare_cat,
against=against_cat,
envs=envs,
current_env=env)
else: else:
log.warn('Access to catalog interface disabled by administrator') log.warn('Access to catalog interface disabled by administrator')
abort(403) abort(403)

View File

@@ -2,7 +2,10 @@ from __future__ import unicode_literals
from __future__ import absolute_import from __future__ import absolute_import
from flask.ext.wtf import Form from flask.ext.wtf import Form
from wtforms import RadioField, TextAreaField, validators from wtforms import (
HiddenField, RadioField, SelectField,
TextAreaField, validators
)
class QueryForm(Form): class QueryForm(Form):
@@ -14,7 +17,17 @@ class QueryForm(Form):
('nodes', 'Nodes'), ('nodes', 'Nodes'),
('resources', 'Resources'), ('resources', 'Resources'),
('facts', 'Facts'), ('facts', 'Facts'),
('fact-names', 'Fact Names'), ('factsets', 'Fact Sets'),
('fact-paths', 'Fact Paths'),
('fact-contents', 'Fact Contents'),
('reports', 'Reports'), ('reports', 'Reports'),
('events', 'Events'), ('events', 'Events'),
('catalogs', 'Catalogs'),
('edges', 'Edges'),
('environments', 'Environments'),
]) ])
class CatalogForm(Form):
"""The form used to compare the catalogs of different nodes."""
compare = HiddenField('compare')
against = SelectField('against')

View File

@@ -31,7 +31,7 @@ th.tablesorter-headerDesc::after {
} }
.status { .status {
width: 47.5%; width: 45%;
text-align: center; text-align: center;
display: block; display: block;
} }
@@ -75,6 +75,23 @@ th.tablesorter-headerDesc::after {
color: #FFF; color: #FFF;
} }
.ui.menu.yellow {
background-color: #F0E965;
}
.ui.yellow.header, i.yellow {
color: #F0E965;
}
.ui.labels .yellow.label::before, .ui.yellow.labels .label::before, .ui.yellow.label::before {
background-color: #F0E965;
}
.ui.yellow.labels .label, .ui.yellow.label {
background-color: #F0E965;
border-color: #F0E965;
}
#scroll-btn-top { #scroll-btn-top {
position: fixed; position: fixed;
overflow: hidden; overflow: hidden;

View File

@@ -57,6 +57,10 @@
sortList: [[0, 0]] sortList: [[0, 0]]
}) })
$('.reports').tablesorter({
sortList: [[0, 0]]
})
$('input.filter-table').parent('div').removeClass('hide'); $('input.filter-table').parent('div').removeClass('hide');
$("input.filter-table").on("keyup", function(e) { $("input.filter-table").on("keyup", function(e) {

View File

@@ -1,4 +1,4 @@
{% macro facts_table(facts, autofocus=False, condensed=False, show_node=False, show_value=True, link_facts=False, margin_top=20, margin_bottom=20) -%} {% macro facts_table(facts, current_env, autofocus=False, condensed=False, show_node=False, show_value=True, link_facts=False, margin_top=20, margin_bottom=20) -%}
<div class="ui fluid icon input hide" style="margin-bottom:20px"> <div class="ui fluid icon input hide" style="margin-bottom:20px">
<input {% if autofocus %} autofocus="autofocus" {% endif %} class="filter-table" placeholder="Type here to filter..."> <input {% if autofocus %} autofocus="autofocus" {% endif %} class="filter-table" placeholder="Type here to filter...">
</div> </div>
@@ -19,17 +19,25 @@
{% for fact in facts %} {% for fact in facts %}
<tr> <tr>
{% if show_node %} {% if show_node %}
<td><a href="{{url_for('node', node_name=fact.node)}}">{{fact.node}}</a></td> <td><a href="{{url_for('node', env=current_env, node_name=fact.node)}}">{{fact.node}}</a></td>
{% else %} {% else %}
<td><a href="{{url_for('fact', fact=fact.name)}}">{{fact.name}}</a></td> <td><a href="{{url_for('fact', env=current_env, fact=fact.name)}}">{{fact.name}}</a></td>
{% endif %} {% endif %}
{% if show_value %} {% if show_value %}
<td style="word-wrap:break-word"> <td style="word-wrap:break-word">
{% if link_facts %} {% if link_facts %}
<a href="{{url_for('fact_value', fact=fact.name, value=fact.value)}}">{{fact.value}}</a> {% if fact.value is mapping %}
<a href="{{url_for('fact_value', env=current_env, fact=fact.name, value=fact.value)}}"><pre>{{fact.value|jsonprint}}</pre></a>
{% else %}
<a href="{{url_for('fact_value', env=current_env, fact=fact.name, value=fact.value)}}">{{fact.value}}</a>
{% endif %}
{% else %}
{% if fact.value is mapping %}
<pre>{{fact.value|jsonprint}}</pre>
{% else %} {% else %}
{{fact.value}} {{fact.value}}
{% endif %} {% endif %}
{% endif %}
</td> </td>
{% endif %} {% endif %}
</tr> </tr>
@@ -70,30 +78,40 @@
</script> </script>
{%- endmacro %} {%- endmacro %}
{% macro reports_table(reports, nodename, reports_count, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True) -%} {% macro reports_table(reports, reports_count, report_event_counts, current_env, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True, show_run_col=False, show_full_col=False, show_search_bar=False, searchable=False) -%}
{% if show_search_bar %}
<div class="ui fluid icon input hide" style="margin-bottom:20px">
<input autofocus="autofocus" class="filter-table" placeholder="Type here to filter...">
</div>
{% endif %}
<div class="ui info message"> <div class="ui info message">
Only showing the last {{reports_count}} reports. Only showing {{reports_count}} reports sorted by Start Time.
</div> </div>
<table class='ui table basic {% if condensed %}compact{% endif %}'> <table class='ui table basic {% if condensed %}compact{% endif %} report'>
<thead> <thead>
<tr> <tr>
<th>Start time</th> <th>Start time</th>
<th>Status</th>
{% if show_host_col %}
<th>Hostname</th>
{% endif %}
{% if show_run_col %}
<th>Run time</th> <th>Run time</th>
{% endif %}
{% if show_full_col %}
<th>Full report</th> <th>Full report</th>
{% endif %}
{% if show_conf_col %} {% if show_conf_col %}
<th>Configuration version</th> <th>Configuration version</th>
{% endif %} {% endif %}
{% if show_agent_col %} {% if show_agent_col %}
<th>Agent version</th> <th>Agent version</th>
{% endif %} {% endif %}
{% if show_host_col %}
<th>Hostname</th>
{% endif %}
<tr> <tr>
</thead> </thead>
<tbody> <tbody {% if searchable %}class="searchable" {% endif %}>
{% for report in reports %} {% for report in reports %}
{% if hash_truncate %} {% if hash_truncate %}
{% set rep_hash = "%s&hellip;"|format(report.hash_[0:10])|safe %} {% set rep_hash = "%s&hellip;"|format(report.hash_[0:10])|safe %}
@@ -106,20 +124,77 @@
<tr> <tr>
{% endif %} {% endif %}
<td rel="utctimestamp">{{report.start}}</td> <td rel="utctimestamp">{{report.start}}</td>
<td>
{% call status_counts(status=report.status, node_name=report.node, events=report_event_counts[report.hash_], report_hash=report.hash_, current_env=current_env) %}{% endcall %}
</td>
{% if show_host_col %}
<td><a href="{{url_for('node', env=current_env, node_name=report.node)}}">{{ report.node }}</a></td>
{% endif %}
{% if show_run_col %}
<td>{{report.run_time}}</td> <td>{{report.run_time}}</td>
{% endif %}
<td><a href="{{url_for('report', node_name=nodename, report_id=report.hash_)}}">{{rep_hash}}</a></td> {% if show_full_col %}
<td><a href="{{url_for('report', env=current_env, node_name=report.node, report_id=report.hash_)}}">{{rep_hash}}</a></td>
{% endif %}
{% if show_conf_col %} {% if show_conf_col %}
<td>{{report.version}}</td> <td>{{report.version}}</td>
{% endif %} {% endif %}
{% if show_agent_col %} {% if show_agent_col %}
<td>{{report.agent_version}}</td> <td>{{report.agent_version}}</td>
{% endif %} {% endif %}
{% if show_host_col %}
<td><a href="{{url_for('node', node_name=report.node)}}">{{ report.node }}</a></td>
{% endif %}
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{%- endmacro %} {%- endmacro %}
{% macro status_counts(caller, status, node_name, events, current_env, unreported_time=False, report_hash=False) -%}
<a class="ui small status label
{% if status == 'failed' -%}
red
{% elif status == 'changed' -%}
green
{% elif status == 'unreported' -%}
black
{% elif status == 'noop' -%}
blue
{% endif -%}
" href="
{% if report_hash -%}
{{url_for('report', env=current_env, node_name=node_name, report_id=report_hash)}}
{% else -%}
{{url_for('report_latest', env=current_env, node_name=node_name)}}
{% endif -%}
">
{{status}}
</a>
{% if status == 'unreported' %}
<span class="ui small label status"> {{ unreported_time }} </span>
{% else %}
{% if events['failures'] %}<span class="ui small count label red">{{events['failures']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% if events['successes'] %}<span class="ui small count label green">{{events['successes']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% if events['skips'] %}<span class="ui small count label orange">{{events['skips']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% endif %}
{%- endmacro %}
{% macro render_pagination(pagination) -%}
<div class="pagination">
{% if pagination.has_prev %}
<a href="{{url_for_pagination(1)}}">&laquo; First</a>
<a href="{{url_for_pagination(pagination.page - 1)}}">Prev</a>
{% endif %}
{% for page in pagination.iter_pages() %}
{% if page %}
{% if page != pagination.page %}
<a href="{{url_for_pagination(page)}}">{{page}}</a>
{% else %}
<span style="font-weight:bold;">{{page}}</span>
{% endif %}
{% else %}
<span class="ellipsis">...</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{url_for_pagination(pagination.page + 1)}}">Next</a>
<a href="{{url_for_pagination(pagination.pages)}}">Last &raquo;</a>
{% endif %}
</div>
{% endmacro %}

View File

@@ -14,7 +14,7 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><a href="{{url_for('node', node_name=catalog.node)}}">{{catalog.node}}</a></td> <td><a href="{{url_for('node', env=current_env, node_name=catalog.node)}}">{{catalog.node}}</a></td>
<td>{{catalog.version}}</td> <td>{{catalog.version}}</td>
<td>{{catalog.transaction_uuid}}</td> <td>{{catalog.transaction_uuid}}</td>
</tr> </tr>
@@ -48,7 +48,7 @@
<th>Target</th> <th>Target</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody class='searchable'>
{% for edge in catalog.get_edges() %} {% for edge in catalog.get_edges() %}
<tr> <tr>
<td>{{edge.source}}</td> <td>{{edge.source}}</td>

View File

@@ -0,0 +1,90 @@
{% extends 'layout.html' %}
{% block content %}
<div class="ui fluid icon input hide" style="margin-bottom:20px">
<input autofocus="autofocus" class="filter-table" placeholder="Type here to filter...">
</div>
<table class="ui basic table">
<tbody>
<tr>
<th><h1>Comparing</h1></th>
<th><h1>Against</h1></th>
</tr>
<tr>
<td>{{compare.node}}</td>
<td>{{against.node}}</td>
</tr>
<tr>
<td>
<table class="ui basic table compact catalog">
<thead>
<tr><th>Resources</th></tr>
</thead>
<tbody class='searchable'>
{% for resource in compare.get_resources() %}
<tr>
<td>{{resource.type_}}[{{resource.name}}]</td>
</tr>
{% endfor %}
</tbody>
</table>
</td>
<td>
<table class="ui basic table compact catalog">
<thead>
<tr><th>Resources</th></tr>
</thead>
<tbody class='searchable'>
{% for resource in against.get_resources() %}
<tr>
<td>{{resource.type_}}[{{resource.name}}]</td>
</tr>
{% endfor %}
</tbody>
</table>
</td>
</tr>
<tr>
<td>
<table class="ui basic table compact catalog">
<thead>
<tr>
<th>Edges</th>
<th>-&gt;</th>
<th>Target</th>
</tr>
</thead>
<tbody class='searchable'>
{% for edge in compare.get_edges() %}
<tr>
<td>{{edge.source}}</td>
<td>{{edge.relationship}}</td>
<td>{{edge.target}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</td>
<td>
<table class="ui basic table compact catalog">
<thead>
<tr>
<th>Edge</th>
<th>-&gt;</th>
<th>Target</th>
</tr>
</thead>
<tbody class='searchable'>
{% for edge in against.get_edges() %}
<tr>
<td>{{edge.source}}</td>
<td>{{edge.relationship}}</td>
<td>{{edge.target}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
{% endblock content %}

View File

@@ -0,0 +1,40 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %}
<div class="ui fluid icon input hide" style="margin-bottom:20px">
<input autofocus="autofocus" class="filter-table" placeholder="Type here to filter...">
</div>
<table class='ui compact basic table nodes'>
<thead>
<tr>
<th></th>
<th>Hostname</th>
<th>Compile Time</th>
<th>Compare With</th>
</tr>
</thead>
<tbody class="searchable">
{% for node in nodes %}
<tr>
<td></td>
<td><a href="{{url_for('node', env=current_env, node_name=node.name)}}">{{node.name}}</a></td>
<td><a rel="utctimestamp" href="{{url_for('catalog_node', env=current_env, node_name=node.name)}}">{{node.catalog_timestamp}}</a></td>
<td>
{% if node.form %}
<div class="ui form">
<form method="POST" action="{{url_for('catalog_submit', env=current_env)}}">
{{node.form.csrf_token}}
<div class="field inline">
{{node.form.compare}}
{{node.form.against}}
<input type="submit" class="ui submit button" style="height:auto;" value="Compare"/>
</div>
</form>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}

View File

@@ -6,8 +6,8 @@
{{macros.facts_graph(facts, autofocus=True, show_node=True, margin_bottom=10)}} {{macros.facts_graph(facts, autofocus=True, show_node=True, margin_bottom=10)}}
{% endif %} {% endif %}
{% if value %} {% if value %}
{{macros.facts_table(facts, autofocus=True, show_node=True, show_value=False, margin_bottom=10)}} {{macros.facts_table(facts, current_env=current_env, autofocus=True, show_node=True, show_value=False, margin_bottom=10)}}
{% else %} {% else %}
{{macros.facts_table(facts, autofocus=True, show_node=True, link_facts=True, margin_bottom=10)}} {{macros.facts_table(facts, current_env=current_env, autofocus=True, show_node=True, link_facts=True, margin_bottom=10)}}
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -8,7 +8,7 @@
<span class='ui label darkblue'>{{key}}</span> <span class='ui label darkblue'>{{key}}</span>
<ul class="searchable"> <ul class="searchable">
{%- for fact in facts_list %} {%- for fact in facts_list %}
<li><a href="{{url_for('fact', fact=fact)}}">{{fact}}</a></li> <li><a href="{{url_for('fact', env=current_env, fact=fact)}}">{{fact}}</a></li>
{%- endfor %} {%- endfor %}
</ul> </ul>
{% endfor %} {% endfor %}

View File

@@ -1,9 +1,10 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %} {% block content %}
<div class="ui vertical grid"> <div class="ui vertical grid">
<div class="four column row"> <div class="four column row">
<div class="column"> <div class="column">
<a href="nodes?status=failed"> <a href="{{url_for('nodes', env=current_env, status='failed')}}">
<h1 class="ui red header no-margin-bottom"> <h1 class="ui red header no-margin-bottom">
{{stats['failed']}} {{stats['failed']}}
<small>{% if stats['failed']== 1 %} node {% else %} nodes {% endif %}</small> <small>{% if stats['failed']== 1 %} node {% else %} nodes {% endif %}</small>
@@ -12,7 +13,7 @@
<span>with status failed</span> <span>with status failed</span>
</div> </div>
<div class="column"> <div class="column">
<a href="nodes?status=noop"> <a href="{{url_for('nodes', env=current_env, status='noop')}}">
<h1 class="ui header purple no-margin-bottom"> <h1 class="ui header purple no-margin-bottom">
{{stats['noop']}} {{stats['noop']}}
<small>{% if stats['noop']== 1 %} node {% else %} nodes {% endif %}</small> <small>{% if stats['noop']== 1 %} node {% else %} nodes {% endif %}</small>
@@ -21,7 +22,7 @@
<span>with status pending</span> <span>with status pending</span>
</div> </div>
<div class="column"> <div class="column">
<a href="nodes?status=changed"> <a href="{{url_for('nodes', env=current_env, status='changed')}}">
<h1 class="ui header green no-margin-bottom"> <h1 class="ui header green no-margin-bottom">
{{stats['changed']}} {{stats['changed']}}
<small>{% if stats['changed']== 1 %} node {% else %} nodes {% endif %}</small> <small>{% if stats['changed']== 1 %} node {% else %} nodes {% endif %}</small>
@@ -30,7 +31,7 @@
<span>with status changed</span> <span>with status changed</span>
</div> </div>
<div class="column"> <div class="column">
<a href="nodes?status=unreported"> <a href="{{url_for('nodes', env=current_env, status='unreported')}}">
<h1 class="ui header black no-margin-bottom"> <h1 class="ui header black no-margin-bottom">
{{ stats['unreported'] }} {{ stats['unreported'] }}
<small>{% if stats['unreported']== 1 %} node {% else %} nodes {% endif %}</small> <small>{% if stats['unreported']== 1 %} node {% else %} nodes {% endif %}</small>
@@ -75,40 +76,21 @@
{% if node.status != 'unchanged' %} {% if node.status != 'unchanged' %}
<tr> <tr>
<td> <td>
<a class="ui small status label {{macros.status_counts(status=node.status, node_name=node.name, events=node.events, unreported_time=node.unreported_time, current_env=current_env)}}
{% if node.status == 'failed' %}
red
{% elif node.status == 'changed' %}
green
{% elif node.status == 'unreported' %}
black
{% elif node.status == 'noop' %}
blue
{% endif %}
" href="{{url_for('report_latest', node_name=node.name)}}">
{{node.status}}
</a>
{% if node.status=='unreported'%}
<span class="ui small label status"> {{ node.unreported_time }} </span>
{% else %}
{% if node.events['failures'] %}<span class="ui small count label red">{{node.events['failures']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% if node.events['successes'] %}<span class="ui small count label green">{{node.events['successes']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% if node.events['skips'] %}<span class="ui small count label orange">{{node.events['skips']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% endif %}
</td> </td>
<td> <td>
<a href="{{url_for('node', node_name=node.name)}}">{{ node.name }}</a> <a href="{{url_for('node', env=current_env, node_name=node.name)}}">{{ node.name }}</a>
</td> </td>
<td> <td>
{% if node.report_timestamp %} {% if node.report_timestamp %}
<a href="{{url_for('report_latest', node_name=node.name)}}" rel='utctimestamp'>{{ node.report_timestamp }}</a> <a href="{{url_for('report_latest', env=current_env, node_name=node.name)}}" rel='utctimestamp'>{{ node.report_timestamp }}</a>
{% else %} {% else %}
<i class="large ban circle icon"></i> <i class="large ban circle icon"></i>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if node.report_timestamp %} {% if node.report_timestamp %}
<a title='Reports' href="{{url_for('reports_node', node_name=node.name)}}"><i class='large darkblue book icon'></i></a> <a title='Reports' href="{{url_for('reports_node', env=current_env, node_name=node.name)}}"><i class='large darkblue book icon'></i></a>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@@ -23,7 +23,7 @@
</head> </head>
<body> <body>
<nav class="ui fixed darkblue inverted menu"> <div class="ui fixed darkblue inverted menu">
<div class="title item"> <div class="title item">
Puppetboard Puppetboard
</div> </div>
@@ -34,13 +34,23 @@
('reports', 'Reports'), ('reports', 'Reports'),
('metrics', 'Metrics'), ('metrics', 'Metrics'),
('inventory', 'Inventory'), ('inventory', 'Inventory'),
('catalogs', 'Catalogs'),
('query', 'Query'), ('query', 'Query'),
] %} ] %}
<a {% if endpoint == request.endpoint %} class="active item" {% else %} class="item" {% endif %} <a {% if endpoint == request.endpoint %} class="active item" {% else %} class="item" {% endif %}
href="{{ url_for(endpoint) }}">{{ caption }}</a> href="{{ url_for(endpoint, env=current_env) }}">{{ caption }}</a>
{%- endfor %} {%- endfor %}
<div class="item" style="float:right"><a href="https://github.com/puppet-community/puppetboard" target="_blank">v0.0.5</a></div> <div class="ui item dropdown">
</nav> Environments
<i class="dropdown icon"></i>
<div class="menu">
{% for env in envs %}
<a class="item {% if env == current_env %}active{% endif %}" href="{{url_for_environments(env)}}">{{env}}</a>
{% endfor %}
</div>
</div>
<div class="item right"><a href="https://github.com/puppet-community/puppetboard" target="_blank">v0.1.0</a></div>
</div>
<div class="ui grid padding-bottom"> <div class="ui grid padding-bottom">
<div class="one wide column"></div> <div class="one wide column"></div>
<div class="fourteen wide column"> <div class="fourteen wide column">
@@ -55,7 +65,7 @@
<footer class="ui absolute fixed bottom"> <footer class="ui absolute fixed bottom">
<div> <div>
Copyright &copy; 2013-{{ now('%Y') }} <a href="https://github.com/daenney" target="_blank">Daniele Sluijters</a>. <span style="float:right">Live from PuppetDB.</span> Copyright &copy; 2013-{{ now('%Y') }} <a href="https://github.com/puppet-community" target="_blank">Puppet Community</a>. <span style="float:right">Live from PuppetDB.</span>
</div> </div>
</footer> </footer>
@@ -80,6 +90,9 @@
<script src="{{ url_for('static', filename='js/lists.js') }}"></script> <script src="{{ url_for('static', filename='js/lists.js') }}"></script>
<script src="{{ url_for('static', filename='js/tables.js') }}"></script> <script src="{{ url_for('static', filename='js/tables.js') }}"></script>
<script src="{{ url_for('static', filename='js/scroll.top.js') }}"></script> <script src="{{ url_for('static', filename='js/scroll.top.js') }}"></script>
<script type="text/javascript">
$(".ui.dropdown").dropdown();
</script>
{% block script %} {% endblock script %} {% block script %} {% endblock script %}
</body> </body>

View File

@@ -3,7 +3,7 @@
<h1>Metrics</h1> <h1>Metrics</h1>
<ul> <ul>
{% for key,value in metrics %} {% for key,value in metrics %}
<li><a href="{{url_for('metric', metric=value)}}">{{key}}</li> <li><a href="{{url_for('metric', env=current_env, metric=value)}}">{{key}}</li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endblock content %} {% endblock content %}

View File

@@ -28,12 +28,12 @@
</div> </div>
<div class='row'> <div class='row'>
<h1>Reports</h1> <h1>Reports</h1>
{{ macros.reports_table(reports, node.name, reports_count, condensed=True, hash_truncate=True, show_conf_col=False, show_agent_col=False, show_host_col=False)}} {{ macros.reports_table(reports, reports_count, report_event_counts, condensed=True, hash_truncate=True, show_conf_col=False, show_agent_col=False, show_host_col=False, current_env=current_env)}}
</div> </div>
</div> </div>
<div class='column'> <div class='column'>
<h1>Facts</h1> <h1>Facts</h1>
{{macros.facts_table(facts, link_facts=True, condensed=True)}} {{macros.facts_table(facts, link_facts=True, condensed=True, current_env=current_env)}}
</div> </div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -1,4 +1,5 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %} {% block content %}
<div class="ui fluid icon input hide" style="margin-bottom:20px"> <div class="ui fluid icon input hide" style="margin-bottom:20px">
<input autofocus="autofocus" class="filter-table" placeholder="Type here to filter..."> <input autofocus="autofocus" class="filter-table" placeholder="Type here to filter...">
@@ -17,40 +18,20 @@
{% for node in nodes %} {% for node in nodes %}
<tr> <tr>
<td> <td>
<a class="ui small status label {{macros.status_counts(status=node.status, node_name=node.name, events=node.events, unreported_time=node.unreported_time, current_env=current_env)}}
{% if node.status == 'failed' %}
red
{% elif node.status == 'changed' %}
green
{% elif node.status == 'unreported' %}
black
{% elif node.status == 'noop' %}
blue
{% endif %}
" href="{{url_for('report_latest', node_name=node.name)}}">
{{node.status}}
</a>
{% if node.status=='unreported'%}
<span class="ui small label status"> {{ node.unreported_time }} </label>
{% else %}
<span>{% if node.events['failures'] %}<span class="ui small count label red">{{node.events['failures']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% if node.events['successes'] %}<span class="ui small count label green">{{node.events['successes']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}</span>
{% if node.events['skips'] %}<span class="ui small count label yellow">{{node.events['skips']}}</span>{% else %}<span class="ui small count label">0</span>{% endif%}
{% endif %}
</td> </td>
<td><a href="{{url_for('node', node_name=node.name)}}">{{node.name}}</a></td> <td><a href="{{url_for('node', env=current_env, node_name=node.name)}}">{{node.name}}</a></td>
<td><a rel="utctimestamp" href="{{url_for('catalog_node', node_name=node.name)}}">{{node.catalog_timestamp}}</a></td> <td><a rel="utctimestamp" href="{{url_for('catalog_node', env=current_env, node_name=node.name)}}">{{node.catalog_timestamp}}</a></td>
<td> <td>
{% if node.report_timestamp %} {% if node.report_timestamp %}
<a href="{{url_for('report_latest', node_name=node.name)}}" rel='utctimestamp'>{{ node.report_timestamp }}</a> <a href="{{url_for('report_latest', env=current_env, node_name=node.name)}}" rel='utctimestamp'>{{ node.report_timestamp }}</a>
{% else %} {% else %}
<i class="large ban circle icon"></i> <i class="large ban circle icon"></i>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if node.report_timestamp %} {% if node.report_timestamp %}
<a title='Reports' href="{{url_for('reports_node', node_name=node.name)}}"><i class='large darkblue book icon'></i></a> <a title='Reports' href="{{url_for('reports_node', env=current_env, node_name=node.name, page=1)}}"><i class='large darkblue book icon'></i></a>
<i class='large darkblue trash icon'></i>
{% endif %} {% endif %}
</td> </td>
</tr> </tr>

View File

@@ -11,10 +11,10 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<div class="ui form"> <div class="ui form">
<form method="POST" action="{{ url_for('query') }}"> <form method="POST" action="{{ url_for('query', env=current_env) }}">
{{ form.csrf_token }} {{ form.csrf_token }}
<div class="field {% if form.query.errors %} error {% endif %}"> <div class="field {% if form.query.errors %} error {% endif %}">
{{ form.query(autofocus="autofocus", rows=5, placeholder="Enter your query: [\"=\", \"name\", \"hostname\"]. You may omit the opening and closing bracket.") }} {{ form.query(autofocus="autofocus", rows=5, placeholder="Enter your query: [\"=\", \"certname\", \"hostname\"]. You may omit the opening and closing bracket.") }}
</div> </div>
<div class="inline fields"> <div class="inline fields">
{% for subfield in form.endpoints %} {% for subfield in form.endpoints %}

View File

@@ -12,7 +12,7 @@
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td><a href="{{url_for('node', node_name=report.node)}}">{{ report.node }}</a></td> <td><a href="{{url_for('node', env=current_env, node_name=report.node)}}">{{ report.node }}</a></td>
<td> <td>
{{report.version}} {{report.version}}
</td> </td>
@@ -48,26 +48,62 @@
<td>{{event.item['old']}}</td> <td>{{event.item['old']}}</td>
<td>{{event.item['new']}}</td> <td>{{event.item['new']}}</td>
</tr> </tr>
{# <tr> {% endfor %}
<td class='message' colspan='4'> </tbody>
<div id='message-event-{{loop.index}}'> </table>
{{event.item['message']}}
</div> <h1>Logs</h1>
</td> <table class="ui basic table compact">
</tr>#} <thead>
<tr>
<th>Timestamp</th>
<th>Source</th>
<th>Tags</th>
<th>Message</th>
<th>Location</th>
<tr>
</thead>
<tbody>
{% for log in logs %}
{% if log.level == 'info' or log.level == 'notice' %}
<tr class='positive'>
{% elif log.level == 'warning' %}
<tr class='warning'>
{% else %}
<tr class='error'>
{% endif %}
<td rel="utctimestamp">{{log.time}}</td>
<td>{{log.source}}</td>
<td>{{log.tags|join(', ')}}</td>
<td>{{log.message}}</td>
{% if log.file != None and log.line != None %}
<td>{{log.file}}:{{log.line}}</td>
{% else %}
<td></td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
<h1>Metrics</h1>
<table class="ui basic table compact">
<thead>
<tr>
<th>Category</th>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{% for metric in metrics %}
<tr>
<td>{{metric.category}}</td>
<td>{{metric.name}}</td>
<td>{{metric.value|round(2)}}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
{% endblock content %} {% endblock content %}
{% block script %}
<script type='text/javascript'>
jQuery(function ($) {
$("[rel=tooltip]").tooltip();
$(".event").click(function() {
$("#message-" + this.id).slideToggle(200);
return false;
});
});
</script>
{% endblock script %}

View File

@@ -1,6 +1,6 @@
{% extends 'layout.html' %} {% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %} {% block content %}
<div class="ui warning message"> {{ macros.reports_table(reports, reports_count, report_event_counts, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True, show_search_bar=True, searchable=True)}}
Pending <a href="https://tickets.puppetlabs.com/browse/PDB-201">#PDB-201</a>. You can access reports for a node or individual reports through the <a href="{{url_for('nodes')}}">Nodes</a> tab. {{ macros.render_pagination(pagination)}}
</div>
{% endblock content %} {% endblock content %}

View File

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

View File

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
import json import json
from math import ceil
from requests.exceptions import HTTPError, ConnectionError from requests.exceptions import HTTPError, ConnectionError
from pypuppetdb.errors import EmptyResponseError from pypuppetdb.errors import EmptyResponseError
@@ -29,17 +30,6 @@ def get_or_abort(func, *args, **kwargs):
abort(204) abort(204)
def limit_reports(reports, limit):
"""Helper to yield a number of from the reports generator.
This is an ugly solution at best...
"""
for count, report in enumerate(reports):
if count == limit:
raise StopIteration
yield report
def yield_or_stop(generator): def yield_or_stop(generator):
"""Similar in intent to get_or_abort this helper will iterate over our """Similar in intent to get_or_abort this helper will iterate over our
generators and handle certain errors. generators and handle certain errors.
@@ -54,3 +44,35 @@ def yield_or_stop(generator):
raise raise
except (EmptyResponseError, ConnectionError, HTTPError): except (EmptyResponseError, ConnectionError, HTTPError):
raise StopIteration raise StopIteration
class Pagination(object):
def __init__(self, page, per_page, total_count):
self.page = page
self.per_page = per_page
self.total_count = total_count
@property
def pages(self):
return int(ceil(self.total_count / float(self.per_page)))
@property
def has_prev(self):
return self.page > 1
@property
def has_next(self):
return self.page < self.pages
def iter_pages(self, left_edge=2, left_current=2,
right_current=5, right_edge=2):
last = 0
for num in xrange(1, self.pages + 1):
if num <= left_edge or \
(num > self.page - left_current - 1 and \
num < self.page + right_current) or \
num > self.pages - right_edge:
if last + 1 != num:
yield None
yield num
last = num

View File

@@ -5,5 +5,5 @@ MarkupSafe==0.19
WTForms==1.0.5 WTForms==1.0.5
Werkzeug==0.9.4 Werkzeug==0.9.4
itsdangerous==0.23 itsdangerous==0.23
pypuppetdb==0.1.1 pypuppetdb==0.2.0
requests==2.2.1 requests==2.2.1

View File

@@ -9,7 +9,7 @@ if sys.argv[-1] == 'publish':
os.system('python setup.py sdist upload') os.system('python setup.py sdist upload')
sys.exit() sys.exit()
VERSION = "0.0.5" VERSION = "0.1.0"
with codecs.open('README.rst', encoding='utf-8') as f: with codecs.open('README.rst', encoding='utf-8') as f:
README = f.read() README = f.read()
@@ -23,7 +23,7 @@ setup(
author='Daniele Sluijters', author='Daniele Sluijters',
author_email='daniele.sluijters+pypi@gmail.com', author_email='daniele.sluijters+pypi@gmail.com',
packages=find_packages(), packages=find_packages(),
url='https://github.com/nedap/puppetboard', url='https://github.com/puppet-community/puppetboard',
license='Apache License 2.0', license='Apache License 2.0',
description='Web frontend for PuppetDB', description='Web frontend for PuppetDB',
include_package_data=True, include_package_data=True,
@@ -32,7 +32,7 @@ setup(
"Flask >= 0.10.1", "Flask >= 0.10.1",
"Flask-WTF >= 0.9.4, <= 0.9.5", "Flask-WTF >= 0.9.4, <= 0.9.5",
"WTForms < 2.0", "WTForms < 2.0",
"pypuppetdb >= 0.1.0, < 0.2.0", "pypuppetdb >= 0.2.0, < 0.3.0",
], ],
keywords="puppet puppetdb puppetboard", keywords="puppet puppetdb puppetboard",
classifiers=[ classifiers=[