diff options
Diffstat (limited to 'deluge/plugins/Stats/deluge_stats')
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/__init__.py | 37 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/common.py | 20 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/core.py | 218 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/data/config.ui | 284 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/data/stats.js | 27 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/data/tabs.ui | 169 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/graph.py | 343 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/gtkui.py | 296 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/template/graph.html | 12 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/tests/__init__.py | 0 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/tests/test.html | 9 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/tests/test_stats.py | 106 | ||||
-rw-r--r-- | deluge/plugins/Stats/deluge_stats/webui.py | 32 |
13 files changed, 1553 insertions, 0 deletions
diff --git a/deluge/plugins/Stats/deluge_stats/__init__.py b/deluge/plugins/Stats/deluge_stats/__init__.py new file mode 100644 index 0000000..ca7b0bb --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/__init__.py @@ -0,0 +1,37 @@ +# +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# +# Basic plugin template created by: +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# Copyright (C) 2007-2009 Andrew Resch <andrewresch@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +from deluge.plugins.init import PluginInitBase + + +class CorePlugin(PluginInitBase): + def __init__(self, plugin_name): + from .core import Core as _pluginCls + + self._plugin_cls = _pluginCls + super().__init__(plugin_name) + + +class GtkUIPlugin(PluginInitBase): + def __init__(self, plugin_name): + from .gtkui import GtkUI as _pluginCls + + self._plugin_cls = _pluginCls + super().__init__(plugin_name) + + +class WebUIPlugin(PluginInitBase): + def __init__(self, plugin_name): + from .webui import WebUI as _pluginCls + + self._plugin_cls = _pluginCls + super().__init__(plugin_name) diff --git a/deluge/plugins/Stats/deluge_stats/common.py b/deluge/plugins/Stats/deluge_stats/common.py new file mode 100644 index 0000000..eb47f13 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/common.py @@ -0,0 +1,20 @@ +# +# Basic plugin template created by: +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# 2007-2009 Andrew Resch <andrewresch@gmail.com> +# 2009 Damien Churchill <damoxc@gmail.com> +# 2010 Pedro Algarvio <pedro@algarvio.me> +# 2017 Calum Lind <calumlind+deluge@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import os.path + +from pkg_resources import resource_filename + + +def get_resource(filename): + return resource_filename(__package__, os.path.join('data', filename)) diff --git a/deluge/plugins/Stats/deluge_stats/core.py b/deluge/plugins/Stats/deluge_stats/core.py new file mode 100644 index 0000000..1be51e6 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/core.py @@ -0,0 +1,218 @@ +# +# Copyright (C) 2009 Ian Martin <ianmartin@cantab.net> +# Copyright (C) 2008 Damien Churchill <damoxc@gmail.com> +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# Copyright (C) 2007 Marcos Mobley <markybob@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging +import time + +from twisted.internet.task import LoopingCall + +from deluge import component, configmanager +from deluge.core.rpcserver import export +from deluge.plugins.pluginbase import CorePluginBase + +DEFAULT_PREFS = { + 'test': 'NiNiNi', + 'update_interval': 1, # 2 seconds. + 'length': 150, # 2 seconds * 150 --> 5 minutes. +} + +DEFAULT_TOTALS = { + 'total_upload': 0, + 'total_download': 0, + 'total_payload_upload': 0, + 'total_payload_download': 0, + 'stats': {}, +} + +log = logging.getLogger(__name__) + + +def get_key(config, key): + try: + return config[key] + except KeyError: + return None + + +def mean(items): + try: + return sum(items) // len(items) + except Exception: + return 0 + + +class Core(CorePluginBase): + totals = {} # class var to catch only updating this once per session in enable. + + def enable(self): + log.debug('Stats plugin enabled') + self.core = component.get('Core') + self.stats = {} + self.count = {} + self.intervals = [1, 5, 30, 300] + + self.last_update = {} + t = time.time() + for i in self.intervals: + self.stats[i] = {} + self.last_update[i] = t + self.count[i] = 0 + + self.config = configmanager.ConfigManager('stats.conf', DEFAULT_PREFS) + self.saved_stats = configmanager.ConfigManager('stats.totals', DEFAULT_TOTALS) + if self.totals == {}: + self.totals.update(self.saved_stats.config) + + self.length = self.config['length'] + + # self.stats = get_key(self.saved_stats, 'stats') or {} + + # keys needed from core.get_session_status + self.stat_keys = [ + 'upload_rate', + 'download_rate', + 'dht.dht_nodes', + 'dht.dht_node_cache', + 'dht.dht_torrents', + 'peer.num_peers_connected', + 'peer.num_peers_half_open', + ] + # collected statistics and functions to get them + self.stat_getters = { + 'upload_rate': lambda s: s['upload_rate'], + 'download_rate': lambda s: s['download_rate'], + 'dht_nodes': lambda s: s['dht.dht_nodes'], + 'dht_cache_nodes': lambda s: s['dht.dht_node_cache'], + 'dht_torrents': lambda s: s['dht.dht_torrents'], + 'num_peers': lambda s: s['peer.num_peers_connected'], + 'num_connections': lambda s: s['peer.num_peers_connected'] + + s['peer.num_peers_half_open'], + } + + # initialize stats object + for key in self.stat_getters.keys(): + for i in self.intervals: + if key not in self.stats[i]: + self.stats[i][key] = [] + + self.update_stats() + + self.update_timer = LoopingCall(self.update_stats) + self.update_timer.start(self.config['update_interval']) + + self.save_timer = LoopingCall(self.save_stats) + self.save_timer.start(60) + + def disable(self): + self.update_timer.stop() if self.update_timer.running else None + self.save_timer.stop() if self.save_timer.running else None + self.save_stats() + + def update_stats(self): + # Get all possible stats! + stats = {} + raw_stats = self.core.get_session_status(self.stat_keys) + for name, fn in self.stat_getters.items(): + stats[name] = fn(raw_stats) + + # status = self.core.session.status() + # for stat in dir(status): + # if not stat.startswith('_') and stat not in stats: + # stats[stat] = getattr(status, stat, None) + + update_time = time.time() + self.last_update[1] = update_time + + # extract the ones we are interested in + # adding them to the 1s array + for stat, stat_list in self.stats[1].items(): + if stat in stats: + stat_list.insert(0, int(stats[stat])) + else: + stat_list.insert(0, 0) + if len(stat_list) > self.length: + stat_list.pop() + + def update_interval(interval, base, multiplier): + self.count[interval] = self.count[interval] + 1 + if self.count[interval] >= interval: + self.last_update[interval] = update_time + self.count[interval] = 0 + current_stats = self.stats[interval] + for stat, stat_list in self.stats[base].items(): + try: + avg = mean(stat_list[0:multiplier]) + except ValueError: + avg = 0 + current_stats[stat].insert(0, avg) + if len(current_stats[stat]) > self.length: + current_stats[stat].pop() + + update_interval(5, 1, 5) + update_interval(30, 5, 6) + update_interval(300, 30, 10) + + def save_stats(self): + self.saved_stats['stats'] = self.stats + self.saved_stats.config.update(self.get_totals()) + self.saved_stats.save() + + # export: + @export + def get_stats(self, keys, interval): + if interval not in self.intervals: + return None + + stats_dict = {} + for key in keys: + if key in self.stats[interval]: + stats_dict[key] = self.stats[interval][key] + + stats_dict['_last_update'] = self.last_update[interval] + stats_dict['_length'] = self.config['length'] + stats_dict['_update_interval'] = interval + return stats_dict + + @export + def get_totals(self): + result = {} + session_totals = self.get_session_totals() + for key in session_totals: + result[key] = self.totals[key] + session_totals[key] + return result + + @export + def get_session_totals(self): + return self.core.get_session_status( + [ + 'total_upload', + 'total_download', + 'total_payload_upload', + 'total_payload_download', + ] + ) + + @export + def set_config(self, config): + """Sets the config dictionary.""" + for key in config: + self.config[key] = config[key] + self.config.save() + + @export + def get_config(self): + """Returns the config dictionary.""" + return self.config.config + + @export + def get_intervals(self): + """Returns the available resolutions.""" + return self.intervals diff --git a/deluge/plugins/Stats/deluge_stats/data/config.ui b/deluge/plugins/Stats/deluge_stats/data/config.ui new file mode 100644 index 0000000..326598b --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/data/config.ui @@ -0,0 +1,284 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.22.1 --> +<interface> + <requires lib="gtk+" version="3.0"/> + <object class="GtkWindow" id="window1"> + <property name="can_focus">False</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkBox" id="prefs_box"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkFrame" id="frame1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label_xalign">0</property> + <property name="shadow_type">none</property> + <child> + <object class="GtkAlignment" id="alignment4"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="left_padding">15</property> + <child> + <object class="GtkTable" id="table2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="n_rows">10</property> + <property name="n_columns">2</property> + <property name="column_spacing">15</property> + <child> + <object class="GtkColorButton" id="bandwidth_graph_download_rate_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Download color:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">1</property> + <property name="bottom_attach">2</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Upload color:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + </packing> + </child> + <child> + <object class="GtkColorButton" id="bandwidth_graph_upload_rate_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">2</property> + <property name="bottom_attach">3</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label8"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes"><b>Connections Graph</b></property> + <property name="use_markup">True</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="right_attach">2</property> + <property name="top_attach">3</property> + <property name="bottom_attach">4</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label9"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes"><b>Bandwidth Graph</b></property> + <property name="use_markup">True</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="right_attach">2</property> + </packing> + </child> + <child> + <object class="GtkColorButton" id="connections_graph_dht_nodes_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">4</property> + <property name="bottom_attach">5</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label10"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">DHT nodes:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">4</property> + <property name="bottom_attach">5</property> + </packing> + </child> + <child> + <object class="GtkColorButton" id="connections_graph_dht_cache_nodes_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">5</property> + <property name="bottom_attach">6</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label11"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Cached DHT nodes:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">5</property> + <property name="bottom_attach">6</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label12"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">DHT torrents:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">6</property> + <property name="bottom_attach">7</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label13"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Connections:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">7</property> + <property name="bottom_attach">8</property> + </packing> + </child> + <child> + <object class="GtkColorButton" id="connections_graph_dht_torrents_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">6</property> + <property name="bottom_attach">7</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkColorButton" id="connections_graph_num_connections_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">7</property> + <property name="bottom_attach">8</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label16"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes"><b>Seeds / Peers</b></property> + <property name="use_markup">True</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="right_attach">2</property> + <property name="top_attach">8</property> + <property name="bottom_attach">9</property> + </packing> + </child> + <child> + <object class="GtkColorButton" id="seeds_graph_num_peers_color"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="receives_default">True</property> + <property name="color">#000000000000</property> + </object> + <packing> + <property name="left_attach">1</property> + <property name="right_attach">2</property> + <property name="top_attach">9</property> + <property name="bottom_attach">10</property> + <property name="x_options">GTK_EXPAND</property> + </packing> + </child> + <child> + <object class="GtkLabel" id="label17"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Peers:</property> + <property name="xalign">0</property> + </object> + <packing> + <property name="top_attach">9</property> + <property name="bottom_attach">10</property> + </packing> + </child> + </object> + </child> + </object> + </child> + <child type="label"> + <object class="GtkLabel" id="label3"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes"><b>Graph Colors</b></property> + <property name="use_markup">True</property> + </object> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + </object> + </child> + </object> +</interface> diff --git a/deluge/plugins/Stats/deluge_stats/data/stats.js b/deluge/plugins/Stats/deluge_stats/data/stats.js new file mode 100644 index 0000000..7ba3d27 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/data/stats.js @@ -0,0 +1,27 @@ +/** + * Script: stats.js + * The javascript client-side code for the Stats plugin. + * + * Copyright (c) Damien Churchill 2009-2010 <damoxc@gmail.com> + * + * This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with + * the additional special exception to link portions of this program with the OpenSSL library. + * See LICENSE for more details. + */ + +StatsPlugin = Ext.extend(Deluge.Plugin, { + constructor: function (config) { + config = Ext.apply( + { + name: 'Stats', + }, + config + ); + StatsPlugin.superclass.constructor.call(this, config); + }, + + onDisable: function () {}, + + onEnable: function () {}, +}); +new StatsPlugin(); diff --git a/deluge/plugins/Stats/deluge_stats/data/tabs.ui b/deluge/plugins/Stats/deluge_stats/data/tabs.ui new file mode 100644 index 0000000..4b35765 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/data/tabs.ui @@ -0,0 +1,169 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Generated with glade 3.22.1 --> +<interface> + <requires lib="gtk+" version="3.0"/> + <object class="GtkWindow" id="window1"> + <property name="can_focus">False</property> + <child> + <placeholder/> + </child> + <child> + <object class="GtkBox" id="vbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkBox" id="graph_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkLabel" id="graph_label_text"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Stats</property> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkScrolledWindow" id="graph_tab"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <child> + <object class="GtkViewport" id="viewport1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="resize_mode">queue</property> + <property name="shadow_type">none</property> + <child> + <object class="GtkBox" id="vbox2"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="orientation">vertical</property> + <child> + <object class="GtkBox" id="hbox1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <child> + <object class="GtkLabel" id="label1"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Resolution</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkComboBox" id="combo_intervals"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">1</property> + </packing> + </child> + </object> + <packing> + <property name="expand">False</property> + <property name="fill">False</property> + <property name="position">0</property> + </packing> + </child> + <child> + <object class="GtkNotebook" id="graph_notebook"> + <property name="visible">True</property> + <property name="can_focus">True</property> + <property name="tab_pos">left</property> + <child> + <object class="GtkDrawingArea" id="bandwidth_graph"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + </child> + <child type="tab"> + <object class="GtkLabel" id="bandwidth_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Bandwidth</property> + </object> + <packing> + <property name="tab_fill">False</property> + </packing> + </child> + <child> + <object class="GtkDrawingArea" id="connections_graph"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="position">1</property> + </packing> + </child> + <child type="tab"> + <object class="GtkLabel" id="connections_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Connections</property> + </object> + <packing> + <property name="position">1</property> + <property name="tab_fill">False</property> + </packing> + </child> + <child> + <object class="GtkDrawingArea" id="seeds_graph"> + <property name="visible">True</property> + <property name="can_focus">False</property> + </object> + <packing> + <property name="position">2</property> + </packing> + </child> + <child type="tab"> + <object class="GtkLabel" id="seeds_label"> + <property name="visible">True</property> + <property name="can_focus">False</property> + <property name="label" translatable="yes">Seeds/Peers</property> + </object> + <packing> + <property name="position">2</property> + <property name="tab_fill">False</property> + </packing> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + </child> + </object> + </child> + </object> + <packing> + <property name="expand">True</property> + <property name="fill">True</property> + <property name="position">1</property> + </packing> + </child> + </object> + </child> + </object> +</interface> diff --git a/deluge/plugins/Stats/deluge_stats/graph.py b/deluge/plugins/Stats/deluge_stats/graph.py new file mode 100644 index 0000000..ddb8f54 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/graph.py @@ -0,0 +1,343 @@ +# +# Copyright (C) 2009 Ian Martin <ianmartin@cantab.net> +# Copyright (C) 2008 Damien Churchill <damoxc@gmail.com> +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# Copyright (C) 2007 Marcos Mobley <markybob@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +""" +port of old plugin by markybob. +""" + +import logging +import math +import time + +import gi + +gi.require_foreign('cairo') + +import cairo # isort:skip (gi checks required before import). + +log = logging.getLogger(__name__) + +black = (0, 0, 0) +gray = (0.75, 0.75, 0.75) +white = (1.0, 1.0, 1.0) +darkred = (0.65, 0, 0) +red = (1.0, 0, 0) +green = (0, 1.0, 0) +blue = (0, 0, 1.0) +orange = (1.0, 0.74, 0) + + +def default_formatter(value): + return str(value) + + +def size_formatter_scale(value): + scale = 1.0 + for i in range(0, 3): + scale = scale * 1024.0 + if value // scale < 1024: + return scale + + +def change_opacity(color, opactiy): + """A method to assist in changing the opactiy of a color inorder to draw the + fills. + """ + color = list(color) + if len(color) == 4: + color[3] = opactiy + else: + color.append(opactiy) + return tuple(color) + + +class Graph: + def __init__(self): + self.width = 100 + self.height = 100 + self.length = 150 + self.stat_info = {} + self.line_size = 2 + self.dash_length = [10] + self.mean_selected = True + self.legend_selected = True + self.max_selected = True + self.black = (0, 0, 0) + self.interval = 2 # 2 secs + self.text_bg = (255, 255, 255, 128) # prototyping + self.set_left_axis() + + def set_left_axis(self, **kargs): + self.left_axis = kargs + + def add_stat(self, stat, label='', axis='left', line=True, fill=True, color=None): + self.stat_info[stat] = { + 'axis': axis, + 'label': label, + 'line': line, + 'fill': fill, + 'color': color, + } + + def set_stats(self, stats): + self.last_update = stats['_last_update'] + del stats['_last_update'] + self.length = stats['_length'] + del stats['_length'] + self.interval = stats['_update_interval'] + del stats['_update_interval'] + self.stats = stats + return + + # def set_config(self, config): + # self.length = config["length"] + # self.interval = config["update_interval"] + + def set_interval(self, interval): + self.interval = interval + + def draw_to_context(self, ctx, width, height): + self.width, self.height = width, height + self.draw_rect(ctx, white, 0, 0, self.width, self.height) + self.draw_graph(ctx) + + def draw(self, width, height): + """Create surface with context for use in tests""" + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height) + ctx = cairo.Context(surface) + self.draw_to_context(ctx, width, height) + return surface + + def draw_x_axis(self, ctx, bounds): + (left, top, right, bottom) = bounds + duration = self.length * self.interval + start = self.last_update - duration + ratio = (right - left) / duration + + if duration < 1800 * 10: + # try rounding to nearest 1min, 5mins, 10mins, 30mins + for step in [60, 300, 600, 1800]: + if duration // step < 10: + x_step = step + break + else: + # If there wasnt anything useful find a nice fitting hourly divisor + x_step = ((duration // 5) // 3600) * 3600 + + # this doesnt allow for dst and timezones... + seconds_to_step = math.ceil(start / x_step) * x_step - start + + for i in range(0, duration // x_step + 1): + text = time.strftime( + '%H:%M', time.localtime(start + seconds_to_step + i * x_step) + ) + # + 0.5 to allign x to nearest pixel + x = int(ratio * (seconds_to_step + i * x_step) + left) + 0.5 + self.draw_x_text(ctx, text, x, bottom) + self.draw_dotted_line(ctx, gray, x, top - 0.5, x, bottom + 0.5) + + self.draw_line(ctx, gray, left, bottom + 0.5, right, bottom + 0.5) + + def draw_graph(self, ctx): + font_extents = ctx.font_extents() + x_axis_space = font_extents[2] + 2 + self.line_size / 2 + plot_height = self.height - x_axis_space + # lets say we need 2n-1*font height pixels to plot the y ticks + tick_limit = plot_height / font_extents[3] + + max_value = 0 + for stat in self.stat_info: + if self.stat_info[stat]['axis'] == 'left': + try: + l_max = max(self.stats[stat]) + except ValueError: + l_max = 0 + if l_max > max_value: + max_value = l_max + if max_value < self.left_axis['min']: + max_value = self.left_axis['min'] + + y_ticks = self.intervalise(max_value, tick_limit) + max_value = y_ticks[-1] + # find the width of the y_ticks + y_tick_text = [self.left_axis['formatter'](tick) for tick in y_ticks] + + def space_required(ctx, text): + te = ctx.text_extents(text) + return math.ceil(te[4] - te[0]) + + y_tick_width = max(space_required(ctx, text) for text in y_tick_text) + + top = font_extents[2] / 2 + # bounds(left, top, right, bottom) + bounds = (y_tick_width + 4, top + 2, self.width, self.height - x_axis_space) + + self.draw_x_axis(ctx, bounds) + self.draw_left_axis(ctx, bounds, y_ticks, y_tick_text) + + def intervalise(self, x, limit=None): + """Given a value x create an array of tick points to got with the graph + The number of ticks returned can be constrained by limit, minimum of 3 + """ + # Limit is the number of ticks which is 1 + the number of steps as we + # count the 0 tick in limit + if limit is not None: + if limit < 3: + limit = 2 + else: + limit = limit - 1 + scale = 1 + if 'formatter_scale' in self.left_axis: + scale = self.left_axis['formatter_scale'](x) + x = x / scale + + # Find the largest power of 10 less than x + comm_log = math.log10(x) + intbit = math.floor(comm_log) + + interval = math.pow(10, intbit) + steps = int(math.ceil(x / interval)) + + if steps <= 1 and (limit is None or limit >= 10 * steps): + interval = interval * 0.1 + steps = steps * 10 + elif steps <= 2 and (limit is None or limit >= 5 * steps): + interval = interval * 0.2 + steps = steps * 5 + elif steps <= 5 and (limit is None or limit >= 2 * steps): + interval = interval * 0.5 + steps = steps * 2 + + if limit is not None and steps > limit: + multi = steps / limit + if multi > 2: + interval = interval * 5 + else: + interval = interval * 2 + + intervals = [ + i * interval * scale for i in range(1 + int(math.ceil(x / interval))) + ] + return intervals + + def draw_left_axis(self, ctx, bounds, y_ticks, y_tick_text): + (left, top, right, bottom) = bounds + stats = {} + for stat in self.stat_info: + if self.stat_info[stat]['axis'] == 'left': + stats[stat] = self.stat_info[stat] + stats[stat]['values'] = self.stats[stat] + stats[stat]['fill_color'] = change_opacity(stats[stat]['color'], 0.5) + stats[stat]['color'] = change_opacity(stats[stat]['color'], 0.8) + + height = bottom - top + max_value = y_ticks[-1] + ratio = height / max_value + + for i, y_val in enumerate(y_ticks): + y = int(bottom - y_val * ratio) - 0.5 + if i != 0: + self.draw_dotted_line(ctx, gray, left, y, right, y) + self.draw_y_text(ctx, y_tick_text[i], left, y) + self.draw_line(ctx, gray, left, top, left, bottom) + + for stat, info in stats.items(): + if len(info['values']) > 0: + self.draw_value_poly( + ctx, info['values'], info['color'], max_value, bounds + ) + self.draw_value_poly( + ctx, + info['values'], + info['fill_color'], + max_value, + bounds, + info['fill'], + ) + + def draw_legend(self): + pass + + def trace_path(self, ctx, values, max_value, bounds): + (left, top, right, bottom) = bounds + ratio = (bottom - top) / max_value + line_width = self.line_size + + ctx.set_line_width(line_width) + ctx.move_to(right, bottom) + + ctx.line_to(right, int(bottom - values[0] * ratio)) + + x = right + step = (right - left) / (self.length - 1) + for i, value in enumerate(values): + if i == self.length - 1: + x = left + + ctx.line_to(x, int(bottom - value * ratio)) + x -= step + + ctx.line_to(int(right - (len(values) - 1) * step), bottom) + ctx.close_path() + + def draw_value_poly(self, ctx, values, color, max_value, bounds, fill=False): + self.trace_path(ctx, values, max_value, bounds) + ctx.set_source_rgba(*color) + + if fill: + ctx.fill() + else: + ctx.stroke() + + def draw_x_text(self, ctx, text, x, y): + """Draws text below and horizontally centered about x,y""" + fe = ctx.font_extents() + te = ctx.text_extents(text) + height = fe[2] + x_bearing = te[0] + width = te[2] + ctx.move_to(int(x - width / 2 + x_bearing), int(y + height)) + ctx.set_source_rgba(*self.black) + ctx.show_text(text) + + def draw_y_text(self, ctx, text, x, y): + """Draws text left of and vertically centered about x,y""" + fe = ctx.font_extents() + te = ctx.text_extents(text) + descent = fe[1] + ascent = fe[0] + x_bearing = te[0] + width = te[4] + ctx.move_to(int(x - width - x_bearing - 2), int(y + (ascent - descent) / 2)) + ctx.set_source_rgba(*self.black) + ctx.show_text(text) + + def draw_rect(self, ctx, color, x, y, height, width): + ctx.set_source_rgba(*color) + ctx.rectangle(x, y, height, width) + ctx.fill() + + def draw_line(self, ctx, color, x1, y1, x2, y2): + ctx.set_source_rgba(*color) + ctx.set_line_width(1) + ctx.move_to(x1, y1) + ctx.line_to(x2, y2) + ctx.stroke() + + def draw_dotted_line(self, ctx, color, x1, y1, x2, y2): + ctx.set_source_rgba(*color) + ctx.set_line_width(1) + dash, offset = ctx.get_dash() + ctx.set_dash(self.dash_length, 0) + ctx.move_to(x1, y1) + ctx.line_to(x2, y2) + ctx.stroke() + ctx.set_dash(dash, offset) diff --git a/deluge/plugins/Stats/deluge_stats/gtkui.py b/deluge/plugins/Stats/deluge_stats/gtkui.py new file mode 100644 index 0000000..39c1d4c --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/gtkui.py @@ -0,0 +1,296 @@ +# +# Copyright (C) 2009 Ian Martin <ianmartin@cantab.net> +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# +# Basic plugin template created by: +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# Copyright (C) 2007-2008 Andrew Resch <andrewresch@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. + +import logging + +import gi # isort:skip (Required before Gtk import). + +gi.require_version('Gtk', '3.0') + +from gi.repository import Gtk +from gi.repository.Gdk import RGBA + +import deluge +from deluge import component +from deluge.common import fspeed +from deluge.plugins.pluginbase import Gtk3PluginBase +from deluge.ui.client import client +from deluge.ui.gtk3.torrentdetails import Tab + +from .common import get_resource +from .graph import Graph, size_formatter_scale + +log = logging.getLogger(__name__) + +# Gdk.RGBA textual spec +RED = 'rgb(255,0,0)' +GREEN = 'rgb(0,128,0)' +BLUE = 'rgb(0,0,255)' +DARKRED = 'rgb(139,0,0)' +ORANGE = 'rgb(255,165,0)' + +DEFAULT_CONF = { + 'version': 2, + 'colors': { + 'bandwidth_graph': {'upload_rate': BLUE, 'download_rate': GREEN}, + 'connections_graph': { + 'dht_nodes': ORANGE, + 'dht_cache_nodes': BLUE, + 'dht_torrents': GREEN, + 'num_connections': DARKRED, + }, + 'seeds_graph': {'num_peers': BLUE}, + }, +} + + +def neat_time(column, cell, model, data): + """Render seconds as seconds or minutes with label""" + seconds = model.get_value(data, 0) + if seconds > 60: + text = '%d %s' % (seconds // 60, _('minutes')) + elif seconds == 60: + text = _('1 minute') + elif seconds == 1: + text = _('1 second') + else: + text = '%d %s' % (seconds, _('seconds')) + cell.set_property('text', text) + return + + +def int_str(number): + return str(int(number)) + + +def fspeed_shortform(value): + return fspeed(value, shortform=True) + + +def text_to_rgba(color): + """Turns a Color into a tuple with range 0-1 as used by the graph""" + color_rgba = RGBA() + color_rgba.parse(color) + return color_rgba + + +class GraphsTab(Tab): + def __init__(self, colors): + super().__init__() + + builder = Gtk.Builder() + builder.add_from_file(get_resource('tabs.ui')) + self.window = builder.get_object('graph_tab') + self.notebook = builder.get_object('graph_notebook') + self.label = builder.get_object('graph_label') + + self._name = 'Stats' + self._child_widget = self.window + self._tab_label = self.label + + self.colors = colors + + self.bandwidth_graph = builder.get_object('bandwidth_graph') + self.bandwidth_graph.connect('draw', self.on_graph_draw) + + self.connections_graph = builder.get_object('connections_graph') + self.connections_graph.connect('draw', self.on_graph_draw) + + self.seeds_graph = builder.get_object('seeds_graph') + self.seeds_graph.connect('draw', self.on_graph_draw) + + self.notebook.connect('switch-page', self._on_notebook_switch_page) + + self.selected_interval = 1 # Should come from config or similar + self.select_bandwidth_graph() + + self.window.unparent() + self.label.unparent() + + self.intervals = None + self.intervals_combo = builder.get_object('combo_intervals') + cell = Gtk.CellRendererText() + self.intervals_combo.pack_start(cell, True) + self.intervals_combo.set_cell_data_func(cell, neat_time) + self.intervals_combo.connect('changed', self._on_selected_interval_changed) + self.update_intervals() + + def on_graph_draw(self, widget, context): + self.graph.draw_to_context( + context, + self.graph_widget.get_allocated_width(), + self.graph_widget.get_allocated_height(), + ) + # Do not propagate the event + return True + + def update(self): + d1 = client.stats.get_stats(list(self.graph.stat_info), self.selected_interval) + d1.addCallback(self.graph.set_stats) + + def _update_complete(result): + self.graph_widget.queue_draw() + return result + + d1.addCallback(_update_complete) + return d1 + + def clear(self): + pass + + def update_intervals(self): + client.stats.get_intervals().addCallback(self._on_intervals_changed) + + def select_bandwidth_graph(self): + log.debug('Selecting bandwidth graph') + self.graph_widget = self.bandwidth_graph + self.graph = Graph() + colors = self.colors['bandwidth_graph'] + self.graph.add_stat( + 'download_rate', + label='Download Rate', + color=text_to_rgba(colors['download_rate']), + ) + self.graph.add_stat( + 'upload_rate', + label='Upload Rate', + color=text_to_rgba(colors['upload_rate']), + ) + self.graph.set_left_axis( + formatter=fspeed_shortform, min=10240, formatter_scale=size_formatter_scale + ) + + def select_connections_graph(self): + log.debug('Selecting connections graph') + self.graph_widget = self.connections_graph + g = Graph() + self.graph = g + colors = self.colors['connections_graph'] + g.add_stat('dht_nodes', color=text_to_rgba(colors['dht_nodes'])) + g.add_stat('dht_cache_nodes', color=text_to_rgba(colors['dht_cache_nodes'])) + g.add_stat('dht_torrents', color=text_to_rgba(colors['dht_torrents'])) + g.add_stat('num_connections', color=text_to_rgba(colors['num_connections'])) + g.set_left_axis(formatter=int_str, min=10) + + def select_seeds_graph(self): + log.debug('Selecting connections graph') + self.graph_widget = self.seeds_graph + self.graph = Graph() + colors = self.colors['seeds_graph'] + self.graph.add_stat('num_peers', color=text_to_rgba(colors['num_peers'])) + self.graph.set_left_axis(formatter=int_str, min=10) + + def set_colors(self, colors): + self.colors = colors + # Fake switch page to update the graph colors (HACKY) + self._on_notebook_switch_page( + self.notebook, None, self.notebook.get_current_page() # This is unused + ) + + def _on_intervals_changed(self, intervals): + liststore = Gtk.ListStore(int) + for inter in intervals: + liststore.append([inter]) + self.intervals_combo.set_model(liststore) + try: + current = intervals.index(self.selected_interval) + except Exception: + current = 0 + # should select the value saved in config + self.intervals_combo.set_active(current) + + def _on_selected_interval_changed(self, combobox): + model = combobox.get_model() + tree_iter = combobox.get_active_iter() + self.selected_interval = model.get_value(tree_iter, 0) + self.update() + return True + + def _on_notebook_switch_page(self, notebook, page, page_num): + p = notebook.get_nth_page(page_num) + if p is self.bandwidth_graph: + self.select_bandwidth_graph() + self.update() + elif p is self.connections_graph: + self.select_connections_graph() + self.update() + elif p is self.seeds_graph: + self.select_seeds_graph() + self.update() + return True + + +class GtkUI(Gtk3PluginBase): + def enable(self): + log.debug('Stats plugin enable called') + self.config = deluge.configmanager.ConfigManager( + 'stats.gtk3ui.conf', DEFAULT_CONF + ) + + self.builder = Gtk.Builder() + self.builder.add_from_file(get_resource('config.ui')) + + component.get('Preferences').add_page( + 'Stats', self.builder.get_object('prefs_box') + ) + component.get('PluginManager').register_hook( + 'on_apply_prefs', self.on_apply_prefs + ) + component.get('PluginManager').register_hook( + 'on_show_prefs', self.on_show_prefs + ) + self.on_show_prefs() + + self.graphs_tab = GraphsTab(self.config['colors']) + self.torrent_details = component.get('TorrentDetails') + self.torrent_details.add_tab(self.graphs_tab) + + def disable(self): + component.get('Preferences').remove_page('Stats') + component.get('PluginManager').deregister_hook( + 'on_apply_prefs', self.on_apply_prefs + ) + component.get('PluginManager').deregister_hook( + 'on_show_prefs', self.on_show_prefs + ) + self.torrent_details.remove_tab(self.graphs_tab.get_name()) + + def on_apply_prefs(self): + log.debug('applying prefs for Stats') + gtkconf = {} + for graph, colors in self.config['colors'].items(): + gtkconf[graph] = {} + for value, color in colors.items(): + color_btn = self.builder.get_object(f'{graph}_{value}_color') + try: + gtkconf[graph][value] = color_btn.get_color().to_string() + except Exception: + gtkconf[graph][value] = DEFAULT_CONF['colors'][graph][value] + self.config['colors'] = gtkconf + self.graphs_tab.set_colors(self.config['colors']) + + config = {} + client.stats.set_config(config) + + def on_show_prefs(self): + for graph, colors in self.config['colors'].items(): + for value, color in colors.items(): + try: + color_btn = self.builder.get_object(f'{graph}_{value}_color') + color_btn.set_rgba(text_to_rgba(color)) + except Exception as ex: + log.debug('Unable to set %s %s %s: %s', graph, value, color, ex) + client.stats.get_config().addCallback(self.cb_get_config) + + def cb_get_config(self, config): + """Callback for on show_prefs.""" + pass diff --git a/deluge/plugins/Stats/deluge_stats/template/graph.html b/deluge/plugins/Stats/deluge_stats/template/graph.html new file mode 100644 index 0000000..2ff803b --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/template/graph.html @@ -0,0 +1,12 @@ +$:render.header(_("Network Graph"), 'graph') +$:render.admin_toolbar('graph') + +<div style="padding-left:20px"> + +<img src="$base/graph/network.png?height=300&width=1000"><br \> +<img src="$base/graph/connections.png?height=300&width=1000"><br \> +</div> + + + +$:render.footer() diff --git a/deluge/plugins/Stats/deluge_stats/tests/__init__.py b/deluge/plugins/Stats/deluge_stats/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/tests/__init__.py diff --git a/deluge/plugins/Stats/deluge_stats/tests/test.html b/deluge/plugins/Stats/deluge_stats/tests/test.html new file mode 100644 index 0000000..7af5f15 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/tests/test.html @@ -0,0 +1,9 @@ +<html> + <head> + <meta http-equiv="refresh" content="2" /> + </head> + <body> + <img src="output_async.png" /> <br /> + <img src="output_dht.png" /> + </body> +</html> diff --git a/deluge/plugins/Stats/deluge_stats/tests/test_stats.py b/deluge/plugins/Stats/deluge_stats/tests/test_stats.py new file mode 100644 index 0000000..d61cd46 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/tests/test_stats.py @@ -0,0 +1,106 @@ +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# +import pytest +import pytest_twisted +from twisted.internet import defer + +from deluge.common import fsize, fspeed +from deluge.ui.client import client + + +def print_totals(totals): + for name, value in totals.items(): + print(name, fsize(value)) + + print('overhead:') + print('up:', fsize(totals['total_upload'] - totals['total_payload_upload'])) + print('down:', fsize(totals['total_download'] - totals['total_payload_download'])) + + +class TestStatsPlugin: + @pytest_twisted.async_yield_fixture(autouse=True) + async def set_up(self, component): + defer.setDebugging(True) + client.start_standalone() + client.core.enable_plugin('Stats') + await component.start() + yield + client.stop_standalone() + + @defer.inlineCallbacks + def test_client_totals(self): + plugins = yield client.core.get_available_plugins() + if 'Stats' not in plugins: + pytest.skip('Stats plugin not available for testing') + + totals = yield client.stats.get_totals() + assert totals['total_upload'] == 0 + assert totals['total_payload_upload'] == 0 + assert totals['total_payload_download'] == 0 + assert totals['total_download'] == 0 + # print_totals(totals) + + @defer.inlineCallbacks + def test_session_totals(self): + plugins = yield client.core.get_available_plugins() + if 'Stats' not in plugins: + pytest.skip('Stats plugin not available for testing') + + totals = yield client.stats.get_session_totals() + assert totals['total_upload'] == 0 + assert totals['total_payload_upload'] == 0 + assert totals['total_payload_download'] == 0 + assert totals['total_download'] == 0 + # print_totals(totals) + + @pytest.mark.gtkui + @defer.inlineCallbacks + def test_write(self, tmp_path): + """ + writing to a file-like object; need this for webui. + + Not strictly a unit test, but tests if calls do not fail... + """ + from deluge_stats import graph, gtkui + + from deluge.configmanager import ConfigManager + from deluge.ui.gtk3.gtkui import DEFAULT_PREFS + from deluge.ui.gtk3.mainwindow import MainWindow + from deluge.ui.gtk3.pluginmanager import PluginManager + from deluge.ui.gtk3.preferences import Preferences + from deluge.ui.gtk3.torrentdetails import TorrentDetails + from deluge.ui.gtk3.torrentview import TorrentView + + ConfigManager('gtk3ui.conf', defaults=DEFAULT_PREFS) + + self.plugins = PluginManager() + MainWindow() + TorrentView() + TorrentDetails() + Preferences() + + class FakeFile: + def __init__(self): + self.data = [] + + def write(self, data): + self.data.append(data) + + stats_gtkui = gtkui.GtkUI('test_stats') + stats_gtkui.enable() + yield stats_gtkui.graphs_tab.update() + + g = stats_gtkui.graphs_tab.graph + g.add_stat('download_rate', color=graph.green) + g.add_stat('upload_rate', color=graph.blue) + g.set_left_axis(formatter=fspeed, min=10240) + + surface = g.draw(900, 150) + file_like = FakeFile() + surface.write_to_png(file_like) + data = b''.join(file_like.data) + with open(tmp_path / 'file_like.png', 'wb') as _file: + _file.write(data) diff --git a/deluge/plugins/Stats/deluge_stats/webui.py b/deluge/plugins/Stats/deluge_stats/webui.py new file mode 100644 index 0000000..2c2ed46 --- /dev/null +++ b/deluge/plugins/Stats/deluge_stats/webui.py @@ -0,0 +1,32 @@ +# +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# +# Basic plugin template created by: +# Copyright (C) 2008 Martijn Voncken <mvoncken@gmail.com> +# Copyright (C) 2007-2008 Andrew Resch <andrewresch@gmail.com> +# +# This file is part of Deluge and is licensed under GNU General Public License 3.0, or later, with +# the additional special exception to link portions of this program with the OpenSSL library. +# See LICENSE for more details. +# + +import logging + +from deluge.plugins.pluginbase import WebPluginBase + +from .common import get_resource + +log = logging.getLogger(__name__) + + +class WebUI(WebPluginBase): + scripts = [get_resource('stats.js')] + + # The enable and disable methods are not scrictly required on the WebUI + # plugins. They are only here if you need to register images/stylesheets + # with the webserver. + def enable(self): + log.debug('Stats Web plugin enabled!') + + def disable(self): + log.debug('Stats Web plugin disabled!') |