summaryrefslogtreecommitdiffstats
path: root/deluge/plugins/Stats/deluge_stats
diff options
context:
space:
mode:
Diffstat (limited to 'deluge/plugins/Stats/deluge_stats')
-rw-r--r--deluge/plugins/Stats/deluge_stats/__init__.py37
-rw-r--r--deluge/plugins/Stats/deluge_stats/common.py20
-rw-r--r--deluge/plugins/Stats/deluge_stats/core.py218
-rw-r--r--deluge/plugins/Stats/deluge_stats/data/config.ui284
-rw-r--r--deluge/plugins/Stats/deluge_stats/data/stats.js27
-rw-r--r--deluge/plugins/Stats/deluge_stats/data/tabs.ui169
-rw-r--r--deluge/plugins/Stats/deluge_stats/graph.py343
-rw-r--r--deluge/plugins/Stats/deluge_stats/gtkui.py296
-rw-r--r--deluge/plugins/Stats/deluge_stats/template/graph.html12
-rw-r--r--deluge/plugins/Stats/deluge_stats/tests/__init__.py0
-rw-r--r--deluge/plugins/Stats/deluge_stats/tests/test.html9
-rw-r--r--deluge/plugins/Stats/deluge_stats/tests/test_stats.py106
-rw-r--r--deluge/plugins/Stats/deluge_stats/webui.py32
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">&lt;b&gt;Connections Graph&lt;/b&gt;</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">&lt;b&gt;Bandwidth Graph&lt;/b&gt;</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">&lt;b&gt;Seeds / Peers&lt;/b&gt;</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">&lt;b&gt;Graph Colors&lt;/b&gt;</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!')