]> git.ipfire.org Git - collecty.git/blame - src/collecty/plugins/base.py
Add some magic to collecty that makes the graph templates smaller
[collecty.git] / src / collecty / plugins / base.py
CommitLineData
f37913e8 1#!/usr/bin/python3
eed405de
MT
2###############################################################################
3# #
4# collecty - A system statistics collection daemon for IPFire #
5# Copyright (C) 2012 IPFire development team #
6# #
7# This program is free software: you can redistribute it and/or modify #
8# it under the terms of the GNU General Public License as published by #
9# the Free Software Foundation, either version 3 of the License, or #
10# (at your option) any later version. #
11# #
12# This program is distributed in the hope that it will be useful, #
13# but WITHOUT ANY WARRANTY; without even the implied warranty of #
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
15# GNU General Public License for more details. #
16# #
17# You should have received a copy of the GNU General Public License #
18# along with this program. If not, see <http://www.gnu.org/licenses/>. #
19# #
20###############################################################################
21
72364063 22import datetime
4ac0cdf0 23import logging
965a9c51 24import math
4ac0cdf0 25import os
cd8bba0b 26import re
4ac0cdf0 27import rrdtool
c968f6d9 28import tempfile
49ce926e 29import threading
4ac0cdf0 30import time
f37913e8 31import unicodedata
4ac0cdf0 32
429ba506 33from .. import locales
4ac0cdf0 34from ..constants import *
eed405de
MT
35from ..i18n import _
36
cd8bba0b
MT
37DEF_MATCH = re.compile(r"C?DEF:([A-Za-z0-9_]+)=")
38
4be39bf9
MT
39class Timer(object):
40 def __init__(self, timeout, heartbeat=1):
41 self.timeout = timeout
42 self.heartbeat = heartbeat
43
e746a56e
MT
44 self.delay = 0
45
4be39bf9
MT
46 self.reset()
47
e746a56e 48 def reset(self, delay=0):
4be39bf9
MT
49 # Save start time.
50 self.start = time.time()
51
e746a56e
MT
52 self.delay = delay
53
4be39bf9
MT
54 # Has this timer been killed?
55 self.killed = False
56
57 @property
58 def elapsed(self):
e746a56e 59 return time.time() - self.start - self.delay
4be39bf9
MT
60
61 def cancel(self):
62 self.killed = True
63
64 def wait(self):
65 while self.elapsed < self.timeout and not self.killed:
66 time.sleep(self.heartbeat)
67
68 return self.elapsed > self.timeout
69
70
cb1ccb4f
MT
71class Environment(object):
72 """
73 Sets the correct environment for rrdtool to create
74 localised graphs and graphs in the correct timezone.
75 """
76 def __init__(self, timezone, locale):
77 # Build the new environment
78 self.new_environment = {
79 "TZ" : timezone or DEFAULT_TIMEZONE,
80 }
81
82 for k in ("LANG", "LC_ALL"):
83 self.new_environment[k] = locale or DEFAULT_LOCALE
84
85 def __enter__(self):
86 # Save the current environment
87 self.old_environment = {}
88 for k in self.new_environment:
89 self.old_environment[k] = os.environ.get(k, None)
90
91 # Apply the new one
92 os.environ.update(self.new_environment)
93
94 def __exit__(self, type, value, traceback):
95 # Roll back to the previous environment
96 for k, v in self.old_environment.items():
97 if v is None:
98 try:
99 del os.environ[k]
100 except KeyError:
101 pass
102 else:
103 os.environ[k] = v
104
105
f37913e8
MT
106class PluginRegistration(type):
107 plugins = {}
108
109 def __init__(plugin, name, bases, dict):
110 type.__init__(plugin, name, bases, dict)
111
112 # The main class from which is inherited is not registered
113 # as a plugin.
114 if name == "Plugin":
115 return
116
117 if not all((plugin.name, plugin.description)):
118 raise RuntimeError(_("Plugin is not properly configured: %s") % plugin)
119
120 PluginRegistration.plugins[plugin.name] = plugin
121
122
123def get():
124 """
125 Returns a list with all automatically registered plugins.
126 """
127 return PluginRegistration.plugins.values()
128
129class Plugin(object, metaclass=PluginRegistration):
4ac0cdf0
MT
130 # The name of this plugin.
131 name = None
132
133 # A description for this plugin.
134 description = None
135
b1ea4956
MT
136 # Templates which can be used to generate a graph out of
137 # the data from this data source.
138 templates = []
139
72364063
MT
140 # The default interval for all plugins
141 interval = 60
4ac0cdf0 142
eed405de 143 def __init__(self, collecty, **kwargs):
eed405de
MT
144 self.collecty = collecty
145
4ac0cdf0
MT
146 # Check if this plugin was configured correctly.
147 assert self.name, "Name of the plugin is not set: %s" % self.name
148 assert self.description, "Description of the plugin is not set: %s" % self.description
4ac0cdf0
MT
149
150 # Initialize the logger.
151 self.log = logging.getLogger("collecty.plugins.%s" % self.name)
152 self.log.propagate = 1
eed405de 153
eed405de
MT
154 self.data = []
155
269f74cd
MT
156 # Run some custom initialization.
157 self.init(**kwargs)
158
0ee0c42d 159 self.log.debug(_("Successfully initialized %s") % self.__class__.__name__)
4ac0cdf0 160
73db5226 161 @property
72364063 162 def path(self):
73db5226 163 """
72364063
MT
164 Returns the name of the sub directory in which all RRD files
165 for this plugin should be stored in.
73db5226
MT
166 """
167 return self.name
168
72364063
MT
169 ### Basic methods
170
171 def init(self, **kwargs):
4ac0cdf0 172 """
72364063 173 Do some custom initialization stuff here.
4ac0cdf0 174 """
72364063
MT
175 pass
176
177 def collect(self):
178 """
179 Gathers the statistical data, this plugin collects.
180 """
181 time_start = time.time()
182
183 # Run through all objects of this plugin and call the collect method.
184 for o in self.objects:
185 now = datetime.datetime.utcnow()
186 try:
187 result = o.collect()
f648421a 188
a9af411f 189 result = self._format_result(result)
72364063
MT
190 except:
191 self.log.warning(_("Unhandled exception in %s.collect()") % o, exc_info=True)
192 continue
193
194 if not result:
195 self.log.warning(_("Received empty result: %s") % o)
196 continue
197
198 self.log.debug(_("Collected %s: %s") % (o, result))
199
200 # Add the object to the write queue so that the data is written
201 # to the databases later.
202 self.collecty.write_queue.add(o, now, result)
203
204 # Returns the time this function took to complete.
49c1b8fd 205 delay = time.time() - time_start
72364063 206
49c1b8fd
MT
207 # Log some warning when a collect method takes too long to return some data
208 if delay >= 60:
209 self.log.warning(_("A worker thread was stalled for %.4fs") % delay)
4ac0cdf0 210
a9af411f
MT
211 @staticmethod
212 def _format_result(result):
213 if not isinstance(result, tuple) and not isinstance(result, list):
214 return result
215
216 # Replace all Nones by NaN
217 s = []
218
219 for e in result:
220 if e is None:
221 e = "NaN"
222
223 # Format as string
224 e = "%s" % e
225
226 s.append(e)
227
228 return ":".join(s)
229
c968f6d9
MT
230 def get_object(self, id):
231 for object in self.objects:
232 if not object.id == id:
233 continue
234
235 return object
236
429ba506 237 def get_template(self, template_name, object_id, locale=None, timezone=None):
c968f6d9
MT
238 for template in self.templates:
239 if not template.name == template_name:
240 continue
241
429ba506 242 return template(self, object_id, locale=locale, timezone=timezone)
c968f6d9 243
429ba506
MT
244 def generate_graph(self, template_name, object_id="default",
245 timezone=None, locale=None, **kwargs):
246 template = self.get_template(template_name, object_id=object_id,
247 timezone=timezone, locale=locale)
c968f6d9
MT
248 if not template:
249 raise RuntimeError("Could not find template %s" % template_name)
250
251 time_start = time.time()
252
0308c0f3 253 graph = template.generate_graph(**kwargs)
c968f6d9
MT
254
255 duration = time.time() - time_start
0ee0c42d 256 self.log.debug(_("Generated graph %s in %.1fms") \
c968f6d9
MT
257 % (template, duration * 1000))
258
259 return graph
260
a3864812
MT
261 def graph_info(self, template_name, object_id="default",
262 timezone=None, locale=None, **kwargs):
263 template = self.get_template(template_name, object_id=object_id,
264 timezone=timezone, locale=locale)
265 if not template:
266 raise RuntimeError("Could not find template %s" % template_name)
267
268 return template.graph_info()
269
8ee5a71a
MT
270 def last_update(self, object_id="default"):
271 object = self.get_object(object_id)
272 if not object:
273 raise RuntimeError("Could not find object %s" % object_id)
274
275 return object.last_update()
276
72364063
MT
277
278class Object(object):
279 # The schema of the RRD database.
280 rrd_schema = None
281
282 # RRA properties.
418174a4
MT
283 rra_types = ("AVERAGE", "MIN", "MAX")
284 rra_timespans = (
285 ("1m", "10d"),
286 ("1h", "18M"),
287 ("1d", "5y"),
288 )
72364063
MT
289
290 def __init__(self, plugin, *args, **kwargs):
291 self.plugin = plugin
292
293 # Indicates if this object has collected its data
294 self.collected = False
295
296 # Initialise this object
297 self.init(*args, **kwargs)
298
299 # Create the database file.
300 self.create()
301
302 def __repr__(self):
303 return "<%s>" % self.__class__.__name__
4ac0cdf0 304
965a9c51 305 @property
72364063
MT
306 def collecty(self):
307 return self.plugin.collecty
965a9c51 308
881751ed 309 @property
72364063
MT
310 def log(self):
311 return self.plugin.log
312
313 @property
314 def id(self):
315 """
316 Returns a UNIQUE identifier for this object. As this is incorporated
317 into the path of RRD file, it must only contain ASCII characters.
318 """
319 raise NotImplementedError
881751ed 320
4ac0cdf0
MT
321 @property
322 def file(self):
323 """
324 The absolute path to the RRD file of this plugin.
325 """
57797e47
MT
326 filename = self._normalise_filename("%s.rrd" % self.id)
327
328 return os.path.join(DATABASE_DIR, self.plugin.path, filename)
329
330 @staticmethod
331 def _normalise_filename(filename):
332 # Convert the filename into ASCII characters only
f37913e8 333 filename = unicodedata.normalize("NFKC", filename)
57797e47
MT
334
335 # Replace any spaces by dashes
336 filename = filename.replace(" ", "-")
337
338 return filename
72364063
MT
339
340 ### Basic methods
341
342 def init(self, *args, **kwargs):
343 """
344 Do some custom initialization stuff here.
345 """
346 pass
eed405de 347
4ac0cdf0
MT
348 def create(self):
349 """
350 Creates an empty RRD file with the desired data structures.
351 """
352 # Skip if the file does already exist.
353 if os.path.exists(self.file):
354 return
eed405de 355
4ac0cdf0
MT
356 dirname = os.path.dirname(self.file)
357 if not os.path.exists(dirname):
358 os.makedirs(dirname)
eed405de 359
965a9c51 360 # Create argument list.
ff0bbd88 361 args = self.get_rrd_schema()
965a9c51
MT
362
363 rrdtool.create(self.file, *args)
eed405de 364
4ac0cdf0 365 self.log.debug(_("Created RRD file %s.") % self.file)
dadb8fb0
MT
366 for arg in args:
367 self.log.debug(" %s" % arg)
eed405de 368
72364063
MT
369 def info(self):
370 return rrdtool.info(self.file)
371
8ee5a71a
MT
372 def last_update(self):
373 """
374 Returns a dictionary with the timestamp and
375 data set of the last database update.
376 """
377 return {
378 "dataset" : self.last_dataset,
379 "timestamp" : self.last_updated,
380 }
381
382 def _last_update(self):
383 return rrdtool.lastupdate(self.file)
384
385 @property
386 def last_updated(self):
387 """
388 Returns the timestamp when this database was last updated
389 """
390 lu = self._last_update()
391
392 if lu:
393 return lu.get("date")
394
395 @property
396 def last_dataset(self):
397 """
398 Returns the latest dataset in the database
399 """
400 lu = self._last_update()
401
402 if lu:
403 return lu.get("ds")
404
72364063
MT
405 @property
406 def stepsize(self):
407 return self.plugin.interval
408
409 @property
410 def heartbeat(self):
411 return self.stepsize * 2
412
965a9c51
MT
413 def get_rrd_schema(self):
414 schema = [
415 "--step", "%s" % self.stepsize,
416 ]
417 for line in self.rrd_schema:
418 if line.startswith("DS:"):
419 try:
420 (prefix, name, type, lower_limit, upper_limit) = line.split(":")
421
422 line = ":".join((
423 prefix,
424 name,
425 type,
881751ed 426 "%s" % self.heartbeat,
965a9c51
MT
427 lower_limit,
428 upper_limit
429 ))
430 except ValueError:
431 pass
432
433 schema.append(line)
434
435 xff = 0.1
436
418174a4
MT
437 for steps, rows in self.rra_timespans:
438 for type in self.rra_types:
439 schema.append("RRA:%s:%s:%s:%s" % (type, xff, steps, rows))
965a9c51
MT
440
441 return schema
442
cd8bba0b
MT
443 def make_rrd_defs(self, prefix=None):
444 defs = []
445
446 for line in self.rrd_schema:
447 (def_type, name, type, lower_limit, upper_limit) = line.split(":")
448
449 if prefix:
450 p = "%s_%s" % (prefix, name)
451 else:
452 p = name
453
454 defs += [
455 "DEF:%s=%s:%s:AVERAGE" % (p, self.file, name),
456 ]
457
458 return defs
459
72364063
MT
460 def execute(self):
461 if self.collected:
462 raise RuntimeError("This object has already collected its data")
eed405de 463
72364063
MT
464 self.collected = True
465 self.now = datetime.datetime.utcnow()
4ac0cdf0 466
72364063
MT
467 # Call the collect
468 result = self.collect()
4ac0cdf0 469
72364063 470 def commit(self):
4ac0cdf0 471 """
72364063 472 Will commit the collected data to the database.
4ac0cdf0 473 """
72364063 474 # Make sure that the RRD database has been created
dadb8fb0
MT
475 self.create()
476
ca9b9221
MT
477 # Write everything to disk that is in the write queue
478 self.collecty.write_queue.commit_file(self.file)
479
b1ea4956
MT
480
481class GraphTemplate(object):
482 # A unique name to identify this graph template.
483 name = None
484
f181246a
MT
485 # Headline of the graph image
486 graph_title = None
487
488 # Vertical label of the graph
489 graph_vertical_label = None
490
491 # Limits
492 lower_limit = None
493 upper_limit = None
494
b1ea4956
MT
495 # Instructions how to create the graph.
496 rrd_graph = None
497
498 # Extra arguments passed to rrdgraph.
499 rrd_graph_args = []
500
c968f6d9
MT
501 intervals = {
502 None : "-3h",
503 "hour" : "-1h",
504 "day" : "-25h",
59385e95 505 "month": "-30d",
c968f6d9
MT
506 "week" : "-360h",
507 "year" : "-365d",
508 }
509
510 # Default dimensions for this graph
511 height = GRAPH_DEFAULT_HEIGHT
512 width = GRAPH_DEFAULT_WIDTH
513
429ba506 514 def __init__(self, plugin, object_id, locale=None, timezone=None):
c968f6d9
MT
515 self.plugin = plugin
516
429ba506
MT
517 # Save localisation parameters
518 self.locale = locales.get(locale)
519 self.timezone = timezone
520
0308c0f3
MT
521 # Get all required RRD objects
522 self.object_id = object_id
523
524 # Get the main object
525 self.object = self.get_object(self.object_id)
526
c968f6d9
MT
527 def __repr__(self):
528 return "<%s>" % self.__class__.__name__
b1ea4956
MT
529
530 @property
531 def collecty(self):
c968f6d9 532 return self.plugin.collecty
b1ea4956 533
c968f6d9
MT
534 @property
535 def log(self):
536 return self.plugin.log
537
5913a52c 538 def _make_command_line(self, interval, format=DEFAULT_IMAGE_FORMAT,
edf268c7
MT
539 width=None, height=None, with_title=True, thumbnail=False):
540 args = [e for e in GRAPH_DEFAULT_ARGUMENTS]
c968f6d9 541
edf268c7
MT
542 # Set the default dimensions
543 default_height, default_width = GRAPH_DEFAULT_HEIGHT, GRAPH_DEFAULT_WIDTH
544
545 # A thumbnail doesn't have a legend and other labels
546 if thumbnail:
547 args.append("--only-graph")
548
549 default_height = THUMBNAIL_DEFAULT_HEIGHT
550 default_width = THUMBNAIL_DEFAULT_WIDTH
c968f6d9
MT
551
552 args += [
5913a52c 553 "--imgformat", format,
edf268c7
MT
554 "--height", "%s" % (height or default_height),
555 "--width", "%s" % (width or default_width),
73db5226 556 ]
eed405de 557
c968f6d9 558 args += self.rrd_graph_args
eed405de 559
f181246a 560 # Graph title
ca8a6cfa 561 if with_title and self.graph_title:
f181246a
MT
562 args += ["--title", self.graph_title]
563
564 # Vertical label
565 if self.graph_vertical_label:
566 args += ["--vertical-label", self.graph_vertical_label]
567
568 if self.lower_limit is not None or self.upper_limit is not None:
569 # Force to honour the set limits
570 args.append("--rigid")
571
572 if self.lower_limit is not None:
573 args += ["--lower-limit", self.lower_limit]
574
575 if self.upper_limit is not None:
576 args += ["--upper-limit", self.upper_limit]
577
b1ea4956 578 try:
c9351f4f 579 interval = self.intervals[interval]
b1ea4956 580 except KeyError:
c9351f4f
MT
581 interval = "end-%s" % interval
582
583 # Add interval
584 args += ["--start", interval]
c968f6d9
MT
585
586 return args
587
cd8bba0b
MT
588 def _add_vdefs(self, args):
589 ret = []
590
591 for arg in args:
592 ret.append(arg)
593
594 # Search for all DEFs and CDEFs
595 m = re.match(DEF_MATCH, "%s" % arg)
596 if m:
597 name = m.group(1)
598
599 # Add the VDEFs for minimum, maximum, etc. values
600 ret += [
601 "VDEF:%s_cur=%s,LAST" % (name, name),
602 "VDEF:%s_avg=%s,AVERAGE" % (name, name),
603 "VDEF:%s_max=%s,MAXIMUM" % (name, name),
604 "VDEF:%s_min=%s,MINIMUM" % (name, name),
605 ]
606
607 return ret
608
0308c0f3
MT
609 def get_object(self, *args, **kwargs):
610 return self.plugin.get_object(*args, **kwargs)
611
612 def get_object_table(self):
c968f6d9 613 return {
0308c0f3 614 "file" : self.object,
c968f6d9
MT
615 }
616
fc359f4d
MT
617 @property
618 def object_table(self):
619 if not hasattr(self, "_object_table"):
620 self._object_table = self.get_object_table()
621
622 return self._object_table
623
0308c0f3 624 def get_object_files(self):
c968f6d9
MT
625 files = {}
626
fc359f4d 627 for id, obj in self.object_table.items():
c968f6d9
MT
628 files[id] = obj.file
629
630 return files
631
429ba506 632 def generate_graph(self, interval=None, **kwargs):
ca9b9221
MT
633 # Make sure that all collected data is in the database
634 # to get a recent graph image
635 if self.object:
636 self.object.commit()
637
c968f6d9
MT
638 args = self._make_command_line(interval, **kwargs)
639
640 self.log.info(_("Generating graph %s") % self)
c968f6d9 641
cd8bba0b 642 #object_files = self.get_object_files()
eed405de 643
cd8bba0b
MT
644 if self.object:
645 args += self.object.make_rrd_defs()
eed405de 646
cd8bba0b
MT
647 args += self.rrd_graph
648 args = self._add_vdefs(args)
0308c0f3 649
699e99fb 650 # Convert arguments to string
5913a52c
MT
651 args = [str(e) for e in args]
652
cd8bba0b
MT
653 for arg in args:
654 self.log.debug(" %s" % arg)
655
429ba506 656 with Environment(self.timezone, self.locale.lang):
cb1ccb4f 657 graph = rrdtool.graphv("-", *args)
c968f6d9 658
a3864812
MT
659 return {
660 "image" : graph.get("image"),
661 "image_height" : graph.get("image_height"),
662 "image_width" : graph.get("image_width"),
663 }
664
665 def graph_info(self):
666 """
667 Returns a dictionary with useful information
668 about this graph.
669 """
670 return {
671 "title" : self.graph_title,
672 "object_id" : self.object_id or "",
673 "template" : self.name,
674 }