diff --git a/dev.py b/dev.py new file mode 100644 index 0000000..edb201c --- /dev/null +++ b/dev.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +from puppetboard.app import app + +if __name__ == '__main__': + app.debug=True + app.run('127.0.0.1') diff --git a/puppetboard/__init__.py b/puppetboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/puppetboard/app.py b/puppetboard/app.py new file mode 100644 index 0000000..d835363 --- /dev/null +++ b/puppetboard/app.py @@ -0,0 +1,198 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +import os +import logging +import collections + +from flask import ( + Flask, render_template, abort, url_for, + Response, stream_with_context, + ) + +from pypuppetdb import connect +from pypuppetdb.errors import ExperimentalDisabledError + +from puppetboard.forms import QueryForm +from puppetboard.utils import ( + get_or_abort, yield_or_stop, + ten_reports, + ) + + +app = Flask(__name__) +app.config.from_object('puppetboard.default_settings') +app.config.from_envvar('PUPPETBOARD_SETTINGS', silent=True) +app.secret_key = os.urandom(24) + +puppetdb = connect( + host=app.config['PUPPETDB_HOST'], + port=app.config['PUPPETDB_PORT'], + ssl=app.config['PUPPETDB_SSL'], + ssl_key=app.config['PUPPETDB_KEY'], + ssl_cert=app.config['PUPPETDB_CERT'], + timeout=app.config['PUPPETDB_TIMEOUT'], + experimental=app.config['PUPPETDB_EXPERIMENTAL']) + +numeric_level = getattr(logging, app.config['LOGLEVEL'].upper(), None) +if not isinstance(numeric_level, int): + raise ValueError('Invalid log level: %s' % loglevel) +logging.basicConfig(level=numeric_level) +log = logging.getLogger(__name__) + +def stream_template(template_name, **context): + app.update_template_context(context) + t = app.jinja_env.get_template(template_name) + rv = t.stream(context) + rv.enable_buffering(5) + return rv + +@app.errorhandler(400) +def bad_request(e): + return render_template('400.html'), 400 + +@app.errorhandler(404) +def not_found(e): + return render_template('404.html'), 404 + +@app.errorhandler(412) +def precond_failed(e): + """We're slightly abusing 412 to handle ExperimentalDisabled errors.""" + return render_template('412.html'), 412 + +@app.errorhandler(500) +def server_error(e): + return render_template('500.html'), 500 + +@app.route('/') +def index(): + """This view generates the index page and displays a set of metrics fetched + from PuppetDB.""" + # TODO: Would be great if we could parallelize this somehow, doing these + # requests in sequence is rather pointless. + num_nodes = get_or_abort(puppetdb.metric, + 'com.puppetlabs.puppetdb.query.population:type=default,name=num-nodes') + num_resources = get_or_abort(puppetdb.metric, + 'com.puppetlabs.puppetdb.query.population:type=default,name=num-resources') + avg_resources_node = get_or_abort(puppetdb.metric, + 'com.puppetlabs.puppetdb.query.population:type=default,name=avg-resources-per-node') + mean_failed_commands = get_or_abort(puppetdb.metric, + 'com.puppetlabs.puppetdb.command:type=global,name=fatal') + mean_command_time = get_or_abort(puppetdb.metric, + 'com.puppetlabs.puppetdb.command:type=global,name=processing-time') + metrics = { + 'num_nodes': num_nodes['Value'], + 'num_resources': num_resources['Value'], + 'avg_resources_node': "{:10.6f}".format(avg_resources_node['Value']), + 'mean_failed_commands': mean_failed_commands['MeanRate'], + 'mean_command_time': "{:10.6f}".format(mean_command_time['MeanRate']), + } + return render_template('index.html', metrics=metrics) + +@app.route('/nodes') +def nodes(): + """Fetch all (active) nodes from PuppetDB and stream a table displaying + those nodes. + + Downside of the streaming aproach is that since we've already sent our + headers we can't abort the request if we detect an error. Because of this + we'll end up with an empty table instead because of how yield_or_stop + works. Once pagination is in place we can change this but we'll need to + provide a search feature instead. + """ + return Response(stream_with_context(stream_template('nodes.html', + nodes=yield_or_stop(puppetdb.nodes())))) + +@app.route('/node/') +def node(node_name): + """Display a dashboard for a node showing as much data as we have on that + node. This includes facts and reports but not Resources as that is too + heavy to do within a single request. + """ + node = get_or_abort(puppetdb.node, node_name) + facts = node.facts() + if app.config['PUPPETDB_EXPERIMENTAL']: + reports = ten_reports(node.reports()) + else: + reports = iter([]) + return render_template('node.html', node=node, facts=yield_or_stop(facts), + reports=yield_or_stop(reports)) + +@app.route('/reports') +def reports(): + """Doesn't do much yet but is meant to show something like the reports of + the last half our, something like that.""" + if app.config['PUPPETDB_EXPERIMENTAL']: + return render_template('reports.html') + else: + log.warn('Access to experimental endpoint not allowed.') + abort(412) + +@app.route('/reports/') +def reports_node(node): + """Fetches all reports for a node and processes them eventually rendering + a table displaying those reports.""" + if app.config['PUPPETDB_EXPERIMENTAL']: + reports = ten_reports(yield_or_stop( + puppetdb.reports('["=", "certname", "{0}"]'.format(node)))) + else: + log.warn('Access to experimental endpoint not allowed.') + abort(412) + return render_template('reports_node.html', reports=reports, + nodename=node) + +@app.route('/report//') +def report(node, report_id): + """Displays a single report including all the events associated with that + report and their status.""" + if app.config['PUPPETDB_EXPERIMENTAL']: + reports = puppetdb.reports('["=", "certname", "{0}"]'.format(node)) + else: + log.warn('Access to experimental endpoint not allowed.') + abort(412) + + for report in reports: + if report.hash_ == report_id: + events = puppetdb.events('["=", "report", "{0}"]'.format( + report.hash_)) + return render_template('report.html', report=report, + events=yield_or_stop(events)) + else: + abort(404) + +@app.route('/facts') +def facts(): + """Displays an alphabetical list of all facts currently known to + PuppetDB.""" + facts_dict = collections.defaultdict(list) + facts = get_or_abort(puppetdb.fact_names) + for fact in facts: + letter = fact[0].upper() + letter_list = facts_dict[letter] + letter_list.append(fact) + facts_dict[letter] = letter_list + + sorted_facts_dict = sorted(facts_dict.items()) + return render_template('facts.html', facts_dict=sorted_facts_dict) + +@app.route('/fact/') +def fact(fact): + """Fetches the specific fact from PuppetDB and displays its value per + node for which this fact is known.""" + return Response(stream_with_context(stream_template('fact.html', + name=fact, + facts=yield_or_stop(puppetdb.facts(name=fact))))) + +@app.route('/query', methods=('GET', 'POST')) +def query(): + """Allows to execute raw, user created querries against PuppetDB. This is + currently highly experimental and explodes in interesting ways since none + of the possible exceptions are being handled just yet. This will return + the JSON of the response or a message telling you what whent wrong / + why nothing was returned.""" + form = QueryForm() + if form.validate_on_submit(): + result = get_or_abort(puppetdb._query, form.endpoints.data, + query='[{0}]'.format(form.query.data)) + return render_template('query.html', form=form, result=result) + return render_template('query.html', form=form) diff --git a/puppetboard/default_settings.py b/puppetboard/default_settings.py new file mode 100644 index 0000000..9c126a3 --- /dev/null +++ b/puppetboard/default_settings.py @@ -0,0 +1,8 @@ +PUPPETDB_HOST='localhost' +PUPPETDB_PORT=8080 +PUPPETDB_SSL=False +PUPPETDB_KEY=None +PUPPETDB_CERT=None +PUPPETDB_TIMEOUT=20 +PUPPETDB_EXPERIMENTAL=False +LOGLEVEL='info' diff --git a/puppetboard/forms.py b/puppetboard/forms.py new file mode 100644 index 0000000..382cfb1 --- /dev/null +++ b/puppetboard/forms.py @@ -0,0 +1,20 @@ +from __future__ import unicode_literals +from __future__ import absolute_import + +from flask.ext.wtf import Form +from wtforms import RadioField, TextAreaField, validators + +class QueryForm(Form): + """The form used to allow freeform queries to be executed against + PuppetDB.""" + query = TextAreaField('Query', [validators.Required( + message='A query is required.')]) + endpoints = RadioField('API endpoint', choices = [ + ('nodes', 'Nodes'), + ('resources', 'Resources'), + ('facts', 'Facts'), + ('fact-names', 'Fact Names'), + ('reports', 'Reports'), + ('events', 'Events'), + ]) + diff --git a/puppetboard/static/coffeescript/lists.coffee b/puppetboard/static/coffeescript/lists.coffee new file mode 100644 index 0000000..da7998c --- /dev/null +++ b/puppetboard/static/coffeescript/lists.coffee @@ -0,0 +1,17 @@ +$ = jQuery +$ -> +$('input.filter-list').parent('div').removeClass('hide') +$("input.filter-list").on "keyup", (e) -> + rex = new RegExp($(this).val(), "i") + + $(".searchable li").hide() + $(".searchable li").filter( -> + rex.test $(this).text() + ).show() + + if e.keyCode is 27 + $(e.currentTarget).val "" + ev = $.Event("keyup") + ev.keyCode = 13 + $(e.currentTarget).trigger(ev) + e.currentTarget.blur() diff --git a/puppetboard/static/coffeescript/tables.coffee b/puppetboard/static/coffeescript/tables.coffee new file mode 100644 index 0000000..137f11e --- /dev/null +++ b/puppetboard/static/coffeescript/tables.coffee @@ -0,0 +1,28 @@ +$ = jQuery +$ -> +$('.nodes').tablesorter( + headers: + 3: + sorter: false + sortList: [[0,0]] +) + +$('.facts').tablesorter( + sortList: [[0,0]] +) + +$('input.filter-table').parent('div').removeClass('hide') +$("input.filter-table").on "keyup", (e) -> + rex = new RegExp($(this).val(), "i") + + $(".searchable tr").hide() + $(".searchable tr").filter( -> + rex.test $(this).text() + ).show() + + if e.keyCode is 27 + $(e.currentTarget).val "" + ev = $.Event("keyup") + ev.keyCode = 13 + $(e.currentTarget).trigger(ev) + e.currentTarget.blur() diff --git a/puppetboard/static/css/puppetboard.css b/puppetboard/static/css/puppetboard.css new file mode 100644 index 0000000..0865544 --- /dev/null +++ b/puppetboard/static/css/puppetboard.css @@ -0,0 +1,51 @@ +body { + padding-top: 60px; +} +th.headerSortUp { + position: relative +} +th.headerSortDown { + position: relative +} +th.header { + position: relative +} +th.header:after { + content: "\f0dc"; + font-family: FontAwesome; + font-style: normal; + font-weight: normal; + text-decoration: inherit; + color: #000; + font-size: 18px; + padding-right: 0.5em; + float:right; +} +th.headerSortUp:after { + content: "\f0de"; + font-family: FontAwesome; + font-style: normal; + font-weight: normal; + text-decoration: inherit; + color: #000; + font-size: 18px; + padding-right: 0.5em; + float:right; +} +th.headerSortDown:after { + content: "\f0dd"; + font-family: FontAwesome; + font-style: normal; + font-weight: normal; + text-decoration: inherit; + color: #000; + font-size: 18px; + padding-right: 0.5em; + float:right; +} +.stat { + margin-bottom: 40px; +} +.navbar .brand:hover { + color: #fff; +} diff --git a/puppetboard/static/js/lists.js b/puppetboard/static/js/lists.js new file mode 100644 index 0000000..30e61cc --- /dev/null +++ b/puppetboard/static/js/lists.js @@ -0,0 +1,27 @@ +// Generated by CoffeeScript 1.6.3 +(function() { + var $; + + $ = jQuery; + + $(function() {}); + + $('input.filter-list').parent('div').removeClass('hide'); + + $("input.filter-list").on("keyup", function(e) { + var ev, rex; + rex = new RegExp($(this).val(), "i"); + $(".searchable li").hide(); + $(".searchable li").filter(function() { + return rex.test($(this).text()); + }).show(); + if (e.keyCode === 27) { + $(e.currentTarget).val(""); + ev = $.Event("keyup"); + ev.keyCode = 13; + $(e.currentTarget).trigger(ev); + return e.currentTarget.blur(); + } + }); + +}).call(this); diff --git a/puppetboard/static/js/moment.js b/puppetboard/static/js/moment.js new file mode 100644 index 0000000..9ff57aa --- /dev/null +++ b/puppetboard/static/js/moment.js @@ -0,0 +1,1400 @@ +// moment.js +// version : 2.0.0 +// author : Tim Wood +// license : MIT +// momentjs.com + +(function (undefined) { + + /************************************ + Constants + ************************************/ + + var moment, + VERSION = "2.0.0", + round = Math.round, i, + // internal storage for language config files + languages = {}, + + // check for nodeJS + hasModule = (typeof module !== 'undefined' && module.exports), + + // ASP.NET json date format regex + aspNetJsonRegex = /^\/?Date\((\-?\d+)/i, + + // format tokens + formattingTokens = /(\[[^\[]*\])|(\\)?(Mo|MM?M?M?|Do|DDDo|DD?D?D?|ddd?d?|do?|w[o|w]?|W[o|W]?|YYYYY|YYYY|YY|a|A|hh?|HH?|mm?|ss?|SS?S?|X|zz?|ZZ?|.)/g, + localFormattingTokens = /(\[[^\[]*\])|(\\)?(LT|LL?L?L?|l{1,4})/g, + + // parsing tokens + parseMultipleFormatChunker = /([0-9a-zA-Z\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+)/gi, + + // parsing token regexes + parseTokenOneOrTwoDigits = /\d\d?/, // 0 - 99 + parseTokenOneToThreeDigits = /\d{1,3}/, // 0 - 999 + parseTokenThreeDigits = /\d{3}/, // 000 - 999 + parseTokenFourDigits = /\d{1,4}/, // 0 - 9999 + parseTokenSixDigits = /[+\-]?\d{1,6}/, // -999,999 - 999,999 + parseTokenWord = /[0-9]*[a-z\u00A0-\u05FF\u0700-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]+|[\u0600-\u06FF]+\s*?[\u0600-\u06FF]+/i, // any word (or two) characters or numbers including two word month in arabic. + parseTokenTimezone = /Z|[\+\-]\d\d:?\d\d/i, // +00:00 -00:00 +0000 -0000 or Z + parseTokenT = /T/i, // T (ISO seperator) + parseTokenTimestampMs = /[\+\-]?\d+(\.\d{1,3})?/, // 123456789 123456789.123 + + // preliminary iso regex + // 0000-00-00 + T + 00 or 00:00 or 00:00:00 or 00:00:00.000 + +00:00 or +0000 + isoRegex = /^\s*\d{4}-\d\d-\d\d((T| )(\d\d(:\d\d(:\d\d(\.\d\d?\d?)?)?)?)?([\+\-]\d\d:?\d\d)?)?/, + isoFormat = 'YYYY-MM-DDTHH:mm:ssZ', + + // iso time formats and regexes + isoTimes = [ + ['HH:mm:ss.S', /(T| )\d\d:\d\d:\d\d\.\d{1,3}/], + ['HH:mm:ss', /(T| )\d\d:\d\d:\d\d/], + ['HH:mm', /(T| )\d\d:\d\d/], + ['HH', /(T| )\d\d/] + ], + + // timezone chunker "+10:00" > ["10", "00"] or "-1530" > ["-15", "30"] + parseTimezoneChunker = /([\+\-]|\d\d)/gi, + + // getter and setter names + proxyGettersAndSetters = 'Month|Date|Hours|Minutes|Seconds|Milliseconds'.split('|'), + unitMillisecondFactors = { + 'Milliseconds' : 1, + 'Seconds' : 1e3, + 'Minutes' : 6e4, + 'Hours' : 36e5, + 'Days' : 864e5, + 'Months' : 2592e6, + 'Years' : 31536e6 + }, + + // format function strings + formatFunctions = {}, + + // tokens to ordinalize and pad + ordinalizeTokens = 'DDD w W M D d'.split(' '), + paddedTokens = 'M D H h m s w W'.split(' '), + + formatTokenFunctions = { + M : function () { + return this.month() + 1; + }, + MMM : function (format) { + return this.lang().monthsShort(this, format); + }, + MMMM : function (format) { + return this.lang().months(this, format); + }, + D : function () { + return this.date(); + }, + DDD : function () { + return this.dayOfYear(); + }, + d : function () { + return this.day(); + }, + dd : function (format) { + return this.lang().weekdaysMin(this, format); + }, + ddd : function (format) { + return this.lang().weekdaysShort(this, format); + }, + dddd : function (format) { + return this.lang().weekdays(this, format); + }, + w : function () { + return this.week(); + }, + W : function () { + return this.isoWeek(); + }, + YY : function () { + return leftZeroFill(this.year() % 100, 2); + }, + YYYY : function () { + return leftZeroFill(this.year(), 4); + }, + YYYYY : function () { + return leftZeroFill(this.year(), 5); + }, + a : function () { + return this.lang().meridiem(this.hours(), this.minutes(), true); + }, + A : function () { + return this.lang().meridiem(this.hours(), this.minutes(), false); + }, + H : function () { + return this.hours(); + }, + h : function () { + return this.hours() % 12 || 12; + }, + m : function () { + return this.minutes(); + }, + s : function () { + return this.seconds(); + }, + S : function () { + return ~~(this.milliseconds() / 100); + }, + SS : function () { + return leftZeroFill(~~(this.milliseconds() / 10), 2); + }, + SSS : function () { + return leftZeroFill(this.milliseconds(), 3); + }, + Z : function () { + var a = -this.zone(), + b = "+"; + if (a < 0) { + a = -a; + b = "-"; + } + return b + leftZeroFill(~~(a / 60), 2) + ":" + leftZeroFill(~~a % 60, 2); + }, + ZZ : function () { + var a = -this.zone(), + b = "+"; + if (a < 0) { + a = -a; + b = "-"; + } + return b + leftZeroFill(~~(10 * a / 6), 4); + }, + X : function () { + return this.unix(); + } + }; + + function padToken(func, count) { + return function (a) { + return leftZeroFill(func.call(this, a), count); + }; + } + function ordinalizeToken(func) { + return function (a) { + return this.lang().ordinal(func.call(this, a)); + }; + } + + while (ordinalizeTokens.length) { + i = ordinalizeTokens.pop(); + formatTokenFunctions[i + 'o'] = ordinalizeToken(formatTokenFunctions[i]); + } + while (paddedTokens.length) { + i = paddedTokens.pop(); + formatTokenFunctions[i + i] = padToken(formatTokenFunctions[i], 2); + } + formatTokenFunctions.DDDD = padToken(formatTokenFunctions.DDD, 3); + + + /************************************ + Constructors + ************************************/ + + function Language() { + + } + + // Moment prototype object + function Moment(config) { + extend(this, config); + } + + // Duration Constructor + function Duration(duration) { + var data = this._data = {}, + years = duration.years || duration.year || duration.y || 0, + months = duration.months || duration.month || duration.M || 0, + weeks = duration.weeks || duration.week || duration.w || 0, + days = duration.days || duration.day || duration.d || 0, + hours = duration.hours || duration.hour || duration.h || 0, + minutes = duration.minutes || duration.minute || duration.m || 0, + seconds = duration.seconds || duration.second || duration.s || 0, + milliseconds = duration.milliseconds || duration.millisecond || duration.ms || 0; + + // representation for dateAddRemove + this._milliseconds = milliseconds + + seconds * 1e3 + // 1000 + minutes * 6e4 + // 1000 * 60 + hours * 36e5; // 1000 * 60 * 60 + // Because of dateAddRemove treats 24 hours as different from a + // day when working around DST, we need to store them separately + this._days = days + + weeks * 7; + // It is impossible translate months into days without knowing + // which months you are are talking about, so we have to store + // it separately. + this._months = months + + years * 12; + + // The following code bubbles up values, see the tests for + // examples of what that means. + data.milliseconds = milliseconds % 1000; + seconds += absRound(milliseconds / 1000); + + data.seconds = seconds % 60; + minutes += absRound(seconds / 60); + + data.minutes = minutes % 60; + hours += absRound(minutes / 60); + + data.hours = hours % 24; + days += absRound(hours / 24); + + days += weeks * 7; + data.days = days % 30; + + months += absRound(days / 30); + + data.months = months % 12; + years += absRound(months / 12); + + data.years = years; + } + + + /************************************ + Helpers + ************************************/ + + + function extend(a, b) { + for (var i in b) { + if (b.hasOwnProperty(i)) { + a[i] = b[i]; + } + } + return a; + } + + function absRound(number) { + if (number < 0) { + return Math.ceil(number); + } else { + return Math.floor(number); + } + } + + // left zero fill a number + // see http://jsperf.com/left-zero-filling for performance comparison + function leftZeroFill(number, targetLength) { + var output = number + ''; + while (output.length < targetLength) { + output = '0' + output; + } + return output; + } + + // helper function for _.addTime and _.subtractTime + function addOrSubtractDurationFromMoment(mom, duration, isAdding) { + var ms = duration._milliseconds, + d = duration._days, + M = duration._months, + currentDate; + + if (ms) { + mom._d.setTime(+mom + ms * isAdding); + } + if (d) { + mom.date(mom.date() + d * isAdding); + } + if (M) { + currentDate = mom.date(); + mom.date(1) + .month(mom.month() + M * isAdding) + .date(Math.min(currentDate, mom.daysInMonth())); + } + } + + // check if is an array + function isArray(input) { + return Object.prototype.toString.call(input) === '[object Array]'; + } + + // compare two arrays, return the number of differences + function compareArrays(array1, array2) { + var len = Math.min(array1.length, array2.length), + lengthDiff = Math.abs(array1.length - array2.length), + diffs = 0, + i; + for (i = 0; i < len; i++) { + if (~~array1[i] !== ~~array2[i]) { + diffs++; + } + } + return diffs + lengthDiff; + } + + + /************************************ + Languages + ************************************/ + + + Language.prototype = { + set : function (config) { + var prop, i; + for (i in config) { + prop = config[i]; + if (typeof prop === 'function') { + this[i] = prop; + } else { + this['_' + i] = prop; + } + } + }, + + _months : "January_February_March_April_May_June_July_August_September_October_November_December".split("_"), + months : function (m) { + return this._months[m.month()]; + }, + + _monthsShort : "Jan_Feb_Mar_Apr_May_Jun_Jul_Aug_Sep_Oct_Nov_Dec".split("_"), + monthsShort : function (m) { + return this._monthsShort[m.month()]; + }, + + monthsParse : function (monthName) { + var i, mom, regex, output; + + if (!this._monthsParse) { + this._monthsParse = []; + } + + for (i = 0; i < 12; i++) { + // make the regex if we don't have it already + if (!this._monthsParse[i]) { + mom = moment([2000, i]); + regex = '^' + this.months(mom, '') + '|^' + this.monthsShort(mom, ''); + this._monthsParse[i] = new RegExp(regex.replace('.', ''), 'i'); + } + // test the regex + if (this._monthsParse[i].test(monthName)) { + return i; + } + } + }, + + _weekdays : "Sunday_Monday_Tuesday_Wednesday_Thursday_Friday_Saturday".split("_"), + weekdays : function (m) { + return this._weekdays[m.day()]; + }, + + _weekdaysShort : "Sun_Mon_Tue_Wed_Thu_Fri_Sat".split("_"), + weekdaysShort : function (m) { + return this._weekdaysShort[m.day()]; + }, + + _weekdaysMin : "Su_Mo_Tu_We_Th_Fr_Sa".split("_"), + weekdaysMin : function (m) { + return this._weekdaysMin[m.day()]; + }, + + _longDateFormat : { + LT : "h:mm A", + L : "MM/DD/YYYY", + LL : "MMMM D YYYY", + LLL : "MMMM D YYYY LT", + LLLL : "dddd, MMMM D YYYY LT" + }, + longDateFormat : function (key) { + var output = this._longDateFormat[key]; + if (!output && this._longDateFormat[key.toUpperCase()]) { + output = this._longDateFormat[key.toUpperCase()].replace(/MMMM|MM|DD|dddd/g, function (val) { + return val.slice(1); + }); + this._longDateFormat[key] = output; + } + return output; + }, + + meridiem : function (hours, minutes, isLower) { + if (hours > 11) { + return isLower ? 'pm' : 'PM'; + } else { + return isLower ? 'am' : 'AM'; + } + }, + + _calendar : { + sameDay : '[Today at] LT', + nextDay : '[Tomorrow at] LT', + nextWeek : 'dddd [at] LT', + lastDay : '[Yesterday at] LT', + lastWeek : '[last] dddd [at] LT', + sameElse : 'L' + }, + calendar : function (key, mom) { + var output = this._calendar[key]; + return typeof output === 'function' ? output.apply(mom) : output; + }, + + _relativeTime : { + future : "in %s", + past : "%s ago", + s : "a few seconds", + m : "a minute", + mm : "%d minutes", + h : "an hour", + hh : "%d hours", + d : "a day", + dd : "%d days", + M : "a month", + MM : "%d months", + y : "a year", + yy : "%d years" + }, + relativeTime : function (number, withoutSuffix, string, isFuture) { + var output = this._relativeTime[string]; + return (typeof output === 'function') ? + output(number, withoutSuffix, string, isFuture) : + output.replace(/%d/i, number); + }, + pastFuture : function (diff, output) { + var format = this._relativeTime[diff > 0 ? 'future' : 'past']; + return typeof format === 'function' ? format(output) : format.replace(/%s/i, output); + }, + + ordinal : function (number) { + return this._ordinal.replace("%d", number); + }, + _ordinal : "%d", + + preparse : function (string) { + return string; + }, + + postformat : function (string) { + return string; + }, + + week : function (mom) { + return weekOfYear(mom, this._week.dow, this._week.doy); + }, + _week : { + dow : 0, // Sunday is the first day of the week. + doy : 6 // The week that contains Jan 1st is the first week of the year. + } + }; + + // Loads a language definition into the `languages` cache. The function + // takes a key and optionally values. If not in the browser and no values + // are provided, it will load the language file module. As a convenience, + // this function also returns the language values. + function loadLang(key, values) { + values.abbr = key; + if (!languages[key]) { + languages[key] = new Language(); + } + languages[key].set(values); + return languages[key]; + } + + // Determines which language definition to use and returns it. + // + // With no parameters, it will return the global language. If you + // pass in a language key, such as 'en', it will return the + // definition for 'en', so long as 'en' has already been loaded using + // moment.lang. + function getLangDefinition(key) { + if (!key) { + return moment.fn._lang; + } + if (!languages[key] && hasModule) { + require('./lang/' + key); + } + return languages[key]; + } + + + /************************************ + Formatting + ************************************/ + + + function removeFormattingTokens(input) { + if (input.match(/\[.*\]/)) { + return input.replace(/^\[|\]$/g, ""); + } + return input.replace(/\\/g, ""); + } + + function makeFormatFunction(format) { + var array = format.match(formattingTokens), i, length; + + for (i = 0, length = array.length; i < length; i++) { + if (formatTokenFunctions[array[i]]) { + array[i] = formatTokenFunctions[array[i]]; + } else { + array[i] = removeFormattingTokens(array[i]); + } + } + + return function (mom) { + var output = ""; + for (i = 0; i < length; i++) { + output += typeof array[i].call === 'function' ? array[i].call(mom, format) : array[i]; + } + return output; + }; + } + + // format date using native date object + function formatMoment(m, format) { + var i = 5; + + function replaceLongDateFormatTokens(input) { + return m.lang().longDateFormat(input) || input; + } + + while (i-- && localFormattingTokens.test(format)) { + format = format.replace(localFormattingTokens, replaceLongDateFormatTokens); + } + + if (!formatFunctions[format]) { + formatFunctions[format] = makeFormatFunction(format); + } + + return formatFunctions[format](m); + } + + + /************************************ + Parsing + ************************************/ + + + // get the regex to find the next token + function getParseRegexForToken(token) { + switch (token) { + case 'DDDD': + return parseTokenThreeDigits; + case 'YYYY': + return parseTokenFourDigits; + case 'YYYYY': + return parseTokenSixDigits; + case 'S': + case 'SS': + case 'SSS': + case 'DDD': + return parseTokenOneToThreeDigits; + case 'MMM': + case 'MMMM': + case 'dd': + case 'ddd': + case 'dddd': + case 'a': + case 'A': + return parseTokenWord; + case 'X': + return parseTokenTimestampMs; + case 'Z': + case 'ZZ': + return parseTokenTimezone; + case 'T': + return parseTokenT; + case 'MM': + case 'DD': + case 'YY': + case 'HH': + case 'hh': + case 'mm': + case 'ss': + case 'M': + case 'D': + case 'd': + case 'H': + case 'h': + case 'm': + case 's': + return parseTokenOneOrTwoDigits; + default : + return new RegExp(token.replace('\\', '')); + } + } + + // function to convert string input to date + function addTimeToArrayFromToken(token, input, config) { + var a, b, + datePartArray = config._a; + + switch (token) { + // MONTH + case 'M' : // fall through to MM + case 'MM' : + datePartArray[1] = (input == null) ? 0 : ~~input - 1; + break; + case 'MMM' : // fall through to MMMM + case 'MMMM' : + a = getLangDefinition(config._l).monthsParse(input); + // if we didn't find a month name, mark the date as invalid. + if (a != null) { + datePartArray[1] = a; + } else { + config._isValid = false; + } + break; + // DAY OF MONTH + case 'D' : // fall through to DDDD + case 'DD' : // fall through to DDDD + case 'DDD' : // fall through to DDDD + case 'DDDD' : + if (input != null) { + datePartArray[2] = ~~input; + } + break; + // YEAR + case 'YY' : + datePartArray[0] = ~~input + (~~input > 68 ? 1900 : 2000); + break; + case 'YYYY' : + case 'YYYYY' : + datePartArray[0] = ~~input; + break; + // AM / PM + case 'a' : // fall through to A + case 'A' : + config._isPm = ((input + '').toLowerCase() === 'pm'); + break; + // 24 HOUR + case 'H' : // fall through to hh + case 'HH' : // fall through to hh + case 'h' : // fall through to hh + case 'hh' : + datePartArray[3] = ~~input; + break; + // MINUTE + case 'm' : // fall through to mm + case 'mm' : + datePartArray[4] = ~~input; + break; + // SECOND + case 's' : // fall through to ss + case 'ss' : + datePartArray[5] = ~~input; + break; + // MILLISECOND + case 'S' : + case 'SS' : + case 'SSS' : + datePartArray[6] = ~~ (('0.' + input) * 1000); + break; + // UNIX TIMESTAMP WITH MS + case 'X': + config._d = new Date(parseFloat(input) * 1000); + break; + // TIMEZONE + case 'Z' : // fall through to ZZ + case 'ZZ' : + config._useUTC = true; + a = (input + '').match(parseTimezoneChunker); + if (a && a[1]) { + config._tzh = ~~a[1]; + } + if (a && a[2]) { + config._tzm = ~~a[2]; + } + // reverse offsets + if (a && a[0] === '+') { + config._tzh = -config._tzh; + config._tzm = -config._tzm; + } + break; + } + + // if the input is null, the date is not valid + if (input == null) { + config._isValid = false; + } + } + + // convert an array to a date. + // the array should mirror the parameters below + // note: all values past the year are optional and will default to the lowest possible value. + // [year, month, day , hour, minute, second, millisecond] + function dateFromArray(config) { + var i, date, input = []; + + if (config._d) { + return; + } + + for (i = 0; i < 7; i++) { + config._a[i] = input[i] = (config._a[i] == null) ? (i === 2 ? 1 : 0) : config._a[i]; + } + + // add the offsets to the time to be parsed so that we can have a clean array for checking isValid + input[3] += config._tzh || 0; + input[4] += config._tzm || 0; + + date = new Date(0); + + if (config._useUTC) { + date.setUTCFullYear(input[0], input[1], input[2]); + date.setUTCHours(input[3], input[4], input[5], input[6]); + } else { + date.setFullYear(input[0], input[1], input[2]); + date.setHours(input[3], input[4], input[5], input[6]); + } + + config._d = date; + } + + // date from string and format string + function makeDateFromStringAndFormat(config) { + // This array is used to make a Date, either with `new Date` or `Date.UTC` + var tokens = config._f.match(formattingTokens), + string = config._i, + i, parsedInput; + + config._a = []; + + for (i = 0; i < tokens.length; i++) { + parsedInput = (getParseRegexForToken(tokens[i]).exec(string) || [])[0]; + if (parsedInput) { + string = string.slice(string.indexOf(parsedInput) + parsedInput.length); + } + // don't parse if its not a known token + if (formatTokenFunctions[tokens[i]]) { + addTimeToArrayFromToken(tokens[i], parsedInput, config); + } + } + // handle am pm + if (config._isPm && config._a[3] < 12) { + config._a[3] += 12; + } + // if is 12 am, change hours to 0 + if (config._isPm === false && config._a[3] === 12) { + config._a[3] = 0; + } + // return + dateFromArray(config); + } + + // date from string and array of format strings + function makeDateFromStringAndArray(config) { + var tempConfig, + tempMoment, + bestMoment, + + scoreToBeat = 99, + i, + currentDate, + currentScore; + + while (config._f.length) { + tempConfig = extend({}, config); + tempConfig._f = config._f.pop(); + makeDateFromStringAndFormat(tempConfig); + tempMoment = new Moment(tempConfig); + + if (tempMoment.isValid()) { + bestMoment = tempMoment; + break; + } + + currentScore = compareArrays(tempConfig._a, tempMoment.toArray()); + + if (currentScore < scoreToBeat) { + scoreToBeat = currentScore; + bestMoment = tempMoment; + } + } + + extend(config, bestMoment); + } + + // date from iso format + function makeDateFromString(config) { + var i, + string = config._i; + if (isoRegex.exec(string)) { + config._f = 'YYYY-MM-DDT'; + for (i = 0; i < 4; i++) { + if (isoTimes[i][1].exec(string)) { + config._f += isoTimes[i][0]; + break; + } + } + if (parseTokenTimezone.exec(string)) { + config._f += " Z"; + } + makeDateFromStringAndFormat(config); + } else { + config._d = new Date(string); + } + } + + function makeDateFromInput(config) { + var input = config._i, + matched = aspNetJsonRegex.exec(input); + + if (input === undefined) { + config._d = new Date(); + } else if (matched) { + config._d = new Date(+matched[1]); + } else if (typeof input === 'string') { + makeDateFromString(config); + } else if (isArray(input)) { + config._a = input.slice(0); + dateFromArray(config); + } else { + config._d = input instanceof Date ? new Date(+input) : new Date(input); + } + } + + + /************************************ + Relative Time + ************************************/ + + + // helper function for moment.fn.from, moment.fn.fromNow, and moment.duration.fn.humanize + function substituteTimeAgo(string, number, withoutSuffix, isFuture, lang) { + return lang.relativeTime(number || 1, !!withoutSuffix, string, isFuture); + } + + function relativeTime(milliseconds, withoutSuffix, lang) { + var seconds = round(Math.abs(milliseconds) / 1000), + minutes = round(seconds / 60), + hours = round(minutes / 60), + days = round(hours / 24), + years = round(days / 365), + args = seconds < 45 && ['s', seconds] || + minutes === 1 && ['m'] || + minutes < 45 && ['mm', minutes] || + hours === 1 && ['h'] || + hours < 22 && ['hh', hours] || + days === 1 && ['d'] || + days <= 25 && ['dd', days] || + days <= 45 && ['M'] || + days < 345 && ['MM', round(days / 30)] || + years === 1 && ['y'] || ['yy', years]; + args[2] = withoutSuffix; + args[3] = milliseconds > 0; + args[4] = lang; + return substituteTimeAgo.apply({}, args); + } + + + /************************************ + Week of Year + ************************************/ + + + // firstDayOfWeek 0 = sun, 6 = sat + // the day of the week that starts the week + // (usually sunday or monday) + // firstDayOfWeekOfYear 0 = sun, 6 = sat + // the first week is the week that contains the first + // of this day of the week + // (eg. ISO weeks use thursday (4)) + function weekOfYear(mom, firstDayOfWeek, firstDayOfWeekOfYear) { + var end = firstDayOfWeekOfYear - firstDayOfWeek, + daysToDayOfWeek = firstDayOfWeekOfYear - mom.day(); + + + if (daysToDayOfWeek > end) { + daysToDayOfWeek -= 7; + } + + if (daysToDayOfWeek < end - 7) { + daysToDayOfWeek += 7; + } + + return Math.ceil(moment(mom).add('d', daysToDayOfWeek).dayOfYear() / 7); + } + + + /************************************ + Top Level Functions + ************************************/ + + function makeMoment(config) { + var input = config._i, + format = config._f; + + if (input === null || input === '') { + return null; + } + + if (typeof input === 'string') { + config._i = input = getLangDefinition().preparse(input); + } + + if (moment.isMoment(input)) { + config = extend({}, input); + config._d = new Date(+input._d); + } else if (format) { + if (isArray(format)) { + makeDateFromStringAndArray(config); + } else { + makeDateFromStringAndFormat(config); + } + } else { + makeDateFromInput(config); + } + + return new Moment(config); + } + + moment = function (input, format, lang) { + return makeMoment({ + _i : input, + _f : format, + _l : lang, + _isUTC : false + }); + }; + + // creating with utc + moment.utc = function (input, format, lang) { + return makeMoment({ + _useUTC : true, + _isUTC : true, + _l : lang, + _i : input, + _f : format + }); + }; + + // creating with unix timestamp (in seconds) + moment.unix = function (input) { + return moment(input * 1000); + }; + + // duration + moment.duration = function (input, key) { + var isDuration = moment.isDuration(input), + isNumber = (typeof input === 'number'), + duration = (isDuration ? input._data : (isNumber ? {} : input)), + ret; + + if (isNumber) { + if (key) { + duration[key] = input; + } else { + duration.milliseconds = input; + } + } + + ret = new Duration(duration); + + if (isDuration && input.hasOwnProperty('_lang')) { + ret._lang = input._lang; + } + + return ret; + }; + + // version number + moment.version = VERSION; + + // default format + moment.defaultFormat = isoFormat; + + // This function will load languages and then set the global language. If + // no arguments are passed in, it will simply return the current global + // language key. + moment.lang = function (key, values) { + var i; + + if (!key) { + return moment.fn._lang._abbr; + } + if (values) { + loadLang(key, values); + } else if (!languages[key]) { + getLangDefinition(key); + } + moment.duration.fn._lang = moment.fn._lang = getLangDefinition(key); + }; + + // returns language data + moment.langData = function (key) { + if (key && key._lang && key._lang._abbr) { + key = key._lang._abbr; + } + return getLangDefinition(key); + }; + + // compare moment object + moment.isMoment = function (obj) { + return obj instanceof Moment; + }; + + // for typechecking Duration objects + moment.isDuration = function (obj) { + return obj instanceof Duration; + }; + + + /************************************ + Moment Prototype + ************************************/ + + + moment.fn = Moment.prototype = { + + clone : function () { + return moment(this); + }, + + valueOf : function () { + return +this._d; + }, + + unix : function () { + return Math.floor(+this._d / 1000); + }, + + toString : function () { + return this.format("ddd MMM DD YYYY HH:mm:ss [GMT]ZZ"); + }, + + toDate : function () { + return this._d; + }, + + toJSON : function () { + return moment.utc(this).format('YYYY-MM-DD[T]HH:mm:ss.SSS[Z]'); + }, + + toArray : function () { + var m = this; + return [ + m.year(), + m.month(), + m.date(), + m.hours(), + m.minutes(), + m.seconds(), + m.milliseconds() + ]; + }, + + isValid : function () { + if (this._isValid == null) { + if (this._a) { + this._isValid = !compareArrays(this._a, (this._isUTC ? moment.utc(this._a) : moment(this._a)).toArray()); + } else { + this._isValid = !isNaN(this._d.getTime()); + } + } + return !!this._isValid; + }, + + utc : function () { + this._isUTC = true; + return this; + }, + + local : function () { + this._isUTC = false; + return this; + }, + + format : function (inputString) { + var output = formatMoment(this, inputString || moment.defaultFormat); + return this.lang().postformat(output); + }, + + add : function (input, val) { + var dur; + // switch args to support add('s', 1) and add(1, 's') + if (typeof input === 'string') { + dur = moment.duration(+val, input); + } else { + dur = moment.duration(input, val); + } + addOrSubtractDurationFromMoment(this, dur, 1); + return this; + }, + + subtract : function (input, val) { + var dur; + // switch args to support subtract('s', 1) and subtract(1, 's') + if (typeof input === 'string') { + dur = moment.duration(+val, input); + } else { + dur = moment.duration(input, val); + } + addOrSubtractDurationFromMoment(this, dur, -1); + return this; + }, + + diff : function (input, units, asFloat) { + var that = this._isUTC ? moment(input).utc() : moment(input).local(), + zoneDiff = (this.zone() - that.zone()) * 6e4, + diff, output; + + if (units) { + // standardize on singular form + units = units.replace(/s$/, ''); + } + + if (units === 'year' || units === 'month') { + diff = (this.daysInMonth() + that.daysInMonth()) * 432e5; // 24 * 60 * 60 * 1000 / 2 + output = ((this.year() - that.year()) * 12) + (this.month() - that.month()); + output += ((this - moment(this).startOf('month')) - (that - moment(that).startOf('month'))) / diff; + if (units === 'year') { + output = output / 12; + } + } else { + diff = (this - that) - zoneDiff; + output = units === 'second' ? diff / 1e3 : // 1000 + units === 'minute' ? diff / 6e4 : // 1000 * 60 + units === 'hour' ? diff / 36e5 : // 1000 * 60 * 60 + units === 'day' ? diff / 864e5 : // 1000 * 60 * 60 * 24 + units === 'week' ? diff / 6048e5 : // 1000 * 60 * 60 * 24 * 7 + diff; + } + return asFloat ? output : absRound(output); + }, + + from : function (time, withoutSuffix) { + return moment.duration(this.diff(time)).lang(this.lang()._abbr).humanize(!withoutSuffix); + }, + + fromNow : function (withoutSuffix) { + return this.from(moment(), withoutSuffix); + }, + + calendar : function () { + var diff = this.diff(moment().startOf('day'), 'days', true), + format = diff < -6 ? 'sameElse' : + diff < -1 ? 'lastWeek' : + diff < 0 ? 'lastDay' : + diff < 1 ? 'sameDay' : + diff < 2 ? 'nextDay' : + diff < 7 ? 'nextWeek' : 'sameElse'; + return this.format(this.lang().calendar(format, this)); + }, + + isLeapYear : function () { + var year = this.year(); + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + }, + + isDST : function () { + return (this.zone() < moment([this.year()]).zone() || + this.zone() < moment([this.year(), 5]).zone()); + }, + + day : function (input) { + var day = this._isUTC ? this._d.getUTCDay() : this._d.getDay(); + return input == null ? day : + this.add({ d : input - day }); + }, + + startOf: function (units) { + units = units.replace(/s$/, ''); + // the following switch intentionally omits break keywords + // to utilize falling through the cases. + switch (units) { + case 'year': + this.month(0); + /* falls through */ + case 'month': + this.date(1); + /* falls through */ + case 'week': + case 'day': + this.hours(0); + /* falls through */ + case 'hour': + this.minutes(0); + /* falls through */ + case 'minute': + this.seconds(0); + /* falls through */ + case 'second': + this.milliseconds(0); + /* falls through */ + } + + // weeks are a special case + if (units === 'week') { + this.day(0); + } + + return this; + }, + + endOf: function (units) { + return this.startOf(units).add(units.replace(/s?$/, 's'), 1).subtract('ms', 1); + }, + + isAfter: function (input, units) { + units = typeof units !== 'undefined' ? units : 'millisecond'; + return +this.clone().startOf(units) > +moment(input).startOf(units); + }, + + isBefore: function (input, units) { + units = typeof units !== 'undefined' ? units : 'millisecond'; + return +this.clone().startOf(units) < +moment(input).startOf(units); + }, + + isSame: function (input, units) { + units = typeof units !== 'undefined' ? units : 'millisecond'; + return +this.clone().startOf(units) === +moment(input).startOf(units); + }, + + zone : function () { + return this._isUTC ? 0 : this._d.getTimezoneOffset(); + }, + + daysInMonth : function () { + return moment.utc([this.year(), this.month() + 1, 0]).date(); + }, + + dayOfYear : function (input) { + var dayOfYear = round((moment(this).startOf('day') - moment(this).startOf('year')) / 864e5) + 1; + return input == null ? dayOfYear : this.add("d", (input - dayOfYear)); + }, + + isoWeek : function (input) { + var week = weekOfYear(this, 1, 4); + return input == null ? week : this.add("d", (input - week) * 7); + }, + + week : function (input) { + var week = this.lang().week(this); + return input == null ? week : this.add("d", (input - week) * 7); + }, + + // If passed a language key, it will set the language for this + // instance. Otherwise, it will return the language configuration + // variables for this instance. + lang : function (key) { + if (key === undefined) { + return this._lang; + } else { + this._lang = getLangDefinition(key); + return this; + } + } + }; + + // helper for adding shortcuts + function makeGetterAndSetter(name, key) { + moment.fn[name] = moment.fn[name + 's'] = function (input) { + var utc = this._isUTC ? 'UTC' : ''; + if (input != null) { + this._d['set' + utc + key](input); + return this; + } else { + return this._d['get' + utc + key](); + } + }; + } + + // loop through and add shortcuts (Month, Date, Hours, Minutes, Seconds, Milliseconds) + for (i = 0; i < proxyGettersAndSetters.length; i ++) { + makeGetterAndSetter(proxyGettersAndSetters[i].toLowerCase().replace(/s$/, ''), proxyGettersAndSetters[i]); + } + + // add shortcut for year (uses different syntax than the getter/setter 'year' == 'FullYear') + makeGetterAndSetter('year', 'FullYear'); + + // add plural methods + moment.fn.days = moment.fn.day; + moment.fn.weeks = moment.fn.week; + moment.fn.isoWeeks = moment.fn.isoWeek; + + /************************************ + Duration Prototype + ************************************/ + + + moment.duration.fn = Duration.prototype = { + weeks : function () { + return absRound(this.days() / 7); + }, + + valueOf : function () { + return this._milliseconds + + this._days * 864e5 + + this._months * 2592e6; + }, + + humanize : function (withSuffix) { + var difference = +this, + output = relativeTime(difference, !withSuffix, this.lang()); + + if (withSuffix) { + output = this.lang().pastFuture(difference, output); + } + + return this.lang().postformat(output); + }, + + lang : moment.fn.lang + }; + + function makeDurationGetter(name) { + moment.duration.fn[name] = function () { + return this._data[name]; + }; + } + + function makeDurationAsGetter(name, factor) { + moment.duration.fn['as' + name] = function () { + return +this / factor; + }; + } + + for (i in unitMillisecondFactors) { + if (unitMillisecondFactors.hasOwnProperty(i)) { + makeDurationAsGetter(i, unitMillisecondFactors[i]); + makeDurationGetter(i.toLowerCase()); + } + } + + makeDurationAsGetter('Weeks', 6048e5); + + + /************************************ + Default Lang + ************************************/ + + + // Set default language, other languages will inherit from English. + moment.lang('en', { + ordinal : function (number) { + var b = number % 10, + output = (~~ (number % 100 / 10) === 1) ? 'th' : + (b === 1) ? 'st' : + (b === 2) ? 'nd' : + (b === 3) ? 'rd' : 'th'; + return number + output; + } + }); + + + /************************************ + Exposing Moment + ************************************/ + + + // CommonJS module is defined + if (hasModule) { + module.exports = moment; + } + /*global ender:false */ + if (typeof ender === 'undefined') { + // here, `this` means `window` in the browser, or `global` on the server + // add `moment` as a global object via a string identifier, + // for Closure Compiler "advanced" mode + this['moment'] = moment; + } + /*global define:false */ + if (typeof define === "function" && define.amd) { + define("moment", [], function () { + return moment; + }); + } +}).call(this); diff --git a/puppetboard/static/js/tables.js b/puppetboard/static/js/tables.js new file mode 100644 index 0000000..4628d49 --- /dev/null +++ b/puppetboard/static/js/tables.js @@ -0,0 +1,40 @@ +// Generated by CoffeeScript 1.6.3 +(function() { + var $; + + $ = jQuery; + + $(function() {}); + + $('.nodes').tablesorter({ + headers: { + 3: { + sorter: false + } + }, + sortList: [[0, 0]] + }); + + $('.facts').tablesorter({ + sortList: [[0, 0]] + }); + + $('input.filter-table').parent('div').removeClass('hide'); + + $("input.filter-table").on("keyup", function(e) { + var ev, rex; + rex = new RegExp($(this).val(), "i"); + $(".searchable tr").hide(); + $(".searchable tr").filter(function() { + return rex.test($(this).text()); + }).show(); + if (e.keyCode === 27) { + $(e.currentTarget).val(""); + ev = $.Event("keyup"); + ev.keyCode = 13; + $(e.currentTarget).trigger(ev); + return e.currentTarget.blur(); + } + }); + +}).call(this); diff --git a/puppetboard/static/js/timestamps.js b/puppetboard/static/js/timestamps.js new file mode 100644 index 0000000..c682c09 --- /dev/null +++ b/puppetboard/static/js/timestamps.js @@ -0,0 +1,23 @@ +jQuery(function ($) { + var localise_timestamp = function(timestamp){ + if (timestamp === "None"){ + return ''; + }; + d = moment.utc(timestamp); + d.local(); + return d; + }; + + $("[rel=utctimestamp]").each( + function(index, timestamp){ + var tstamp = $(timestamp); + var tstring = tstamp.text().trim(); + var result = localise_timestamp(tstring); + if (result == '') { + tstamp.text('Unknown'); + } else { + tstamp.text(localise_timestamp(tstring).format('LLLL')); + }; + }); + +}); diff --git a/puppetboard/templates/400.html b/puppetboard/templates/400.html new file mode 100644 index 0000000..7763953 --- /dev/null +++ b/puppetboard/templates/400.html @@ -0,0 +1,11 @@ +{% extends 'layout.html' %} +{% block row_fluid %} +
+
+
+

Bad Request

+

The request sent to PuppetDB was invalid. This is usually caused by using an unsupported operator.

+
+
+
+{% endblock %} diff --git a/puppetboard/templates/404.html b/puppetboard/templates/404.html new file mode 100644 index 0000000..e45a201 --- /dev/null +++ b/puppetboard/templates/404.html @@ -0,0 +1,11 @@ +{% extends 'layout.html' %} +{% block row_fluid %} +
+
+
+

Not Found

+

What you were looking for could not be found in PuppetDB.

+
+
+
+{% endblock %} diff --git a/puppetboard/templates/412.html b/puppetboard/templates/412.html new file mode 100644 index 0000000..c9a4037 --- /dev/null +++ b/puppetboard/templates/412.html @@ -0,0 +1,11 @@ +{% extends 'layout.html' %} +{% block row_fluid %} +
+
+
+

Experimental Disabled

+

You're trying to access a feature restricted to PuppetDB's Experimental API but haven't configured Puppetboard to allow this.

+
+
+
+{% endblock %} diff --git a/puppetboard/templates/500.html b/puppetboard/templates/500.html new file mode 100644 index 0000000..b79f451 --- /dev/null +++ b/puppetboard/templates/500.html @@ -0,0 +1,16 @@ +{% extends 'layout.html' %} +{% block row_fluid %} +
+
+
+

Internal Server Error

+

This error usually occurs because: +

    +
  • We were unable to reach PuppetDB;
  • +
  • The query to be executed was malformed resulting in an incorrectly encoded request.
  • +

+

Please have a look at the log output for further information.

+
+
+
+{% endblock %} diff --git a/puppetboard/templates/_macros.html b/puppetboard/templates/_macros.html new file mode 100644 index 0000000..15990c2 --- /dev/null +++ b/puppetboard/templates/_macros.html @@ -0,0 +1,77 @@ +{% macro facts_table(facts, autofocus=False, condensed=False, show_node=False, margin_top=20, margin_bottom=20) -%} +
+ +
+ + + + + + + + + {% for fact in facts %} + + {% if show_node %} + + {% else %} + + {% endif %} + + + {% endfor %} + +
FactValue
{{fact.node}}{{fact.name}}{{fact.value}}
+{%- endmacro %} + +{% macro reports_table(reports, nodename, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True) -%} +
+ Only showing the last ten reports. +
+ + + + + + + {% if show_conf_col %} + + {% endif %} + {% if show_agent_col %} + + {% endif %} + {% if show_host_col %} + + {% endif %} + + + + {% for report in reports %} + {% if hash_truncate %} + {% set rep_hash = "%s…"|format(report.hash_[0:6])|safe %} + {% else %} + {% set rep_hash = report.hash_ %} + {% endif %} + {% if report.failed %} + + {% else %} + + {% endif %} + + + + + {% if show_conf_col %} + + {% endif %} + {% if show_agent_col %} + + {% endif %} + {% if show_host_col %} + + {% endif %} + + {% endfor %} + +
Start timeRun timeFull reportConfiguration versionAgent versionHostname
{{report.start}}{{report.run_time}}{{rep_hash}}{{report.version}}{{report.agent_version}}{{nodename}}
+{%- endmacro %} diff --git a/puppetboard/templates/fact.html b/puppetboard/templates/fact.html new file mode 100644 index 0000000..02df106 --- /dev/null +++ b/puppetboard/templates/fact.html @@ -0,0 +1,6 @@ +{% extends 'layout.html' %} +{% import '_macros.html' as macros %} +{% block content %} +

{{name}}

+{{macros.facts_table(facts, autofocus=True, show_node=True, margin_bottom=10)}} +{% endblock content %} diff --git a/puppetboard/templates/facts.html b/puppetboard/templates/facts.html new file mode 100644 index 0000000..44177ec --- /dev/null +++ b/puppetboard/templates/facts.html @@ -0,0 +1,16 @@ +{% extends 'layout.html' %} +{% block content %} +
+ +
+
+ {%- for key,facts_list in facts_dict %} + {{key}} +
    + {%- for fact in facts_list %} +
  • {{fact}}
  • + {%- endfor %} +
+ {% endfor %} +
+{% endblock content %} diff --git a/puppetboard/templates/index.html b/puppetboard/templates/index.html new file mode 100644 index 0000000..4e41277 --- /dev/null +++ b/puppetboard/templates/index.html @@ -0,0 +1,38 @@ +{% extends 'layout.html' %} +{% block row_fluid %} +
+
+ We need something fancy here. +
+
+
+
+
+
+

{{metrics['num_nodes']}}

+ Population +
+
+

{{metrics['num_resources']}}

+ Resources managed +
+
+

{{metrics['avg_resources_node']}}

+ Avg. resources/node +
+
+
+
+
+
+

{{metrics['mean_failed_commands']}}

+ Mean command failures +
+
+

{{metrics['mean_command_time']}}s

+ Mean command execution time +
+
+
+
+{% endblock row_fluid %} diff --git a/puppetboard/templates/layout.html b/puppetboard/templates/layout.html new file mode 100644 index 0000000..abad3af --- /dev/null +++ b/puppetboard/templates/layout.html @@ -0,0 +1,66 @@ + + + + + Puppetᴃoard + + + + + + + + {% block container %} +
+
+ {% block row_fluid %} +
+ {% block content %} {% endblock content %} +
+ {% endblock row_fluid %} +
+
+ {% endblock container %} + + + + + + + + + + {% block script %} {% endblock script %} + + diff --git a/puppetboard/templates/node.html b/puppetboard/templates/node.html new file mode 100644 index 0000000..bd7ef92 --- /dev/null +++ b/puppetboard/templates/node.html @@ -0,0 +1,46 @@ +{% extends 'layout.html' %} +{% import '_macros.html' as macros %} +{% block content %} +
+
+

Details

+ + + + + + + + + + + + + + + {% if config.PUPPETDB_EXPERIMENTAL %} + + + + + {% endif %} + +
Hostname{{node.name}}
Catalog compiled at{{node.catalog_timestamp}}
Facts retrieved at{{node.facts_timestamp}}
Report uploaded at{{node.report_timestamp}}
+
+ {% if config.PUPPETDB_EXPERIMENTAL %} +
+

Facts

+ {{macros.facts_table(facts, condensed=True, margin_top=10)}} +
+
+

Reports

+ {{ macros.reports_table(reports, node.name, condensed=True, hash_truncate=True, show_conf_col=False, show_agent_col=False, show_host_col=False)}} +
+ {% else %} +
+

Facts

+ {{macros.facts_table(facts, condensed=True, margin_top=10)}} +
+ {% endif %} +
+{% endblock content %} diff --git a/puppetboard/templates/nodes.html b/puppetboard/templates/nodes.html new file mode 100644 index 0000000..bb26e79 --- /dev/null +++ b/puppetboard/templates/nodes.html @@ -0,0 +1,50 @@ +{% extends 'layout.html' %} +{% block content %} +
+ PuppetDB currently only returns active nodes. +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{message}} +
+ {% endfor %} + {% endif %} + {% endwith %} +
+ +
+ + + + + + {% if config.PUPPETDB_EXPERIMENTAL %} + + + {% endif %} + + + + {% for node in nodes %} + + + + {% if config.PUPPETDB_EXPERIMENTAL %} + + + {% endif %} + + {% endfor %} + +
HostnameCatalog compiled atLast report 
{{node.name}}{{node.catalog_timestamp}} + {% if node.report_timestamp %} + {{ node.report_timestamp }} + {% else %} + + {% endif %} + + Reports +
+{% endblock content %} diff --git a/puppetboard/templates/query.html b/puppetboard/templates/query.html new file mode 100644 index 0000000..92cf5b2 --- /dev/null +++ b/puppetboard/templates/query.html @@ -0,0 +1,61 @@ +{% extends 'layout.html' %} +{% block row_fluid %} +
+
+ This is highly exeprimental and will likely set your server on fire. +
+
+
+
+
+

Compose

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{message}} +
+ {% endfor %} + {% endif %} + {% endwith %} +
+ {{ form.csrf_token }} +
+ {{form.query.label(class_="control-label")}} +
+ {{form.query(class_="input-block-level", autofocus="autofocus", rows=5, placeholder="\"=\", \"name\" \"hostname\"")}} + {% if form.query.errors %} + {% for error in form.query.errors %}{{error}}{% endfor %} + {% endif %} +
+
+
+ {{form.endpoints.label(class_="control-label")}} +
+ {% for subfield in form.endpoints %} + {{subfield.label(class_="radio inline")}} + {{subfield }} + {% endfor %} + {% if form.endpoints.errors %} + {% for error in form.endpoints.errors %}{{error}}{% endfor %} + {% endif %} +
+
+
+ + +
+ {{ form.hidden_tag() }} +
+
+
+ {% if result %} +
+
+

Result

+
{{ result|tojson|replace(", ", ",\n") }}
+
+
+ {% endif %} +
+{% endblock row_fluid %} diff --git a/puppetboard/templates/report.html b/puppetboard/templates/report.html new file mode 100644 index 0000000..a131dc5 --- /dev/null +++ b/puppetboard/templates/report.html @@ -0,0 +1,62 @@ +{% extends 'layout.html' %} +{% block content %} +

Summary

+ + + + + + + + + + + + + + + + + +
HostnameConfiguration versionStart timeEnd time
{{report.node}} + {{report.version}} + + {{report.start}} + + {{report.end}} +
+ +

Events

+ + + + + + + + + + + {% for event in events %} + {% if not event.failed and event.item['old'] != event.item['new'] %} + + {% elif event.failed %} + + {% endif %} + + + + + + {% endfor %} + +
ResourceStatusChanged FromChanged To
{{event.item['type']}}[{{event.item['title']}}]{{event.status}}{{event.item['old']}}{{event.item['new']}}
+ +{% endblock content %} +{% block script %} + +{% endblock script %} diff --git a/puppetboard/templates/reports.html b/puppetboard/templates/reports.html new file mode 100644 index 0000000..2d65c70 --- /dev/null +++ b/puppetboard/templates/reports.html @@ -0,0 +1,6 @@ +{% extends 'layout.html' %} +{% block content %} +
+ Pending #21600. You can access reports for a node or individual reports through the Nodes tab. +
+{% endblock content %} diff --git a/puppetboard/templates/reports_node.html b/puppetboard/templates/reports_node.html new file mode 100644 index 0000000..028911d --- /dev/null +++ b/puppetboard/templates/reports_node.html @@ -0,0 +1,5 @@ +{% extends 'layout.html' %} +{% import '_macros.html' as macros %} +{% block content %} +{{ macros.reports_table(reports, nodename, condensed=False, hash_truncate=False, show_conf_col=True, show_agent_col=True, show_host_col=True)}} +{% endblock content %} diff --git a/puppetboard/utils.py b/puppetboard/utils.py new file mode 100644 index 0000000..9ae5a8b --- /dev/null +++ b/puppetboard/utils.py @@ -0,0 +1,61 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from requests.exceptions import HTTPError, ConnectionError +from pypuppetdb.errors import EmptyResponseError, ExperimentalDisabledError + +from flask import abort, flash + +def get_or_abort(func, *args, **kwargs): + """Execute the function with its arguments and handle the possible + errors that might occur. + + In this case, if we get an exception we simply abort the request. + """ + try: + return func(*args, **kwargs) + except HTTPError, e: + abort(e.response.status_code) + except ConnectionError: + abort(500) + except ExperimentalDisabledError: + abort(412) + except EmptyResponseError: + abort(204) + + +def ten_reports(reports): + """Helper to yield the first then reports from the reports generator. + + This is an ugly solution at best... + """ + for count, report in enumerate(reports): + if count == 10: + raise StopIteration + yield report + + +def yield_or_stop(generator): + """Similar in intent to get_or_abort this helper will iterate over our + generators and handle certain errors. + + Since this is also used in streaming responses where we can't just abort + a request we always yield empty and then raise StopIteration. + """ + while True: + try: + yield next(generator) + except StopIteration: + raise + except ExperimentalDisabledError: + yield + raise StopIteration + except EmptyResponseError: + yield + raise StopIteration + except ConnectionError: + yield + raise StopIteration + except HTTPError: + yield + raise StopIteration diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3361b7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==0.10.1 +Flask-WTF==0.8.4 +pypuppetdb=0.0.1 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..adfa0a8 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,11 @@ +from __future__ import absolute_import + +import os +import sys + +me = os.path.dirname(os.path.abspath(__file__)) +# Add us to the PYTHONPATH/sys.path if we're not on it +if not me in sys.path: + sys.path.insert(0, me) + +from puppetboard.app import app as application