22
|
1 |
"""
|
|
2 |
Dynamic frontend
|
|
3 |
"""
|
|
4 |
|
|
5 |
import werkzeug
|
|
6 |
from werkzeug import exceptions
|
|
7 |
from werkzeug import Request, Response
|
|
8 |
from werkzeug.routing import Map, Rule
|
|
9 |
|
|
10 |
from rrdweb import html, graph
|
|
11 |
|
|
12 |
import os, os.path
|
|
13 |
import errno
|
|
14 |
import logging
|
|
15 |
|
|
16 |
|
|
17 |
# logging
|
|
18 |
log = logging.getLogger('rrdweb.wsgi')
|
|
19 |
|
|
20 |
|
|
21 |
class WSGIApp (object) :
|
|
22 |
def __init__ (self, rrdpath, tplpath, imgpath) :
|
|
23 |
"""
|
|
24 |
Configure
|
|
25 |
|
|
26 |
rrdpath - path to directory containing *.rrd files
|
|
27 |
tplpath - path to HTML templates
|
|
28 |
imgpath - path to generated PNG images. Must be writeable
|
|
29 |
"""
|
|
30 |
|
|
31 |
self.rrdpath = os.path.abspath(rrdpath)
|
|
32 |
self.templates = html.BaseFormatter(tplpath)
|
|
33 |
|
|
34 |
# XXX: some kind of fancy cache thingie :)
|
|
35 |
self.imgpath = os.path.abspath(imgpath)
|
|
36 |
|
|
37 |
|
|
38 |
# wrap to use werkzeug's Request/Response
|
|
39 |
@Request.application
|
|
40 |
def __call__ (self, req) :
|
|
41 |
"""
|
|
42 |
Main WSGI entry point
|
|
43 |
"""
|
|
44 |
|
|
45 |
try :
|
|
46 |
response = self.request(req)
|
|
47 |
|
|
48 |
except exceptions.HTTPException, e :
|
|
49 |
# format as response
|
|
50 |
return e.get_response(req.environ)
|
|
51 |
|
|
52 |
else :
|
|
53 |
# a-ok
|
|
54 |
return response
|
|
55 |
|
|
56 |
|
|
57 |
def request (self, req) :
|
|
58 |
"""
|
|
59 |
Wrapped request handler
|
|
60 |
"""
|
|
61 |
|
|
62 |
|
|
63 |
# map URLs against this request
|
|
64 |
urls = self.URLS.bind_to_environ(req)
|
|
65 |
|
|
66 |
# lookup URL against endpoint and dict of matched values from URL
|
|
67 |
endpoint, args = urls.match()
|
|
68 |
|
|
69 |
def build_url (method, **args) :
|
|
70 |
"""
|
|
71 |
Small wrapper around Werkzeug's routing.MapAdapter.build to suit our puroses
|
|
72 |
"""
|
|
73 |
|
|
74 |
return urls.build(method.im_func, args)
|
|
75 |
|
|
76 |
# invoke
|
|
77 |
# XXX: non-methods?
|
|
78 |
response = endpoint(self, req, build_url, **args)
|
|
79 |
|
|
80 |
return response
|
|
81 |
|
|
82 |
|
|
83 |
def scan_dir (self, dir) :
|
|
84 |
"""
|
|
85 |
Scan for RRD files and subdirectories directly underneath the given path.
|
|
86 |
|
|
87 |
Returns a ([subdir_name], [rrd_name]) tuple, with the sorted lists of subdirs and rrds.
|
|
88 |
"""
|
|
89 |
|
|
90 |
# we need to do this procedurally, because we collect two lists :(
|
|
91 |
subdirs = []
|
|
92 |
rrds = []
|
|
93 |
|
|
94 |
log.debug("Scanning dir %s", dir)
|
|
95 |
|
|
96 |
for name in os.listdir(dir) :
|
|
97 |
# skip hidden files
|
|
98 |
if name.startswith('.') :
|
|
99 |
continue
|
|
100 |
|
|
101 |
# path to file
|
|
102 |
path = os.path.join(dir, name)
|
|
103 |
|
|
104 |
# possible extesion
|
|
105 |
basename, extname = os.path.splitext(name)
|
|
106 |
|
|
107 |
log.debug("\tname=%s - %s", name, extname)
|
|
108 |
|
|
109 |
# collect subdirs
|
|
110 |
if os.path.isdir(path) :
|
|
111 |
subdirs.append(name)
|
|
112 |
|
|
113 |
# log.debug("\tsubdir: %s", name)
|
|
114 |
|
|
115 |
# collect .rrd's
|
|
116 |
elif extname == '.rrd' :
|
|
117 |
# without the .rrd
|
|
118 |
rrds.append(basename)
|
|
119 |
|
|
120 |
# log.debug("\trrd: %s", basename)
|
|
121 |
|
|
122 |
# return sorted lists
|
|
123 |
subdirs.sort()
|
|
124 |
rrds.sort()
|
|
125 |
|
|
126 |
return subdirs, rrds
|
|
127 |
|
|
128 |
def fmt_page (self, breadcrumb, content) :
|
|
129 |
"""
|
|
130 |
Render page with master layout as HTML.
|
|
131 |
|
|
132 |
breadcrumb - breadcrumb nav as HTML
|
|
133 |
content - main content as HTML
|
|
134 |
"""
|
|
135 |
|
|
136 |
log.debug("content = %r", content)
|
|
137 |
|
|
138 |
return self.templates.render('layout',
|
|
139 |
title = "MRTG", # XXX: some context
|
|
140 |
breadcrumb = breadcrumb,
|
|
141 |
content = content
|
|
142 |
)
|
|
143 |
|
|
144 |
def fmt_breadcrumb_segment (self, url, node, name) :
|
|
145 |
"""
|
|
146 |
Render the breadcrumb link for the given node as HTML.
|
|
147 |
"""
|
|
148 |
|
|
149 |
# real path
|
|
150 |
path = self.fs_path(node)
|
|
151 |
|
|
152 |
if os.path.isdir(path) :
|
|
153 |
# index
|
|
154 |
return dict(
|
|
155 |
url = url(self.index, dir=node),
|
|
156 |
name = name + '/',
|
|
157 |
)
|
|
158 |
|
|
159 |
else :
|
|
160 |
# XXX: assume .rrd
|
|
161 |
|
|
162 |
return dict(
|
|
163 |
url = url(self.target, rrd=node),
|
|
164 |
name = name,
|
|
165 |
)
|
|
166 |
|
|
167 |
def fmt_breadcrumb_segments (self, url, segments) :
|
|
168 |
"""
|
|
169 |
Render a sequence of segments for each of the nodes in the given list of segments.
|
|
170 |
"""
|
|
171 |
|
|
172 |
# root node
|
|
173 |
yield dict(
|
|
174 |
url = url(self.index),
|
|
175 |
name = "MRTG"
|
|
176 |
)
|
|
177 |
|
|
178 |
path = ''
|
|
179 |
|
|
180 |
for segment in segments :
|
|
181 |
# cumulative path
|
|
182 |
path = os.path.join(path, segment)
|
|
183 |
|
|
184 |
# format the induvidual node
|
|
185 |
yield self.fmt_breadcrumb_segment(url, path, segment)
|
|
186 |
|
|
187 |
|
|
188 |
def fmt_breadcrumb (self, url, node) :
|
|
189 |
"""
|
|
190 |
Render the breadcrumb for the given node's path as HTML.
|
|
191 |
"""
|
|
192 |
|
|
193 |
# split path to node into segments
|
|
194 |
segments = node.split('/')
|
|
195 |
|
|
196 |
log.debug("%r -> %r", node, segments)
|
|
197 |
|
|
198 |
# join segments together as hrefs
|
|
199 |
return " » ".join(
|
|
200 |
'<a href="%(url)s">%(name)s</a>' % html for html in self.fmt_breadcrumb_segments(url, segments)
|
|
201 |
)
|
|
202 |
|
|
203 |
|
|
204 |
def fmt_overview (self, url, dir, subdirs, rrds) :
|
|
205 |
"""
|
|
206 |
Render overview page listing given RRDs to HTML.
|
|
207 |
"""
|
|
208 |
|
|
209 |
if not dir :
|
|
210 |
dir = '/'
|
|
211 |
|
|
212 |
log.debug("Overview for %r with %d subdirs and %d rrds", dir, len(subdirs), len(rrds))
|
|
213 |
|
|
214 |
return self.templates.render('overview',
|
|
215 |
dir = dir,
|
|
216 |
overview_subdirs = '\n'.join(
|
|
217 |
self.fmt_overview_subdir(url, subdir, os.path.join(dir, subdir)) for subdir in subdirs
|
|
218 |
),
|
|
219 |
overview_graphs = '\n'.join(
|
|
220 |
self.fmt_overview_target(url, os.path.join(dir, rrd)) for rrd in rrds
|
|
221 |
),
|
|
222 |
)
|
|
223 |
|
|
224 |
|
|
225 |
def fmt_overview_subdir (self, url, subdir, dir) :
|
|
226 |
"""
|
|
227 |
Render overview item for given subdir to HTML.
|
|
228 |
"""
|
|
229 |
|
|
230 |
return self.templates.render('overview-subdir',
|
|
231 |
dir_url = url(self.index, dir=dir),
|
|
232 |
dir_name = subdir,
|
|
233 |
)
|
|
234 |
|
|
235 |
|
|
236 |
def fmt_overview_target (self, url, rrd) :
|
|
237 |
"""
|
|
238 |
Render overview item for given target to HTML.
|
|
239 |
"""
|
|
240 |
|
|
241 |
return self.templates.render('overview-target',
|
|
242 |
title = self.rrd_title(rrd),
|
|
243 |
target_url = url(self.target, rrd=rrd),
|
|
244 |
daily_overview_img = url(self.graph, rrd=rrd, style='overview'),
|
|
245 |
)
|
|
246 |
|
|
247 |
|
|
248 |
def fmt_target (self, url, rrd) :
|
|
249 |
"""
|
|
250 |
Render target overview page to HTML.
|
|
251 |
"""
|
|
252 |
|
|
253 |
return self.templates.render('target',
|
|
254 |
title = self.rrd_title(rrd),
|
|
255 |
daily_img = url(self.graph, rrd=rrd, style='detail', interval='daily'),
|
|
256 |
weekly_img = url(self.graph, rrd=rrd, style='detail', interval='weekly'),
|
|
257 |
yearly_img = url(self.graph, rrd=rrd, style='detail', interval='yearly'),
|
|
258 |
)
|
|
259 |
|
|
260 |
|
|
261 |
def fs_path (self, node) :
|
|
262 |
"""
|
|
263 |
Lookup and return the full filesystem path to the given relative RRD/dir path.
|
|
264 |
"""
|
|
265 |
|
|
266 |
# dir is relative (no leading slash)
|
|
267 |
# full path
|
|
268 |
path = os.path.normpath(os.path.join(self.rrdpath, node))
|
|
269 |
|
|
270 |
# check inside base path
|
|
271 |
if not path.startswith(self.rrdpath) :
|
|
272 |
# mask
|
|
273 |
raise exceptions.NotFound(node)
|
|
274 |
|
|
275 |
# ok
|
|
276 |
return path
|
|
277 |
|
|
278 |
def rrd_path (self, rrd) :
|
|
279 |
"""
|
|
280 |
Lookup and return the full filesystem path to the given RRD name.
|
|
281 |
"""
|
|
282 |
|
|
283 |
# real path
|
|
284 |
path = self.fs_path(rrd + '.rrd')
|
|
285 |
|
|
286 |
# found as file?
|
|
287 |
if not os.path.isfile(path) :
|
|
288 |
raise exceptions.NotFound("No such RRD file: %s" % (rrd, ))
|
|
289 |
|
|
290 |
return path
|
|
291 |
|
|
292 |
def rrd_title (self, rrd) :
|
|
293 |
"""
|
|
294 |
Generate a neat human-readable title from the given RRD name.
|
|
295 |
"""
|
|
296 |
|
|
297 |
# XXX: path components...
|
|
298 |
return " » ".join(rrd.split('/'))
|
|
299 |
|
|
300 |
def render_graph (self, rrd, style, interval, png_path) :
|
|
301 |
"""
|
|
302 |
Render the given graph for the given RRD to the given path.
|
|
303 |
"""
|
|
304 |
|
|
305 |
rrd_path = self.rrd_path(rrd)
|
|
306 |
|
|
307 |
# title
|
|
308 |
# this is »
|
|
309 |
title = " / ".join(rrd.split('/'))
|
|
310 |
|
|
311 |
log.debug("%s -> %s", rrd_path, png_path)
|
|
312 |
|
|
313 |
# XXX: always generate
|
|
314 |
graph.mrtg(style, interval, title, rrd_path, png_path)
|
|
315 |
|
|
316 |
def rrd_graph (self, rrd, style, interval, flush=False) :
|
|
317 |
"""
|
|
318 |
Return an open file object representing the given graph's PNG image.
|
|
319 |
|
|
320 |
This is returned directly from cache, if a fresh copy is available. The cached copy is compared against
|
|
321 |
the source RRD file.
|
|
322 |
"""
|
|
323 |
|
|
324 |
# real path to .rrd
|
|
325 |
rrd_path = self.rrd_path(rrd)
|
|
326 |
|
|
327 |
# path to cached img
|
|
328 |
img_path = os.path.join(self.imgpath, style, interval, rrd) + '.png'
|
|
329 |
|
|
330 |
# this should always exist..
|
|
331 |
rrd_stat = os.stat(rrd_path)
|
|
332 |
|
|
333 |
try :
|
|
334 |
# this may not exist
|
|
335 |
img_stat = os.stat(img_path)
|
|
336 |
|
|
337 |
except OSError, e :
|
|
338 |
if e.errno == errno.ENOENT :
|
|
339 |
# doesn't exist
|
|
340 |
img_stat = None
|
|
341 |
|
|
342 |
else :
|
|
343 |
# can't handle
|
|
344 |
raise
|
|
345 |
|
|
346 |
# check freshness
|
|
347 |
if flush or img_stat is None or rrd_stat.st_mtime > img_stat.st_mtime :
|
|
348 |
# generate containing dir if missiong
|
|
349 |
dir_path = os.path.dirname(img_path)
|
|
350 |
|
|
351 |
if not os.path.isdir(dir_path) :
|
|
352 |
log.warn("makedirs %s", dir_path)
|
|
353 |
|
|
354 |
os.makedirs(dir_path)
|
|
355 |
|
|
356 |
# re-generate to tmp file
|
|
357 |
tmp_path = os.path.join(self.imgpath, style, interval, rrd) + '.tmp'
|
|
358 |
|
|
359 |
self.render_graph(rrd, style, interval, tmp_path)
|
|
360 |
|
|
361 |
# replace .png with .tmp (semi-atomic, but atomic enough..)
|
|
362 |
os.rename(tmp_path, img_path)
|
|
363 |
|
|
364 |
# open the now-fresh .png and return that
|
|
365 |
return open(img_path)
|
|
366 |
|
|
367 |
|
|
368 |
|
|
369 |
### Request handlers
|
|
370 |
|
|
371 |
|
|
372 |
# def node (self, url, path) :
|
|
373 |
# """
|
|
374 |
# Arbitrate between URLs to dirs and to RRDs.
|
|
375 |
# """
|
|
376 |
#
|
|
377 |
|
|
378 |
|
|
379 |
def index (self, req, url, dir = '') :
|
|
380 |
"""
|
|
381 |
Directory overview
|
|
382 |
|
|
383 |
dir - (optional) relative path to subdir from base rrdpath
|
|
384 |
"""
|
|
385 |
|
|
386 |
# lookup fs path
|
|
387 |
path = self.fs_path(dir)
|
|
388 |
|
|
389 |
# found?
|
|
390 |
if not os.path.isdir(path) :
|
|
391 |
raise exceptions.NotFound("No such RRD directory: %s" % (dir, ))
|
|
392 |
|
|
393 |
# scan
|
|
394 |
subdirs, rrds = self.scan_dir(path)
|
|
395 |
|
|
396 |
# render
|
|
397 |
html = self.fmt_page(
|
|
398 |
self.fmt_breadcrumb(url, dir),
|
|
399 |
self.fmt_overview(url, dir, subdirs, rrds)
|
|
400 |
)
|
|
401 |
|
|
402 |
return Response(html, mimetype='text/html')
|
|
403 |
|
|
404 |
|
|
405 |
def target (self, req, url, rrd) :
|
|
406 |
"""
|
|
407 |
Target overview
|
|
408 |
|
|
409 |
"""
|
|
410 |
|
|
411 |
# verify existance
|
|
412 |
path = self.rrd_path(rrd)
|
|
413 |
|
|
414 |
# render
|
|
415 |
html = self.fmt_page(
|
|
416 |
self.fmt_breadcrumb(url, rrd),
|
|
417 |
self.fmt_target(url, rrd)
|
|
418 |
)
|
|
419 |
|
|
420 |
return Response(html, mimetype='text/html')
|
|
421 |
|
|
422 |
|
|
423 |
STYLES = graph.STYLE_DEFS.keys()
|
|
424 |
INTERVALS = graph.INTERVAL_DEFS.keys()
|
|
425 |
|
|
426 |
def graph (self, req, url, rrd, style, interval) :
|
|
427 |
"""
|
|
428 |
Target graph
|
|
429 |
"""
|
|
430 |
|
|
431 |
# validate style/interval
|
|
432 |
if style not in self.STYLES or interval not in self.INTERVALS :
|
|
433 |
raise exceptions.BadRequest("Invalid style/interval")
|
|
434 |
|
|
435 |
# flush if asked to by ?flush
|
|
436 |
flush = ('flush' in req.args)
|
|
437 |
|
|
438 |
# render
|
|
439 |
png = self.rrd_graph(rrd, style, interval, flush=flush)
|
|
440 |
|
|
441 |
# construct wrapper for response file, using either werkzeug's own wrapper, or the one provided by the WSGI server
|
|
442 |
response_file = werkzeug.wrap_file(req.environ, png)
|
|
443 |
|
|
444 |
# respond with file wrapper
|
|
445 |
return Response(response_file, mimetype='image/png', direct_passthrough=True)
|
|
446 |
|
|
447 |
|
|
448 |
# map URLs to various methods
|
|
449 |
# XXX: this uses the method object as the endpoint, which is a bit silly, since it's not bound and we need to pass
|
|
450 |
# in self explicitly..
|
|
451 |
URLS = Map((
|
|
452 |
Rule('/', endpoint=index, defaults=dict(dir = '')),
|
|
453 |
Rule('/<path:dir>/', endpoint=index),
|
|
454 |
Rule('/<path:rrd>.rrd', endpoint=target),
|
|
455 |
Rule('/<path:rrd>.rrd/<string:style>.png', endpoint=graph, defaults=dict(interval = 'daily')),
|
|
456 |
Rule('/<path:rrd>.rrd/<string:style>/<string:interval>.png', endpoint=graph),
|
|
457 |
))
|
|
458 |
|
|
459 |
|