aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--rcynic/rcynic-html.py645
1 files changed, 645 insertions, 0 deletions
diff --git a/rcynic/rcynic-html.py b/rcynic/rcynic-html.py
new file mode 100644
index 00000000..0531c8c1
--- /dev/null
+++ b/rcynic/rcynic-html.py
@@ -0,0 +1,645 @@
+"""
+Render rcynic's XML output to basic (X)HTML with some rrdtool graphis.
+
+$Id$
+
+Copyright (C) 2010-2012 Internet Systems Consortium, Inc. ("ISC")
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH
+REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT,
+INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
+OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+PERFORMANCE OF THIS SOFTWARE.
+"""
+
+import sys
+import urlparse
+import os
+import getopt
+import time
+import subprocess
+import copy
+
+try:
+ from lxml.etree import (ElementTree, Element, SubElement, Comment)
+except ImportError:
+ from xml.etree.ElementTree import (ElementTree, Element, SubElement, Comment)
+
+session = None
+
+opt = {
+ "refresh" : 1800,
+ "show-detailed-status" : True,
+ "show-problems" : False,
+ "show-graphs" : True,
+ "update-rrds" : True,
+ "rrdtool-binary" : "rrdtool",
+ "suckerfish-javascript" : False,
+ "png-height" : 190,
+ "png-width" : 1350,
+ "svg-height" : 600,
+ "svg-width" : 1200 }
+
+def usage(msg = 0):
+ f = sys.stderr if msg else sys.stdout
+ f.write("Usage: %s %s [options] input_file output_directory\n" % (sys.executable, sys.argv[0]))
+ f.write("Options:\n")
+ for i in sorted(opt):
+ if "_" not in i and not isinstance(opt[i], bool):
+ f.write(" --%-30s (%s)\n" % (i + " <value>", opt[i]))
+ for i in sorted(opt):
+ if "_" not in i and isinstance(opt[i], bool):
+ f.write(" --[no-]%-25s (--%s%s)\n" % (i, "" if opt[i] else "no-", i))
+ if msg:
+ f.write("\n")
+ sys.exit(msg)
+
+def parse_options():
+
+ opts = ["help"]
+ for i in opt:
+ if isinstance(opt[i], bool):
+ opts.append(i)
+ opts.append("no-" + i)
+ else:
+ opts.append(i + "=")
+
+ try:
+ opts, argv = getopt.getopt(sys.argv[1:], "h?", opts)
+ for o, a in opts:
+ if o in ("-?", "-h", "--help"):
+ usage(0)
+ negated = o.startswith("--no-")
+ o = o[5:] if negated else o[2:]
+ if isinstance(opt[o], bool):
+ opt[o] = not negated
+ elif isinstance(opt[o], int):
+ opt[o] = int(a)
+ else:
+ opt[o] = a
+ except Exception, e:
+ usage("%s: %s" % (e.__class__.__name__, str(e)))
+
+ if len(argv) > 2:
+ usage("Unexpected arguments")
+
+ try:
+ opt["input_file"] = argv[0]
+ opt["output_directory"] = argv[1]
+ except IndexError:
+ usage("Missing required arguments")
+
+ if not os.path.isdir(opt["output_directory"]):
+ try:
+ os.makedirs(opt["output_directory"])
+ except OSError, e:
+ sys.exit("Couldn't create output directory: %s" % e)
+
+
+def parse_utc(s):
+ return int(time.mktime(time.strptime(s, "%Y-%m-%dT%H:%M:%SZ")))
+
+class Label(object):
+
+ def __init__(self, elt):
+ self.code = elt.tag
+ self.mood = elt.get("kind")
+ self.text = elt.text.strip()
+ self.count = 0
+
+ def get_count(self):
+ return self.count
+
+class Validation_Status(object):
+
+ def __init__(self, elt, label_map):
+ self.uri = elt.text.strip()
+ self.timestamp = elt.get("timestamp")
+ self.generation = elt.get("generation")
+ self.hostname = urlparse.urlparse(self.uri).hostname or None
+ self.fn2 = os.path.splitext(self.uri)[1] or None if self.generation else None
+ self.label = label_map[elt.get("status")]
+
+ @property
+ def code(self):
+ return self.label.code
+
+ @property
+ def mood(self):
+ return self.label.mood
+
+ @property
+ def accepted(self):
+ return self.label.code == "object_accepted"
+
+ @property
+ def rejected(self):
+ return self.label.code == "object_rejected"
+
+ @property
+ def is_current(self):
+ return self.generation == "current"
+
+ @property
+ def is_backup(self):
+ return self.generation == "backup"
+
+ @property
+ def is_problem(self):
+ return self.label.mood != "good"
+
+ @property
+ def is_connection_problem(self):
+ return self.label.mood != "good" and self.label.code.startswith("rsync_transfer_")
+
+ @property
+ def is_object_problem(self):
+ return self.label.mood != "good" and not self.label.code.startswith("rsync_transfer_")
+
+class Host(object):
+
+ def __init__(self, hostname, timestamp):
+ self.hostname = hostname
+ self.timestamp = timestamp
+ self.elapsed = 0
+ self.connections = 0
+ self.failures = 0
+ self.uris = set()
+ self.graph = None
+ self.counters = {}
+ self.totals = {}
+ self.validation_status = []
+
+ def add_connection(self, elt):
+ self.elapsed += parse_utc(elt.get("finished")) - parse_utc(elt.get("started"))
+ self.connections += 1
+ if elt.get("error") is not None:
+ self.failures += 1
+
+ def add_validation_status(self, v):
+ self.validation_status.append(v)
+ if v.generation == "current":
+ self.uris.add(v.uri)
+ self.counters[(v.fn2, v.generation, v.label)] = self.get_counter(v.fn2, v.generation, v.label) + 1
+ self.totals[v.label] = self.get_total(v.label) + 1
+ v.label.count += 1
+
+ def get_counter(self, fn2, generation, label):
+ return self.counters.get((fn2, generation, label), 0)
+
+ def get_total(self, label):
+ return self.totals.get(label, 0)
+
+ @property
+ def failed(self):
+ return 1 if self.failures > 0 else 0
+
+ @property
+ def objects(self):
+ return len(self.uris)
+
+ field_table = (("connections", "GAUGE"),
+ ("objects", "GAUGE"),
+ ("elapsed", "GAUGE"),
+ ("failed", "ABSOLUTE"))
+
+ rras = tuple("RRA:AVERAGE:0.5:%s:9600" % steps
+ for steps in (1, 4, 24))
+
+ @classmethod
+ def field_ds_specifiers(cls, heartbeat = 24 * 60 * 60, minimum = 0, maximum = "U"):
+ return ["DS:%s:%s:%s:%s:%s" % (field[0], field[1], heartbeat, minimum, maximum)
+ for field in cls.field_table]
+
+ @property
+ def field_values(self):
+ return tuple(str(getattr(self, field[0])) for field in self.field_table)
+
+ @classmethod
+ def field_defs(cls, filebase):
+ return ["DEF:%s=%s.rrd:%s:AVERAGE" % (field[0], filebase, field[0])
+ for field in cls.field_table]
+
+ graph_opts = (
+ "--vertical-label", "Sync time (seconds)",
+ "--right-axis-label", "Objects (count)",
+ "--lower-limit", "0",
+ "--right-axis", "1:0",
+ "--dynamic-labels",
+ "--full-size-mode" )
+
+ graph_cmds = (
+
+ # Split elapsed into separate data sets, so we can color
+ # differently to indicate how succesful transfer was. Intent is
+ # that exactly one of these be defined for every value in elapsed.
+
+ "CDEF:success=failed,UNKN,elapsed,IF",
+ "CDEF:failure=connections,1,EQ,failed,*,elapsed,UNKN,IF",
+ "CDEF:partial=connections,1,NE,failed,*,elapsed,UNKN,IF",
+
+ # Show connection timing first, as color-coded semi-transparent
+ # areas with opaque borders. Intent is to make the colors stand
+ # out, since they're a major health indicator. Transparency is
+ # handled via an alpha channel (fourth octet of color code). We
+ # draw this stuff first so that later lines can overwrite it.
+
+ "AREA:success#00FF0080:Sync time (success)",
+ "AREA:partial#FFA50080:Sync time (partial failure)",
+ "AREA:failure#FF000080:Sync time (total failure)",
+
+ "LINE1:success#00FF00", # Green
+ "LINE1:partial#FFA500", # Orange
+ "LINE1:failure#FF0000", # Red
+
+ # Now show object counts, as a simple black line.
+
+ "LINE1:objects#000000:Objects", # Black
+
+ # Add averages over period to chart legend.
+
+ "VDEF:avg_elapsed=elapsed,AVERAGE",
+ "VDEF:avg_connections=connections,AVERAGE",
+ "VDEF:avg_objects=objects,AVERAGE",
+ "COMMENT:\j",
+ "GPRINT:avg_elapsed:Average sync time (seconds)\: %5.2lf",
+ "GPRINT:avg_connections:Average connection count\: %5.2lf",
+ "GPRINT:avg_objects:Average object count\: %5.2lf" )
+
+ graph_periods = (("week", "-1w"),
+ ("month", "-31d"),
+ ("year", "-1y"))
+
+ def rrd_run(self, cmd):
+ try:
+ cmd = [str(i) for i in cmd]
+ cmd.insert(0, opt["rrdtool-binary"])
+ return subprocess.check_output(cmd).splitlines()
+ except OSError, e:
+ usage("Problem running %s, perhaps you need to set --rrdtool-binary? (%s)" % (
+ opt["rrdtool-binary"], e))
+
+ def rrd_update(self):
+ filename = os.path.join(opt["output_directory"], self.hostname) + ".rrd"
+ if not os.path.exists(filename):
+ cmd = ["create", filename, "--start", self.timestamp - 1, "--step", "3600"]
+ cmd.extend(self.field_ds_specifiers())
+ cmd.extend(self.rras)
+ self.rrd_run(cmd)
+ self.rrd_run(["update", filename,
+ "%s:%s" % (self.timestamp, ":".join(str(v) for v in self.field_values))])
+
+ def rrd_graph(self, html):
+ filebase = os.path.join(opt["output_directory"], self.hostname)
+ for period, start in self.graph_periods:
+ for format in ("png", "svg"):
+ cmds = [ "graph", "%s_%s.%s" % (filebase, period, format),
+ "--title", "%s last %s" % (self.hostname, period),
+ "--start", start,
+ "--width", opt[format + "-width"],
+ "--height", opt[format + "-height"],
+ "--imgformat", format.upper() ]
+ cmds.extend(self.graph_opts)
+ cmds.extend(self.field_defs(filebase))
+ cmds.extend(self.graph_cmds)
+ self.rrd_run(cmds)
+ img = Element("img", src = "%s_%s.png" % (filebase, period),
+ width = str(opt["png-width"]),
+ height = str(opt["png-height"]))
+ if self.graph is None:
+ self.graph = copy.copy(img)
+ html.BodyElement("h2").text = "%s over last %s" % (self.hostname, period)
+ html.BodyElement("a", href = "%s_%s.svg" % (filebase, period)).append(img)
+ html.BodyElement("br")
+
+class Session(object):
+
+ def __init__(self):
+ self.hosts = {}
+
+ if opt["input_file"] == "-":
+ self.root = ElementTree(file = sys.stdin).getroot()
+ else:
+ self.root = ElementTree(file = opt["input_file"]).getroot()
+
+ self.rcynic_version = self.root.get("rcynic-version")
+ self.rcynic_date = self.root.get("date")
+ self.timestamp = parse_utc(self.rcynic_date)
+
+ self.labels = [Label(elt) for elt in self.root.find("labels")]
+ self.load_validation_status()
+
+ for elt in self.root.findall("rsync_history"):
+ self.get_host(urlparse.urlparse(elt.text.strip()).hostname).add_connection(elt)
+
+ generations = set()
+ fn2s = set()
+
+ for v in self.validation_status:
+ self.get_host(v.hostname).add_validation_status(v)
+ generations.add(v.generation)
+ fn2s.add(v.fn2)
+
+ self.labels = [l for l in self.labels if l.count > 0]
+
+ self.hostnames = sorted(self.hosts)
+ self.generations = sorted(generations)
+ self.fn2s = sorted(fn2s)
+
+ def load_validation_status(self):
+ label_map = dict((label.code, label) for label in self.labels)
+ full_validation_status = [Validation_Status(elt, label_map)
+ for elt in self.root.findall("validation_status")]
+ accepted_current = set(v.uri for v in full_validation_status
+ if v.is_current and v.accepted)
+ self.validation_status = [v for v in full_validation_status
+ if not v.is_backup
+ or v.uri not in accepted_current]
+
+ def get_host(self, hostname):
+ if hostname not in self.hosts:
+ self.hosts[hostname] = Host(hostname, self.timestamp)
+ return self.hosts[hostname]
+
+ def get_sum(self, fn2, generation, label):
+ return sum(h.get_counter(fn2, generation, label)
+ for h in self.hosts.itervalues())
+
+ def rrd_update(self):
+ if opt["update-rrds"]:
+ for h in self.hosts.itervalues():
+ h.rrd_update()
+
+css = '''
+ /*
+ * Cascading style sheet for rcynic-html output. Much of this
+ * comes, indirectly, at a remove of many years from
+ * http://www.htmldog.com/articles/suckerfish/dropdowns/example/
+ */
+
+ th, td {
+ text-align: center; padding: 4px;
+ }
+
+ td.uri {
+ text-align: left;
+ }
+
+ thead tr th, tfoot tr td {
+ font-weight: bold;
+ }
+
+ .good {
+ background-color: #77ff77;
+ }
+
+ .warn {
+ background-color: yellow;
+ }
+
+ .bad {
+ background-color: #ff5500;
+ }
+
+ body {
+ font-family: arial, helvetica, serif;
+ }
+
+ #nav, #nav ul {
+ float: left;
+ width: 100%;
+ list-style: none;
+ line-height: 1;
+ background: white;
+ font-weight: normal;
+ padding: 0;
+ border: solid black;
+ border-width: 1px 0;
+ margin: 0 0 1em 0;
+ }
+
+ #nav a {
+ display: block;
+ color: black;
+ text-decoration: none;
+ padding: 0.25em 0.75em;
+ }
+
+ #nav li {
+ float: left;
+ padding: 0;
+ }
+
+ /*
+ * There's no useful way to set a width here, we have to set it as a style
+ * attribute in the <ul/> elements. CSS2, maybe, someday.
+ */
+ #nav li ul {
+ position: absolute;
+ left: -999em;
+ height: auto;
+ border-width: 1px;
+ margin: 0;
+ }
+
+ #nav li li {
+ width: 100%;
+ }
+
+ #nav li:hover ul ul, #nav li:hover ul ul ul, #nav li.sfhover ul ul, #nav li.sfhover ul ul ul {
+ left: -999em;
+ }
+
+ #nav li:hover ul, #nav li li:hover ul, #nav li li li:hover ul, #nav li.sfhover ul, #nav li li.sfhover ul, #nav li li li.sfhover ul {
+ left: auto;
+ }
+
+ #nav li:hover, #nav li.sfhover {
+ background: white;
+ }
+'''
+
+suckerfish = '''
+ // The amazing Suckerfish hack to let Internet Exploder use CSS dropdowns.
+ // See http://www.htmldog.com/articles/suckerfish/dropdowns/
+
+ sfHover = function() {
+ var sfEls = document.getElementById("nav").getElementsByTagName("li");
+ for (var i = 0; i < sfEls.length; i++) {
+ sfEls[i].onmouseover = function() {
+ this.className += " sfhover";
+ }
+ sfEls[i].onmouseout = function() {
+ this.className = this.className.replace(new RegExp(" sfhover\\b"), "");
+ }
+ }
+ }
+
+ if (window.attachEvent)
+ window.attachEvent("onload", sfHover);
+'''
+
+class HTML(object):
+
+ css_name = "rcynic-html.css"
+ suckerfish_name = "suckerfish.js"
+
+ @classmethod
+ def write_static_files(cls):
+ f = open(os.path.join(opt["output_directory"], cls.css_name), "w")
+ f.write(css)
+ f.close()
+ if opt["suckerfish-javascript"]:
+ f = open(os.path.join(opt["output_directory"], cls.suckerfish_name), "w")
+ f.write(suckerfish)
+ f.close()
+
+ def __init__(self, title, filebase):
+
+ self.filename = os.path.join(opt["output_directory"], filebase + ".html")
+
+ self.html = Element("html")
+ self.html.append(Comment(" Generators:\n" +
+ " " + session.rcynic_version + "\n" +
+ " $Id$\n"))
+ self.head = SubElement(self.html, "head")
+ self.body = SubElement(self.html, "body")
+
+ title += " " + session.rcynic_date
+ SubElement(self.head, "title").text = title
+ SubElement(self.body, "h1").text = title
+
+ if opt["refresh"]:
+ SubElement(self.head, "meta", { "http-equiv" : "Refresh", "content" : str(opt["refresh"]) })
+
+ hostwidth = max(len(hostname) for hostname in session.hostnames)
+
+ SubElement(self.head, "link", href = self.css_name, rel = "stylesheet", type = "text/css")
+ if opt["suckerfish-javascript"]:
+ SubElement(self.head, "script", src = self.suckerfish_name, type = "text/javascript")
+
+ toc = SubElement(self.body, "ul", id = "nav")
+ li = SubElement(toc, "li")
+ SubElement(li, "a", href = "index.html").text = "Repositories"
+ hul = SubElement(li, "ul", style = "width: %sem" % hostwidth)
+ for hostname in session.hostnames:
+ SubElement(SubElement(hul, "li"), "a", href = "%s.html" % hostname).text = hostname
+ SubElement(SubElement(toc, "li"), "a", href = "problems.html").text = "Problems"
+ SubElement(SubElement(toc, "li"), "a", href = "details.html").text = "Per-object details"
+ SubElement(self.body, "br")
+
+ def close(self):
+ ElementTree(element = self.html).write(self.filename)
+
+ def BodyElement(self, tag, **attrib):
+ return SubElement(self.body, tag, **attrib)
+
+ def counter_table(self, data_func, total_func):
+ table = self.BodyElement("table", rules = "all", border = "1")
+ thead = SubElement(table, "thead")
+ tfoot = SubElement(table, "tfoot")
+ tbody = SubElement(table, "tbody")
+ tr = SubElement(thead, "tr")
+ SubElement(tr, "th")
+ for label in session.labels:
+ SubElement(tr, "th").text = label.text
+ for fn2 in session.fn2s:
+ for generation in session.generations:
+ counters = [data_func(fn2, generation, label) for label in session.labels]
+ if sum(counters) > 0:
+ tr = SubElement(tbody, "tr")
+ SubElement(tr, "td").text = ((generation or "") + " " + (fn2 or "")).strip()
+ for label, count in zip(session.labels, counters):
+ td = SubElement(tr, "td")
+ if count > 0:
+ td.set("class", label.mood)
+ td.text = str(count)
+ tr = SubElement(tfoot, "tr")
+ SubElement(tr, "td").text = "Total"
+ counters = [total_func(label) for label in session.labels]
+ for label, count in zip(session.labels, counters):
+ td = SubElement(tr, "td")
+ if count > 0:
+ td.set("class", label.mood)
+ td.text = str(count)
+ return table
+
+ def detail_table(self, records):
+ if records:
+ table = self.BodyElement("table", rules = "all", border = "1")
+ thead = SubElement(table, "thead")
+ tbody = SubElement(table, "tbody")
+ tr = SubElement(thead, "tr")
+ SubElement(tr, "th").text = "Timestamp"
+ SubElement(tr, "th").text = "Generation"
+ SubElement(tr, "th").text = "Status"
+ SubElement(tr, "th").text = "URI"
+ for v in records:
+ tr = SubElement(tbody, "tr", { "class" : v.mood })
+ SubElement(tr, "td").text = v.timestamp
+ SubElement(tr, "td").text = v.generation
+ SubElement(tr, "td").text = v.label.text
+ SubElement(tr, "td", { "class" : "uri"}).text = v.uri
+ return table
+ else:
+ self.BodyElement("p").text = "None found"
+ return None
+
+def main():
+
+ global session
+
+ os.putenv("TZ", "UTC")
+ time.tzset()
+
+ parse_options()
+ HTML.write_static_files()
+
+ session = Session()
+ session.rrd_update()
+
+ for hostname in session.hostnames:
+ html = HTML("Host Details For %s" % hostname, hostname)
+ html.counter_table(session.hosts[hostname].get_counter, session.hosts[hostname].get_total)
+ if opt["show-graphs"]:
+ session.hosts[hostname].rrd_graph(html)
+ html.BodyElement("h2").text = "Connection Problems"
+ html.detail_table([v for v in session.hosts[hostname].validation_status if v.is_connection_problem])
+ html.BodyElement("h2").text = "Object Problems"
+ html.detail_table([v for v in session.hosts[hostname].validation_status if v.is_object_problem])
+ html.close()
+
+ html = HTML("rcynic Summary", "index")
+ html.BodyElement("h2").text = "Grand Totals"
+ html.counter_table(session.get_sum, Label.get_count)
+ for hostname in session.hostnames:
+ html.BodyElement("br")
+ html.BodyElement("hr")
+ html.BodyElement("br")
+ html.BodyElement("h2").text = "Overview For Repository %s" % hostname
+ html.counter_table(session.hosts[hostname].get_counter, session.hosts[hostname].get_total)
+ if opt["show-graphs"]:
+ html.BodyElement("br")
+ html.BodyElement("a", href = "%s.html" % hostname).append(session.hosts[hostname].graph)
+ html.close()
+
+ html = HTML("Problems", "problems")
+ html.BodyElement("h2").text = "Connection Problems"
+ html.detail_table([v for v in session.validation_status if v.is_connection_problem])
+ html.BodyElement("h2").text = "Object Problems"
+ html.detail_table([v for v in session.validation_status if v.is_object_problem])
+ html.close()
+
+ html = HTML("All Details", "details")
+ html.detail_table(session.validation_status)
+ html.close()
+
+
+if __name__ == "__main__":
+ main()