[tor-commits] [arm/master] Merging graph module into single file

atagar at torproject.org atagar at torproject.org
Sun Oct 19 01:28:43 UTC 2014


commit f3d55aa694bffdbc1ce95046fea4d01e7e1917b1
Author: Damian Johnson <atagar at torproject.org>
Date:   Sun Oct 12 19:43:17 2014 -0700

    Merging graph module into single file
    
    Devious scheme is to rip this module a new one, simplifying it to the point
    that it can comfortably live in a single file. It's presently way on the long
    end of uncomfortable, but merging it will help push us along with this
    overhaul.
---
 arm/controller.py               |   11 +-
 arm/graph_panel.py              |  947 +++++++++++++++++++++++++++++++++++++++
 arm/graphing/__init__.py        |   10 -
 arm/graphing/bandwidth_stats.py |  285 ------------
 arm/graphing/conn_stats.py      |   59 ---
 arm/graphing/graph_panel.py     |  581 ------------------------
 arm/graphing/resource_stats.py  |   57 ---
 arm/menu/actions.py             |    4 +-
 8 files changed, 953 insertions(+), 1001 deletions(-)

diff --git a/arm/controller.py b/arm/controller.py
index 42d0531..1525412 100644
--- a/arm/controller.py
+++ b/arm/controller.py
@@ -15,10 +15,7 @@ import arm.header_panel
 import arm.log_panel
 import arm.config_panel
 import arm.torrc_panel
-import arm.graphing.graph_panel
-import arm.graphing.bandwidth_stats
-import arm.graphing.conn_stats
-import arm.graphing.resource_stats
+import arm.graph_panel
 import arm.connections.conn_panel
 import arm.util.tracker
 
@@ -26,9 +23,9 @@ import stem
 
 from stem.control import State
 
-from arm.util import msg, panel, tor_config, tor_controller, ui_tools
+from arm.util import panel, tor_config, tor_controller, ui_tools
 
-from stem.util import conf, enum, log, str_tools, system
+from stem.util import conf, log, system
 
 ARM_CONTROLLER = None
 
@@ -105,7 +102,7 @@ def init_controller(stdscr, start_time):
 
   # first page: graph and log
   if CONFIG["features.panels.show.graph"]:
-    first_page_panels.append(arm.graphing.graph_panel.GraphPanel(stdscr))
+    first_page_panels.append(arm.graph_panel.GraphPanel(stdscr))
 
   if CONFIG["features.panels.show.log"]:
     expanded_events = arm.arguments.expand_events(CONFIG["startup.events"])
diff --git a/arm/graph_panel.py b/arm/graph_panel.py
new file mode 100644
index 0000000..8f08fb3
--- /dev/null
+++ b/arm/graph_panel.py
@@ -0,0 +1,947 @@
+"""
+Flexible panel for presenting bar graphs for a variety of stats. This panel is
+just concerned with the rendering of information, which is actually collected
+and stored by implementations of the GraphStats interface. Panels are made up
+of a title, followed by headers and graphs for two sets of stats. For
+instance...
+
+Bandwidth (cap: 5 MB, burst: 10 MB):
+Downloaded (0.0 B/sec):           Uploaded (0.0 B/sec):
+  34                                30
+                            *                                 *
+                    **  *   *                          *      **
+      *   *  *      ** **   **          ***  **       ** **   **
+     *********      ******  ******     *********      ******  ******
+   0 ************ ****************   0 ************ ****************
+         25s  50   1m   1.6  2.0           25s  50   1m   1.6  2.0
+"""
+
+import copy
+import curses
+import time
+
+import arm.popups
+import arm.controller
+import arm.util.tracker
+
+import stem.control
+
+from arm.util import bandwidth_from_state, msg, panel, tor_controller
+
+from stem.control import Listener, State
+from stem.util import conf, enum, log, str_tools, system
+
+GraphStat = enum.Enum('BANDWIDTH', 'CONNECTIONS', 'SYSTEM_RESOURCES')
+
+# maps 'features.graph.type' config values to the initial types
+
+GRAPH_INIT_STATS = {1: GraphStat.BANDWIDTH, 2: GraphStat.CONNECTIONS, 3: GraphStat.SYSTEM_RESOURCES}
+
+DEFAULT_CONTENT_HEIGHT = 4  # space needed for labeling above and below the graph
+PRIMARY_COLOR, SECONDARY_COLOR = 'green', 'cyan'
+MIN_GRAPH_HEIGHT = 1
+
+# enums for graph bounds:
+#   Bounds.GLOBAL_MAX - global maximum (highest value ever seen)
+#   Bounds.LOCAL_MAX - local maximum (highest value currently on the graph)
+#   Bounds.TIGHT - local maximum and minimum
+
+Bounds = enum.Enum('GLOBAL_MAX', 'LOCAL_MAX', 'TIGHT')
+
+WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
+
+ACCOUNTING_RATE = 5
+
+
+def conf_handler(key, value):
+  if key == 'features.graph.height':
+    return max(MIN_GRAPH_HEIGHT, value)
+  elif key == 'features.graph.max_width':
+    return max(1, value)
+  elif key == 'features.graph.bound':
+    return max(0, min(2, value))
+
+
+# used for setting defaults when initializing GraphStats and GraphPanel instances
+
+CONFIG = conf.config_dict('arm', {
+  'attr.hibernate_color': {},
+  'attr.graph.intervals': {},
+  'features.graph.height': 7,
+  'features.graph.interval': 0,
+  'features.graph.bound': 1,
+  'features.graph.max_width': 150,
+  'features.graph.showIntermediateBounds': True,
+  'features.graph.type': 1,
+  'features.panels.show.connection': True,
+  'features.graph.bw.prepopulate': True,
+  'features.graph.bw.transferInBytes': False,
+  'features.graph.bw.accounting.show': True,
+  'tor.chroot': '',
+}, conf_handler)
+
+# width at which panel abandons placing optional stats (avg and total) with
+# header in favor of replacing the x-axis label
+
+COLLAPSE_WIDTH = 135
+
+
+class GraphStats:
+  """
+  Module that's expected to update dynamically and provide attributes to be
+  graphed. Up to two graphs (a 'primary' and 'secondary') can be displayed at a
+  time and timescale parameters use the labels defined in CONFIG['attr.graph.intervals'].
+  """
+
+  def __init__(self):
+    """
+    Initializes parameters needed to present a graph.
+    """
+
+    # panel to be redrawn when updated (set when added to GraphPanel)
+
+    self._graph_panel = None
+    self.is_selected = False
+    self.is_pause_buffer = False
+
+    # tracked stats
+
+    self.tick = 0                                    # number of processed events
+    self.last_primary, self.last_secondary = 0, 0    # most recent registered stats
+    self.primary_total, self.secondary_total = 0, 0  # sum of all stats seen
+
+    # timescale dependent stats
+
+    self.max_column = CONFIG['features.graph.max_width']
+    self.max_primary, self.max_secondary = {}, {}
+    self.primary_counts, self.secondary_counts = {}, {}
+
+    for i in range(len(CONFIG['attr.graph.intervals'])):
+      # recent rates for graph
+
+      self.max_primary[i] = 0
+      self.max_secondary[i] = 0
+
+      # historic stats for graph, first is accumulator
+      # iterative insert needed to avoid making shallow copies (nasty, nasty gotcha)
+
+      self.primary_counts[i] = (self.max_column + 1) * [0]
+      self.secondary_counts[i] = (self.max_column + 1) * [0]
+
+    # tracks BW events
+
+    tor_controller().add_event_listener(self.bandwidth_event, stem.control.EventType.BW)
+
+  def clone(self, new_copy=None):
+    """
+    Provides a deep copy of this instance.
+
+    Arguments:
+      new_copy - base instance to build copy off of
+    """
+
+    if not new_copy:
+      new_copy = GraphStats()
+
+    new_copy.tick = self.tick
+    new_copy.last_primary = self.last_primary
+    new_copy.last_secondary = self.last_secondary
+    new_copy.primary_total = self.primary_total
+    new_copy.secondary_total = self.secondary_total
+    new_copy.max_primary = dict(self.max_primary)
+    new_copy.max_secondary = dict(self.max_secondary)
+    new_copy.primary_counts = copy.deepcopy(self.primary_counts)
+    new_copy.secondary_counts = copy.deepcopy(self.secondary_counts)
+    new_copy.is_pause_buffer = True
+    return new_copy
+
+  def event_tick(self):
+    """
+    Called when it's time to process another event. All graphs use tor BW
+    events to keep in sync with each other (this happens once a second).
+    """
+
+    pass
+
+  def is_next_tick_redraw(self):
+    """
+    Provides true if the following tick (call to _process_event) will result in
+    being redrawn.
+    """
+
+    if self._graph_panel and self.is_selected and not self._graph_panel.is_paused():
+      # use the minimum of the current refresh rate and the panel's
+      update_rate = int(CONFIG['attr.graph.intervals'].values()[self._graph_panel.update_interval])
+      return (self.tick + 1) % update_rate == 0
+    else:
+      return False
+
+  def get_title(self, width):
+    """
+    Provides top label.
+    """
+
+    return ''
+
+  def primary_header(self, width):
+    return ''
+
+  def secondary_header(self, width):
+    return ''
+
+  def get_content_height(self):
+    """
+    Provides the height content should take up (not including the graph).
+    """
+
+    return DEFAULT_CONTENT_HEIGHT
+
+  def draw(self, panel, width, height):
+    """
+    Allows for any custom drawing monitor wishes to append.
+    """
+
+    pass
+
+  def bandwidth_event(self, event):
+    if not self.is_pause_buffer:
+      self.event_tick()
+
+  def _process_event(self, primary, secondary):
+    """
+    Includes new stats in graphs and notifies associated GraphPanel of changes.
+    """
+
+    is_redraw = self.is_next_tick_redraw()
+
+    self.last_primary, self.last_secondary = primary, secondary
+    self.primary_total += primary
+    self.secondary_total += secondary
+
+    # updates for all time intervals
+
+    self.tick += 1
+
+    for i in range(len(CONFIG['attr.graph.intervals'])):
+      lable, timescale = CONFIG['attr.graph.intervals'].items()[i]
+      timescale = int(timescale)
+
+      self.primary_counts[i][0] += primary
+      self.secondary_counts[i][0] += secondary
+
+      if self.tick % timescale == 0:
+        self.max_primary[i] = max(self.max_primary[i], self.primary_counts[i][0] / timescale)
+        self.primary_counts[i][0] /= timescale
+        self.primary_counts[i].insert(0, 0)
+        del self.primary_counts[i][self.max_column + 1:]
+
+        self.max_secondary[i] = max(self.max_secondary[i], self.secondary_counts[i][0] / timescale)
+        self.secondary_counts[i][0] /= timescale
+        self.secondary_counts[i].insert(0, 0)
+        del self.secondary_counts[i][self.max_column + 1:]
+
+    if is_redraw and self._graph_panel:
+      self._graph_panel.redraw(True)
+
+
+class BandwidthStats(GraphStats):
+  """
+  Uses tor BW events to generate bandwidth usage graph.
+  """
+
+  def __init__(self, is_pause_buffer = False):
+    GraphStats.__init__(self)
+
+    # listens for tor reload (sighup) events which can reset the bandwidth
+    # rate/burst and if tor's using accounting
+
+    controller = tor_controller()
+    self._title_stats = []
+    self._accounting_stats = None
+
+    if not is_pause_buffer:
+      self.reset_listener(controller, State.INIT, None)  # initializes values
+
+    controller.add_status_listener(self.reset_listener)
+    self.new_desc_event(None)  # updates title params
+
+    # We both show our 'total' attributes and use it to determine our average.
+    #
+    # If we can get *both* our start time and the totals from tor (via 'GETINFO
+    # traffic/*') then that's ideal, but if not then just track the total for
+    # the time arm is run.
+
+    read_total = controller.get_info('traffic/read', None)
+    write_total = controller.get_info('traffic/written', None)
+    start_time = system.start_time(controller.get_pid(None))
+
+    if read_total and write_total and start_time:
+      self.primary_total = int(read_total) / 1024  # Bytes -> KB
+      self.secondary_total = int(write_total) / 1024  # Bytes -> KB
+      self.start_time = start_time
+    else:
+      self.start_time = time.time()
+
+  def clone(self, new_copy = None):
+    if not new_copy:
+      new_copy = BandwidthStats(True)
+
+    new_copy._accounting_stats = self._accounting_stats
+    new_copy._title_stats = self._title_stats
+
+    return GraphStats.clone(self, new_copy)
+
+  def reset_listener(self, controller, event_type, _):
+    # updates title parameters and accounting status if they changed
+
+    self.new_desc_event(None)  # updates title params
+
+    if event_type in (State.INIT, State.RESET) and CONFIG['features.graph.bw.accounting.show']:
+      is_accounting_enabled = controller.get_info('accounting/enabled', None) == '1'
+
+      if is_accounting_enabled != bool(self._accounting_stats):
+        self._accounting_stats = tor_controller().get_accounting_stats(None)
+
+        # redraws the whole screen since our height changed
+
+        arm.controller.get_controller().redraw()
+
+    # redraws to reflect changes (this especially noticeable when we have
+    # accounting and shut down since it then gives notice of the shutdown)
+
+    if self._graph_panel and self.is_selected:
+      self._graph_panel.redraw(True)
+
+  def prepopulate_from_state(self):
+    """
+    Attempts to use tor's state file to prepopulate values for the 15 minute
+    interval via the BWHistoryReadValues/BWHistoryWriteValues values. This
+    returns True if successful and False otherwise.
+    """
+
+    stats = bandwidth_from_state()
+
+    missing_read_entries = int((time.time() - stats.last_read_time) / 900)
+    missing_write_entries = int((time.time() - stats.last_write_time) / 900)
+
+    # fills missing entries with the last value
+
+    bw_read_entries = stats.read_entries + [stats.read_entries[-1]] * missing_read_entries
+    bw_write_entries = stats.write_entries + [stats.write_entries[-1]] * missing_write_entries
+
+    # crops starting entries so they're the same size
+
+    entry_count = min(len(bw_read_entries), len(bw_write_entries), self.max_column)
+    bw_read_entries = bw_read_entries[len(bw_read_entries) - entry_count:]
+    bw_write_entries = bw_write_entries[len(bw_write_entries) - entry_count:]
+
+    # gets index for 15-minute interval
+
+    interval_index = 0
+
+    for interval_rate in CONFIG['attr.graph.intervals'].values():
+      if int(interval_rate) == 900:
+        break
+      else:
+        interval_index += 1
+
+    # fills the graphing parameters with state information
+
+    for i in range(entry_count):
+      read_value, write_value = bw_read_entries[i], bw_write_entries[i]
+
+      self.last_primary, self.last_secondary = read_value, write_value
+
+      self.primary_counts[interval_index].insert(0, read_value)
+      self.secondary_counts[interval_index].insert(0, write_value)
+
+    self.max_primary[interval_index] = max(self.primary_counts)
+    self.max_secondary[interval_index] = max(self.secondary_counts)
+
+    del self.primary_counts[interval_index][self.max_column + 1:]
+    del self.secondary_counts[interval_index][self.max_column + 1:]
+
+    return time.time() - min(stats.last_read_time, stats.last_write_time)
+
+  def bandwidth_event(self, event):
+    if self._accounting_stats and self.is_next_tick_redraw():
+      if time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
+        self._accounting_stats = tor_controller().get_accounting_stats(None)
+
+    # scales units from B to KB for graphing
+
+    self._process_event(event.read / 1024.0, event.written / 1024.0)
+
+  def draw(self, panel, width, height):
+    # line of the graph's x-axis labeling
+
+    labeling_line = GraphStats.get_content_height(self) + panel.graph_height - 2
+
+    # if display is narrow, overwrites x-axis labels with avg / total stats
+
+    if width <= COLLAPSE_WIDTH:
+      # clears line
+
+      panel.addstr(labeling_line, 0, ' ' * width)
+      graph_column = min((width - 10) / 2, self.max_column)
+
+      runtime = time.time() - self.start_time
+      primary_footer = 'total: %s, avg: %s/sec' % (_size_label(self.primary_total * 1024), _size_label(self.primary_total / runtime * 1024))
+      secondary_footer = 'total: %s, avg: %s/sec' % (_size_label(self.secondary_total * 1024), _size_label(self.secondary_total / runtime * 1024))
+
+      panel.addstr(labeling_line, 1, primary_footer, PRIMARY_COLOR)
+      panel.addstr(labeling_line, graph_column + 6, secondary_footer, SECONDARY_COLOR)
+
+    # provides accounting stats if enabled
+
+    if self._accounting_stats:
+      if tor_controller().is_alive():
+        hibernate_color = CONFIG['attr.hibernate_color'].get(self._accounting_stats.status, 'red')
+
+        x, y = 0, labeling_line + 2
+        x = panel.addstr(y, x, 'Accounting (', curses.A_BOLD)
+        x = panel.addstr(y, x, self._accounting_stats.status, curses.A_BOLD, hibernate_color)
+        x = panel.addstr(y, x, ')', curses.A_BOLD)
+
+        panel.addstr(y, 35, 'Time to reset: %s' % str_tools.short_time_label(self._accounting_stats.time_until_reset))
+
+        panel.addstr(y + 1, 2, '%s / %s' % (self._accounting_stats.read_bytes, self._accounting_stats.read_limit), PRIMARY_COLOR)
+        panel.addstr(y + 1, 37, '%s / %s' % (self._accounting_stats.written_bytes, self._accounting_stats.write_limit), SECONDARY_COLOR)
+      else:
+        panel.addstr(labeling_line + 2, 0, 'Accounting:', curses.A_BOLD)
+        panel.addstr(labeling_line + 2, 12, 'Connection Closed...')
+
+  def get_title(self, width):
+    stats_label = str_tools.join(self._title_stats, ', ', width - 13)
+    return 'Bandwidth (%s):' % stats_label if stats_label else 'Bandwidth:'
+
+  def primary_header(self, width):
+    stats = ['%-14s' % ('%s/sec' % _size_label(self.last_primary * 1024))]
+
+    # if wide then avg and total are part of the header, otherwise they're on
+    # the x-axis
+
+    if width * 2 > COLLAPSE_WIDTH:
+      stats.append('- avg: %s/sec' % _size_label(self.primary_total / (time.time() - self.start_time) * 1024))
+      stats.append(', total: %s' % _size_label(self.primary_total * 1024))
+
+    stats_label = str_tools.join(stats, '', width - 12)
+
+    if stats_label:
+      return 'Download (%s):' % stats_label
+    else:
+      return 'Download:'
+
+  def secondary_header(self, width):
+    stats = ['%-14s' % ('%s/sec' % _size_label(self.last_secondary * 1024))]
+
+    # if wide then avg and total are part of the header, otherwise they're on
+    # the x-axis
+
+    if width * 2 > COLLAPSE_WIDTH:
+      stats.append('- avg: %s/sec' % _size_label(self.secondary_total / (time.time() - self.start_time) * 1024))
+      stats.append(', total: %s' % _size_label(self.secondary_total * 1024))
+
+    stats_label = str_tools.join(stats, '', width - 10)
+
+    if stats_label:
+      return 'Upload (%s):' % stats_label
+    else:
+      return 'Upload:'
+
+  def get_content_height(self):
+    base_height = GraphStats.get_content_height(self)
+    return base_height + 3 if self._accounting_stats else base_height
+
+  def new_desc_event(self, event):
+    controller = tor_controller()
+
+    if not controller.is_alive():
+      return  # keep old values
+
+    my_fingerprint = controller.get_info('fingerprint', None)
+
+    if not event or (my_fingerprint and my_fingerprint in [fp for fp, _ in event.relays]):
+      stats = []
+
+      bw_rate = controller.get_effective_rate(None)
+      bw_burst = controller.get_effective_rate(None, burst = True)
+
+      if bw_rate and bw_burst:
+        bw_rate_label = _size_label(bw_rate)
+        bw_burst_label = _size_label(bw_burst)
+
+        # if both are using rounded values then strip off the '.0' decimal
+
+        if '.0' in bw_rate_label and '.0' in bw_burst_label:
+          bw_rate_label = bw_rate_label.split('.', 1)[0]
+          bw_burst_label = bw_burst_label.split('.', 1)[0]
+
+        stats.append('limit: %s/s' % bw_rate_label)
+        stats.append('burst: %s/s' % bw_burst_label)
+
+      my_router_status_entry = controller.get_network_status(default = None)
+      measured_bw = getattr(my_router_status_entry, 'bandwidth', None)
+
+      if measured_bw:
+        stats.append('measured: %s/s' % _size_label(measured_bw))
+      else:
+        my_server_descriptor = controller.get_server_descriptor(default = None)
+        observed_bw = getattr(my_server_descriptor, 'observed_bandwidth', None)
+
+        if observed_bw:
+          stats.append('observed: %s/s' % _size_label(observed_bw))
+
+      self._title_stats = stats
+
+
+class ConnStats(GraphStats):
+  """
+  Tracks number of connections, counting client and directory connections as
+  outbound. Control connections are excluded from counts.
+  """
+
+  def clone(self, new_copy=None):
+    if not new_copy:
+      new_copy = ConnStats()
+
+    return GraphStats.clone(self, new_copy)
+
+  def event_tick(self):
+    """
+    Fetches connection stats from cached information.
+    """
+
+    inbound_count, outbound_count = 0, 0
+
+    controller = tor_controller()
+
+    or_ports = controller.get_ports(Listener.OR)
+    dir_ports = controller.get_ports(Listener.DIR)
+    control_ports = controller.get_ports(Listener.CONTROL)
+
+    for entry in arm.util.tracker.get_connection_tracker().get_value():
+      local_port = entry.local_port
+
+      if local_port in or_ports or local_port in dir_ports:
+        inbound_count += 1
+      elif local_port in control_ports:
+        pass  # control connection
+      else:
+        outbound_count += 1
+
+    self._process_event(inbound_count, outbound_count)
+
+  def get_title(self, width):
+    return 'Connection Count:'
+
+  def primary_header(self, width):
+    avg = self.primary_total / max(1, self.tick)
+    return 'Inbound (%s, avg: %s):' % (self.last_primary, avg)
+
+  def secondary_header(self, width):
+    avg = self.secondary_total / max(1, self.tick)
+    return 'Outbound (%s, avg: %s):' % (self.last_secondary, avg)
+
+
+class ResourceStats(GraphStats):
+  """
+  System resource usage tracker.
+  """
+
+  def __init__(self):
+    GraphStats.__init__(self)
+    self._last_counter = None
+
+  def clone(self, new_copy=None):
+    if not new_copy:
+      new_copy = ResourceStats()
+
+    return GraphStats.clone(self, new_copy)
+
+  def get_title(self, width):
+    return 'System Resources:'
+
+  def primary_header(self, width):
+    avg = self.primary_total / max(1, self.tick)
+    return 'CPU (%0.1f%%, avg: %0.1f%%):' % (self.last_primary, avg)
+
+  def secondary_header(self, width):
+    # memory sizes are converted from MB to B before generating labels
+
+    usage_label = str_tools.size_label(self.last_secondary * 1048576, 1)
+
+    avg = self.secondary_total / max(1, self.tick)
+    avg_label = str_tools.size_label(avg * 1048576, 1)
+
+    return 'Memory (%s, avg: %s):' % (usage_label, avg_label)
+
+  def event_tick(self):
+    """
+    Fetch the cached measurement of resource usage from the ResourceTracker.
+    """
+
+    resource_tracker = arm.util.tracker.get_resource_tracker()
+
+    if resource_tracker and resource_tracker.run_counter() != self._last_counter:
+      resources = resource_tracker.get_value()
+      primary = resources.cpu_sample * 100  # decimal percentage to whole numbers
+      secondary = resources.memory_bytes / 1048576  # translate size to MB so axis labels are short
+
+      self._last_counter = resource_tracker.run_counter()
+      self._process_event(primary, secondary)
+
+
+class GraphPanel(panel.Panel):
+  """
+  Panel displaying a graph, drawing statistics from custom GraphStats
+  implementations.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'graph', 0)
+    self.update_interval = CONFIG['features.graph.interval']
+
+    if self.update_interval < 0 or self.update_interval > len(CONFIG['attr.graph.intervals']) - 1:
+      self.update_interval = 0  # user configured it with a value that's out of bounds
+
+    self.bounds = list(Bounds)[CONFIG['features.graph.bound']]
+    self.graph_height = CONFIG['features.graph.height']
+    self.current_display = None    # label of the stats currently being displayed
+
+    self.stats = {
+      GraphStat.BANDWIDTH: BandwidthStats(),
+      GraphStat.SYSTEM_RESOURCES: ResourceStats(),
+    }
+
+    if CONFIG['features.panels.show.connection']:
+      self.stats[GraphStat.CONNECTIONS] = ConnStats()
+
+    for stat in self.stats.values():
+      stat._graph_panel = self
+
+    self.set_pause_attr('stats')
+
+    try:
+      initial_stats = GRAPH_INIT_STATS.get(CONFIG['features.graph.type'])
+      self.set_stats(initial_stats)
+    except ValueError:
+      pass  # invalid stats, maybe connections when lookups are disabled
+
+    # prepopulates bandwidth values from state file
+
+    if CONFIG["features.graph.bw.prepopulate"] and tor_controller().is_alive():
+      try:
+        missing_seconds = self.stats[GraphStat.BANDWIDTH].prepopulate_from_state()
+
+        if missing_seconds:
+          log.notice(msg('panel.graphing.prepopulation_successful', duration = str_tools.time_label(missing_seconds, 0, True)))
+        else:
+          log.notice(msg('panel.graphing.prepopulation_all_successful'))
+
+        self.update_interval = 4
+      except ValueError as exc:
+        log.info(msg('panel.graphing.prepopulation_failure', error = str(exc)))
+
+  def get_update_interval(self):
+    """
+    Provides the rate that we update the graph at.
+    """
+
+    return self.update_interval
+
+  def set_update_interval(self, update_interval):
+    """
+    Sets the rate that we update the graph at.
+
+    Arguments:
+      update_interval - update time enum
+    """
+
+    self.update_interval = update_interval
+
+  def get_bounds_type(self):
+    """
+    Provides the type of graph bounds used.
+    """
+
+    return self.bounds
+
+  def set_bounds_type(self, bounds_type):
+    """
+    Sets the type of graph boundaries we use.
+
+    Arguments:
+      bounds_type - graph bounds enum
+    """
+
+    self.bounds = bounds_type
+
+  def get_height(self):
+    """
+    Provides the height requested by the currently displayed GraphStats (zero
+    if hidden).
+    """
+
+    if self.current_display:
+      return self.stats[self.current_display].get_content_height() + self.graph_height
+    else:
+      return 0
+
+  def set_graph_height(self, new_graph_height):
+    """
+    Sets the preferred height used for the graph (restricted to the
+    MIN_GRAPH_HEIGHT minimum).
+
+    Arguments:
+      new_graph_height - new height for the graph
+    """
+
+    self.graph_height = max(MIN_GRAPH_HEIGHT, new_graph_height)
+
+  def resize_graph(self):
+    """
+    Prompts for user input to resize the graph panel. Options include...
+      down arrow - grow graph
+      up arrow - shrink graph
+      enter / space - set size
+    """
+
+    control = arm.controller.get_controller()
+
+    with panel.CURSES_LOCK:
+      try:
+        while True:
+          msg = 'press the down/up to resize the graph, and enter when done'
+          control.set_msg(msg, curses.A_BOLD, True)
+          curses.cbreak()
+          key = control.key_input()
+
+          if key.match('down'):
+            # don't grow the graph if it's already consuming the whole display
+            # (plus an extra line for the graph/log gap)
+
+            max_height = self.parent.getmaxyx()[0] - self.top
+            current_height = self.get_height()
+
+            if current_height < max_height + 1:
+              self.set_graph_height(self.graph_height + 1)
+          elif key.match('up'):
+            self.set_graph_height(self.graph_height - 1)
+          elif key.is_selection():
+            break
+
+          control.redraw()
+      finally:
+        control.set_msg()
+
+  def handle_key(self, key):
+    if key.match('r'):
+      self.resize_graph()
+    elif key.match('b'):
+      # uses the next boundary type
+      self.bounds = Bounds.next(self.bounds)
+      self.redraw(True)
+    elif key.match('s'):
+      # provides a menu to pick the graphed stats
+
+      available_stats = self.stats.keys()
+      available_stats.sort()
+
+      # uses sorted, camel cased labels for the options
+
+      options = ['None']
+
+      for label in available_stats:
+        words = label.split()
+        options.append(' '.join(word[0].upper() + word[1:] for word in words))
+
+      if self.current_display:
+        initial_selection = available_stats.index(self.current_display) + 1
+      else:
+        initial_selection = 0
+
+      selection = arm.popups.show_menu('Graphed Stats:', options, initial_selection)
+
+      # applies new setting
+
+      if selection == 0:
+        self.set_stats(None)
+      elif selection != -1:
+        self.set_stats(available_stats[selection - 1])
+    elif key.match('i'):
+      # provides menu to pick graph panel update interval
+
+      options = CONFIG['attr.graph.intervals'].keys()
+      selection = arm.popups.show_menu('Update Interval:', options, self.update_interval)
+
+      if selection != -1:
+        self.update_interval = selection
+    else:
+      return False
+
+    return True
+
+  def get_help(self):
+    return [
+      ('r', 'resize graph', None),
+      ('s', 'graphed stats', self.current_display if self.current_display else 'none'),
+      ('b', 'graph bounds', self.bounds.lower()),
+      ('i', 'graph update interval', CONFIG['attr.graph.intervals'].keys()[self.update_interval]),
+    ]
+
+  def draw(self, width, height):
+    if not self.current_display:
+      return
+
+    param = self.get_attr('stats')[self.current_display]
+    graph_column = min((width - 10) / 2, param.max_column)
+
+    if self.is_title_visible():
+      self.addstr(0, 0, param.get_title(width), curses.A_STANDOUT)
+
+    # top labels
+
+    left, right = param.primary_header(width / 2), param.secondary_header(width / 2)
+
+    if left:
+      self.addstr(1, 0, left, curses.A_BOLD, PRIMARY_COLOR)
+
+    if right:
+      self.addstr(1, graph_column + 5, right, curses.A_BOLD, SECONDARY_COLOR)
+
+    # determines max/min value on the graph
+
+    if self.bounds == Bounds.GLOBAL_MAX:
+      primary_max_bound = int(param.max_primary[self.update_interval])
+      secondary_max_bound = int(param.max_secondary[self.update_interval])
+    else:
+      # both Bounds.LOCAL_MAX and Bounds.TIGHT use local maxima
+      if graph_column < 2:
+        # nothing being displayed
+        primary_max_bound, secondary_max_bound = 0, 0
+      else:
+        primary_max_bound = int(max(param.primary_counts[self.update_interval][1:graph_column + 1]))
+        secondary_max_bound = int(max(param.secondary_counts[self.update_interval][1:graph_column + 1]))
+
+    primary_min_bound = secondary_min_bound = 0
+
+    if self.bounds == Bounds.TIGHT:
+      primary_min_bound = int(min(param.primary_counts[self.update_interval][1:graph_column + 1]))
+      secondary_min_bound = int(min(param.secondary_counts[self.update_interval][1:graph_column + 1]))
+
+      # if the max = min (ie, all values are the same) then use zero lower
+      # bound so a graph is still displayed
+
+      if primary_min_bound == primary_max_bound:
+        primary_min_bound = 0
+
+      if secondary_min_bound == secondary_max_bound:
+        secondary_min_bound = 0
+
+    # displays upper and lower bounds
+
+    self.addstr(2, 0, '%4i' % primary_max_bound, PRIMARY_COLOR)
+    self.addstr(self.graph_height + 1, 0, '%4i' % primary_min_bound, PRIMARY_COLOR)
+
+    self.addstr(2, graph_column + 5, '%4i' % secondary_max_bound, SECONDARY_COLOR)
+    self.addstr(self.graph_height + 1, graph_column + 5, '%4i' % secondary_min_bound, SECONDARY_COLOR)
+
+    # displays intermediate bounds on every other row
+
+    if CONFIG['features.graph.showIntermediateBounds']:
+      ticks = (self.graph_height - 3) / 2
+
+      for i in range(ticks):
+        row = self.graph_height - (2 * i) - 3
+
+        if self.graph_height % 2 == 0 and i >= (ticks / 2):
+          row -= 1
+
+        if primary_min_bound != primary_max_bound:
+          primary_val = (primary_max_bound - primary_min_bound) * (self.graph_height - row - 1) / (self.graph_height - 1)
+
+          if primary_val not in (primary_min_bound, primary_max_bound):
+            self.addstr(row + 2, 0, '%4i' % primary_val, PRIMARY_COLOR)
+
+        if secondary_min_bound != secondary_max_bound:
+          secondary_val = (secondary_max_bound - secondary_min_bound) * (self.graph_height - row - 1) / (self.graph_height - 1)
+
+          if secondary_val not in (secondary_min_bound, secondary_max_bound):
+            self.addstr(row + 2, graph_column + 5, '%4i' % secondary_val, SECONDARY_COLOR)
+
+    # creates bar graph (both primary and secondary)
+
+    for col in range(graph_column):
+      column_count = int(param.primary_counts[self.update_interval][col + 1]) - primary_min_bound
+      column_height = min(self.graph_height, self.graph_height * column_count / (max(1, primary_max_bound) - primary_min_bound))
+
+      for row in range(column_height):
+        self.addstr(self.graph_height + 1 - row, col + 5, ' ', curses.A_STANDOUT, PRIMARY_COLOR)
+
+      column_count = int(param.secondary_counts[self.update_interval][col + 1]) - secondary_min_bound
+      column_height = min(self.graph_height, self.graph_height * column_count / (max(1, secondary_max_bound) - secondary_min_bound))
+
+      for row in range(column_height):
+        self.addstr(self.graph_height + 1 - row, col + graph_column + 10, ' ', curses.A_STANDOUT, SECONDARY_COLOR)
+
+    # bottom labeling of x-axis
+
+    interval_sec = int(CONFIG['attr.graph.intervals'].values()[self.update_interval])  # seconds per labeling
+
+    interval_spacing = 10 if graph_column >= WIDE_LABELING_GRAPH_COL else 5
+    units_label, decimal_precision = None, 0
+
+    for i in range((graph_column - 4) / interval_spacing):
+      loc = (i + 1) * interval_spacing
+      time_label = str_tools.time_label(loc * interval_sec, decimal_precision)
+
+      if not units_label:
+        units_label = time_label[-1]
+      elif units_label != time_label[-1]:
+        # upped scale so also up precision of future measurements
+        units_label = time_label[-1]
+        decimal_precision += 1
+      else:
+        # if constrained on space then strips labeling since already provided
+        time_label = time_label[:-1]
+
+      self.addstr(self.graph_height + 2, 4 + loc, time_label, PRIMARY_COLOR)
+      self.addstr(self.graph_height + 2, graph_column + 10 + loc, time_label, SECONDARY_COLOR)
+
+    param.draw(self, width, height)  # allows current stats to modify the display
+
+  def get_stats(self):
+    """
+    Provides the currently selected stats label.
+    """
+
+    return self.current_display
+
+  def set_stats(self, label):
+    """
+    Sets the currently displayed stats instance, hiding panel if None.
+    """
+
+    if label != self.current_display:
+      if self.current_display:
+        self.stats[self.current_display].is_selected = False
+
+      if not label:
+        self.current_display = None
+      elif label in self.stats.keys():
+        self.current_display = label
+        self.stats[self.current_display].is_selected = True
+      else:
+        raise ValueError('Unrecognized stats label: %s' % label)
+
+  def copy_attr(self, attr):
+    if attr == 'stats':
+      # uses custom clone method to copy GraphStats instances
+      return dict([(key, self.stats[key].clone()) for key in self.stats])
+    else:
+      return panel.Panel.copy_attr(self, attr)
+
+
+def _size_label(byte_count):
+  return str_tools.size_label(byte_count, 1, is_bytes = CONFIG['features.graph.bw.transferInBytes'])
diff --git a/arm/graphing/__init__.py b/arm/graphing/__init__.py
deleted file mode 100644
index 74f7e07..0000000
--- a/arm/graphing/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-"""
-Graphing panel resources.
-"""
-
-__all__ = [
-  'bandwidth_stats',
-  'conn_stats',
-  'graph_panel',
-  'resource_stats',
-]
diff --git a/arm/graphing/bandwidth_stats.py b/arm/graphing/bandwidth_stats.py
deleted file mode 100644
index 4d52aae..0000000
--- a/arm/graphing/bandwidth_stats.py
+++ /dev/null
@@ -1,285 +0,0 @@
-"""
-Tracks bandwidth usage of the tor process, expanding to include accounting
-stats if they're set.
-"""
-
-import time
-import curses
-
-import arm.controller
-
-from arm.graphing import graph_panel
-from arm.util import bandwidth_from_state, tor_controller
-
-from stem.control import State
-from stem.util import conf, str_tools, system
-
-ACCOUNTING_RATE = 5
-
-CONFIG = conf.config_dict('arm', {
-  'attr.hibernate_color': {},
-  'attr.graph.intervals': {},
-  'features.graph.bw.transferInBytes': False,
-  'features.graph.bw.accounting.show': True,
-  'tor.chroot': '',
-})
-
-# width at which panel abandons placing optional stats (avg and total) with
-# header in favor of replacing the x-axis label
-
-COLLAPSE_WIDTH = 135
-
-
-class BandwidthStats(graph_panel.GraphStats):
-  """
-  Uses tor BW events to generate bandwidth usage graph.
-  """
-
-  def __init__(self, is_pause_buffer = False):
-    graph_panel.GraphStats.__init__(self)
-
-    # listens for tor reload (sighup) events which can reset the bandwidth
-    # rate/burst and if tor's using accounting
-
-    controller = tor_controller()
-    self._title_stats = []
-    self._accounting_stats = None
-
-    if not is_pause_buffer:
-      self.reset_listener(controller, State.INIT, None)  # initializes values
-
-    controller.add_status_listener(self.reset_listener)
-    self.new_desc_event(None)  # updates title params
-
-    # We both show our 'total' attributes and use it to determine our average.
-    #
-    # If we can get *both* our start time and the totals from tor (via 'GETINFO
-    # traffic/*') then that's ideal, but if not then just track the total for
-    # the time arm is run.
-
-    read_total = controller.get_info('traffic/read', None)
-    write_total = controller.get_info('traffic/written', None)
-    start_time = system.start_time(controller.get_pid(None))
-
-    if read_total and write_total and start_time:
-      self.primary_total = int(read_total) / 1024  # Bytes -> KB
-      self.secondary_total = int(write_total) / 1024  # Bytes -> KB
-      self.start_time = start_time
-    else:
-      self.start_time = time.time()
-
-  def clone(self, new_copy = None):
-    if not new_copy:
-      new_copy = BandwidthStats(True)
-
-    new_copy._accounting_stats = self._accounting_stats
-    new_copy._title_stats = self._title_stats
-
-    return graph_panel.GraphStats.clone(self, new_copy)
-
-  def reset_listener(self, controller, event_type, _):
-    # updates title parameters and accounting status if they changed
-
-    self.new_desc_event(None)  # updates title params
-
-    if event_type in (State.INIT, State.RESET) and CONFIG['features.graph.bw.accounting.show']:
-      is_accounting_enabled = controller.get_info('accounting/enabled', None) == '1'
-
-      if is_accounting_enabled != bool(self._accounting_stats):
-        self._accounting_stats = tor_controller().get_accounting_stats(None)
-
-        # redraws the whole screen since our height changed
-
-        arm.controller.get_controller().redraw()
-
-    # redraws to reflect changes (this especially noticeable when we have
-    # accounting and shut down since it then gives notice of the shutdown)
-
-    if self._graph_panel and self.is_selected:
-      self._graph_panel.redraw(True)
-
-  def prepopulate_from_state(self):
-    """
-    Attempts to use tor's state file to prepopulate values for the 15 minute
-    interval via the BWHistoryReadValues/BWHistoryWriteValues values. This
-    returns True if successful and False otherwise.
-    """
-
-    stats = bandwidth_from_state()
-
-    missing_read_entries = int((time.time() - stats.last_read_time) / 900)
-    missing_write_entries = int((time.time() - stats.last_write_time) / 900)
-
-    # fills missing entries with the last value
-
-    bw_read_entries = stats.read_entries + [stats.read_entries[-1]] * missing_read_entries
-    bw_write_entries = stats.write_entries + [stats.write_entries[-1]] * missing_write_entries
-
-    # crops starting entries so they're the same size
-
-    entry_count = min(len(bw_read_entries), len(bw_write_entries), self.max_column)
-    bw_read_entries = bw_read_entries[len(bw_read_entries) - entry_count:]
-    bw_write_entries = bw_write_entries[len(bw_write_entries) - entry_count:]
-
-    # gets index for 15-minute interval
-
-    interval_index = 0
-
-    for interval_rate in CONFIG['attr.graph.intervals'].values():
-      if int(interval_rate) == 900:
-        break
-      else:
-        interval_index += 1
-
-    # fills the graphing parameters with state information
-
-    for i in range(entry_count):
-      read_value, write_value = bw_read_entries[i], bw_write_entries[i]
-
-      self.last_primary, self.last_secondary = read_value, write_value
-
-      self.primary_counts[interval_index].insert(0, read_value)
-      self.secondary_counts[interval_index].insert(0, write_value)
-
-    self.max_primary[interval_index] = max(self.primary_counts)
-    self.max_secondary[interval_index] = max(self.secondary_counts)
-
-    del self.primary_counts[interval_index][self.max_column + 1:]
-    del self.secondary_counts[interval_index][self.max_column + 1:]
-
-    return time.time() - min(stats.last_read_time, stats.last_write_time)
-
-  def bandwidth_event(self, event):
-    if self._accounting_stats and self.is_next_tick_redraw():
-      if time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
-        self._accounting_stats = tor_controller().get_accounting_stats(None)
-
-    # scales units from B to KB for graphing
-
-    self._process_event(event.read / 1024.0, event.written / 1024.0)
-
-  def draw(self, panel, width, height):
-    # line of the graph's x-axis labeling
-
-    labeling_line = graph_panel.GraphStats.get_content_height(self) + panel.graph_height - 2
-
-    # if display is narrow, overwrites x-axis labels with avg / total stats
-
-    if width <= COLLAPSE_WIDTH:
-      # clears line
-
-      panel.addstr(labeling_line, 0, ' ' * width)
-      graph_column = min((width - 10) / 2, self.max_column)
-
-      runtime = time.time() - self.start_time
-      primary_footer = 'total: %s, avg: %s/sec' % (_size_label(self.primary_total * 1024), _size_label(self.primary_total / runtime * 1024))
-      secondary_footer = 'total: %s, avg: %s/sec' % (_size_label(self.secondary_total * 1024), _size_label(self.secondary_total / runtime * 1024))
-
-      panel.addstr(labeling_line, 1, primary_footer, graph_panel.PRIMARY_COLOR)
-      panel.addstr(labeling_line, graph_column + 6, secondary_footer, graph_panel.SECONDARY_COLOR)
-
-    # provides accounting stats if enabled
-
-    if self._accounting_stats:
-      if tor_controller().is_alive():
-        hibernate_color = CONFIG['attr.hibernate_color'].get(self._accounting_stats.status, 'red')
-
-        x, y = 0, labeling_line + 2
-        x = panel.addstr(y, x, 'Accounting (', curses.A_BOLD)
-        x = panel.addstr(y, x, self._accounting_stats.status, curses.A_BOLD, hibernate_color)
-        x = panel.addstr(y, x, ')', curses.A_BOLD)
-
-        panel.addstr(y, 35, 'Time to reset: %s' % str_tools.short_time_label(self._accounting_stats.time_until_reset))
-
-        panel.addstr(y + 1, 2, '%s / %s' % (self._accounting_stats.read_bytes, self._accounting_stats.read_limit), graph_panel.PRIMARY_COLOR)
-        panel.addstr(y + 1, 37, '%s / %s' % (self._accounting_stats.written_bytes, self._accounting_stats.write_limit), graph_panel.SECONDARY_COLOR)
-      else:
-        panel.addstr(labeling_line + 2, 0, 'Accounting:', curses.A_BOLD)
-        panel.addstr(labeling_line + 2, 12, 'Connection Closed...')
-
-  def get_title(self, width):
-    stats_label = str_tools.join(self._title_stats, ', ', width - 13)
-    return 'Bandwidth (%s):' % stats_label if stats_label else 'Bandwidth:'
-
-  def primary_header(self, width):
-    stats = ['%-14s' % ('%s/sec' % _size_label(self.last_primary * 1024))]
-
-    # if wide then avg and total are part of the header, otherwise they're on
-    # the x-axis
-
-    if width * 2 > COLLAPSE_WIDTH:
-      stats.append('- avg: %s/sec' % _size_label(self.primary_total / (time.time() - self.start_time) * 1024))
-      stats.append(', total: %s' % _size_label(self.primary_total * 1024))
-
-    stats_label = str_tools.join(stats, '', width - 12)
-
-    if stats_label:
-      return 'Download (%s):' % stats_label
-    else:
-      return 'Download:'
-
-  def secondary_header(self, width):
-    stats = ['%-14s' % ('%s/sec' % _size_label(self.last_secondary * 1024))]
-
-    # if wide then avg and total are part of the header, otherwise they're on
-    # the x-axis
-
-    if width * 2 > COLLAPSE_WIDTH:
-      stats.append('- avg: %s/sec' % _size_label(self.secondary_total / (time.time() - self.start_time) * 1024))
-      stats.append(', total: %s' % _size_label(self.secondary_total * 1024))
-
-    stats_label = str_tools.join(stats, '', width - 10)
-
-    if stats_label:
-      return 'Upload (%s):' % stats_label
-    else:
-      return 'Upload:'
-
-  def get_content_height(self):
-    base_height = graph_panel.GraphStats.get_content_height(self)
-    return base_height + 3 if self._accounting_stats else base_height
-
-  def new_desc_event(self, event):
-    controller = tor_controller()
-
-    if not controller.is_alive():
-      return  # keep old values
-
-    my_fingerprint = controller.get_info('fingerprint', None)
-
-    if not event or (my_fingerprint and my_fingerprint in [fp for fp, _ in event.relays]):
-      stats = []
-
-      bw_rate = controller.get_effective_rate(None)
-      bw_burst = controller.get_effective_rate(None, burst = True)
-
-      if bw_rate and bw_burst:
-        bw_rate_label = _size_label(bw_rate)
-        bw_burst_label = _size_label(bw_burst)
-
-        # if both are using rounded values then strip off the '.0' decimal
-
-        if '.0' in bw_rate_label and '.0' in bw_burst_label:
-          bw_rate_label = bw_rate_label.split('.', 1)[0]
-          bw_burst_label = bw_burst_label.split('.', 1)[0]
-
-        stats.append('limit: %s/s' % bw_rate_label)
-        stats.append('burst: %s/s' % bw_burst_label)
-
-      my_router_status_entry = controller.get_network_status(default = None)
-      measured_bw = getattr(my_router_status_entry, 'bandwidth', None)
-
-      if measured_bw:
-        stats.append('measured: %s/s' % _size_label(measured_bw))
-      else:
-        my_server_descriptor = controller.get_server_descriptor(default = None)
-        observed_bw = getattr(my_server_descriptor, 'observed_bandwidth', None)
-
-        if observed_bw:
-          stats.append('observed: %s/s' % _size_label(observed_bw))
-
-      self._title_stats = stats
-
-
-def _size_label(byte_count):
-  return str_tools.size_label(byte_count, 1, is_bytes = CONFIG['features.graph.bw.transferInBytes'])
diff --git a/arm/graphing/conn_stats.py b/arm/graphing/conn_stats.py
deleted file mode 100644
index c5b1c83..0000000
--- a/arm/graphing/conn_stats.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-Tracks stats concerning tor's current connections.
-"""
-
-import arm.util.tracker
-
-from arm.graphing import graph_panel
-from arm.util import tor_controller
-
-from stem.control import Listener
-
-
-class ConnStats(graph_panel.GraphStats):
-  """
-  Tracks number of connections, counting client and directory connections as
-  outbound. Control connections are excluded from counts.
-  """
-
-  def clone(self, new_copy=None):
-    if not new_copy:
-      new_copy = ConnStats()
-
-    return graph_panel.GraphStats.clone(self, new_copy)
-
-  def event_tick(self):
-    """
-    Fetches connection stats from cached information.
-    """
-
-    inbound_count, outbound_count = 0, 0
-
-    controller = tor_controller()
-
-    or_ports = controller.get_ports(Listener.OR)
-    dir_ports = controller.get_ports(Listener.DIR)
-    control_ports = controller.get_ports(Listener.CONTROL)
-
-    for entry in arm.util.tracker.get_connection_tracker().get_value():
-      local_port = entry.local_port
-
-      if local_port in or_ports or local_port in dir_ports:
-        inbound_count += 1
-      elif local_port in control_ports:
-        pass  # control connection
-      else:
-        outbound_count += 1
-
-    self._process_event(inbound_count, outbound_count)
-
-  def get_title(self, width):
-    return 'Connection Count:'
-
-  def primary_header(self, width):
-    avg = self.primary_total / max(1, self.tick)
-    return 'Inbound (%s, avg: %s):' % (self.last_primary, avg)
-
-  def secondary_header(self, width):
-    avg = self.secondary_total / max(1, self.tick)
-    return 'Outbound (%s, avg: %s):' % (self.last_secondary, avg)
diff --git a/arm/graphing/graph_panel.py b/arm/graphing/graph_panel.py
deleted file mode 100644
index 77f9404..0000000
--- a/arm/graphing/graph_panel.py
+++ /dev/null
@@ -1,581 +0,0 @@
-"""
-Flexible panel for presenting bar graphs for a variety of stats. This panel is
-just concerned with the rendering of information, which is actually collected
-and stored by implementations of the GraphStats interface. Panels are made up
-of a title, followed by headers and graphs for two sets of stats. For
-instance...
-
-Bandwidth (cap: 5 MB, burst: 10 MB):
-Downloaded (0.0 B/sec):           Uploaded (0.0 B/sec):
-  34                                30
-                            *                                 *
-                    **  *   *                          *      **
-      *   *  *      ** **   **          ***  **       ** **   **
-     *********      ******  ******     *********      ******  ******
-   0 ************ ****************   0 ************ ****************
-         25s  50   1m   1.6  2.0           25s  50   1m   1.6  2.0
-"""
-
-import copy
-import curses
-
-import arm.popups
-import arm.controller
-
-import stem.control
-
-from arm.util import msg, panel, tor_controller
-
-from stem.util import conf, enum, log, str_tools
-
-GraphStat = enum.Enum('BANDWIDTH', 'CONNECTIONS', 'SYSTEM_RESOURCES')
-
-# maps 'features.graph.type' config values to the initial types
-
-GRAPH_INIT_STATS = {1: GraphStat.BANDWIDTH, 2: GraphStat.CONNECTIONS, 3: GraphStat.SYSTEM_RESOURCES}
-
-DEFAULT_CONTENT_HEIGHT = 4  # space needed for labeling above and below the graph
-PRIMARY_COLOR, SECONDARY_COLOR = 'green', 'cyan'
-MIN_GRAPH_HEIGHT = 1
-
-# enums for graph bounds:
-#   Bounds.GLOBAL_MAX - global maximum (highest value ever seen)
-#   Bounds.LOCAL_MAX - local maximum (highest value currently on the graph)
-#   Bounds.TIGHT - local maximum and minimum
-
-Bounds = enum.Enum('GLOBAL_MAX', 'LOCAL_MAX', 'TIGHT')
-
-WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
-
-
-def conf_handler(key, value):
-  if key == 'features.graph.height':
-    return max(MIN_GRAPH_HEIGHT, value)
-  elif key == 'features.graph.max_width':
-    return max(1, value)
-  elif key == 'features.graph.bound':
-    return max(0, min(2, value))
-
-
-# used for setting defaults when initializing GraphStats and GraphPanel instances
-
-CONFIG = conf.config_dict('arm', {
-  'attr.graph.intervals': {},
-  'features.graph.height': 7,
-  'features.graph.interval': 0,
-  'features.graph.bound': 1,
-  'features.graph.max_width': 150,
-  'features.graph.showIntermediateBounds': True,
-  'features.graph.type': 1,
-  'features.panels.show.connection': True,
-  'features.graph.bw.prepopulate': True,
-}, conf_handler)
-
-
-class GraphStats:
-  """
-  Module that's expected to update dynamically and provide attributes to be
-  graphed. Up to two graphs (a 'primary' and 'secondary') can be displayed at a
-  time and timescale parameters use the labels defined in CONFIG['attr.graph.intervals'].
-  """
-
-  def __init__(self):
-    """
-    Initializes parameters needed to present a graph.
-    """
-
-    # panel to be redrawn when updated (set when added to GraphPanel)
-
-    self._graph_panel = None
-    self.is_selected = False
-    self.is_pause_buffer = False
-
-    # tracked stats
-
-    self.tick = 0                                    # number of processed events
-    self.last_primary, self.last_secondary = 0, 0    # most recent registered stats
-    self.primary_total, self.secondary_total = 0, 0  # sum of all stats seen
-
-    # timescale dependent stats
-
-    self.max_column = CONFIG['features.graph.max_width']
-    self.max_primary, self.max_secondary = {}, {}
-    self.primary_counts, self.secondary_counts = {}, {}
-
-    for i in range(len(CONFIG['attr.graph.intervals'])):
-      # recent rates for graph
-
-      self.max_primary[i] = 0
-      self.max_secondary[i] = 0
-
-      # historic stats for graph, first is accumulator
-      # iterative insert needed to avoid making shallow copies (nasty, nasty gotcha)
-
-      self.primary_counts[i] = (self.max_column + 1) * [0]
-      self.secondary_counts[i] = (self.max_column + 1) * [0]
-
-    # tracks BW events
-
-    tor_controller().add_event_listener(self.bandwidth_event, stem.control.EventType.BW)
-
-  def clone(self, new_copy=None):
-    """
-    Provides a deep copy of this instance.
-
-    Arguments:
-      new_copy - base instance to build copy off of
-    """
-
-    if not new_copy:
-      new_copy = GraphStats()
-
-    new_copy.tick = self.tick
-    new_copy.last_primary = self.last_primary
-    new_copy.last_secondary = self.last_secondary
-    new_copy.primary_total = self.primary_total
-    new_copy.secondary_total = self.secondary_total
-    new_copy.max_primary = dict(self.max_primary)
-    new_copy.max_secondary = dict(self.max_secondary)
-    new_copy.primary_counts = copy.deepcopy(self.primary_counts)
-    new_copy.secondary_counts = copy.deepcopy(self.secondary_counts)
-    new_copy.is_pause_buffer = True
-    return new_copy
-
-  def event_tick(self):
-    """
-    Called when it's time to process another event. All graphs use tor BW
-    events to keep in sync with each other (this happens once a second).
-    """
-
-    pass
-
-  def is_next_tick_redraw(self):
-    """
-    Provides true if the following tick (call to _process_event) will result in
-    being redrawn.
-    """
-
-    if self._graph_panel and self.is_selected and not self._graph_panel.is_paused():
-      # use the minimum of the current refresh rate and the panel's
-      update_rate = int(CONFIG['attr.graph.intervals'].values()[self._graph_panel.update_interval])
-      return (self.tick + 1) % update_rate == 0
-    else:
-      return False
-
-  def get_title(self, width):
-    """
-    Provides top label.
-    """
-
-    return ''
-
-  def primary_header(self, width):
-    return ''
-
-  def secondary_header(self, width):
-    return ''
-
-  def get_content_height(self):
-    """
-    Provides the height content should take up (not including the graph).
-    """
-
-    return DEFAULT_CONTENT_HEIGHT
-
-  def draw(self, panel, width, height):
-    """
-    Allows for any custom drawing monitor wishes to append.
-    """
-
-    pass
-
-  def bandwidth_event(self, event):
-    if not self.is_pause_buffer:
-      self.event_tick()
-
-  def _process_event(self, primary, secondary):
-    """
-    Includes new stats in graphs and notifies associated GraphPanel of changes.
-    """
-
-    is_redraw = self.is_next_tick_redraw()
-
-    self.last_primary, self.last_secondary = primary, secondary
-    self.primary_total += primary
-    self.secondary_total += secondary
-
-    # updates for all time intervals
-
-    self.tick += 1
-
-    for i in range(len(CONFIG['attr.graph.intervals'])):
-      lable, timescale = CONFIG['attr.graph.intervals'].items()[i]
-      timescale = int(timescale)
-
-      self.primary_counts[i][0] += primary
-      self.secondary_counts[i][0] += secondary
-
-      if self.tick % timescale == 0:
-        self.max_primary[i] = max(self.max_primary[i], self.primary_counts[i][0] / timescale)
-        self.primary_counts[i][0] /= timescale
-        self.primary_counts[i].insert(0, 0)
-        del self.primary_counts[i][self.max_column + 1:]
-
-        self.max_secondary[i] = max(self.max_secondary[i], self.secondary_counts[i][0] / timescale)
-        self.secondary_counts[i][0] /= timescale
-        self.secondary_counts[i].insert(0, 0)
-        del self.secondary_counts[i][self.max_column + 1:]
-
-    if is_redraw and self._graph_panel:
-      self._graph_panel.redraw(True)
-
-
-class GraphPanel(panel.Panel):
-  """
-  Panel displaying a graph, drawing statistics from custom GraphStats
-  implementations.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'graph', 0)
-    self.update_interval = CONFIG['features.graph.interval']
-
-    if self.update_interval < 0 or self.update_interval > len(CONFIG['attr.graph.intervals']) - 1:
-      self.update_interval = 0  # user configured it with a value that's out of bounds
-
-    self.bounds = list(Bounds)[CONFIG['features.graph.bound']]
-    self.graph_height = CONFIG['features.graph.height']
-    self.current_display = None    # label of the stats currently being displayed
-
-    self.stats = {
-      GraphStat.BANDWIDTH: arm.graphing.bandwidth_stats.BandwidthStats(),
-      GraphStat.SYSTEM_RESOURCES: arm.graphing.resource_stats.ResourceStats(),
-    }
-
-    if CONFIG['features.panels.show.connection']:
-      self.stats[GraphStat.CONNECTIONS] = arm.graphing.conn_stats.ConnStats()
-
-    for stat in self.stats.values():
-      stat._graph_panel = self
-
-    self.set_pause_attr('stats')
-
-    try:
-      initial_stats = GRAPH_INIT_STATS.get(CONFIG['features.graph.type'])
-      self.set_stats(initial_stats)
-    except ValueError:
-      pass  # invalid stats, maybe connections when lookups are disabled
-
-    # prepopulates bandwidth values from state file
-
-    if CONFIG["features.graph.bw.prepopulate"] and tor_controller().is_alive():
-      try:
-        missing_seconds = self.stats[GraphStat.BANDWIDTH].prepopulate_from_state()
-
-        if missing_seconds:
-          log.notice(msg('panel.graphing.prepopulation_successful', duration = str_tools.time_label(missing_seconds, 0, True)))
-        else:
-          log.notice(msg('panel.graphing.prepopulation_all_successful'))
-
-        self.update_interval = 4
-      except ValueError as exc:
-        log.info(msg('panel.graphing.prepopulation_failure', error = str(exc)))
-
-  def get_update_interval(self):
-    """
-    Provides the rate that we update the graph at.
-    """
-
-    return self.update_interval
-
-  def set_update_interval(self, update_interval):
-    """
-    Sets the rate that we update the graph at.
-
-    Arguments:
-      update_interval - update time enum
-    """
-
-    self.update_interval = update_interval
-
-  def get_bounds_type(self):
-    """
-    Provides the type of graph bounds used.
-    """
-
-    return self.bounds
-
-  def set_bounds_type(self, bounds_type):
-    """
-    Sets the type of graph boundaries we use.
-
-    Arguments:
-      bounds_type - graph bounds enum
-    """
-
-    self.bounds = bounds_type
-
-  def get_height(self):
-    """
-    Provides the height requested by the currently displayed GraphStats (zero
-    if hidden).
-    """
-
-    if self.current_display:
-      return self.stats[self.current_display].get_content_height() + self.graph_height
-    else:
-      return 0
-
-  def set_graph_height(self, new_graph_height):
-    """
-    Sets the preferred height used for the graph (restricted to the
-    MIN_GRAPH_HEIGHT minimum).
-
-    Arguments:
-      new_graph_height - new height for the graph
-    """
-
-    self.graph_height = max(MIN_GRAPH_HEIGHT, new_graph_height)
-
-  def resize_graph(self):
-    """
-    Prompts for user input to resize the graph panel. Options include...
-      down arrow - grow graph
-      up arrow - shrink graph
-      enter / space - set size
-    """
-
-    control = arm.controller.get_controller()
-
-    with panel.CURSES_LOCK:
-      try:
-        while True:
-          msg = 'press the down/up to resize the graph, and enter when done'
-          control.set_msg(msg, curses.A_BOLD, True)
-          curses.cbreak()
-          key = control.key_input()
-
-          if key.match('down'):
-            # don't grow the graph if it's already consuming the whole display
-            # (plus an extra line for the graph/log gap)
-
-            max_height = self.parent.getmaxyx()[0] - self.top
-            current_height = self.get_height()
-
-            if current_height < max_height + 1:
-              self.set_graph_height(self.graph_height + 1)
-          elif key.match('up'):
-            self.set_graph_height(self.graph_height - 1)
-          elif key.is_selection():
-            break
-
-          control.redraw()
-      finally:
-        control.set_msg()
-
-  def handle_key(self, key):
-    if key.match('r'):
-      self.resize_graph()
-    elif key.match('b'):
-      # uses the next boundary type
-      self.bounds = Bounds.next(self.bounds)
-      self.redraw(True)
-    elif key.match('s'):
-      # provides a menu to pick the graphed stats
-
-      available_stats = self.stats.keys()
-      available_stats.sort()
-
-      # uses sorted, camel cased labels for the options
-
-      options = ['None']
-
-      for label in available_stats:
-        words = label.split()
-        options.append(' '.join(word[0].upper() + word[1:] for word in words))
-
-      if self.current_display:
-        initial_selection = available_stats.index(self.current_display) + 1
-      else:
-        initial_selection = 0
-
-      selection = arm.popups.show_menu('Graphed Stats:', options, initial_selection)
-
-      # applies new setting
-
-      if selection == 0:
-        self.set_stats(None)
-      elif selection != -1:
-        self.set_stats(available_stats[selection - 1])
-    elif key.match('i'):
-      # provides menu to pick graph panel update interval
-
-      options = CONFIG['attr.graph.intervals'].keys()
-      selection = arm.popups.show_menu('Update Interval:', options, self.update_interval)
-
-      if selection != -1:
-        self.update_interval = selection
-    else:
-      return False
-
-    return True
-
-  def get_help(self):
-    return [
-      ('r', 'resize graph', None),
-      ('s', 'graphed stats', self.current_display if self.current_display else 'none'),
-      ('b', 'graph bounds', self.bounds.lower()),
-      ('i', 'graph update interval', CONFIG['attr.graph.intervals'].keys()[self.update_interval]),
-    ]
-
-  def draw(self, width, height):
-    if not self.current_display:
-      return
-
-    param = self.get_attr('stats')[self.current_display]
-    graph_column = min((width - 10) / 2, param.max_column)
-
-    if self.is_title_visible():
-      self.addstr(0, 0, param.get_title(width), curses.A_STANDOUT)
-
-    # top labels
-
-    left, right = param.primary_header(width / 2), param.secondary_header(width / 2)
-
-    if left:
-      self.addstr(1, 0, left, curses.A_BOLD, PRIMARY_COLOR)
-
-    if right:
-      self.addstr(1, graph_column + 5, right, curses.A_BOLD, SECONDARY_COLOR)
-
-    # determines max/min value on the graph
-
-    if self.bounds == Bounds.GLOBAL_MAX:
-      primary_max_bound = int(param.max_primary[self.update_interval])
-      secondary_max_bound = int(param.max_secondary[self.update_interval])
-    else:
-      # both Bounds.LOCAL_MAX and Bounds.TIGHT use local maxima
-      if graph_column < 2:
-        # nothing being displayed
-        primary_max_bound, secondary_max_bound = 0, 0
-      else:
-        primary_max_bound = int(max(param.primary_counts[self.update_interval][1:graph_column + 1]))
-        secondary_max_bound = int(max(param.secondary_counts[self.update_interval][1:graph_column + 1]))
-
-    primary_min_bound = secondary_min_bound = 0
-
-    if self.bounds == Bounds.TIGHT:
-      primary_min_bound = int(min(param.primary_counts[self.update_interval][1:graph_column + 1]))
-      secondary_min_bound = int(min(param.secondary_counts[self.update_interval][1:graph_column + 1]))
-
-      # if the max = min (ie, all values are the same) then use zero lower
-      # bound so a graph is still displayed
-
-      if primary_min_bound == primary_max_bound:
-        primary_min_bound = 0
-
-      if secondary_min_bound == secondary_max_bound:
-        secondary_min_bound = 0
-
-    # displays upper and lower bounds
-
-    self.addstr(2, 0, '%4i' % primary_max_bound, PRIMARY_COLOR)
-    self.addstr(self.graph_height + 1, 0, '%4i' % primary_min_bound, PRIMARY_COLOR)
-
-    self.addstr(2, graph_column + 5, '%4i' % secondary_max_bound, SECONDARY_COLOR)
-    self.addstr(self.graph_height + 1, graph_column + 5, '%4i' % secondary_min_bound, SECONDARY_COLOR)
-
-    # displays intermediate bounds on every other row
-
-    if CONFIG['features.graph.showIntermediateBounds']:
-      ticks = (self.graph_height - 3) / 2
-
-      for i in range(ticks):
-        row = self.graph_height - (2 * i) - 3
-
-        if self.graph_height % 2 == 0 and i >= (ticks / 2):
-          row -= 1
-
-        if primary_min_bound != primary_max_bound:
-          primary_val = (primary_max_bound - primary_min_bound) * (self.graph_height - row - 1) / (self.graph_height - 1)
-
-          if primary_val not in (primary_min_bound, primary_max_bound):
-            self.addstr(row + 2, 0, '%4i' % primary_val, PRIMARY_COLOR)
-
-        if secondary_min_bound != secondary_max_bound:
-          secondary_val = (secondary_max_bound - secondary_min_bound) * (self.graph_height - row - 1) / (self.graph_height - 1)
-
-          if secondary_val not in (secondary_min_bound, secondary_max_bound):
-            self.addstr(row + 2, graph_column + 5, '%4i' % secondary_val, SECONDARY_COLOR)
-
-    # creates bar graph (both primary and secondary)
-
-    for col in range(graph_column):
-      column_count = int(param.primary_counts[self.update_interval][col + 1]) - primary_min_bound
-      column_height = min(self.graph_height, self.graph_height * column_count / (max(1, primary_max_bound) - primary_min_bound))
-
-      for row in range(column_height):
-        self.addstr(self.graph_height + 1 - row, col + 5, ' ', curses.A_STANDOUT, PRIMARY_COLOR)
-
-      column_count = int(param.secondary_counts[self.update_interval][col + 1]) - secondary_min_bound
-      column_height = min(self.graph_height, self.graph_height * column_count / (max(1, secondary_max_bound) - secondary_min_bound))
-
-      for row in range(column_height):
-        self.addstr(self.graph_height + 1 - row, col + graph_column + 10, ' ', curses.A_STANDOUT, SECONDARY_COLOR)
-
-    # bottom labeling of x-axis
-
-    interval_sec = int(CONFIG['attr.graph.intervals'].values()[self.update_interval])  # seconds per labeling
-
-    interval_spacing = 10 if graph_column >= WIDE_LABELING_GRAPH_COL else 5
-    units_label, decimal_precision = None, 0
-
-    for i in range((graph_column - 4) / interval_spacing):
-      loc = (i + 1) * interval_spacing
-      time_label = str_tools.time_label(loc * interval_sec, decimal_precision)
-
-      if not units_label:
-        units_label = time_label[-1]
-      elif units_label != time_label[-1]:
-        # upped scale so also up precision of future measurements
-        units_label = time_label[-1]
-        decimal_precision += 1
-      else:
-        # if constrained on space then strips labeling since already provided
-        time_label = time_label[:-1]
-
-      self.addstr(self.graph_height + 2, 4 + loc, time_label, PRIMARY_COLOR)
-      self.addstr(self.graph_height + 2, graph_column + 10 + loc, time_label, SECONDARY_COLOR)
-
-    param.draw(self, width, height)  # allows current stats to modify the display
-
-  def get_stats(self):
-    """
-    Provides the currently selected stats label.
-    """
-
-    return self.current_display
-
-  def set_stats(self, label):
-    """
-    Sets the currently displayed stats instance, hiding panel if None.
-    """
-
-    if label != self.current_display:
-      if self.current_display:
-        self.stats[self.current_display].is_selected = False
-
-      if not label:
-        self.current_display = None
-      elif label in self.stats.keys():
-        self.current_display = label
-        self.stats[self.current_display].is_selected = True
-      else:
-        raise ValueError('Unrecognized stats label: %s' % label)
-
-  def copy_attr(self, attr):
-    if attr == 'stats':
-      # uses custom clone method to copy GraphStats instances
-      return dict([(key, self.stats[key].clone()) for key in self.stats])
-    else:
-      return panel.Panel.copy_attr(self, attr)
diff --git a/arm/graphing/resource_stats.py b/arm/graphing/resource_stats.py
deleted file mode 100644
index d38803a..0000000
--- a/arm/graphing/resource_stats.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""
-Tracks the system resource usage (cpu and memory) of the tor process.
-"""
-
-import arm.util.tracker
-
-from arm.graphing import graph_panel
-
-from stem.util import str_tools
-
-
-class ResourceStats(graph_panel.GraphStats):
-  """
-  System resource usage tracker.
-  """
-
-  def __init__(self):
-    graph_panel.GraphStats.__init__(self)
-    self._last_counter = None
-
-  def clone(self, new_copy=None):
-    if not new_copy:
-      new_copy = ResourceStats()
-
-    return graph_panel.GraphStats.clone(self, new_copy)
-
-  def get_title(self, width):
-    return 'System Resources:'
-
-  def primary_header(self, width):
-    avg = self.primary_total / max(1, self.tick)
-    return 'CPU (%0.1f%%, avg: %0.1f%%):' % (self.last_primary, avg)
-
-  def secondary_header(self, width):
-    # memory sizes are converted from MB to B before generating labels
-
-    usage_label = str_tools.size_label(self.last_secondary * 1048576, 1)
-
-    avg = self.secondary_total / max(1, self.tick)
-    avg_label = str_tools.size_label(avg * 1048576, 1)
-
-    return 'Memory (%s, avg: %s):' % (usage_label, avg_label)
-
-  def event_tick(self):
-    """
-    Fetch the cached measurement of resource usage from the ResourceTracker.
-    """
-
-    resource_tracker = arm.util.tracker.get_resource_tracker()
-
-    if resource_tracker and resource_tracker.run_counter() != self._last_counter:
-      resources = resource_tracker.get_value()
-      primary = resources.cpu_sample * 100  # decimal percentage to whole numbers
-      secondary = resources.memory_bytes / 1048576  # translate size to MB so axis labels are short
-
-      self._last_counter = resource_tracker.run_counter()
-      self._process_event(primary, secondary)
diff --git a/arm/menu/actions.py b/arm/menu/actions.py
index b95f1fc..d4323b3 100644
--- a/arm/menu/actions.py
+++ b/arm/menu/actions.py
@@ -7,7 +7,7 @@ import functools
 import arm.popups
 import arm.controller
 import arm.menu.item
-import arm.graphing.graph_panel
+import arm.graph_panel
 import arm.util.tracker
 
 from arm.util import tor_controller, ui_tools
@@ -182,7 +182,7 @@ def make_graph_menu(graph_panel):
   bounds_menu = arm.menu.item.Submenu("Bounds")
   bounds_group = arm.menu.item.SelectionGroup(graph_panel.set_bounds_type, graph_panel.get_bounds_type())
 
-  for bounds_type in arm.graphing.graph_panel.Bounds:
+  for bounds_type in arm.graph_panel.Bounds:
     bounds_menu.add(arm.menu.item.SelectionMenuItem(bounds_type, bounds_group, bounds_type))
 
   graph_menu.add(bounds_menu)





More information about the tor-commits mailing list