5 Commits

Author SHA1 Message Date
Mike Terzo
5e4f1518ab Merge pull request #374 from mterzo/release_0.2.2
Prepare for release 0.2.2
2017-03-20 13:59:12 -04:00
Mike Terzo
6ae2e9d0c5 Prepare for release 0.2.2 2017-03-20 13:48:14 -04:00
Tim Meusel
db8f62cc0b Merge pull request #373 from mterzo/rel_0.2
Bounded the latest version of Werkzeug
2017-03-20 18:45:51 +01:00
Mike Terzo
a038d86d2c Newest release of Werkzeug is not compatible with puppetboard. 2017-03-20 13:37:59 -04:00
Mike Terzo
1dcf1ae154 Travis failure definition has changed.
Do not test unpinned versions of pip packages.
2017-03-20 13:37:22 -04:00
56 changed files with 631 additions and 1277 deletions

View File

@@ -7,13 +7,12 @@ python:
- "3.6"
install:
- pip install -r requirements-test.txt
- pip install -q coveralls --use-wheel
- pip install -r requirements.txt
- pip install -U -r requirements-test.txt
- pip install -q coverage coveralls --use-wheel
script:
- pytest --pep8
- if [ "${TRAVIS_PYTHON_VERSION}" != "2.6" ]; then
pip install bandit;
bandit -r puppetboard;
fi
- py.test --cov=puppetboard --pep8 -v
- ./bandit.sh
after_success:
- coveralls

View File

@@ -4,21 +4,9 @@ Changelog
This is the changelog for Puppetboard.
0.3.0
0.2.2
=====
* 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
* Ensure Werkzeug <= 0.11.5
0.2.1
=====

View File

@@ -1,15 +1,12 @@
FROM python:2.7-alpine
ENV PUPPETBOARD_PORT 80
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
RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
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

@@ -2,4 +2,4 @@ include README.rst
include CHANGELOG.rst
include LICENSE
recursive-include puppetboard/static *.css *.js *icons.* Open_Sans.woff
recursive-include puppetboard/templates *
recursive-include puppetboard/templates *.html *.json.tpl

View File

@@ -14,7 +14,7 @@ functionality of `Puppet Dashboard`_.
Puppetboard relies on the `pypuppetdb`_ library to fetch data from PuppetDB
and is built with the help of the `Flask`_ microframework.
As of version 0.1.0 and higher, Puppetboard **requires** PuppetDB 3. Version 0.3.0 has been tested with PuppetDB versions 3 through 5.
As of version 0.1.0 and higher, Puppetboard **requires** PuppetDB 3.
.. _pypuppetdb: https://pypi.python.org/pypi/pypuppetdb
.. _PuppetDB: http://docs.puppetlabs.com/puppetdb/latest/index.html
@@ -25,7 +25,7 @@ As of version 0.1.0 and higher, Puppetboard **requires** PuppetDB 3. Version 0.3
At the current time of writing, Puppetboard supports the following Python versions:
* Python 2.6
* Python 2.7
* Python 3.6
.. image:: screenshots/overview.png
:alt: View of a node
:width: 1024
@@ -34,6 +34,18 @@ At the current time of writing, Puppetboard supports the following Python versio
.. contents::
Word of caution
===============
Puppetboard is very, very young but it works fairly well.
That being said a lot of the code is very experimental, just trying
to figure out what works and what not, what we need to do different
and what features we need on the PuppetDB side of things.
As such you should be at least comfortable handling a few errors
this might throw at you.
Installation
============
@@ -98,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 `Sebastian Reitenbach`_ |
| `OpenBSD`_ | available | Maintained by `Jasper Lievisse Adriaanse`_ |
+-------------------+-----------+--------------------------------------------+
.. _ArchLinux: https://aur.archlinux.org/packages/python2-puppetboard/
.. _Tim Meusel: https://github.com/bastelfreak
.. _Sebastian Reitenbach: https://github.com/buzzdeee
.. _Jasper Lievisse Adriaanse: https://github.com/jasperla
.. _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
@@ -118,9 +130,7 @@ image is planned for the 0.2.x series.
.. _Dockerfile: https://github.com/voxpupuli/puppetboard/blob/master/Dockerfile
Usage:
.. code-block:: bash
$ docker build -t puppetboard .
$ docker run -it -p 9080:80 -v /etc/puppetlabs/puppet/ssl:/etc/puppetlabs/puppet/ssl \
-e PUPPETDB_HOST=<hostname> \
@@ -141,7 +151,7 @@ and then install the requirements through:
.. code-block:: bash
$ pip install -r requirements-test.txt
$ pip install -r requirements.txt
You're advised to do this inside a virtualenv specifically created to work on
Puppetboard as to not pollute your global Python installation.
@@ -231,21 +241,6 @@ 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
@@ -290,14 +285,6 @@ 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
^^^^^^^^^^^^^^^^^
@@ -696,11 +683,11 @@ Some people have already started building things with and around Puppetboard.
Packages
--------
* An OpenBSD port is being maintained by `Sebastian Reitenbach`_ and can be viewed `here <http://www.openbsd.org/cgi-bin/cvsweb/ports/www/puppetboard/>`_.
* 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/>`_.
* A Docker image is being maintained by `Julien K.`_ and can be viewed `here <https://registry.hub.docker.com/u/kassis/puppetboard/>`_.
.. _Sebastian Reitenbach: https://github.com/buzzdeee
.. _Jasper Lievisse Adriaanse: https://github.com/jasperla
.. _Julien K.: https://github.com/juliengk
Contributing
@@ -742,7 +729,7 @@ Examples
.. _vagrant-puppetboard: https://github.com/visibilityspots/vagrant-puppet/tree/puppetboard
A vagrant project to show off the puppetboard functionality using the puppetboard puppet module on a puppetserver with puppetdb.
A vagrant project to show off the puppetboard functionallity using the puppetboard puppet module on a puppetserver with puppetdb.
Screenshots
===========

12
bandit.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/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

View File

@@ -1,62 +0,0 @@
# 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
```

View File

@@ -1,21 +0,0 @@
#!/bin/bash
git_version=$(git describe HEAD --tags --abbrev=4)
if [ ${SOURCE_BRANCH} == "master" ]
then
version=$git_version
else
version=$(echo ${SOURCE_BRANCH} | sed 's/v//')
fi
cat << EOF > puppetboard/version.py
#
# Puppetboard version module
#
__version__ = '${version}'
EOF

View File

@@ -1,5 +0,0 @@
#
# 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, unquote_plus, quote_plus
from urllib import unquote
except ImportError:
from urllib.parse import unquote, unquote_plus, quote_plus
from urllib.parse import unquote
from datetime import datetime, timedelta
from itertools import tee
@@ -15,23 +15,19 @@ 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 QueryForm
from puppetboard.utils import (get_or_abort, yield_or_stop,
get_db_version)
from puppetboard.forms import (CatalogForm, QueryForm)
from puppetboard.utils import (
get_or_abort, yield_or_stop, get_db_version,
jsonprint, prettyprint
)
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',
@@ -44,26 +40,31 @@ REPORTS_COLUMNS = [
'name': 'Agent version'},
]
CATALOGS_COLUMNS = [
{'attr': 'certname', 'name': 'Certname', 'type': 'node'},
{'attr': 'catalog_timestamp', 'name': 'Compile Time'},
{'attr': 'form', 'name': 'Compare'},
]
app = Flask(__name__)
app = get_app()
app.config.from_object('puppetboard.default_settings')
graph_facts = app.config['GRAPH_FACTS']
numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None)
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)
@@ -73,10 +74,29 @@ 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():
@@ -86,6 +106,58 @@ 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):
@@ -258,11 +330,29 @@ def nodes(env):
current_env=env)))
def inventory_facts():
# a list of facts descriptions to go in table header
headers = []
# a list of inventory fact names
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.
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
# load the list of items/facts we want in our inventory
try:
@@ -280,70 +370,38 @@ def inventory_facts():
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
total = len(fact_data)
return render_template(
'inventory.json.tpl',
draw=draw,
total=total,
total_filtered=total,
fact_data=fact_data,
columns=fact_names)
return Response(stream_with_context(
stream_template(
'inventory.html',
headers=headers,
fact_names=fact_names,
fact_data=fact_data,
envs=envs,
current_env=env
)))
@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
@@ -362,20 +420,21 @@ 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):
@@ -415,7 +474,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]', 'desc')
order_dir = request.args.get('order[0][dir]')
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)
@@ -479,17 +538,26 @@ def reports_ajax(env, node_name):
reports_events = []
total = 0
# Convert metrics to relational dict
metrics = {}
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'
}
for report in reports_events:
if total is None:
total = puppetdb.total
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']
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
if total is None:
total = 0
@@ -500,7 +568,7 @@ def reports_ajax(env, node_name):
total=total,
total_filtered=total,
reports=reports,
metrics=metrics,
report_event_counts=report_event_counts,
envs=envs,
current_env=env,
columns=REPORTS_COLUMNS[:max_col])
@@ -546,8 +614,6 @@ def report(env, node_name, report_id):
except StopIteration:
abort(404)
report.version = CommonMark.commonmark(report.version)
return render_template(
'report.html',
report=report,
@@ -572,176 +638,106 @@ def facts(env):
check_env(env, envs)
facts = []
order_by = '[{"field": "name", "order": "asc"}]'
facts = get_or_abort(puppetdb.fact_names)
facts_columns = [[]]
letter = None
letter_list = None
break_size = (len(facts) / 4) + 1
next_break = break_size
count = 0
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)
for fact in facts:
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 = fact[0].upper()
letter_list = facts_dict[letter]
letter_list.append(fact)
facts_columns[-1].append(letter_list)
facts_dict[letter] = letter_list
sorted_facts_dict = sorted(facts_dict.items())
return render_template('facts.html',
facts_columns=facts_columns,
facts_dict=sorted_facts_dict,
facts_len=(sum(map(len, facts_dict.values())) +
len(facts_dict) * 5),
envs=envs,
current_env=env)
@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
@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
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`
:param value: Find all facts with this value
:type value: :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 and not value:
if fact in graph_facts:
render_graph = True
value_safe = value
if value is not None:
value_safe = unquote_plus(value)
if env == '*':
query = None
else:
query = EqualsOperator("environment", env)
return render_template(
localfacts = [f for f in yield_or_stop(puppetdb.facts(
name=fact, query=query))]
return Response(stream_with_context(stream_template(
'fact.html',
fact=fact,
value=value,
value_safe=value_safe,
name=fact,
render_graph=render_graph,
facts=localfacts,
envs=envs,
current_env=env)
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
@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 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:
if env == '*':
query = None
else:
query = EqualsOperator("environment", env)
# Generator needs to be converted (graph / total)
try:
value = int(value)
except ValueError:
if value is not None and query is not None:
query.add(EqualsOperator('value', unquote_plus(value)))
except TypeError:
pass
facts = [f for f in get_or_abort(
puppetdb.facts,
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,
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)
value=value,
facts=localfacts,
envs=envs,
current_env=env)
@app.route('/query', methods=('GET', 'POST'),
@@ -832,14 +828,9 @@ def metric(env, metric):
current_env=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):
@app.route('/catalogs', defaults={'env': app.config['DEFAULT_ENVIRONMENT']})
@app.route('/<env>/catalogs')
def catalogs(env):
"""Lists all nodes with a compiled catalog.
:param env: Find the nodes with this catalog_environment value
@@ -848,80 +839,53 @@ def catalogs(env, compare):
envs = environments()
check_env(env, envs)
if not app.config['ENABLE_CATALOG']:
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:
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']})
@@ -947,6 +911,40 @@ 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>')
@@ -1092,15 +1090,3 @@ 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)

View File

@@ -1,64 +0,0 @@
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,15 +18,9 @@ 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
PAGE_TITLE = "Puppetboard"
GRAPH_TYPE = 'pie'
GRAPH_FACTS = ['architecture',
'clientversion',

View File

@@ -33,17 +33,9 @@ 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)
PAGE_TITLE = os.getenv('PAGE_TITLE', 'Puppetboard')
GRAPH_FACTS_DEFAULT = ','.join(['architecture', 'clientversion', 'domain',
'lsbcodename', 'lsbdistcodename', 'lsbdistid',
@@ -54,6 +46,7 @@ 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

View File

@@ -1,45 +0,0 @@
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_wtf import FlaskForm
from flask.ext.wtf import Form
from wtforms import (
HiddenField, RadioField, SelectField,
TextAreaField, BooleanField, validators
)
class QueryForm(FlaskForm):
class QueryForm(Form):
"""The form used to allow freeform queries to be executed against
PuppetDB."""
query = TextAreaField('Query', [validators.Required(
@@ -28,3 +28,9 @@ class QueryForm(FlaskForm):
('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

@@ -1 +1 @@
.c3 svg{font:10px sans-serif;-webkit-tap-highlight-color:transparent}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:grey;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:1;fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-title{font:14px sans-serif}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #ccc}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#fff}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max{fill:#777}.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}.c3-chart-arc.c3-target g path{opacity:1}.c3-chart-arc.c3-target.c3-focused g path{opacity:1}
.c3 svg{font:10px sans-serif;-webkit-tap-highlight-color:transparent}.c3 line,.c3 path{fill:none;stroke:#000}.c3 text{-webkit-user-select:none;-moz-user-select:none;user-select:none}.c3-bars path,.c3-event-rect,.c3-legend-item-tile,.c3-xgrid-focus,.c3-ygrid{shape-rendering:crispEdges}.c3-chart-arc path{stroke:#fff}.c3-chart-arc text{fill:#fff;font-size:13px}.c3-grid line{stroke:#aaa}.c3-grid text{fill:#aaa}.c3-xgrid,.c3-ygrid{stroke-dasharray:3 3}.c3-text.c3-empty{fill:gray;font-size:2em}.c3-line{stroke-width:1px}.c3-circle._expanded_{stroke-width:1px;stroke:#fff}.c3-selected-circle{fill:#fff;stroke-width:2px}.c3-bar{stroke-width:0}.c3-bar._expanded_{fill-opacity:.75}.c3-target.c3-focused{opacity:1}.c3-target.c3-focused path.c3-line,.c3-target.c3-focused path.c3-step{stroke-width:2px}.c3-target.c3-defocused{opacity:.3!important}.c3-region{fill:#4682b4;fill-opacity:.1}.c3-brush .extent{fill-opacity:.1}.c3-legend-item{font-size:12px}.c3-legend-item-hidden{opacity:.15}.c3-legend-background{opacity:.75;fill:#fff;stroke:#d3d3d3;stroke-width:1}.c3-title{font:14px sans-serif}.c3-tooltip-container{z-index:10}.c3-tooltip{border-collapse:collapse;border-spacing:0;background-color:#fff;empty-cells:show;-webkit-box-shadow:7px 7px 12px -9px #777;-moz-box-shadow:7px 7px 12px -9px #777;box-shadow:7px 7px 12px -9px #777;opacity:.9}.c3-tooltip tr{border:1px solid #CCC}.c3-tooltip th{background-color:#aaa;font-size:14px;padding:2px 5px;text-align:left;color:#FFF}.c3-tooltip td{font-size:13px;padding:3px 6px;background-color:#fff;border-left:1px dotted #999}.c3-tooltip td>span{display:inline-block;width:10px;height:10px;margin-right:6px}.c3-tooltip td.value{text-align:right}.c3-area{stroke-width:0;opacity:.2}.c3-chart-arcs-title{dominant-baseline:middle;font-size:1.3em}.c3-chart-arcs .c3-chart-arcs-background{fill:#e0e0e0;stroke:none}.c3-chart-arcs .c3-chart-arcs-gauge-unit{fill:#000;font-size:16px}.c3-chart-arcs .c3-chart-arcs-gauge-max,.c3-chart-arcs .c3-chart-arcs-gauge-min{fill:#777}.c3-chart-arc .c3-gauge-value{fill:#000}

View File

@@ -25,7 +25,6 @@ h1.ui.header.no-margin-bottom {
.ui.table[class*="very compact"] td {
padding: 0.15em 0.1em;
max-width: 320px;
}
.factlist li {
@@ -45,7 +44,7 @@ h1.ui.header.no-margin-bottom {
color: #AA4643;
}
.ui.label.failed, .ui.label.events.failure {
.ui.label.failed {
background-color: #AA4643;
}
@@ -53,7 +52,7 @@ h1.ui.header.no-margin-bottom {
color: #4572A7;
}
.ui.label.changed, .ui.label.events.success {
.ui.label.changed {
background-color: #4572A7;
}
@@ -69,14 +68,10 @@ h1.ui.header.no-margin-bottom {
color: #DB843D;
}
.ui.label.noop, .ui.label.events.noop {
.ui.label.noop {
background-color: #DB843D;
}
.ui.label.resources.total {
background-color: #989898;
}
.ui.label.unchanged {
background-color: #89A54E;
}
@@ -85,7 +80,7 @@ h1.ui.header.no-margin-bottom {
color: orange;
}
.ui.label.skipped, .ui.label.resources.skipped {
.ui.label.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:#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 .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 .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:#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 .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 .label {border-left:1px #333 dashed;}
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 .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 .label {border-left:1px #333 dashed;}
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 .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 .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;}

View File

@@ -1,6 +0,0 @@
/*
A simple, lightweight jQuery plugin for creating sortable tables.
https://github.com/kylefox/jquery-tablesort
Version 0.0.11
*/
!function(t){t.tablesort=function(e,s){var i=this;this.$table=e,this.$thead=this.$table.find("thead"),this.settings=t.extend({},t.tablesort.defaults,s),this.$sortCells=this.$thead.length>0?this.$thead.find("th:not(.no-sort)"):this.$table.find("th:not(.no-sort)"),this.$sortCells.on("click.tablesort",function(){i.sort(t(this))}),this.index=null,this.$th=null,this.direction=null},t.tablesort.prototype={sort:function(e,s){var i=new Date,n=this,o=this.$table,l=o.find("tbody").length>0?o.find("tbody"):o,a=l.find("tr").has("td, th"),r=a.find(":nth-child("+(e.index()+1)+")").filter("td, th"),d=e.data().sortBy,h=[],c=r.map(function(s,i){return d?"function"==typeof d?d(t(e),t(i),n):d:null!=t(this).data().sortValue?t(this).data().sortValue:t(this).text()});0!==c.length&&(this.index!==e.index()?(this.direction="asc",this.index=e.index()):"asc"!==s&&"desc"!==s?this.direction="asc"===this.direction?"desc":"asc":this.direction=s,s="asc"==this.direction?1:-1,n.$table.trigger("tablesort:start",[n]),n.log("Sorting by "+this.index+" "+this.direction),n.$table.css("display"),setTimeout(function(){n.$sortCells.removeClass(n.settings.asc+" "+n.settings.desc);for(var o=0,d=c.length;o<d;o++)h.push({index:o,cell:r[o],row:a[o],value:c[o]});h.sort(function(t,e){return n.settings.compare(t.value,e.value)*s}),t.each(h,function(t,e){l.append(e.row)}),e.addClass(n.settings[n.direction]),n.log("Sort finished in "+((new Date).getTime()-i.getTime())+"ms"),n.$table.trigger("tablesort:complete",[n]),n.$table.css("display")},c.length>2e3?200:10))},log:function(e){(t.tablesort.DEBUG||this.settings.debug)&&console&&console.log&&console.log("[tablesort] "+e)},destroy:function(){return this.$sortCells.off("click.tablesort"),this.$table.data("tablesort",null),null}},t.tablesort.DEBUG=!1,t.tablesort.defaults={debug:t.tablesort.DEBUG,asc:"sorted ascending",desc:"sorted descending",compare:function(t,e){return t>e?1:t<e?-1:0}},t.fn.tablesort=function(e){var s,i;return this.each(function(){s=t(this),i=s.data("tablesort"),i&&i.destroy(),s.data("tablesort",new t.tablesort(s,e))})}}(window.Zepto||window.jQuery);

View File

@@ -0,0 +1,6 @@
/*
A simple, lightweight jQuery plugin for creating sortable tables.
https://github.com/kylefox/jquery-tablesort
Version 0.0.7
*/
!function(t){t.tablesort=function(e,s){var i=this;this.$table=e,this.$thead=this.$table.find("thead"),this.settings=t.extend({},t.tablesort.defaults,s),this.$sortCells=this.$thead.length>0?this.$thead.find("th:not(.no-sort)"):this.$table.find("th:not(.no-sort)"),this.$sortCells.bind("click.tablesort",function(){i.sort(t(this))}),this.index=null,this.$th=null,this.direction=null},t.tablesort.prototype={sort:function(e,s){var i=new Date,n=this,o=this.$table,l=this.$thead.length>0?o.find("tbody tr"):o.find("tr").has("td"),a=o.find("tr td:nth-of-type("+(e.index()+1)+")"),r=e.data().sortBy,d=[],h=a.map(function(s,i){return r?"function"==typeof r?r(t(e),t(i),n):r:null!=t(this).data().sortValue?t(this).data().sortValue:t(this).text()});0!==h.length&&("asc"!==s&&"desc"!==s?this.direction="asc"===this.direction?"desc":"asc":this.direction=s,s="asc"==this.direction?1:-1,n.$table.trigger("tablesort:start",[n]),n.log("Sorting by "+this.index+" "+this.direction),n.$table.css("display"),setTimeout(function(){n.$sortCells.removeClass(n.settings.asc+" "+n.settings.desc);for(var r=0,c=h.length;c>r;r++)d.push({index:r,cell:a[r],row:l[r],value:h[r]});d.sort(function(t,e){return t.value>e.value?1*s:t.value<e.value?-1*s:0}),t.each(d,function(t,e){o.append(e.row)}),e.addClass(n.settings[n.direction]),n.log("Sort finished in "+((new Date).getTime()-i.getTime())+"ms"),n.$table.trigger("tablesort:complete",[n]),n.$table.css("display")},h.length>2e3?200:10))},log:function(e){(t.tablesort.DEBUG||this.settings.debug)&&console&&console.log&&console.log("[tablesort] "+e)},destroy:function(){return this.$sortCells.unbind("click.tablesort"),this.$table.data("tablesort",null),null}},t.tablesort.DEBUG=!1,t.tablesort.defaults={debug:t.tablesort.DEBUG,asc:"sorted ascending",desc:"sorted descending"},t.fn.tablesort=function(e){var s,i;return this.each(function(){s=t(this),i=s.data("tablesort"),i&&i.destroy(),s.data("tablesort",new t.tablesort(s,e))})}}(window.Zepto||window.jQuery);

File diff suppressed because one or more lines are too long

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

@@ -11,9 +11,7 @@
}
$('thead th.date').data('sortBy', function(th, td, tablesort) {
var tdTime = new Date(td.text().replace("-", ""));
if(isNaN(tdTime)) return 0;
else return tdTime;
return moment.utc(td.text()).unix();
});
$('input.filter-table').parent('div').removeClass('hide');

View File

@@ -1,3 +1,51 @@
{% 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' %}
@@ -9,33 +57,9 @@
{% 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,
@@ -52,19 +76,12 @@
// 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,21 +1,40 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %}
<table id="catalogs_table" class='ui very basic table stackable'>
<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'>
<thead>
<tr>
{% for column in columns %}
<th>{{ column.name }}</th>
{% endfor %}
<th></th>
<th>Certname</th>
<th>Compile Time</th>
<th>Compare With</th>
</tr>
</thead>
<tbody>
<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>
</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

@@ -1,40 +0,0 @@
{
"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,45 +1,50 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block onload_script %}
{% 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) }}
{% block javascript %}
{% 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({
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({
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,19 +4,26 @@
<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">
{%- for letter in column %}
{%- set facts_count = 0 -%}
{%- set break = facts_len//4 + 1 -%}
{%- for key,facts_list in facts_dict %}
<div class="ui list_hide_segment segment">
<a class="ui darkblue ribbon label">{{ letter[0][0]|upper }}</a>
<a class="ui darkblue ribbon label">{{key}}</a>
<ul>
{%- for fact in letter %}
<li><a href="{{url_for('fact', env=current_env, fact=fact)}}">{{ fact }}</a></li>
{%- for fact in facts_list %}
<li><a href="{{url_for('fact', env=current_env, fact=fact)}}">{{fact}}</a></li>
{%- endfor %}
</ul>
</div>
{%- endfor %}
{%- 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 %}
</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="date five wide default-sort">Report</th>
<th class="five wide date default-sort">Report</th>
<th class="one wide"></th>
</tr>
</thead>

View File

@@ -1,21 +1,24 @@
{% extends 'layout.html' %}
{% import '_macros.html' as macros %}
{% block content %}
<table id="inventory_table" class='ui fixed compact very basic sortable table'>
<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'>
<thead>
<tr>
{% for head in fact_headers %}
<th>{{head}}</th>
{% for head in headers %}
<th{% if loop.index == 1 %} class="default-sort"{% endif %}>{{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

@@ -1,23 +0,0 @@
{%- 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

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{config.PAGE_TITLE}}</title>
<title>Puppetboard</title>
{% if config.OFFLINE_MODE %}
<style>
@font-face {
@@ -13,13 +13,11 @@
}
</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='css/c3.min.css') }}" rel="stylesheet" />
<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 %}
@@ -46,7 +44,7 @@
<script src="{{url_for('static', filename='js/d3.min.js')}}"></script>
<script src="{{url_for('static', filename='js/c3.min.js')}}"></script>
<script src="{{url_for('static',
filename='jquery-tablesort-v.0.0.11/jquery.tablesort.min.js')}}"></script>
filename='jquery-tablesort-v.0.0.7/jquery.tablesort.min.js')}}"></script>
{% block script %} {% endblock script %}
<script type="text/javascript">
{% block javascript %} {% endblock javascript %}
@@ -89,7 +87,7 @@
{% endfor %}
</div>
</div>
<div class="item right"><a href="https://github.com/voxpupuli/puppetboard" target="_blank">{{version()}}</a></div>
<div class="item right"><a href="https://github.com/voxpupuli/puppetboard" target="_blank">v0.2.2</a></div>
</div>
<div class="ui grid padding-bottom">
<div class="one wide column"></div>

View File

@@ -17,13 +17,7 @@
'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 %}
@@ -75,16 +69,7 @@
</div>
<div class='column'>
<h1>Facts</h1>
<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>
{{macros.facts_table(facts, link_facts=True, condensed=True, current_env=current_env)}}
</div>
</div>
{% endblock content %}

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{config.PAGE_TITLE}}</title>
<title>Puppetboard</title>
{% if config.REFRESH_RATE > 0 %}
<meta http-equiv='refresh' content="{{config.REFRESH_RATE}}"/>
{% endif %}
@@ -52,10 +52,10 @@
<td>
<div>
<p class='label'>
<span>Noop</span>
<span>Pending</span>
</p>
<p class='percent' style='width:{{stats['noop_percent']}}%'>
<span>Noop</span>
<span>Pending</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|safe}}
{{report.version}}
</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.report_status(status=report.status, node_name=report.node, metrics=metrics[report.hash_], report_hash=report.hash_, current_env=current_env) }}
{{ macros.status_counts(status=report.status, node_name=report.node, events=report_event_counts[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

@@ -1,24 +0,0 @@
@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,7 +1,6 @@
from __future__ import absolute_import
from __future__ import unicode_literals
import os.path
import json
import logging
@@ -9,8 +8,8 @@ from math import ceil
from requests.exceptions import HTTPError, ConnectionError
from pypuppetdb.errors import EmptyResponseError
from flask import abort, request, url_for
from jinja2.utils import contextfunction
from flask import abort
# Python 3 compatibility
try:
@@ -21,21 +20,6 @@ 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=(',', ': '))
@@ -67,7 +51,7 @@ def formatvalue(value):
if isinstance(value, str):
return value
elif isinstance(value, list):
return ", ".join(map(formatvalue, value))
return ", ".join(value)
elif isinstance(value, dict):
ret = ""
for k in value:

View File

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

View File

@@ -1,2 +1,10 @@
-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 @@
-r requirements.txt
pep8==1.7.0
coverage==4.3.4
pep8==1.6.2
coverage==4.0
mock==1.3.0
pytest==3.0.7
pytest-pep8==1.0.6
pytest-cov==2.4.0
pytest==3.0.1
pytest-pep8==1.0.5
pytest-cov==2.2.1
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,10 +1,9 @@
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
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

19
scripts/unpin.py Normal file
View File

@@ -0,0 +1,19 @@
#!/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,6 +21,5 @@ exclude=venv
[tool:pytest]
addopts = --cov=puppetboard --cov-report=term-missing
norecursedirs = docs .tox venv .eggs lib
norecursedirs = docs .tox venv
pep8ignore = E402
python_files = test/*.py

View File

@@ -1,11 +1,15 @@
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__
from setuptools import setup, find_packages
if sys.argv[-1] == 'publish':
os.system('python setup.py sdist upload')
sys.exit()
VERSION = "0.2.2"
with codecs.open('README.rst', encoding='utf-8') as f:
README = f.read()
@@ -13,36 +17,9 @@ with codecs.open('README.rst', encoding='utf-8') as f:
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(),
@@ -52,11 +29,14 @@ setup(
include_package_data=True,
long_description='\n'.join((README, CHANGELOG)),
zip_safe=False,
install_requires=requirements,
tests_require=requirements_test,
extras_require={'test': requirements_test},
install_requires=[
"Flask >= 0.10.1",
"Flask-WTF >= 0.12, <= 0.13",
"WTForms >= 2.0, < 3.0",
"Werkzeug >=0.7, <= 0.11.5",
"pypuppetdb >= 0.3.0, < 0.4.0",
],
keywords="puppet puppetdb puppetboard",
cmdclass={'test': PyTest},
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Web Environment',

View File

@@ -71,6 +71,13 @@ 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))
@@ -266,9 +273,6 @@ 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:
@@ -439,6 +443,7 @@ 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
@@ -532,6 +537,9 @@ 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)
@@ -550,310 +558,3 @@ 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
def test_custom_title(client, mocker):
custom_title = 'Dev - Puppetboard'
app.app.config['PAGE_TITLE'] = custom_title
mock_puppetdb_environments(mocker)
mock_puppetdb_default_nodes(mocker)
query_data = {
'nodes': [[{'count': 10}]],
'resources': [[{'count': 40}]],
}
dbquery = MockDbQuery(query_data)
mocker.patch.object(app.puppetdb, '_query', side_effect=dbquery.get)
rv = client.get('/')
soup = BeautifulSoup(rv.data, 'html.parser')
assert soup.title.contents[0] == custom_title

View File

@@ -1,9 +1,7 @@
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
@@ -18,17 +16,15 @@ def mock_puppetdb_environments(mocker):
return_value=environemnts)
@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_no_content():
result = app.no_content(None)
assert result[0] == ''
assert result[1] == 204
def test_error_bad_request(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = bad_request(None)
(output, error_code) = app.bad_request(None)
soup = BeautifulSoup(output, 'html.parser')
assert 'The request sent to PuppetDB was invalid' in soup.p.text
@@ -37,7 +33,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) = forbidden(None)
(output, error_code) = app.forbidden(None)
soup = BeautifulSoup(output, 'html.parser')
long_string = "%s %s" % ('What you were looking for has',
@@ -48,7 +44,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) = not_found(None)
(output, error_code) = app.not_found(None)
soup = BeautifulSoup(output, 'html.parser')
long_string = "%s %s" % ('What you were looking for could not',
@@ -59,7 +55,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) = precond_failed(None)
(output, error_code) = app.precond_failed(None)
soup = BeautifulSoup(output, 'html.parser')
long_string = "%s %s" % ('You\'ve configured Puppetboard with an API',
@@ -70,16 +66,8 @@ def test_error_precond(mock_puppetdb_environments):
def test_error_server(mock_puppetdb_environments):
with app.app.test_request_context():
(output, error_code) = server_error(None)
(output, error_code) = app.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
import puppetboard.core
from puppetboard import app
try:
import future.utils
@@ -100,14 +100,12 @@ def test_graph_facts_custom(cleanUpEnv):
assert 'extra' in facts
def test_bad_log_value(cleanUpEnv, mocker):
def test_bad_log_value(cleanUpEnv):
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:
puppetboard.core.get_app()
reload(app)
def test_default_table_selctor(cleanUpEnv):
@@ -118,11 +116,3 @@ 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

View File

@@ -1,12 +0,0 @@
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,6 +12,7 @@ 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
@@ -107,6 +108,19 @@ 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
View File

@@ -1,10 +0,0 @@
[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