62 Commits

Author SHA1 Message Date
Tim Meusel
4636d2043b Merge pull request #417 from mterzo/rel_0_3_0
Update for release 0.3.0
2017-10-16 12:44:29 +02:00
Tim Meusel
29344e0f17 Merge pull request #416 from mterzo/pydb_udpate
Update for pypuppetdb
2017-10-16 12:44:04 +02:00
Mike Terzo
22603e3e32 Update for release 0.3.0 2017-10-15 16:25:39 -04:00
Mike Terzo
2c423d67c9 Update for pypuppetdb 2017-10-15 16:01:01 -04:00
Mike Terzo
66d344b3c1 Merge pull request #400 from kirkins/patch-1
Remove date classes from nodes page
2017-07-11 10:48:32 -04:00
Philip Kirkbride
a169b25a99 fix sort by date on index.html
"
2017-07-11 10:42:37 -04:00
Philip Kirkbride
b2880f13a5 Remove date classes from nodes page
This change makes sorting by date on nodes page work.
2017-07-11 10:35:46 -04:00
Tim Meusel
28befcef9d Merge pull request #396 from mterzo/docs
Update user documentation
2017-07-08 21:16:46 +02:00
Mike Terzo
6ca7067d8c Merge pull request #397 from mterzo/offline_fix
Add offline mode for Semantic UI.
2017-07-08 10:51:54 -04:00
Mike Terzo
e5c9b300ef Add offline mode for Semantic UI.
Following a similar guide to:
https://github.com/Semantic-Org/Semantic-UI/issues/1521

Using a new endpoint to handle offline mode to host semantic.min.css and
other CSS files.  Need to wrap these in templates to provide URL for
static font files when hosting under a directory.
2017-07-08 10:44:40 -04:00
Mike Terzo
548599bc59 Merge pull request #395 from mterzo/attr_error
Handle attribute error on path.
2017-07-08 10:35:36 -04:00
Mike Terzo
1713f8ec95 Adding docs on how to install on Debian Jessie 2017-07-08 10:30:06 -04:00
Mike Terzo
b82493e201 Update configuration docs 2017-07-08 10:30:06 -04:00
Mike Terzo
c2129e3000 Fix unsafe URLs being passed around causing URL encoding
issues and 404 not found when making requests to the server.
2017-07-08 10:27:06 -04:00
Mike Terzo
126b53aada Stop the Processing widget when server returns an error 2017-07-08 10:23:11 -04:00
Tim Meusel
9bd0784838 Merge pull request #393 from mterzo/clean_error
Handle server exception when unable to connect early.
2017-07-08 09:25:04 +02:00
Mike Terzo
7bef20b116 Adding new line 2017-07-04 22:14:54 -04:00
Mike Terzo
c24e927c27 Adding pep8 compliance in travis 2017-07-04 22:14:43 -04:00
Mike Terzo
9488e8cb83 Refactor out some global space to allow for testable code
and handling error conditions before flask is completely
initialized.
2017-07-01 03:35:48 -04:00
Tim Meusel
df915834a4 Merge pull request #388 from mterzo/radiator_color_scheme
Updating radiator CSS to match that of the main CSS.
2017-06-11 10:03:36 +02:00
Mike Terzo
ecf94ef6fa Updating radiator CSS to match that of the main CSS.
Changed wording for Pending to Noop.
2017-06-10 04:21:07 -04:00
Mike Terzo
f4d751060a Merge pull request #386 from redref/fix_default_order
Fix #383
2017-06-10 03:44:09 -04:00
Mike Terzo
38b2a2fc05 Merge pull request #357 from redref/facts
Facts revamp with datatables.
2017-06-10 03:43:15 -04:00
Mike Terzo
7cd8908adb Merge branch 'master' into facts 2017-06-10 03:40:13 -04:00
Mike Terzo
26825aee4f Merge pull request #355 from redref/inventory
Inventory page revamp with paging
2017-06-10 03:37:00 -04:00
Mike Terzo
8f5f7ac7c4 Merge pull request #344 from redref/reports_noops_display
Add total and noop boxes on reports page.
2017-06-10 03:36:26 -04:00
Mike Terzo
38f7958842 Merge pull request #387 from mterzo/docker_pre_build
Adding Docker puppet version tagging
2017-06-10 03:36:01 -04:00
Mike Terzo
db2f8f8b59 Adding Docker puppet version tagging 2017-06-10 02:32:33 -04:00
redref
64c26e19c2 Fix #383
Default to 'desc' for default column 'end_time'
2017-06-06 19:06:42 +02:00
Mike Terzo
671d538a8b Add report column settings to docker_settings 2017-06-06 18:39:19 +02:00
redref
42ed123fe3 Fix number formating in counters 2017-06-06 18:39:19 +02:00
redref
16b197e0ce Make counters configureable 2017-06-06 18:39:19 +02:00
redref
bb26c5bbea Metrics display tune 2017-06-06 18:39:19 +02:00
redref
1758f972f1 Reports counts - add total and noop 2017-06-06 18:39:19 +02:00
Mike Terzo
2f347fd665 Merge pull request #384 from Celant/master
Fix dailychart.js when using non-root location
2017-05-16 13:29:07 -04:00
Celant
3b817e0a5e Fix dailychart when using non-root location
When puppetboard is being run from a directory instead of the root of the domain, the daily chart will not work as it tries to load the data from the root. This change makes it load the data from the current working URL, instead of forcing root.
2017-05-16 14:08:11 +01:00
Raphaël Pinson
c229f51556 Commonmark version (#321)
* Allow HTML in config_version to be interpreted
* Allow Markdown in config_version
2017-04-03 17:37:21 -04:00
redref
d2c47df31f Fix link formatting 2017-04-03 22:37:11 +02:00
redref
c1fd33fb5c Generate fact JSON directly in python 2017-03-23 23:21:28 +01:00
redref
b17a2b0450 Fix tox deps 2017-03-23 23:13:18 +01:00
redref
d4d7b6a56a Clean CatalogForm 2017-03-23 23:13:12 +01:00
redref
4b96cfe196 Fix facts method doc 2017-03-23 23:12:57 +01:00
redref
484727b62c Fix #356 with the new template
Created a custom template_filter as in python3, the groupby filter cannot order Bool vs Str. Needed to push format before the groupby which is not currently possible in jinja.
2017-03-23 22:35:28 +01:00
redref
40511c007a Fact pages and node page tests 2017-03-23 22:35:28 +01:00
redref
a21bd0ac1d Revamp fact pages and tables to datatables 2017-03-23 22:35:28 +01:00
redref
f13100664a Facts page fix + performance revamp
Removed facts query to let only fact-names. facts query time grow pretty quickly with number of nodes. Drawback: no filter on environment (which seems acceptable)
Add testing about view and column repartition (broken in jinja2 2.9.X / inner loop variables).
Rework facts page (jinja 2.9 compliant)
2017-03-23 22:35:28 +01:00
Tim Meusel
e76fdb578c Merge pull request #376 from mterzo/dep_update
Dependency update
2017-03-21 01:15:55 +01:00
Mike Terzo
c59607564a Updating Forms to the new FlaskForm class name 2017-03-20 19:47:21 -04:00
Mike Terzo
cdedf94506 Remove 204-NoContent, Flask, and Werkzeug no longer provide
public apis to add additional error code exception handling.
2017-03-20 19:47:13 -04:00
Mike Terzo
796e2ee7fd Upgrade to new versions of flask and dependencies 2017-03-20 19:47:06 -04:00
Tim Meusel
ac44aefea4 Merge pull request #375 from mterzo/better_requirements
Simple requirements
2017-03-21 00:38:04 +01:00
Mike Terzo
9e09db5f2d Update test dependencies to latest versions. 2017-03-20 17:50:21 -04:00
Mike Terzo
10147b993e Simply build requirements. Dependency management based on
social_app django project.
2017-03-20 17:41:52 -04:00
redref
507df87234 Inventory page testing 2017-02-15 21:43:14 +01:00
redref
709d14e9a2 Inventory revamp - client side 2017-02-15 21:39:49 +01:00
Mike Terzo
fd4051b619 Merge pull request #353 from redref/catalogs
Revamp catalog page with paging
2017-02-15 04:53:07 -05:00
Mike Terzo
21f9a8de8c Merge pull request #362 from buzzdeee/master
Puppet related ports/package maintenance on OpenBSD was taken over
2017-02-14 18:19:51 -05:00
Sebastian Reitenbach
fd88cad67b Puppet related ports/package maintenance on OpenBSD was taken over
from Jasper Lievisse Adriaanse to Sebastian Reitenbach
2017-02-14 23:56:25 +01:00
Mike Terzo
1e5f683b66 Moving version to a single place in version.py (#358)
* Moving version to a single place in version.py
* Requirements in setup.py + tox.ini
2017-02-13 00:35:15 -05:00
redref
f908a1e86c Catalogs tests - better data testing 2017-02-06 16:19:45 +01:00
redref
4190e36278 Add some tests to catalogs 2017-02-03 23:11:08 +01:00
redref
b947bf05c2 Revamp catalog page: paging + datatables 2017-02-03 22:08:05 +01:00
51 changed files with 1243 additions and 628 deletions

View File

@@ -5,29 +5,15 @@ python:
- "2.7"
- "3.5"
- "3.6"
env:
- PINNED=TRUE
- PINNED=FALSE
matrix:
allow_failures:
- python: 2.6
env: PINNED=FALSE
- python: 2.7
env: PINNED=FALSE
- python: 3.5
env: PINNED=FALSE
- python: 3.6
env: PINNED=FALSE
install:
- if [ "${PINNED}" == "FALSE" ]; then python scripts/unpin.py; fi
- pip install -r requirements.txt
- pip install -U -r requirements-test.txt
- pip install -q coverage coveralls --use-wheel
- pip install -r requirements-test.txt
- pip install -q coveralls --use-wheel
script:
- py.test --cov=puppetboard --pep8 -v
- ./bandit.sh
- pytest --pep8
- if [ "${TRAVIS_PYTHON_VERSION}" != "2.6" ]; then
pip install bandit;
bandit -r puppetboard;
fi
after_success:
- coveralls

View File

@@ -4,6 +4,22 @@ Changelog
This is the changelog for Puppetboard.
0.3.0
=====
* Core UI Reowrk
* Update to pypuppetdb 0.3.3
* Fix sorty on data for index
* Update debian documentation
* Offline mode fix
* Fix fact attrbitue error on paths
* Enhanced testing
* Radiator CSS uses same coloring
* Markdown in config version
* Update Flask
* Cleanup requirements.txt
* Update package maintainer for OpenBSD
0.2.1
=====

View File

@@ -1,12 +1,15 @@
FROM python:2.7-alpine
ENV PUPPETBOARD_PORT 80
ENV PUPPETBOARD_SETTINGS docker_settings.py
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
EXPOSE 80
ENV PUPPETBOARD_SETTINGS docker_settings.py
RUN mkdir -p /usr/src/app/
WORKDIR /usr/src/app/
COPY requirements*.txt /usr/src/app/
RUN pip install -r requirements-docker.txt
COPY requirements-docker.txt /usr/src/app/
RUN pip install --no-cache-dir -r requirements-docker.txt
COPY . /usr/src/app
CMD gunicorn -b 0.0.0.0:${PUPPETBOARD_PORT} --access-logfile=/dev/stdout puppetboard.app:app

View File

@@ -110,12 +110,12 @@ Native packages for your operating system will be provided in the near future.
+-------------------+-----------+--------------------------------------------+
| `ArchLinux`_ | available | Maintained by `Tim Meusel`_ |
+-------------------+-----------+--------------------------------------------+
| `OpenBSD`_ | available | Maintained by `Jasper Lievisse Adriaanse`_ |
| `OpenBSD`_ | available | Maintained by `Sebastian Reitenbach`_ |
+-------------------+-----------+--------------------------------------------+
.. _ArchLinux: https://aur.archlinux.org/packages/python2-puppetboard/
.. _Tim Meusel: https://github.com/bastelfreak
.. _Jasper Lievisse Adriaanse: https://github.com/jasperla
.. _Sebastian Reitenbach: https://github.com/buzzdeee
.. _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 12/13: https://build.opensuse.org/package/show/systemsmanagement:puppet/python-puppetboard
@@ -151,7 +151,7 @@ and then install the requirements through:
.. code-block:: bash
$ pip install -r requirements.txt
$ pip install -r requirements-test.txt
You're advised to do this inside a virtualenv specifically created to work on
Puppetboard as to not pollute your global Python installation.
@@ -241,6 +241,21 @@ Other settings that might be interesting in no particular order:
* ``OFFLINE_MODE``: If set to ``True`` load static assets (jquery,
semantic-ui, etc) from the local web server instead of a CDN.
Defaults to ``False``.
* ``DAILY_REPORTS_CHART_ENABLED``: Enable the use of daily chart graphs when
looking at dashboard and node view.
* ``DAILY_REPORTS_CHART_DAYS``: Number of days to show history for on the daily
report graphs.
* ``DISPLAYED_METRICS``: Metrics to show when displying node summary. Example:
``'resources.total'``, ``'events.noop'``.
* ``TABLE_COUNT_SELECTOR``: Configure the dropdown to limit number of hosts to
show per page.
* ``LITTLE_TABLE_COUNT``: Default number of reports to show when when looking at a node.
* ``NORMAL_TABLE_COUNT``: Default number of nodes to show when displaying reports
and catalog nodes.
* ``LOCALISE_TIMESTAMP``: Normalize time based on localserver time.
* ``DEV_LISTEN_HOST``: For use with `dev.py` for development. Default is localhost
* ``DEV_LISTEN_PORT``: For use with `dev.py` for development. Default is 5000
.. _pypuppetdb documentation: http://pypuppetdb.readthedocs.org/en/v0.1.0/quickstart.html#ssl
.. _Flask documentation: http://flask.pocoo.org/docs/0.10/quickstart/#sessions
@@ -285,6 +300,14 @@ scenarios:
If you deploy Puppetboard through a different setup we'd welcome a pull
request that adds the instructions to this section.
Installation On Linux Distros
^^^^^^^^^^^^^^^^^^^^^^^^
`Debian Jessie Install`_.
.. _Debian Jessie Install: docs/Debian-Jessie.md
Apache + mod_wsgi
^^^^^^^^^^^^^^^^^
@@ -683,11 +706,11 @@ Some people have already started building things with and around Puppetboard.
Packages
--------
* An OpenBSD port is being maintained by `Jasper Lievisse Adriaanse`_ and can be viewed `here <http://www.openbsd.org/cgi-bin/cvsweb/ports/www/puppetboard/>`_.
* An OpenBSD port is being maintained by `Sebastian Reitenbach`_ and can be viewed `here <http://www.openbsd.org/cgi-bin/cvsweb/ports/www/puppetboard/>`_.
* A Docker image is being maintained by `Julien K.`_ and can be viewed `here <https://registry.hub.docker.com/u/kassis/puppetboard/>`_.
.. _Jasper Lievisse Adriaanse: https://github.com/jasperla
.. _Sebastian Reitenbach: https://github.com/buzzdeee
.. _Julien K.: https://github.com/juliengk
Contributing

View File

@@ -1,12 +0,0 @@
#!/bin/bash -xe
# Runs bandit tests
pyver="$(python -V 2>&1)"
if [[ $pyver =~ Python\ 2\.6 ]]
then
echo 'Bandit does not support python 2.6'
else
bandit -r puppetboard
bandit -r tests
fi

62
docs/Debian-Jessie.md Normal file
View File

@@ -0,0 +1,62 @@
# Install Using debian jessie
```
$ apt-get install python-pip git
$ mkdir /opt/voxpupuli-puppetboard/
$ cd /opt/voxpupuli-puppetboard/
$ git clone https://github.com/voxpupuli/puppetboard
$ cd /opt/voxpupuli-puppetboard/puppetboard
$ pip install puppetboard
```
* /etc/apache2/sites-available/voxpupuli-puppetboard.conf
```
<VirtualHost *:80>
ServerName puppetboard.my.domain
WSGIDaemonProcess puppetboard user=www-data group=www-data threads=5 python-path=/usr/local/lib/python2.7/dist-packages/puppetboard:python-home=/opt/voxpupuli-puppetboard/puppetboard:/opt/voxpupuli-puppetboard/puppetboard/puppetboard:/usr/local/lib/python2.7/dist-packages/puppetboard/static
WSGIScriptAlias / /opt/voxpupuli-puppetboard/puppetboard/wsgi.py
ErrorLog /var/log/apache2/puppetboard.error.log
CustomLog /var/log/apache2/puppetboard.access.log combined
<Directory /opt/voxpupuli-puppetboard/puppetboard>
<Files wsgi.py>
Order deny,allow
Allow from all
Require all granted
</Files>
</Directory>
Alias /static /usr/local/lib/python2.7/dist-packages/puppetboard/static
<Directory /usr/local/lib/python2.7/dist-packages/puppetboard/static>
Satisfy Any
Allow from all
Require all granted
</Directory>
<Directory /usr/local/lib/python2.7/dist-packages/puppetboard>
WSGIProcessGroup puppetboard
WSGIApplicationGroup %{GLOBAL}
Order deny,allow
Allow from all
Require all granted
</Directory>
</VirtualHost>
```
```
$ a2ensite voxpupuli-puppetboard.conf
```
* /opt/voxpupuli-puppetboard/puppetboard/wsgi.py
```
from __future__ import absolute_import
import os
import sys
sys.path.append('/opt/voxpupuli-puppetboard/puppetboard')
from puppetboard.app import app as application
```

11
hooks/pre_build Executable file
View File

@@ -0,0 +1,11 @@
#!/bin/bash
version=$(git describe HEAD --abbrev=4)
cat << EOF > puppetboard/version.py
#
# Puppetboard version module
#
__version__ = '${version}'
EOF

View File

@@ -0,0 +1,5 @@
#
# Pupppetboard
#
from .version import __version__

View File

@@ -4,9 +4,9 @@ from __future__ import absolute_import
import logging
import collections
try:
from urllib import unquote
from urllib import unquote, unquote_plus, quote_plus
except ImportError:
from urllib.parse import unquote
from urllib.parse import unquote, unquote_plus, quote_plus
from datetime import datetime, timedelta
from itertools import tee
@@ -15,19 +15,23 @@ from flask import (
Response, stream_with_context, redirect,
request, session, jsonify
)
from jinja2.utils import contextfunction
from pypuppetdb import connect
from pypuppetdb.errors import EmptyResponseError
from pypuppetdb.QueryBuilder import *
from puppetboard.forms import (CatalogForm, QueryForm)
from puppetboard.utils import (
get_or_abort, yield_or_stop, get_db_version,
jsonprint, prettyprint
)
from puppetboard.forms import QueryForm
from puppetboard.utils import (get_or_abort, yield_or_stop,
get_db_version)
from puppetboard.dailychart import get_daily_reports_chart
import werkzeug.exceptions as ex
import CommonMark
from puppetboard.core import get_app, get_puppetdb, environments
import puppetboard.errors
from . import __version__
REPORTS_COLUMNS = [
{'attr': 'end', 'filter': 'end_time',
@@ -40,31 +44,26 @@ REPORTS_COLUMNS = [
'name': 'Agent version'},
]
app = Flask(__name__)
CATALOGS_COLUMNS = [
{'attr': 'certname', 'name': 'Certname', 'type': 'node'},
{'attr': 'catalog_timestamp', 'name': 'Compile Time'},
{'attr': 'form', 'name': 'Compare'},
]
app.config.from_object('puppetboard.default_settings')
app = get_app()
graph_facts = app.config['GRAPH_FACTS']
app.config.from_envvar('PUPPETBOARD_SETTINGS', silent=True)
graph_facts += app.config['GRAPH_FACTS']
app.secret_key = app.config['SECRET_KEY']
app.jinja_env.filters['jsonprint'] = jsonprint
app.jinja_env.filters['prettyprint'] = prettyprint
puppetdb = connect(
host=app.config['PUPPETDB_HOST'],
port=app.config['PUPPETDB_PORT'],
ssl_verify=app.config['PUPPETDB_SSL_VERIFY'],
ssl_key=app.config['PUPPETDB_KEY'],
ssl_cert=app.config['PUPPETDB_CERT'],
timeout=app.config['PUPPETDB_TIMEOUT'],)
numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None)
if not isinstance(numeric_level, int):
raise ValueError('Invalid log level: %s' % app.config['LOGLEVEL'])
logging.basicConfig(level=numeric_level)
log = logging.getLogger(__name__)
puppetdb = get_puppetdb()
@app.template_global()
def version():
return __version__
def stream_template(template_name, **context):
app.update_template_context(context)
@@ -74,29 +73,10 @@ def stream_template(template_name, **context):
return rv
def url_for_field(field, value):
args = request.view_args.copy()
args.update(request.args.copy())
args[field] = value
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, envs):
if env != '*' and env not in envs:
abort(404)
app.jinja_env.globals['url_for_field'] = url_for_field
@app.context_processor
def utility_processor():
@@ -106,58 +86,6 @@ def utility_processor():
return dict(now=now)
#
# 204 doesn't have a mapping in werkzeug, we need to define a custom
# class and then set it to the mappings.
#
class NoContent(ex.HTTPException):
code = 204
description = '<p>No content</p'
abort.mapping[204] = NoContent
try:
@app.errorhandler(204)
def no_content(e):
return '', 204
except KeyError:
@app.errorhandler(EmptyResponseError)
def no_content(e):
return '', 204
@app.errorhandler(400)
def bad_request(e):
envs = environments()
return render_template('400.html', envs=envs), 400
@app.errorhandler(403)
def forbidden(e):
envs = environments()
return render_template('403.html', envs=envs), 403
@app.errorhandler(404)
def not_found(e):
envs = environments()
return render_template('404.html', envs=envs), 404
@app.errorhandler(412)
def precond_failed(e):
"""We're slightly abusing 412 to handle missing features
depending on the API version."""
envs = environments()
return render_template('412.html', envs=envs), 412
@app.errorhandler(500)
def server_error(e):
envs = environments()
return render_template('500.html', envs=envs), 500
@app.route('/', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/')
def index(env):
@@ -330,29 +258,11 @@ def nodes(env):
current_env=env)))
@app.route('/inventory', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/inventory')
def inventory(env):
"""Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes along with a set of facts about them.
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.
:param env: Search for facts in this environment
:type env: :obj:`string`
"""
envs = environments()
check_env(env, envs)
headers = [] # a list of fact descriptions to go
# in the table header
fact_names = [] # a list of inventory fact names
fact_data = {} # a multidimensional dict for node and
# fact data
def inventory_facts():
# a list of facts descriptions to go in table header
headers = []
# a list of inventory fact names
fact_names = []
# load the list of items/facts we want in our inventory
try:
@@ -370,38 +280,70 @@ def inventory(env):
headers.append(desc)
fact_names.append(name)
return headers, fact_names
@app.route('/inventory', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/inventory')
def inventory(env):
"""Fetch all (active) nodes from PuppetDB and stream a table displaying
those nodes along with a set of facts about them.
:param env: Search for facts in this environment
:type env: :obj:`string`
"""
envs = environments()
check_env(env, envs)
headers, fact_names = inventory_facts()
return render_template(
'inventory.html',
envs=envs,
current_env=env,
fact_headers=headers)
@app.route('/inventory/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/inventory/json')
def inventory_ajax(env):
"""Backend endpoint for inventory table"""
draw = int(request.args.get('draw', 0))
envs = environments()
check_env(env, envs)
headers, fact_names = inventory_facts()
query = AndOperator()
fact_query = OrOperator()
fact_query.add([EqualsOperator("name", name) for name in fact_names])
query.add(fact_query)
if env != '*':
query.add(EqualsOperator("environment", env))
query.add(fact_query)
# get all the facts from PuppetDB
facts = puppetdb.facts(query=query)
fact_data = {}
for fact in facts:
if fact.node not in fact_data:
fact_data[fact.node] = {}
fact_data[fact.node][fact.name] = fact.value
return Response(stream_with_context(
stream_template(
'inventory.html',
headers=headers,
fact_names=fact_names,
fact_data=fact_data,
envs=envs,
current_env=env
)))
total = len(fact_data)
return render_template(
'inventory.json.tpl',
draw=draw,
total=total,
total_filtered=total,
fact_data=fact_data,
columns=fact_names)
@app.route('/node/<node_name>/',
@app.route('/node/<node_name>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/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
node. This includes facts and reports but not Resources as that is too
@@ -420,21 +362,20 @@ def node(env, node_name):
query.add(EqualsOperator("certname", node_name))
node = get_or_abort(puppetdb.node, node_name)
facts = node.facts()
return render_template(
'node.html',
node=node,
facts=yield_or_stop(facts),
envs=envs,
current_env=env,
columns=REPORTS_COLUMNS[:2])
@app.route('/reports/',
@app.route('/reports',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'],
'node_name': None})
@app.route('/<env>/reports/', defaults={'node_name': None})
@app.route('/reports/<node_name>/',
@app.route('/<env>/reports', defaults={'node_name': None})
@app.route('/reports/<node_name>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/reports/<node_name>')
def reports(env, node_name):
@@ -474,7 +415,7 @@ def reports_ajax(env, node_name):
order_column = int(request.args.get('order[0][column]', 0))
order_filter = REPORTS_COLUMNS[order_column].get(
'filter', REPORTS_COLUMNS[order_column]['attr'])
order_dir = request.args.get('order[0][dir]')
order_dir = request.args.get('order[0][dir]', 'desc')
order_args = '[{"field": "%s", "order": "%s"}]' % (order_filter, order_dir)
status_args = request.args.get('columns[1][search][value]', '').split('|')
max_col = len(REPORTS_COLUMNS)
@@ -538,26 +479,17 @@ def reports_ajax(env, node_name):
reports_events = []
total = 0
report_event_counts = {}
# Create a map from the metrics data to what the templates
# use to express the data.
report_map = {
'success': 'successes',
'failure': 'failures',
'skipped': 'skips',
'noops': 'noop'
}
# Convert metrics to relational dict
metrics = {}
for report in reports_events:
if total is None:
total = puppetdb.total
report_counts = {'successes': 0, 'failures': 0, 'skips': 0}
for metrics in report.metrics:
if 'name' in metrics and metrics['name'] in report_map:
key_name = report_map[metrics['name']]
report_counts[key_name] = metrics['value']
report_event_counts[report.hash_] = report_counts
metrics[report.hash_] = {}
for m in report.metrics:
if m['category'] not in metrics[report.hash_]:
metrics[report.hash_][m['category']] = {}
metrics[report.hash_][m['category']][m['name']] = m['value']
if total is None:
total = 0
@@ -568,7 +500,7 @@ def reports_ajax(env, node_name):
total=total,
total_filtered=total,
reports=reports,
report_event_counts=report_event_counts,
metrics=metrics,
envs=envs,
current_env=env,
columns=REPORTS_COLUMNS[:max_col])
@@ -614,6 +546,8 @@ def report(env, node_name, report_id):
except StopIteration:
abort(404)
report.version = CommonMark.commonmark(report.version)
return render_template(
'report.html',
report=report,
@@ -638,108 +572,178 @@ def facts(env):
check_env(env, envs)
facts = []
order_by = '[{"field": "name", "order": "asc"}]'
facts = get_or_abort(puppetdb.fact_names)
if env == '*':
facts = get_or_abort(puppetdb.fact_names)
else:
query = ExtractOperator()
query.add_field(str('name'))
query.add_query(EqualsOperator("environment", env))
query.add_group_by(str("name"))
for names in get_or_abort(puppetdb._query,
'facts',
query=query,
order_by=order_by):
facts.append(names['name'])
facts_dict = collections.defaultdict(list)
facts_columns = [[]]
letter = None
letter_list = None
break_size = (len(facts) / 4) + 1
next_break = break_size
count = 0
for fact in facts:
letter = fact[0].upper()
letter_list = facts_dict[letter]
letter_list.append(fact)
facts_dict[letter] = letter_list
count += 1
if letter != fact[0].upper() or not letter:
if count > next_break:
# Create a new column
facts_columns.append([])
next_break += break_size
if letter_list:
facts_columns[-1].append(letter_list)
# Reset
letter = fact[0].upper()
letter_list = []
letter_list.append(fact)
facts_columns[-1].append(letter_list)
sorted_facts_dict = sorted(facts_dict.items())
return render_template('facts.html',
facts_dict=sorted_facts_dict,
facts_len=(sum(map(len, facts_dict.values())) +
len(facts_dict) * 5),
facts_columns=facts_columns,
envs=envs,
current_env=env)
@app.route('/fact/<fact>', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/fact/<fact>')
def fact(env, fact):
"""Fetches the specific fact from PuppetDB and displays its value per
@app.route('/fact/<fact>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'], 'value': None})
@app.route('/<env>/fact/<fact>', defaults={'value': None})
@app.route('/fact/<fact>/<value>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/fact/<fact>/<value>')
def fact(env, fact, value):
"""Fetches the specific fact(/value) from PuppetDB and displays per
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`
"""
envs = environments()
check_env(env, envs)
# we can only consume the generator once, lists can be doubly consumed
# om nom nom
render_graph = False
if fact in graph_facts:
render_graph = True
if env == '*':
query = None
else:
query = EqualsOperator("environment", env)
localfacts = [f for f in yield_or_stop(puppetdb.facts(
name=fact, query=query))]
return Response(stream_with_context(stream_template(
'fact.html',
name=fact,
render_graph=render_graph,
facts=localfacts,
envs=envs,
current_env=env)))
@app.route('/fact/<fact>/<value>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/fact/<fact>/<value>')
def fact_value(env, 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
:param value: Find all facts with this value
:type value: :obj:`string`
"""
envs = environments()
check_env(env, envs)
if env == '*':
query = None
else:
query = EqualsOperator("environment", env)
render_graph = False
if fact in graph_facts and not value:
render_graph = True
value_safe = value
if value is not None:
value_safe = unquote_plus(value)
facts = get_or_abort(puppetdb.facts,
name=fact,
value=value,
query=query)
localfacts = [f for f in yield_or_stop(facts)]
return render_template(
'fact.html',
name=fact,
fact=fact,
value=value,
facts=localfacts,
value_safe=value_safe,
render_graph=render_graph,
envs=envs,
current_env=env)
@app.route('/fact/<fact>/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'],
'node': None, 'value': None})
@app.route('/<env>/fact/<fact>/json', defaults={'node': None, 'value': None})
@app.route('/fact/<fact>/<value>/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'], 'node': None})
@app.route('/fact/<fact>/<path:value>/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'], 'node': None})
@app.route('/<env>/fact/<fact>/<value>/json', defaults={'node': None})
@app.route('/node/<node>/facts/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'],
'fact': None, 'value': None})
@app.route('/<env>/node/<node>/facts/json',
defaults={'fact': None, 'value': None})
def fact_ajax(env, node, fact, value):
"""Fetches the specific facts matching (node/fact/value) from PuppetDB and
return a JSON table
:param env: Searches for facts in this environment
:type env: :obj:`string`
:param node: Find all facts for this node
:type node: :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`
"""
draw = int(request.args.get('draw', 0))
envs = environments()
check_env(env, envs)
render_graph = False
if fact in graph_facts and not value and not node:
render_graph = True
query = AndOperator()
if node:
query.add(EqualsOperator("certname", node))
if env != '*':
query.add(EqualsOperator("environment", env))
if len(query.operations) == 0:
query = None
# Generator needs to be converted (graph / total)
try:
value = int(value)
except ValueError:
if value is not None:
query.add(EqualsOperator('value', unquote_plus(value)))
except TypeError:
pass
facts = [f for f in get_or_abort(
puppetdb.facts,
name=fact,
query=query)]
total = len(facts)
counts = {}
json = {
'draw': draw,
'recordsTotal': total,
'recordsFiltered': total,
'data': []}
for fact_h in facts:
line = []
if not fact:
line.append(fact_h.name)
if not node:
line.append('<a href="{0}">{1}</a>'.format(
url_for('node', env=env, node_name=fact_h.node),
fact_h.node))
if not value:
fact_value = fact_h.value
if isinstance(fact_value, unicode) or isinstance(fact_value, str):
fact_value = quote_plus(fact_h.value)
line.append('<a href="{0}">{1}</a>'.format(
url_for(
'fact', env=env, fact=fact_h.name, value=fact_value),
fact_h.value))
json['data'].append(line)
if render_graph:
if fact_h.value not in counts:
counts[fact_h.value] = 0
counts[fact_h.value] += 1
if render_graph:
json['chart'] = [
{"label": "{0}".format(k).replace('\n', ' '),
"value": counts[k]}
for k in sorted(counts, key=lambda k: counts[k], reverse=True)]
return jsonify(json)
@app.route('/query', methods=('GET', 'POST'),
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/query', methods=('GET', 'POST'))
@@ -828,9 +832,14 @@ def metric(env, metric):
current_env=env)
@app.route('/catalogs', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs')
def catalogs(env):
@app.route('/catalogs',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'],
'compare': None})
@app.route('/<env>/catalogs', defaults={'compare': None})
@app.route('/catalogs/compare/<compare>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs/compare/<compare>')
def catalogs(env, compare):
"""Lists all nodes with a compiled catalog.
:param env: Find the nodes with this catalog_environment value
@@ -839,53 +848,80 @@ def catalogs(env):
envs = environments()
check_env(env, envs)
if app.config['ENABLE_CATALOG']:
nodenames = []
catalog_list = []
query = AndOperator()
if env != '*':
query.add(EqualsOperator("catalog_environment", env))
query.add(NullOperator("catalog_timestamp", False))
order_by_str = '[{"field": "certname", "order": "asc"}]'
nodes = get_or_abort(puppetdb.nodes,
query=query,
with_status=False,
order_by=order_by_str)
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:
if not app.config['ENABLE_CATALOG']:
log.warn('Access to catalog interface disabled by administrator')
abort(403)
return render_template(
'catalogs.html',
compare=compare,
columns=CATALOGS_COLUMNS,
envs=envs,
current_env=env)
@app.route('/catalogs/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT'],
'compare': None})
@app.route('/<env>/catalogs/json', defaults={'compare': None})
@app.route('/catalogs/compare/<compare>/json',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs/compare/<compare>/json')
def catalogs_ajax(env, compare):
"""Server data to catalogs as JSON to Jquery datatables
"""
draw = int(request.args.get('draw', 0))
start = int(request.args.get('start', 0))
length = int(request.args.get('length', app.config['NORMAL_TABLE_COUNT']))
paging_args = {'limit': length, 'offset': start}
search_arg = request.args.get('search[value]')
order_column = int(request.args.get('order[0][column]', 0))
order_filter = CATALOGS_COLUMNS[order_column].get(
'filter', CATALOGS_COLUMNS[order_column]['attr'])
order_dir = request.args.get('order[0][dir]', 'asc')
order_args = '[{"field": "%s", "order": "%s"}]' % (order_filter, order_dir)
envs = environments()
check_env(env, envs)
query = AndOperator()
if env != '*':
query.add(EqualsOperator("catalog_environment", env))
if search_arg:
query.add(RegexOperator("certname", r"%s" % search_arg))
query.add(NullOperator("catalog_timestamp", False))
nodes = get_or_abort(puppetdb.nodes,
query=query,
include_total=True,
order_by=order_args,
**paging_args)
catalog_list = []
total = None
for node in nodes:
if total is None:
total = puppetdb.total
catalog_list.append({
'certname': node.name,
'catalog_timestamp': node.catalog_timestamp,
'form': compare,
})
if total is None:
total = 0
return render_template(
'catalogs.json.tpl',
total=total,
total_filtered=total,
draw=draw,
columns=CATALOGS_COLUMNS,
catalogs=catalog_list,
envs=envs,
current_env=env)
@app.route('/catalog/<node_name>',
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@@ -911,40 +947,6 @@ def catalog_node(env, node_name):
abort(403)
@app.route('/catalog/submit', methods=['POST'],
defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@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`
"""
envs = environments()
check_env(env, envs)
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': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs/compare/<compare>...<against>')
@@ -1090,3 +1092,15 @@ def daily_reports_chart(env):
certname=certname,
)
return jsonify(result=result)
@app.route('/offline/<path:filename>')
def offline_static(filename):
mimetype = 'text/html'
if filename.endswith('.css'):
mimetype = 'text/css'
elif filename.endswith('.js'):
mimetype = 'text/javascript'
return Response(response=render_template('static/%s' % filename),
status=200, mimetype=mimetype)

64
puppetboard/core.py Normal file
View File

@@ -0,0 +1,64 @@
from __future__ import unicode_literals
from __future__ import absolute_import
import logging
from flask import Flask
from pypuppetdb import connect
from puppetboard.utils import (jsonprint, prettyprint, url_for_field,
url_static_offline, get_or_abort)
from . import __version__
APP = None
PUPPETDB = None
def get_app():
global APP
if APP is None:
app = Flask(__name__)
app.config.from_object('puppetboard.default_settings')
app.config.from_envvar('PUPPETBOARD_SETTINGS', silent=True)
app.secret_key = app.config['SECRET_KEY']
numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None)
if not isinstance(numeric_level, int):
raise ValueError('Invalid log level: %s' % app.config['LOGLEVEL'])
app.jinja_env.filters['jsonprint'] = jsonprint
app.jinja_env.filters['prettyprint'] = prettyprint
app.jinja_env.globals['url_for_field'] = url_for_field
app.jinja_env.globals['url_static_offline'] = url_static_offline
APP = app
return APP
def get_puppetdb():
global PUPPETDB
if PUPPETDB is None:
app = get_app()
puppetdb = connect(host=app.config['PUPPETDB_HOST'],
port=app.config['PUPPETDB_PORT'],
ssl_verify=app.config['PUPPETDB_SSL_VERIFY'],
ssl_key=app.config['PUPPETDB_KEY'],
ssl_cert=app.config['PUPPETDB_CERT'],
timeout=app.config['PUPPETDB_TIMEOUT'],)
PUPPETDB = puppetdb
return PUPPETDB
def environments():
puppetdb = get_puppetdb()
envs = get_or_abort(puppetdb.environments)
x = []
for env in envs:
x.append(env['name'])
return x

View File

@@ -18,6 +18,11 @@ LOGLEVEL = 'info'
NORMAL_TABLE_COUNT = 100
LITTLE_TABLE_COUNT = 10
TABLE_COUNT_SELECTOR = [10, 20, 50, 100, 500]
DISPLAYED_METRICS = ['resources.total',
'events.failure',
'events.success',
'resources.skipped',
'events.noop']
OFFLINE_MODE = False
ENABLE_CATALOG = False
OVERVIEW_FILTER = None

View File

@@ -33,6 +33,13 @@ TABLE_COUNT_DEF = "10,20,50,100,500"
TABLE_COUNT_SELECTOR = [int(x) for x in os.getenv('TABLE_COUNT_SELECTOR',
TABLE_COUNT_DEF).split(',')]
DISP_METR_DEF = ','.join(['resources.total', 'events.failure',
'events.success', 'resources.skipped',
'events.noop'])
DISPLAYED_METRICS = [x.strip() for x in os.getenv('DISPLAYED_METRICS',
DISP_METR_DEF).split(',')]
OFFLINE_MODE = bool(os.getenv('OFFLINE_MODE', 'False').upper() == 'TRUE')
ENABLE_CATALOG = bool(os.getenv('ENABLE_CATALOG', 'False').upper() == 'TRUE')
OVERVIEW_FILTER = os.getenv('OVERVIEW_FILTER', None)
@@ -46,7 +53,6 @@ GRAPH_FACTS_DEFAULT = ','.join(['architecture', 'clientversion', 'domain',
GRAPH_FACTS = [x.strip() for x in os.getenv('GRAPH_FACTS',
GRAPH_FACTS_DEFAULT).split(',')]
GRAPH_TYPE = os.getenv('GRAPH_TYPE', 'pie')
# Tuples are hard to express as an environment variable, so here

45
puppetboard/errors.py Normal file
View File

@@ -0,0 +1,45 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from puppetboard.core import get_app, environments
from werkzeug.exceptions import InternalServerError
from flask import render_template
from . import __version__
app = get_app()
@app.errorhandler(400)
def bad_request(e):
envs = environments()
return render_template('400.html', envs=envs), 400
@app.errorhandler(403)
def forbidden(e):
envs = environments()
return render_template('403.html', envs=envs), 403
@app.errorhandler(404)
def not_found(e):
envs = environments()
return render_template('404.html', envs=envs), 404
@app.errorhandler(412)
def precond_failed(e):
"""We're slightly abusing 412 to handle missing features
depending on the API version."""
envs = environments()
return render_template('412.html', envs=envs), 412
@app.errorhandler(500)
def server_error(e):
envs = []
try:
envs = environments()
except InternalServerError as e:
pass
return render_template('500.html', envs=envs), 500

View File

@@ -1,14 +1,14 @@
from __future__ import unicode_literals
from __future__ import absolute_import
from flask.ext.wtf import Form
from flask_wtf import FlaskForm
from wtforms import (
HiddenField, RadioField, SelectField,
TextAreaField, BooleanField, validators
)
class QueryForm(Form):
class QueryForm(FlaskForm):
"""The form used to allow freeform queries to be executed against
PuppetDB."""
query = TextAreaField('Query', [validators.Required(
@@ -28,9 +28,3 @@ class QueryForm(Form):
('pql', 'PQL'),
])
rawjson = BooleanField('Raw JSON')
class CatalogForm(Form):
"""The form used to compare the catalogs of different nodes."""
compare = HiddenField('compare')
against = SelectField('against')

View File

@@ -44,7 +44,7 @@ h1.ui.header.no-margin-bottom {
color: #AA4643;
}
.ui.label.failed {
.ui.label.failed, .ui.label.events.failure {
background-color: #AA4643;
}
@@ -52,7 +52,7 @@ h1.ui.header.no-margin-bottom {
color: #4572A7;
}
.ui.label.changed {
.ui.label.changed, .ui.label.events.success {
background-color: #4572A7;
}
@@ -68,10 +68,14 @@ h1.ui.header.no-margin-bottom {
color: #DB843D;
}
.ui.label.noop {
.ui.label.noop, .ui.label.events.noop {
background-color: #DB843D;
}
.ui.label.resources.total {
background-color: #989898;
}
.ui.label.unchanged {
background-color: #89A54E;
}
@@ -80,7 +84,7 @@ h1.ui.header.no-margin-bottom {
color: orange;
}
.ui.label.skipped {
.ui.label.skipped, .ui.label.resources.skipped {
background-color: orange;
}

View File

@@ -12,20 +12,20 @@ body.radiator_controller table.node_summary {padding:20px;position:absolute;heig
body.radiator_controller table.node_summary .count_column {min-width:2em;width:2em;}
body.radiator_controller table.node_summary tr:last-child td {border-bottom:none;}
body.radiator_controller table.node_summary tr:last-child td .label {border-left:0px #000 solid;}
body.radiator_controller table.node_summary tr.unreported .percent {background-color:#ee7722;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.unreported .label,body.radiator_controller table.node_summary tr.unreported .count {color:#ee7722;}
body.radiator_controller table.node_summary tr.unreported .percent {background-color:#3D96AE;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.unreported .label,body.radiator_controller table.node_summary tr.unreported .count {color:#3D96AE;}
body.radiator_controller table.node_summary tr.unreported .label {border-left:1px #333 dashed;}
body.radiator_controller table.node_summary tr.failed .percent {background-color:#cc2211;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.failed .label,body.radiator_controller table.node_summary tr.failed .count {color:#cc2211;}
body.radiator_controller table.node_summary tr.failed .label {border-left:1px #333 dashed;}
body.radiator_controller table.node_summary tr.noop .percent {background-color:#eddc21;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.noop .label,body.radiator_controller table.node_summary tr.noop .count {color:#eddc21;}
body.radiator_controller table.node_summary tr.noop .percent {background-color:#DB843D;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.noop .label,body.radiator_controller table.node_summary tr.noop .count {color:#DB843D;}
body.radiator_controller table.node_summary tr.noop .label {border-left:1px #333 dashed;}
body.radiator_controller table.node_summary tr.changed .percent {background-color:#009933;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.changed .label,body.radiator_controller table.node_summary tr.changed .count {color:#009933;}
body.radiator_controller table.node_summary tr.changed .percent {background-color:#4572A7;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.changed .label,body.radiator_controller table.node_summary tr.changed .count {color:#4572A7;}
body.radiator_controller table.node_summary tr.changed .label {border-left:1px #333 dashed;}
body.radiator_controller table.node_summary tr.unchanged .percent {background-color:#2198ed;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.unchanged .label,body.radiator_controller table.node_summary tr.unchanged .count {color:#2198ed;}
body.radiator_controller table.node_summary tr.unchanged .percent {background-color:#89A54E;border-radius:0 3px 3px 0;}
body.radiator_controller table.node_summary tr.unchanged .label,body.radiator_controller table.node_summary tr.unchanged .count {color:#89A54E;}
body.radiator_controller table.node_summary tr.unchanged .label {border-left:1px #333 dashed;}
body.radiator_controller table.node_summary tr.total {color:#fff;background-color:#181818;}
body.radiator_controller table.node_summary tr.total .percent {background-color:white;border-radius:0 3px 3px 0;}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,6 @@
jQuery(function ($) {
function generateChart(el) {
var url = "/daily_reports_chart.json";
var url = "daily_reports_chart.json";
var certname = $(el).attr('data-certname');
if (typeof certname !== typeof undefined && certname !== false) {
url = url + "?certname=" + certname;

View File

@@ -1,51 +1,3 @@
{% 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">
<input {% if autofocus %} autofocus="autofocus" {% endif %} class="filter-table" placeholder="Type here to filter...">
</div>
<table class="ui very basic {% if condensed %}very{% endif%} compact sortable table" style="table-layout: fixed;">
<thead>
<tr>
{% if show_node %}
<th>Node</th>
{% else %}
<th class="default-sort">Fact</th>
{% endif %}
{% if show_value %}
<th>Value</th>
{% endif %}
</tr>
</thead>
<tbody class="searchable">
{% for fact in facts %}
<tr>
{% if show_node %}
<td><a href="{{url_for('node', env=current_env, node_name=fact.node)}}">{{fact.node}}</a></td>
{% else %}
<td><a href="{{url_for('fact', env=current_env, fact=fact.name)}}">{{fact.name}}</a></td>
{% endif %}
{% if show_value %}
<td style="word-wrap:break-word">
{% if link_facts %}
{% 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 %}
{{fact.value}}
{% endif %}
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{%- endmacro %}
{% macro status_counts(caller, status, node_name, events, current_env, unreported_time=False, report_hash=False) -%}
<a class="ui {{status}} label status" href="{{url_for('report', env=current_env, node_name=node_name, report_id=report_hash)}}">{{ status|upper }}</a>
{% if status == 'unreported' %}
@@ -57,9 +9,33 @@
{% endif %}
{%- endmacro %}
{% macro report_status(caller, status, node_name, metrics, current_env, unreported_time=False, report_hash=False) -%}
<a class="ui {{status}} label status" href="{{url_for('report', env=current_env, node_name=node_name, report_id=report_hash)}}">{{ status|upper }}</a>
{% if status == 'unreported' %}
<span class="ui label status"> {{ unreported_time|upper }} </span>
{% else %}
{% for metric in config.DISPLAYED_METRICS %}
{% set path = metric.split('.') %}
{% set title = ' '.join(path) %}
{% if metrics[path[0]] and metrics[path[0]][path[1]] %}
{% set value = metrics[path[0]][path[1]] %}
{% if value != 0 and value|int != value %}
{% set format_str = '%.2f' %}
{% else %}
{% set format_str = '%s' %}
{% endif %}
<span title="{{ title }}" class="ui small count label {{ title }}">{{ format_str|format(value) }}</span>
{% else %}
<span title="{{ title }}" class="ui small count label">0</span>
{% endif%}
{% endfor %}
{% endif %}
{%- endmacro %}
{% macro datatable_init(table_html_id, ajax_url, default_length, length_selector, extra_options=None) -%}
// Init datatable
$.fn.dataTable.ext.errMode = 'throw';
var table = $('#{{ table_html_id }}').DataTable({
// Permit flow auto-readjust (responsive)
"autoWidth": false,
@@ -76,12 +52,19 @@
// Paging options
"lengthMenu": {{ length_selector }},
"pageLength": {{ default_length }},
// Search as regex (does not apply if serverSide)
"search": {"regex": true},
// Default sort
"order": [[ 0, "desc" ]],
// Custom options
{% if extra_options %}{% call extra_options() %}Callback to parent defined options{% endcall %}{% endif %}
});
table.on('error', function ( e, settings, json ) {
table.clear().draw();
$('#facts_table_processing').hide(); })
table.on('draw.dt', function(){
$('#{{ table_html_id }} [rel=utctimestamp]').each(
function(index, timestamp){

View File

@@ -1,40 +1,21 @@
{% 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 very basic very compact table nodes'>
<table id="catalogs_table" class='ui very basic table stackable'>
<thead>
<tr>
<th></th>
<th>Certname</th>
<th>Compile Time</th>
<th>Compare With</th>
{% for column in columns %}
<th>{{ column.name }}</th>
{% endfor %}
</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 action input">
<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>
</tbody>
</table>
{% endblock content %}
{% block onload_script %}
{% macro extra_options(caller) %}
"order": [[ 0, "asc" ]],
{% endmacro %}
{{ macros.datatable_init(table_html_id="catalogs_table", ajax_url=url_for('catalogs_ajax', env=current_env, compare=compare), default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
{% endblock onload_script %}

View File

@@ -0,0 +1,40 @@
{
"draw": {{draw}},
"recordsTotal": {{total}},
"recordsFiltered": {{total_filtered}},
"data": [
{% for catalog in catalogs -%}
{%- if not loop.first %},{%- endif -%}
[
{%- for column in columns -%}
{%- if not loop.first %},{%- endif -%}
{%- if column.attr == 'catalog_timestamp' -%}
"<a rel=\"utctimestamp\" href=\"{{url_for('catalog_node', env=current_env, node_name=catalog.certname)}}\">{{ catalog.catalog_timestamp }}</a>"
{%- elif column.type == 'node' -%}
{% filter jsonprint %}<a href="{{url_for('node', env=current_env, node_name=catalog.certname)}}">{{ catalog.certname }}</a>{% endfilter %}
{%- elif column.attr == 'form' -%}
{% filter jsonprint -%}
<div class="ui action input">
{%- if catalog.form -%}
<form method="GET" action="{{url_for('catalog_compare', env=current_env, compare=catalog.form, against=catalog.certname)}}">
{%- else -%}
<form method="GET" action="{{url_for('catalogs', env=current_env, compare=catalog.certname)}}">
{%- endif -%}
<div class="field inline">
{%- if catalog.form -%}
<input type="submit" class="ui submit button" style="height:auto;" value="Compare with {{ catalog.form }}"/>
{%- else -%}
<input type="submit" class="ui submit button" style="height:auto;" value="Compare with ..."/>
{%- endif -%}
</div>
</form>
</div>
{%- endfilter -%}
{%- else -%}
""
{%- endif -%}
{%- endfor -%}
]
{% endfor %}
]
}

View File

@@ -1,50 +1,45 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block javascript %}
{% if render_graph %}
var chart = null;
var data = [
{% for fact in facts|groupby('value') %}
{
label: '{{ fact.grouper.replace("\n", " ") }}',
value: {{ fact.list|length }}
},
{% endfor %}
{
value: 0,
}
]
var fact_values = data.map(function(item) { return [item.label, item.value]; }).filter(function(item){return item[0];}).sort(function(a,b){return b[1] - a[1];});
var realdata = fact_values.slice(0, 15);
var otherdata = fact_values.slice(15);
if (otherdata.length > 0) {
realdata.push(["other", otherdata.reduce(function(a,b){return a + b[1];},0)]);
}
{% endif %}
{% endblock javascript %}
{% block onload_script %}
$('table').tablesort();
{% if render_graph %}
chart = c3.generate({
{% macro extra_options(caller) %}
// No per page AJAX
'serverSide': false,
{% endmacro %}
{{ macros.datatable_init(table_html_id="facts_table", ajax_url=url_for('fact_ajax', env=current_env, fact=fact, value=value), default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
{% if render_graph %}
table.on('xhr', function(e, settings, json){
var fact_values = json['chart'].map(function(item) { return [item.label, item.value]; }).filter(function(item){return item[0];}).sort(function(a,b){return b[1] - a[1];});
var realdata = fact_values.slice(0, 15);
var otherdata = fact_values.slice(15);
if (otherdata.length > 0) {
realdata.push(["other", otherdata.reduce(function(a,b){return a + b[1];},0)]);
}
c3.generate({
bindto: '#factChart',
data: {
columns: realdata,
type : '{{config.GRAPH_TYPE|default('pie')}}',
}
});
{% endif %}
})
{% endif %}
{% endblock onload_script %}
{% block content %}
{% if render_graph %}
<div id="factChart" width="300" height="300"></div>
<h1>{{name}}{% if value %}/{{value}}{% endif %} ({{facts|length}})</h1>
{% if value %}
{{macros.facts_table(facts, current_env=current_env, autofocus=True, show_node=True, show_value=False, margin_bottom=10)}}
{% else %}
{{macros.facts_table(facts, current_env=current_env, autofocus=True, show_node=True, link_facts=True, margin_bottom=10)}}
{% endif %}
<h1>{{ fact }}{% if value_safe %} : {{ value_safe }}{% endif %}</h1>
<table id="facts_table" class='ui fixed very basic compact table stackable'>
<thead>
<tr>
<th>Node</th>
{% if not value %}<th>Value</th>{% endif %}
</tr>
</thead>
<tbody>
</tbody>
</table>
{% endblock content %}

View File

@@ -4,26 +4,19 @@
<input autofocus="autofocus" class="filter-list" placeholder="Type here to filter...">
</div>
<div class="ui searchable stackable doubling four column grid factlist">
{%- for column in facts_columns %}
<div class="column">
{%- set facts_count = 0 -%}
{%- set break = facts_len//4 + 1 -%}
{%- for key,facts_list in facts_dict %}
{%- for letter in column %}
<div class="ui list_hide_segment segment">
<a class="ui darkblue ribbon label">{{key}}</a>
<a class="ui darkblue ribbon label">{{ letter[0][0]|upper }}</a>
<ul>
{%- for fact in facts_list %}
<li><a href="{{url_for('fact', env=current_env, fact=fact)}}">{{fact}}</a></li>
{%- for fact in letter %}
<li><a href="{{url_for('fact', env=current_env, fact=fact)}}">{{ fact }}</a></li>
{%- endfor %}
</ul>
</div>
{%- set facts_count = facts_count + facts_list|length -%}
{%- if facts_count >= break -%}
</div>
<div class="column">
{%- set break = facts_len//4 + 1 + break -%}
{%- endif -%}
{%- set facts_count = facts_count + 5 -%}
{% endfor %}
{%- endfor %}
</div>
{%- endfor %}
</div>
{% endblock content %}

View File

@@ -87,7 +87,7 @@
<tr>
<th class="five wide">Status</th>
<th class="five wide">Certname</th>
<th class="five wide date default-sort">Report</th>
<th class="five wide default-sort">Report</th>
<th class="one wide"></th>
</tr>
</thead>

View File

@@ -1,24 +1,21 @@
{% 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 very basic sortable table'>
<table id="inventory_table" class='ui fixed compact very basic sortable table'>
<thead>
<tr>
{% for head in headers %}
<th{% if loop.index == 1 %} class="default-sort"{% endif %}>{{head}}</th>
{% for head in fact_headers %}
<th>{{head}}</th>
{% endfor %}
</tr>
</thead>
<tbody class="searchable">
{% for node, facts in fact_data.iteritems() %}
<tr>
{% for name in fact_names %}
<td><a href="{{url_for('node', env=current_env, node_name=node)}}">{{facts.get(name, 'undef')}}</a></td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %}
{% block onload_script %}
{% macro extra_options(caller) %}
'serverSide': false,
{% endmacro %}
{{ macros.datatable_init(table_html_id="inventory_table", ajax_url=url_for('inventory_ajax', env=current_env), default_length=config.NORMAL_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
{% endblock onload_script %}

View File

@@ -0,0 +1,23 @@
{%- import '_macros.html' as macros -%}
{
"draw": {{draw}},
"recordsTotal": {{total}},
"recordsFiltered": {{total_filtered}},
"data": [
{% for node in fact_data -%}
{%- if not loop.first %},{%- endif -%}
[
{%- for column in columns -%}
{%- if not loop.first %},{%- endif -%}
{%- if column in ['fqdn', 'hostname'] -%}
{% filter jsonprint %}<a href="{{ url_for('node', env=current_env, node_name=node) }}">{{ node }}</a>{% endfilter %}
{%- elif fact_data[node][column] -%}
{{ fact_data[node][column] | jsonprint }}
{%- else -%}
""
{%- endif -%}
{%- endfor -%}
]
{% endfor -%}
]
}

View File

@@ -13,11 +13,12 @@
}
</style>
<link href='{{ url_for('static', filename='jquery-datatables-1.10.13/dataTables.semanticui.min.css') }}' rel='stylesheet' type='text/css'>
<link href="{{ url_for('offline_static', filename='Semantic-UI-2.1.8/semantic.min.css') }}" rel="stylesheet" />
{% else %}
<link href='//fonts.googleapis.com/css?family=Open+Sans' rel='stylesheet' type='text/css' />
<link href="{{ url_for('static', filename='Semantic-UI-2.1.8/semantic.min.css') }}" rel="stylesheet" />
<link href='//cdnjs.cloudflare.com/ajax/libs/datatables/1.10.13/css/dataTables.semanticui.min.css' rel='stylesheet' type='text/css'>
{% endif %}
<link href="{{ url_for('static', filename='Semantic-UI-2.1.8/semantic.min.css') }}" rel="stylesheet" />
<link href="{{ url_for('static', filename='css/puppetboard.css') }}" rel="stylesheet" />
{% if config.OFFLINE_MODE %}
@@ -87,7 +88,7 @@
{% endfor %}
</div>
</div>
<div class="item right"><a href="https://github.com/voxpupuli/puppetboard" target="_blank">v0.2.1</a></div>
<div class="item right"><a href="https://github.com/voxpupuli/puppetboard" target="_blank">{{version()}}</a></div>
</div>
<div class="ui grid padding-bottom">
<div class="one wide column"></div>

View File

@@ -17,7 +17,13 @@
'pagingType': 'simple',
"bFilter": false,
{% endmacro %}
{% macro facts_extra_options(caller) %}
'paging': false,
// No per page AJAX
'serverSide': false,
{% endmacro %}
{{ macros.datatable_init(table_html_id="reports_table", ajax_url=url_for('reports_ajax', env=current_env, node_name=node.name), default_length=config.LITTLE_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=extra_options) }}
{{ macros.datatable_init(table_html_id="facts_table", ajax_url=url_for('fact_ajax', env=current_env, node=node.name), default_length=config.LITTLE_TABLE_COUNT, length_selector=config.TABLE_COUNT_SELECTOR, extra_options=facts_extra_options) }}
{% endblock onload_script %}
{% block content %}
@@ -69,7 +75,16 @@
</div>
<div class='column'>
<h1>Facts</h1>
{{macros.facts_table(facts, link_facts=True, condensed=True, current_env=current_env)}}
<table id="facts_table" class='ui fixed very basic very compact table stackable'>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
{% endblock content %}

View File

@@ -9,8 +9,8 @@
<tr>
<th>Status</th>
<th class="default">Certname</th>
<th class="date default-sort">Catalog</th>
<th class="date">Report</th>
<th class="default-sort">Catalog</th>
<th>Report</th>
<th>&nbsp;</th>
</tr>
</thead>

View File

@@ -52,10 +52,10 @@
<td>
<div>
<p class='label'>
<span>Pending</span>
<span>Noop</span>
</p>
<p class='percent' style='width:{{stats['noop_percent']}}%'>
<span>Pending</span>
<span>Noop</span>
</p>
</div>
</td>

View File

@@ -14,7 +14,7 @@
<tr>
<td><a href="{{url_for('node', env=current_env, node_name=report.node)}}">{{ report.node }}</a></td>
<td>
{{report.version}}
{{report.version|safe}}
</td>
<td rel="utctimestamp">
{{report.start}}

View File

@@ -15,7 +15,7 @@
"<span rel=\"utctimestamp\">{{ report[column.attr] }}</span>"
{%- elif column.type == 'status' -%}
{% filter jsonprint -%}
{{ macros.status_counts(status=report.status, node_name=report.node, events=report_event_counts[report.hash_], report_hash=report.hash_, current_env=current_env) }}
{{ macros.report_status(status=report.status, node_name=report.node, metrics=metrics[report.hash_], report_hash=report.hash_, current_env=current_env) }}
{%- endfilter %}
{%- elif column.type == 'node' -%}
{% filter jsonprint %}<a href="{{url_for('node', env=current_env, node_name=report.node)}}">{{ report.node }}</a>{% endfilter %}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,24 @@
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 400;
src: local('Lato Regular'), local('Lato-Regular'), url({{ url_for('static', filename='fonts/lato-normal-400.ttf') }}) format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
src: local('Lato Bold'), local('Lato-Bold'), url({{ url_for('static', filename='fonts/lato-normal-700.ttf') }}) format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: 400;
src: local('Lato Italic'), local('Lato-Italic'), url({{ url_for('static', filename='fonts/lato-italic-400.ttf') }}) format('truetype');
}
@font-face {
font-family: 'Lato';
font-style: italic;
font-weight: 700;
src: local('Lato Bold Italic'), local('Lato-BoldItalic'), url({{ url_for('static', filename='fonts/lato-italic-700.ttf') }}) format('truetype');
}

View File

@@ -1,6 +1,7 @@
from __future__ import absolute_import
from __future__ import unicode_literals
import os.path
import json
import logging
@@ -8,8 +9,8 @@ from math import ceil
from requests.exceptions import HTTPError, ConnectionError
from pypuppetdb.errors import EmptyResponseError
from flask import abort
from flask import abort, request, url_for
from jinja2.utils import contextfunction
# Python 3 compatibility
try:
@@ -20,6 +21,21 @@ except NameError:
log = logging.getLogger(__name__)
@contextfunction
def url_static_offline(context, value):
request_parts = os.path.split(os.path.dirname(context.name))
static_path = '/'.join(request_parts[1:])
return url_for('static', filename="%s/%s" % (static_path, value))
def url_for_field(field, value):
args = request.view_args.copy()
args.update(request.args.copy())
args[field] = value
return url_for(request.endpoint, **args)
def jsonprint(value):
return json.dumps(value, indent=2, separators=(',', ': '))

5
puppetboard/version.py Normal file
View File

@@ -0,0 +1,5 @@
#
# Puppetboard version module
#
__version__ = '0.3.0'

View File

@@ -1,10 +1,2 @@
-r requirements.txt
gunicorn==19.6.0
Flask==0.10.1
Flask-WTF==0.12
Jinja2==2.7.2
MarkupSafe==0.19
WTForms==2.1
Werkzeug==0.11.10
itsdangerous==0.23
pypuppetdb==0.3.2
requests==2.6.0

View File

@@ -1,11 +1,11 @@
pep8==1.6.2
coverage==4.0
-r requirements.txt
pep8==1.7.0
coverage==4.3.4
mock==1.3.0
pytest==3.0.1
pytest-pep8==1.0.5
pytest-cov==2.2.1
pytest==3.0.7
pytest-pep8==1.0.6
pytest-cov==2.4.0
pytest-mock==1.5.0
cov-core==1.15.0
unittest2==1.1.0; python_version < '2.7'
bandit
beautifulsoup4==4.5.3

View File

@@ -1,9 +1,10 @@
Flask==0.10.1
Flask-WTF==0.12
Jinja2==2.7.2
MarkupSafe==0.19
WTForms==2.1
Werkzeug==0.11.10
itsdangerous==0.23
pypuppetdb==0.3.2
requests==2.6.0
Flask >=0.12
Flask-WTF >=0.14.2
Jinja2 >=2.9.5
MarkupSafe >=0.19
WTForms >=2.1
Werkzeug >=0.12.1
itsdangerous >=0.23
pypuppetdb >=0.3.3
requests >=2.13.0
CommonMark==0.7.2

View File

@@ -1,19 +0,0 @@
#!/usr/bin/env python
import glob
import re
try:
import future.utils
except:
pass
for req_file in glob.glob('requirements*.txt'):
new_data = []
with open(req_file, 'r') as fp:
data = fp.readlines()
for line in data:
new_data.append(re.sub(r'==\d+(\.\d+){0,3}\s+$', '\n', line))
with open(req_file, 'w') as fp:
fp.writelines(new_data)

View File

@@ -21,5 +21,6 @@ exclude=venv
[tool:pytest]
addopts = --cov=puppetboard --cov-report=term-missing
norecursedirs = docs .tox venv
norecursedirs = docs .tox venv .eggs lib
pep8ignore = E402
python_files = test/*.py

View File

@@ -1,25 +1,48 @@
import sys
import os
import codecs
import re
from setuptools.command.test import test as TestCommand
from setuptools import setup, find_packages
from puppetboard.version import __version__
if sys.argv[-1] == 'publish':
os.system('python setup.py sdist upload')
sys.exit()
VERSION = "0.2.1"
with codecs.open('README.rst', encoding='utf-8') as f:
README = f.read()
with codecs.open('CHANGELOG.rst', encoding='utf-8') as f:
CHANGELOG = f.read()
requirements = None
with open('requirements.txt', 'r') as f:
requirements = [line.rstrip()
for line in f.readlines() if not line.startswith('-')]
requirements_test = None
with open('requirements-test.txt', 'r') as f:
requirements_test = [line.rstrip() for line in f.readlines()
if not line.startswith('-')]
class PyTest(TestCommand):
user_options = [('pytest-args=', 'a', "Arguments to pass to pytest")]
def initialize_options(self):
TestCommand.initialize_options(self)
self.pytest_args = '--cov=puppetboard --cov-report=term-missing'
def run_tests(self):
import shlex
import pytest
errno = pytest.main(shlex.split(self.pytest_args))
sys.exit(errno)
setup(
name='puppetboard',
version=VERSION,
version=__version__,
author='Corey Hammerton',
author_email='corey.hammerton@gmail.com',
packages=find_packages(),
@@ -29,13 +52,11 @@ setup(
include_package_data=True,
long_description='\n'.join((README, CHANGELOG)),
zip_safe=False,
install_requires=[
"Flask >= 0.10.1",
"Flask-WTF >= 0.12, <= 0.13",
"WTForms >= 2.0, < 3.0",
"pypuppetdb >= 0.3.0, < 0.4.0",
],
install_requires=requirements,
tests_require=requirements_test,
extras_require={'test': requirements_test},
keywords="puppet puppetdb puppetboard",
cmdclass={'test': PyTest},
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Web Environment',

View File

@@ -71,13 +71,6 @@ def mock_puppetdb_default_nodes(mocker):
catalog_timestamp='2013-08-01T09:57:00.000Z',
facts_timestamp='2013-08-01T09:57:00.000Z',
status_report='unchanged'),
Node('_', 'node-skipped',
report_timestamp='2013-08-01T09:57:00.000Z',
latest_report_hash='1234567',
catalog_timestamp='2013-08-01T09:57:00.000Z',
facts_timestamp='2013-08-01T09:57:00.000Z',
status_report='skipped')
]
return mocker.patch.object(app.puppetdb, 'nodes',
return_value=iter(node_list))
@@ -273,6 +266,9 @@ def test_offline_mode(client, mocker):
assert soup.title.contents[0] == 'Puppetboard'
for link in soup.find_all('link'):
assert "//" not in link['href']
if 'offline' in link['href']:
static_rv = client.get(link['href'])
assert rv.status_code == 200
for script in soup.find_all('script'):
if "src" in script.attrs:
@@ -443,7 +439,6 @@ def test_radiator_view_json(client, mocker,
assert json_data['noop'] == 1
assert json_data['failed'] == 1
assert json_data['changed'] == 1
assert json_data['skipped'] == 1
assert json_data['unchanged'] == 1
@@ -537,9 +532,6 @@ def test_json_daily_reports_chart_ok(client, mocker):
]
}
import logging
logging.error(query_data)
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
@@ -558,3 +550,290 @@ def test_json_daily_reports_chart_ok(client, mocker):
cur_day = next_day
assert rv.status_code == 200
def test_catalogs_disabled(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
app.app.config['ENABLE_CATALOG'] = False
rv = client.get('/catalogs')
assert rv.status_code == 403
def test_catalogs_view(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
app.app.config['ENABLE_CATALOG'] = True
rv = client.get('/catalogs')
assert rv.status_code == 200
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == 'Puppetboard'
def test_catalogs_json(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
app.app.config['ENABLE_CATALOG'] = True
rv = client.get('/catalogs/json')
assert rv.status_code == 200
result_json = json.loads(rv.data.decode('utf-8'))
assert 'data' in result_json
for line in result_json['data']:
assert len(line) == 3
found_status = None
for status in ['failed', 'changed', 'unchanged', 'noop', 'unreported']:
val = BeautifulSoup(line[0], 'html.parser').find_all(
'a', {"href": "/node/node-%s" % status})
if len(val) == 1:
found_status = status
break
assert found_status, 'Line does not match any known status'
val = BeautifulSoup(line[2], 'html.parser').find_all(
'form', {"method": "GET",
"action": "/catalogs/compare/node-%s" % found_status})
assert len(val) == 1
def test_catalogs_json_compare(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
app.app.config['ENABLE_CATALOG'] = True
rv = client.get('/catalogs/compare/node-unreported/json')
assert rv.status_code == 200
result_json = json.loads(rv.data.decode('utf-8'))
assert 'data' in result_json
for line in result_json['data']:
assert len(line) == 3
found_status = None
for status in ['failed', 'changed', 'unchanged', 'noop', 'unreported']:
val = BeautifulSoup(line[0], 'html.parser').find_all(
'a', {"href": "/node/node-%s" % status})
if len(val) == 1:
found_status = status
break
assert found_status, 'Line does not match any known status'
val = BeautifulSoup(line[2], 'html.parser').find_all(
'form', {"method": "GET",
"action": "/catalogs/compare/node-unreported...node-%s" %
found_status})
assert len(val) == 1
def test_facts_view(client, mocker, mock_puppetdb_environments):
query_data = {
'fact-names': [[chr(i) for i in range(ord('a'), ord('z') + 1)]]
}
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
rv = client.get('/facts')
assert rv.status_code == 200
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == 'Puppetboard'
searchable = soup.find('div', {'class': 'searchable'})
vals = searchable.find_all('div', {'class': 'column'})
assert len(vals) == 4
def test_fact_view_with_graph(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
rv = client.get('/fact/architecture')
assert rv.status_code == 200
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == 'Puppetboard'
vals = soup.find_all('div', {"id": "factChart"})
assert len(vals) == 1
def test_fact_view_without_graph(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
rv = client.get('/%2A/fact/augeas')
assert rv.status_code == 200
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == 'Puppetboard'
vals = soup.find_all('div', {"id": "factChart"})
assert len(vals) == 0
def test_fact_value_view(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
rv = client.get('/fact/architecture/amd64')
assert rv.status_code == 200
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == 'Puppetboard'
vals = soup.find_all('div', {"id": "factChart"})
assert len(vals) == 0
def test_node_view(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
rv = client.get('/node/node-failed')
assert rv.status_code == 200
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == 'Puppetboard'
vals = soup.find_all('table', {"id": "facts_table"})
assert len(vals) == 1
vals = soup.find_all('table', {"id": "reports_table"})
assert len(vals) == 1
def test_fact_json_with_graph(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
values = ['a', 'b', 'b', 'd', True, 'a\nb']
query_data = {'facts': []}
query_data['facts'].append([])
for i, value in enumerate(values):
query_data['facts'][0].append({
'certname': 'node-%s' % i,
'name': 'architecture',
'value': value,
'environment': 'production'
})
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
rv = client.get('/fact/architecture/json')
assert rv.status_code == 200
result_json = json.loads(rv.data.decode('utf-8'))
assert 'data' in result_json
assert len(result_json['data']) == 6
for line in result_json['data']:
assert len(line) == 2
assert 'chart' in result_json
assert len(result_json['chart']) == 5
# Test group_by
assert result_json['chart'][0]['value'] == 2
def test_fact_json_without_graph(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
values = ['a', 'b', 'b', 'd']
query_data = {'facts': []}
query_data['facts'].append([])
for i, value in enumerate(values):
query_data['facts'][0].append({
'certname': 'node-%s' % i,
'name': 'architecture',
'value': value,
'environment': 'production'
})
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
rv = client.get('/%2A/fact/augeas/json')
assert rv.status_code == 200
result_json = json.loads(rv.data.decode('utf-8'))
assert 'data' in result_json
assert len(result_json['data']) == 4
for line in result_json['data']:
assert len(line) == 2
assert 'chart' not in result_json
def test_fact_value_json(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
values = ['a', 'b', 'b', 'd']
query_data = {'facts': []}
query_data['facts'].append([])
for i, value in enumerate(values):
query_data['facts'][0].append({
'certname': 'node-%s' % i,
'name': 'architecture',
'value': value,
'environment': 'production'
})
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
rv = client.get('/fact/architecture/amd64/json')
assert rv.status_code == 200
result_json = json.loads(rv.data.decode('utf-8'))
assert 'data' in result_json
assert len(result_json['data']) == 4
for line in result_json['data']:
assert len(line) == 1
assert 'chart' not in result_json
def test_node_facts_json(client, mocker,
mock_puppetdb_environments,
mock_puppetdb_default_nodes):
values = ['a', 'b', 'b', 'd']
query_data = {'facts': []}
query_data['facts'].append([])
for i, value in enumerate(values):
query_data['facts'][0].append({
'certname': 'node-failed',
'name': 'fact-%s' % i,
'value': value,
'environment': 'production'
})
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
rv = client.get('/node/node-failed/facts/json')
assert rv.status_code == 200
result_json = json.loads(rv.data.decode('utf-8'))
assert 'data' in result_json
assert len(result_json['data']) == 4
for line in result_json['data']:
assert len(line) == 2
assert 'chart' not in result_json
def test_offline_static(client):
rv = client.get('/offline/css/google_fonts.css')
assert 'Content-Type' in rv.headers
assert 'text/css' in rv.headers['Content-Type']
assert rv.status_code == 200
rv = client.get('/offline/Semantic-UI-2.1.8/semantic.min.css')
assert 'Content-Type' in rv.headers
assert 'text/css' in rv.headers['Content-Type']
assert rv.status_code == 200

View File

@@ -1,7 +1,9 @@
import pytest
from flask import Flask, current_app
from werkzeug.exceptions import InternalServerError
from puppetboard import app
from puppetboard.errors import (bad_request, forbidden, not_found,
precond_failed, server_error)
from bs4 import BeautifulSoup
@@ -16,15 +18,17 @@ def mock_puppetdb_environments(mocker):
return_value=environemnts)
def test_error_no_content():
result = app.no_content(None)
assert result[0] == ''
assert result[1] == 204
@pytest.fixture
def mock_server_error(mocker):
def raiseInternalServerError():
raise InternalServerError('Hello world')
return mocker.patch('puppetboard.core.environments',
side_effect=raiseInternalServerError)
def test_error_bad_request(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = app.bad_request(None)
(output, error_code) = bad_request(None)
soup = BeautifulSoup(output, 'html.parser')
assert 'The request sent to PuppetDB was invalid' in soup.p.text
@@ -33,7 +37,7 @@ def test_error_bad_request(mock_puppetdb_environments):
def test_error_forbidden(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = app.forbidden(None)
(output, error_code) = forbidden(None)
soup = BeautifulSoup(output, 'html.parser')
long_string = "%s %s" % ('What you were looking for has',
@@ -44,7 +48,7 @@ def test_error_forbidden(mock_puppetdb_environments):
def test_error_not_found(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = app.not_found(None)
(output, error_code) = not_found(None)
soup = BeautifulSoup(output, 'html.parser')
long_string = "%s %s" % ('What you were looking for could not',
@@ -55,7 +59,7 @@ def test_error_not_found(mock_puppetdb_environments):
def test_error_precond(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = app.precond_failed(None)
(output, error_code) = precond_failed(None)
soup = BeautifulSoup(output, 'html.parser')
long_string = "%s %s" % ('You\'ve configured Puppetboard with an API',
@@ -66,8 +70,16 @@ def test_error_precond(mock_puppetdb_environments):
def test_error_server(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = app.server_error(None)
(output, error_code) = server_error(None)
soup = BeautifulSoup(output, 'html.parser')
assert 'Internal Server Error' in soup.h2.text
assert error_code == 500
def test_early_error_server(mock_server_error):
with app.app.test_request_context():
(output, error_code) = server_error(None)
soup = BeautifulSoup(output, 'html.parser')
assert 'Internal Server Error' in soup.h2.text
assert error_code == 500

View File

@@ -1,7 +1,7 @@
import pytest
import os
from puppetboard import docker_settings
from puppetboard import app
import puppetboard.core
try:
import future.utils
@@ -100,12 +100,14 @@ def test_graph_facts_custom(cleanUpEnv):
assert 'extra' in facts
def test_bad_log_value(cleanUpEnv):
def test_bad_log_value(cleanUpEnv, mocker):
os.environ['LOGLEVEL'] = 'g'
os.environ['PUPPETBOARD_SETTINGS'] = '../puppetboard/docker_settings.py'
reload(docker_settings)
puppetboard.core.APP = None
with pytest.raises(ValueError) as error:
reload(app)
puppetboard.core.get_app()
def test_default_table_selctor(cleanUpEnv):
@@ -116,3 +118,11 @@ def test_env_table_selector(cleanUpEnv):
os.environ['TABLE_COUNT_SELECTOR'] = '5,15,25'
reload(docker_settings)
assert [5, 15, 25] == docker_settings.TABLE_COUNT_SELECTOR
def test_env_column_options(cleanUpEnv):
os.environ['DISPLAYED_METRICS'] = 'resources.total, events.failure'
reload(docker_settings)
assert ['resources.total',
'events.failure'] == docker_settings.DISPLAYED_METRICS

12
test/test_form.py Normal file
View File

@@ -0,0 +1,12 @@
import pytest
from puppetboard import app, forms
def test_form_valid(capsys):
for form in [forms.QueryForm]:
with app.app.test_request_context():
qf = form()
out, err = capsys.readouterr()
assert qf is not None
assert err == ""
assert out == ""

View File

@@ -12,7 +12,6 @@ from werkzeug.exceptions import NotFound, InternalServerError
from puppetboard import utils
from puppetboard import app
from puppetboard.app import NoContent
from bs4 import BeautifulSoup
import logging
@@ -108,19 +107,6 @@ def test_http_connection_error(mock_log):
mock_log.error.assert_called_with(err)
def test_http_empty(mock_log, mocker):
err = "Empty Response"
def connection_error():
raise EmptyResponseError(err)
flask_abort = mocker.patch('flask.abort')
with pytest.raises(NoContent):
utils.get_or_abort(connection_error)
mock_log.error.assert_called_with(err)
flask_abort.assert_called_with('204')
def test_db_version_good(mocker, mock_info_log):
mocker.patch.object(app.puppetdb, 'current_version', return_value='4.2.0')
err = 'PuppetDB Version %d.%d.%d' % (4, 2, 0)

10
tox.ini Normal file
View File

@@ -0,0 +1,10 @@
[tox]
envlist = py{26,27,35,36}
[testenv]
deps=
-rrequirements-test.txt
bandit
commands=
py.test --cov=puppetboard --pep8 -v
py{27,35,36}: bandit -r puppetboard