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