[tor-commits] [nyx/master] Move panels into their own module

atagar at torproject.org atagar at torproject.org
Tue Mar 8 17:39:36 UTC 2016


commit fa3a3f0eb11eec7d5af69148f1fc1deff0f6d9bd
Author: Damian Johnson <atagar at torproject.org>
Date:   Tue Mar 8 09:40:12 2016 -0800

    Move panels into their own module
    
    Now that we're done overhauling the panels lets move them into their own
    module. They've all had the same naming convention ('foo_panel.py') so this
    just makes the grouping more explicit.
---
 nyx/__init__.py         |   5 -
 nyx/config_panel.py     | 357 -----------------------
 nyx/connection_panel.py | 706 ---------------------------------------------
 nyx/controller.py       |  25 +-
 nyx/graph_panel.py      | 753 ------------------------------------------------
 nyx/header_panel.py     | 496 -------------------------------
 nyx/log_panel.py        | 454 -----------------------------
 nyx/menu/actions.py     |   8 +-
 nyx/panel/__init__.py   |  11 +
 nyx/panel/config.py     | 357 +++++++++++++++++++++++
 nyx/panel/connection.py | 706 +++++++++++++++++++++++++++++++++++++++++++++
 nyx/panel/graph.py      | 753 ++++++++++++++++++++++++++++++++++++++++++++++++
 nyx/panel/header.py     | 496 +++++++++++++++++++++++++++++++
 nyx/panel/log.py        | 454 +++++++++++++++++++++++++++++
 nyx/panel/torrc.py      | 171 +++++++++++
 nyx/torrc_panel.py      | 171 -----------
 nyx/util/log.py         |   2 +-
 setup.py                |   2 +-
 18 files changed, 2967 insertions(+), 2960 deletions(-)

diff --git a/nyx/__init__.py b/nyx/__init__.py
index 4d58435..25da9dd 100644
--- a/nyx/__init__.py
+++ b/nyx/__init__.py
@@ -14,14 +14,9 @@ __license__ = 'GPLv3'
 
 __all__ = [
   'arguments',
-  'config_panel',
-  'connection_panel',
   'controller',
-  'header_panel',
-  'log_panel',
   'popups',
   'starter',
-  'torrc_panel',
 ]
 
 
diff --git a/nyx/config_panel.py b/nyx/config_panel.py
deleted file mode 100644
index 2352fe7..0000000
--- a/nyx/config_panel.py
+++ /dev/null
@@ -1,357 +0,0 @@
-"""
-Panel presenting the configuration state for tor or nyx. Options can be edited
-and the resulting configuration files saved.
-"""
-
-import curses
-import os
-
-import nyx.controller
-import nyx.popups
-
-import stem.control
-import stem.manual
-
-from nyx.util import DATA_DIR, panel, tor_controller, ui_tools
-
-from stem.util import conf, enum, log, str_tools
-
-SortAttr = enum.Enum('NAME', 'VALUE', 'VALUE_TYPE', 'CATEGORY', 'USAGE', 'SUMMARY', 'DESCRIPTION', 'MAN_PAGE_ENTRY', 'IS_SET')
-
-DETAILS_HEIGHT = 8
-NAME_WIDTH = 25
-VALUE_WIDTH = 15
-
-
-def conf_handler(key, value):
-  if key == 'features.config.order':
-    return conf.parse_enum_csv(key, value[0], SortAttr, 3)
-
-
-CONFIG = conf.config_dict('nyx', {
-  'attr.config.category_color': {},
-  'attr.config.sort_color': {},
-  'features.config.order': [SortAttr.MAN_PAGE_ENTRY, SortAttr.NAME, SortAttr.IS_SET],
-  'features.config.state.showPrivateOptions': False,
-  'features.config.state.showVirtualOptions': False,
-}, conf_handler)
-
-
-class ConfigEntry(object):
-  """
-  Configuration option presented in the panel.
-
-  :var str name: name of the configuration option
-  :var str value_type: type of value
-  :var stem.manual.ConfigOption manual: manual information about the option
-  """
-
-  def __init__(self, name, value_type, manual):
-    self.name = name
-    self.value_type = value_type
-    self.manual = manual.config_options.get(name, stem.manual.ConfigOption(name))
-    self._index = manual.config_options.keys().index(name) if name in manual.config_options else 99999
-
-  def value(self):
-    """
-    Provides the value of this configuration option.
-
-    :returns: **str** representation of the current config value
-    """
-
-    values = tor_controller().get_conf(self.name, [], True)
-
-    if not values:
-      return '<none>'
-    elif self.value_type == 'Boolean' and values[0] in ('0', '1'):
-      return 'False' if values[0] == '0' else 'True'
-    elif self.value_type == 'DataSize' and values[0].isdigit():
-      return str_tools.size_label(int(values[0]))
-    elif self.value_type == 'TimeInterval' and values[0].isdigit():
-      return str_tools.time_label(int(values[0]), is_long = True)
-    else:
-      return ', '.join(values)
-
-  def is_set(self):
-    """
-    Checks if the configuration option has a custom value.
-
-    :returns: **True** if the option has a custom value, **False** otherwise
-    """
-
-    return tor_controller().is_set(self.name, False)
-
-  def sort_value(self, attr):
-    """
-    Provides a heuristic for sorting by a given value.
-
-    :param SortAttr attr: sort attribute to provide a heuristic for
-
-    :returns: comparable value for sorting
-    """
-
-    if attr == SortAttr.CATEGORY:
-      return self.manual.category
-    elif attr == SortAttr.NAME:
-      return self.name
-    elif attr == SortAttr.VALUE:
-      return self.value()
-    elif attr == SortAttr.VALUE_TYPE:
-      return self.value_type
-    elif attr == SortAttr.USAGE:
-      return self.manual.usage
-    elif attr == SortAttr.SUMMARY:
-      return self.manual.summary
-    elif attr == SortAttr.DESCRIPTION:
-      return self.manual.description
-    elif attr == SortAttr.MAN_PAGE_ENTRY:
-      return self._index
-    elif attr == SortAttr.IS_SET:
-      return not self.is_set()
-
-
-class ConfigPanel(panel.Panel):
-  """
-  Editor for tor's configuration.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'configuration', 0)
-
-    self._contents = []
-    self._scroller = ui_tools.Scroller(True)
-    self._sort_order = CONFIG['features.config.order']
-    self._show_all = False  # show all options, or just the important ones
-
-    cached_manual_path = os.path.join(DATA_DIR, 'manual')
-
-    if os.path.exists(cached_manual_path):
-      manual = stem.manual.Manual.from_cache(cached_manual_path)
-    else:
-      try:
-        manual = stem.manual.Manual.from_man()
-
-        try:
-          manual.save(cached_manual_path)
-        except IOError as exc:
-          log.debug("Unable to cache manual information to '%s'. This is fine, but means starting Nyx takes a little longer than usual: " % (cached_manual_path, exc))
-      except IOError as exc:
-        log.debug("Unable to use 'man tor' to get information about config options (%s), using bundled information instead" % exc)
-        manual = stem.manual.Manual.from_cache()
-
-    try:
-      for line in tor_controller().get_info('config/names').splitlines():
-        # Lines of the form "<option> <type>[ <documentation>]". Documentation
-        # was apparently only in old tor versions like 0.2.1.25.
-
-        if ' ' not in line:
-          continue
-
-        line_comp = line.split()
-        name, value_type = line_comp[0], line_comp[1]
-
-        # skips private and virtual entries if not configured to show them
-
-        if name.startswith('__') and not CONFIG['features.config.state.showPrivateOptions']:
-          continue
-        elif value_type == 'Virtual' and not CONFIG['features.config.state.showVirtualOptions']:
-          continue
-
-        self._contents.append(ConfigEntry(name, value_type, manual))
-
-      self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
-    except stem.ControllerError as exc:
-      log.warn('Unable to determine the configuration options tor supports: %s' % exc)
-
-  def show_sort_dialog(self):
-    """
-    Provides the dialog for sorting our configuration options.
-    """
-
-    sort_colors = dict([(attr, CONFIG['attr.config.sort_color'].get(attr, 'white')) for attr in SortAttr])
-    results = nyx.popups.show_sort_dialog('Config Option Ordering:', SortAttr, self._sort_order, sort_colors)
-
-    if results:
-      self._sort_order = results
-      self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
-
-  def show_write_dialog(self):
-    """
-    Confirmation dialog for saving tor's configuration.
-    """
-
-    selection, controller = 1, tor_controller()
-    config_text = controller.get_info('config-text', None)
-    config_lines = config_text.splitlines() if config_text else []
-
-    with nyx.popups.popup_window(len(config_lines) + 2) as (popup, width, height):
-      if not popup or height <= 2:
-        return
-
-      while True:
-        height, width = popup.get_preferred_size()  # allow us to be resized
-        popup.win.erase()
-
-        for i, full_line in enumerate(config_lines):
-          line = str_tools.crop(full_line, width - 2)
-          option, arg = line.split(' ', 1) if ' ' in line else (line, '')
-
-          popup.addstr(i + 1, 1, option, curses.A_BOLD, 'green')
-          popup.addstr(i + 1, len(option) + 2, arg, curses.A_BOLD, 'cyan')
-
-        x = width - 16
-
-        for i, option in enumerate(['Save', 'Cancel']):
-          x = popup.addstr(height - 2, x, '[')
-          x = popup.addstr(height - 2, x, option, curses.A_BOLD, curses.A_STANDOUT if i == selection else curses.A_NORMAL)
-          x = popup.addstr(height - 2, x, '] ')
-
-        popup.win.box()
-        popup.addstr(0, 0, 'Torrc to save:', curses.A_STANDOUT)
-        popup.win.refresh()
-
-        key = nyx.controller.get_controller().key_input()
-
-        if key.match('left'):
-          selection = max(0, selection - 1)
-        elif key.match('right'):
-          selection = min(1, selection + 1)
-        elif key.is_selection():
-          if selection == 0:
-            try:
-              controller.save_conf()
-              nyx.popups.show_msg('Saved configuration to %s' % controller.get_info('config-file', '<unknown>'), 2)
-            except IOError as exc:
-              nyx.popups.show_msg('Unable to save configuration (%s)' % exc.strerror, 2)
-
-          break
-        elif key.match('esc'):
-          break  # esc - cancel
-
-  def handle_key(self, key):
-    if key.is_scroll():
-      page_height = self.get_preferred_size()[0] - DETAILS_HEIGHT
-      is_changed = self._scroller.handle_key(key, self._get_config_options(), page_height)
-
-      if is_changed:
-        self.redraw(True)
-    elif key.is_selection():
-      selection = self._scroller.get_cursor_selection(self._get_config_options())
-      initial_value = selection.value() if selection.is_set() else ''
-      new_value = nyx.popups.input_prompt('%s Value (esc to cancel): ' % selection.name, initial_value)
-
-      if new_value != initial_value:
-        try:
-          if selection.value_type == 'Boolean':
-            # if the value's a boolean then allow for 'true' and 'false' inputs
-
-            if new_value.lower() == 'true':
-              new_value = '1'
-            elif new_value.lower() == 'false':
-              new_value = '0'
-          elif selection.value_type == 'LineList':
-            new_value = new_value.split(',')  # set_conf accepts list inputs
-
-          tor_controller().set_conf(selection.name, new_value)
-          self.redraw(True)
-        except Exception as exc:
-          nyx.popups.show_msg('%s (press any key)' % exc)
-    elif key.match('a'):
-      self._show_all = not self._show_all
-      self.redraw(True)
-    elif key.match('s'):
-      self.show_sort_dialog()
-    elif key.match('w'):
-      self.show_write_dialog()
-    else:
-      return False
-
-    return True
-
-  def get_help(self):
-    return [
-      ('up arrow', 'scroll up a line', None),
-      ('down arrow', 'scroll down a line', None),
-      ('page up', 'scroll up a page', None),
-      ('page down', 'scroll down a page', None),
-      ('enter', 'edit configuration option', None),
-      ('w', 'write torrc', None),
-      ('a', 'toggle filtering', None),
-      ('s', 'sort ordering', None),
-    ]
-
-  def draw(self, width, height):
-    contents = self._get_config_options()
-    selection = self._scroller.get_cursor_selection(contents)
-    scroll_location = self._scroller.get_scroll_location(contents, height - DETAILS_HEIGHT)
-    is_scrollbar_visible = len(contents) > height - DETAILS_HEIGHT
-
-    if selection is not None:
-      self._draw_selection_details(selection, width)
-
-    if self.is_title_visible():
-      hidden_msg = "press 'a' to hide most options" if self._show_all else "press 'a' to show all options"
-      self.addstr(0, 0, 'Tor Configuration (%s):' % hidden_msg, curses.A_STANDOUT)
-
-    scroll_offset = 1
-
-    if is_scrollbar_visible:
-      scroll_offset = 3
-      self.add_scroll_bar(scroll_location, scroll_location + height - DETAILS_HEIGHT, len(contents), DETAILS_HEIGHT)
-
-      if selection is not None:
-        self.addch(DETAILS_HEIGHT - 1, 1, curses.ACS_TTEE)
-
-    # Description column can grow up to eighty characters. After that any extra
-    # space goes to the value.
-
-    description_width = max(0, width - scroll_offset - NAME_WIDTH - VALUE_WIDTH - 2)
-
-    if description_width > 80:
-      value_width = VALUE_WIDTH + (description_width - 80)
-      description_width = 80
-    else:
-      value_width = VALUE_WIDTH
-
-    for i, entry in enumerate(contents[scroll_location:]):
-      attr = nyx.util.ui_tools.get_color(CONFIG['attr.config.category_color'].get(entry.manual.category, 'white'))
-      attr |= curses.A_BOLD if entry.is_set() else curses.A_NORMAL
-      attr |= curses.A_STANDOUT if entry == selection else curses.A_NORMAL
-
-      option_label = str_tools.crop(entry.name, NAME_WIDTH).ljust(NAME_WIDTH + 1)
-      value_label = str_tools.crop(entry.value(), value_width).ljust(value_width + 1)
-      summary_label = str_tools.crop(entry.manual.summary, description_width).ljust(description_width)
-
-      self.addstr(DETAILS_HEIGHT + i, scroll_offset, option_label + value_label + summary_label, attr)
-
-      if DETAILS_HEIGHT + i >= height:
-        break
-
-  def _get_config_options(self):
-    return self._contents if self._show_all else filter(lambda entry: stem.manual.is_important(entry.name) or entry.is_set(), self._contents)
-
-  def _draw_selection_details(self, selection, width):
-    """
-    Shows details of the currently selected option.
-    """
-
-    description = 'Description: %s' % (selection.manual.description)
-    attr = ', '.join(('custom' if selection.is_set() else 'default', selection.value_type, 'usage: %s' % selection.manual.usage))
-    selected_color = CONFIG['attr.config.category_color'].get(selection.manual.category, 'white')
-    ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT)
-
-    self.addstr(1, 2, '%s (%s Option)' % (selection.name, selection.manual.category), curses.A_BOLD, selected_color)
-    self.addstr(2, 2, 'Value: %s (%s)' % (selection.value(), str_tools.crop(attr, width - len(selection.value()) - 13)), curses.A_BOLD, selected_color)
-
-    for i in range(DETAILS_HEIGHT - 4):
-      if not description:
-        break  # done writing description
-
-      line, description = description.split('\n', 1) if '\n' in description else (description, '')
-
-      if i < DETAILS_HEIGHT - 5:
-        line, remainder = str_tools.crop(line, width - 3, 4, 4, str_tools.Ending.HYPHEN, True)
-        description = '  ' + remainder.strip() + description
-        self.addstr(3 + i, 2, line, curses.A_BOLD, selected_color)
-      else:
-        self.addstr(3 + i, 2, str_tools.crop(line, width - 3, 4, 4), curses.A_BOLD, selected_color)
diff --git a/nyx/connection_panel.py b/nyx/connection_panel.py
deleted file mode 100644
index e28f531..0000000
--- a/nyx/connection_panel.py
+++ /dev/null
@@ -1,706 +0,0 @@
-"""
-Listing of the currently established connections tor has made.
-"""
-
-import re
-import time
-import collections
-import curses
-import itertools
-import threading
-
-import nyx.popups
-import nyx.util.tracker
-import nyx.util.ui_tools
-
-from nyx.util import panel, tor_controller, ui_tools
-
-from stem.control import Listener
-from stem.util import datetime_to_unix, conf, connection, enum, str_tools
-
-try:
-  # added in python 3.2
-  from functools import lru_cache
-except ImportError:
-  from stem.util.lru_cache import lru_cache
-
-# height of the detail panel content, not counting top and bottom border
-
-DETAILS_HEIGHT = 7
-
-EXIT_USAGE_WIDTH = 15
-UPDATE_RATE = 5  # rate in seconds at which we refresh
-
-# cached information from our last _update() call
-
-LAST_RETRIEVED_HS_CONF = None
-LAST_RETRIEVED_CIRCUITS = None
-
-# Connection Categories:
-#   Inbound      Relay connection, coming to us.
-#   Outbound     Relay connection, leaving us.
-#   Exit         Outbound relay connection leaving the Tor network.
-#   Hidden       Connections to a hidden service we're providing.
-#   Socks        Socks connections for applications using Tor.
-#   Circuit      Circuits our tor client has created.
-#   Directory    Fetching tor consensus information.
-#   Control      Tor controller (nyx, vidalia, etc).
-
-Category = enum.Enum('INBOUND', 'OUTBOUND', 'EXIT', 'HIDDEN', 'SOCKS', 'CIRCUIT', 'DIRECTORY', 'CONTROL')
-SortAttr = enum.Enum('CATEGORY', 'UPTIME', 'IP_ADDRESS', 'PORT', 'FINGERPRINT', 'NICKNAME', 'COUNTRY')
-LineType = enum.Enum('CONNECTION', 'CIRCUIT_HEADER', 'CIRCUIT')
-
-Line = collections.namedtuple('Line', [
-  'entry',
-  'line_type',
-  'connection',
-  'circuit',
-  'fingerprint',
-  'nickname',
-  'locale',
-])
-
-
-def conf_handler(key, value):
-  if key == 'features.connection.order':
-    return conf.parse_enum_csv(key, value[0], SortAttr, 3)
-
-
-CONFIG = conf.config_dict('nyx', {
-  'attr.connection.category_color': {},
-  'attr.connection.sort_color': {},
-  'features.connection.resolveApps': True,
-  'features.connection.order': [SortAttr.CATEGORY, SortAttr.IP_ADDRESS, SortAttr.UPTIME],
-  'features.connection.showIps': True,
-}, conf_handler)
-
-
-class Entry(object):
-  @staticmethod
-  @lru_cache()
-  def from_connection(connection):
-    return ConnectionEntry(connection)
-
-  @staticmethod
-  @lru_cache()
-  def from_circuit(circuit):
-    return CircuitEntry(circuit)
-
-  def get_lines(self):
-    """
-    Provides individual lines of connection information.
-
-    :returns: **list** of **ConnectionLine** concerning this entry
-    """
-
-    raise NotImplementedError('should be implemented by subclasses')
-
-  def get_type(self):
-    """
-    Provides our best guess at the type of connection this is.
-
-    :returns: **Category** for the connection's type
-    """
-
-    raise NotImplementedError('should be implemented by subclasses')
-
-  def is_private(self):
-    """
-    Checks if information about this endpoint should be scrubbed. Relaying
-    etiquette (and wiretapping laws) say these are bad things to look at so
-    DON'T CHANGE THIS UNLESS YOU HAVE A DAMN GOOD REASON!
-
-    :returns: **bool** indicating if connection information is sensive or not
-    """
-
-    raise NotImplementedError('should be implemented by subclasses')
-
-  def sort_value(self, attr):
-    """
-    Provides a heuristic for sorting by a given value.
-
-    :param SortAttr attr: sort attribute to provide a heuristic for
-
-    :returns: comparable value for sorting
-    """
-
-    line = self.get_lines()[0]
-    at_end = 'z' * 20
-
-    if attr == SortAttr.IP_ADDRESS:
-      if self.is_private():
-        return 255 ** 4  # orders at the end
-
-      ip_value = 0
-
-      for octet in line.connection.remote_address.split('.'):
-        ip_value = ip_value * 255 + int(octet)
-
-      return ip_value * 65536 + line.connection.remote_port
-    elif attr == SortAttr.PORT:
-      return line.connection.remote_port
-    elif attr == SortAttr.FINGERPRINT:
-      return line.fingerprint if line.fingerprint else at_end
-    elif attr == SortAttr.NICKNAME:
-      return line.nickname if line.nickname else at_end
-    elif attr == SortAttr.CATEGORY:
-      return Category.index_of(self.get_type())
-    elif attr == SortAttr.UPTIME:
-      return line.connection.start_time
-    elif attr == SortAttr.COUNTRY:
-      return line.locale if (line.locale and not self.is_private()) else at_end
-    else:
-      return ''
-
-
-class ConnectionEntry(Entry):
-  def __init__(self, connection):
-    self._connection = connection
-
-  @lru_cache()
-  def get_lines(self):
-    fingerprint, nickname, locale = None, None, None
-
-    if self.get_type() in (Category.OUTBOUND, Category.CIRCUIT, Category.DIRECTORY, Category.EXIT):
-      fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
-
-      if fingerprint:
-        nickname = nyx.util.tracker.get_consensus_tracker().get_relay_nickname(fingerprint)
-        locale = tor_controller().get_info('ip-to-country/%s' % self._connection.remote_address, None)
-
-    return [Line(self, LineType.CONNECTION, self._connection, None, fingerprint, nickname, locale)]
-
-  @lru_cache()
-  def get_type(self):
-    controller = tor_controller()
-
-    if self._connection.local_port in controller.get_ports(Listener.OR, []):
-      return Category.INBOUND
-    elif self._connection.local_port in controller.get_ports(Listener.DIR, []):
-      return Category.INBOUND
-    elif self._connection.local_port in controller.get_ports(Listener.SOCKS, []):
-      return Category.SOCKS
-    elif self._connection.local_port in controller.get_ports(Listener.CONTROL, []):
-      return Category.CONTROL
-
-    if LAST_RETRIEVED_HS_CONF:
-      for hs_config in LAST_RETRIEVED_HS_CONF.values():
-        if self._connection.remote_port == hs_config['HiddenServicePort']:
-          return Category.HIDDEN
-
-    fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
-
-    if fingerprint and LAST_RETRIEVED_CIRCUITS:
-      for circ in LAST_RETRIEVED_CIRCUITS:
-        if circ.path and len(circ.path) == 1 and circ.path[0][0] == fingerprint and circ.status == 'BUILT':
-          return Category.DIRECTORY  # one-hop circuit to retrieve directory information
-    else:
-      # not a known relay, might be an exit connection
-
-      exit_policy = controller.get_exit_policy(None)
-
-      if exit_policy and exit_policy.can_exit_to(self._connection.remote_address, self._connection.remote_port):
-        return Category.EXIT
-
-    return Category.OUTBOUND
-
-  @lru_cache()
-  def is_private(self):
-    if not CONFIG['features.connection.showIps']:
-      return True
-
-    if self.get_type() == Category.INBOUND:
-      controller = tor_controller()
-
-      if controller.is_user_traffic_allowed().inbound:
-        return len(nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address)) == 0
-    elif self.get_type() == Category.EXIT:
-      # DNS connections exiting us aren't private (since they're hitting our
-      # resolvers). Everything else is.
-
-      return not (self._connection.remote_port == 53 and self._connection.protocol == 'udp')
-
-    return False  # for everything else this isn't a concern
-
-
-class CircuitEntry(Entry):
-  def __init__(self, circuit):
-    self._circuit = circuit
-
-  @lru_cache()
-  def get_lines(self):
-    def line(fingerprint, line_type):
-      address, port, nickname, locale = '0.0.0.0', 0, None, None
-      consensus_tracker = nyx.util.tracker.get_consensus_tracker()
-
-      if fingerprint is not None:
-        address, port = consensus_tracker.get_relay_address(fingerprint, ('192.168.0.1', 0))
-        nickname = consensus_tracker.get_relay_nickname(fingerprint)
-        locale = tor_controller().get_info('ip-to-country/%s' % address, None)
-
-      connection = nyx.util.tracker.Connection(datetime_to_unix(self._circuit.created), False, '127.0.0.1', 0, address, port, 'tcp', False)
-      return Line(self, line_type, connection, self._circuit, fingerprint, nickname, locale)
-
-    header_line = line(self._circuit.path[-1][0] if self._circuit.status == 'BUILT' else None, LineType.CIRCUIT_HEADER)
-    return [header_line] + [line(fp, LineType.CIRCUIT) for fp, _ in self._circuit.path]
-
-  def get_type(self):
-    return Category.CIRCUIT
-
-  def is_private(self):
-    return False
-
-
-class ConnectionPanel(panel.Panel, threading.Thread):
-  """
-  Listing of connections tor is making, with information correlated against
-  the current consensus and other data sources.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'connections', 0)
-    threading.Thread.__init__(self)
-    self.setDaemon(True)
-
-    self._scroller = ui_tools.Scroller(True)
-    self._entries = []            # last fetched display entries
-    self._show_details = False    # presents the details panel if true
-    self._sort_order = CONFIG['features.connection.order']
-
-    self._last_resource_fetch = -1  # timestamp of the last ConnectionResolver results used
-
-    self._pause_condition = threading.Condition()
-    self._halt = False  # terminates thread if true
-
-    # Tracks exiting port and client country statistics
-
-    self._client_locale_usage = {}
-    self._exit_port_usage = {}
-    self._counted_connections = set()
-
-    # If we're a bridge and been running over a day then prepopulates with the
-    # last day's clients.
-
-    bridge_clients = tor_controller().get_info('status/clients-seen', None)
-
-    if bridge_clients:
-      # Response has a couple arguments...
-      # TimeStarted="2011-08-17 15:50:49" CountrySummary=us=16,de=8,uk=8
-
-      country_summary = None
-
-      for line in bridge_clients.split():
-        if line.startswith('CountrySummary='):
-          country_summary = line[15:]
-          break
-
-      if country_summary:
-        for entry in country_summary.split(','):
-          if re.match('^..=[0-9]+$', entry):
-            locale, count = entry.split('=', 1)
-            self._client_locale_usage[locale] = int(count)
-
-  def show_sort_dialog(self):
-    """
-    Provides a dialog for sorting our connections.
-    """
-
-    sort_colors = dict([(attr, CONFIG['attr.connection.sort_color'].get(attr, 'white')) for attr in SortAttr])
-    results = nyx.popups.show_sort_dialog('Connection Ordering:', SortAttr, self._sort_order, sort_colors)
-
-    if results:
-      self._sort_order = results
-      self._entries = sorted(self._entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
-
-  def handle_key(self, key):
-    user_traffic_allowed = tor_controller().is_user_traffic_allowed()
-
-    if key.is_scroll():
-      page_height = self.get_preferred_size()[0] - 1
-
-      if self._show_details:
-        page_height -= (DETAILS_HEIGHT + 1)
-
-      lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in self._entries]))
-      is_changed = self._scroller.handle_key(key, lines, page_height)
-
-      if is_changed:
-        self.redraw(True)
-    elif key.is_selection():
-      self._show_details = not self._show_details
-      self.redraw(True)
-    elif key.match('s'):
-      self.show_sort_dialog()
-    elif key.match('r'):
-      connection_tracker = nyx.util.tracker.get_connection_tracker()
-      options = ['auto'] + list(connection.Resolver) + list(nyx.util.tracker.CustomResolver)
-
-      resolver = connection_tracker.get_custom_resolver()
-      selected_index = 0 if resolver is None else options.index(resolver)
-      selection = nyx.popups.show_menu('Connection Resolver:', options, selected_index)
-
-      if selection != -1:
-        connection_tracker.set_custom_resolver(None if selection == 0 else options[selection])
-    elif key.match('d'):
-      self.set_title_visible(False)
-      self.redraw(True)
-      entries = self._entries
-
-      while True:
-        lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
-        selection = self._scroller.get_cursor_selection(lines)
-
-        if not selection:
-          break
-
-        def is_close_key(key):
-          return key.is_selection() or key.match('d') or key.match('left') or key.match('right')
-
-        color = CONFIG['attr.connection.category_color'].get(selection.entry.get_type(), 'white')
-        key = nyx.popups.show_descriptor_popup(selection.fingerprint, color, self.max_x, is_close_key)
-
-        if not key or key.is_selection() or key.match('d'):
-          break  # closes popup
-        elif key.match('left'):
-          self.handle_key(panel.KeyInput(curses.KEY_UP))
-        elif key.match('right'):
-          self.handle_key(panel.KeyInput(curses.KEY_DOWN))
-
-      self.set_title_visible(True)
-      self.redraw(True)
-    elif key.match('c') and user_traffic_allowed.inbound:
-      nyx.popups.show_count_dialog('Client Locales', self._client_locale_usage)
-    elif key.match('e') and user_traffic_allowed.outbound:
-      counts = {}
-      key_width = max(map(len, self._exit_port_usage.keys()))
-
-      for k, v in self._exit_port_usage.items():
-        usage = connection.port_usage(k)
-
-        if usage:
-          k = k.ljust(key_width + 3) + usage.ljust(EXIT_USAGE_WIDTH)
-
-        counts[k] = v
-
-      nyx.popups.show_count_dialog('Exiting Port Usage', counts)
-    else:
-      return False
-
-    return True
-
-  def run(self):
-    """
-    Keeps connections listing updated, checking for new entries at a set rate.
-    """
-
-    last_ran = -1
-
-    while not self._halt:
-      if self.is_paused() or not tor_controller().is_alive() or (time.time() - last_ran) < UPDATE_RATE:
-        with self._pause_condition:
-          if not self._halt:
-            self._pause_condition.wait(0.2)
-
-        continue  # done waiting, try again
-
-      self._update()
-      self.redraw(True)
-
-      # If this is our first run then fill in our fingerprint tracker. This
-      # requires fetching all the router status entries which takes a few
-      # seconds, so best done when we're finished with the rest of the first
-      # iteration to hide the lag.
-
-      if last_ran == -1:
-        nyx.util.tracker.get_consensus_tracker().update(tor_controller().get_network_statuses([]))
-
-      last_ran = time.time()
-
-  def get_help(self):
-    resolver = nyx.util.tracker.get_connection_tracker().get_custom_resolver()
-    user_traffic_allowed = tor_controller().is_user_traffic_allowed()
-
-    options = [
-      ('up arrow', 'scroll up a line', None),
-      ('down arrow', 'scroll down a line', None),
-      ('page up', 'scroll up a page', None),
-      ('page down', 'scroll down a page', None),
-      ('enter', 'show connection details', None),
-      ('d', 'raw consensus descriptor', None),
-      ('s', 'sort ordering', None),
-      ('r', 'connection resolver', 'auto' if resolver is None else resolver),
-    ]
-
-    if user_traffic_allowed.inbound:
-      options.append(('c', 'client locale usage summary', None))
-
-    if user_traffic_allowed.outbound:
-      options.append(('e', 'exit port usage summary', None))
-
-    return options
-
-  def draw(self, width, height):
-    controller = tor_controller()
-    entries = self._entries
-
-    lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
-    selected = self._scroller.get_cursor_selection(lines)
-
-    if self.is_paused():
-      current_time = self.get_pause_time()
-    elif not controller.is_alive():
-      current_time = controller.connection_time()
-    else:
-      current_time = time.time()
-
-    is_showing_details = self._show_details and selected
-    details_offset = DETAILS_HEIGHT + 1 if is_showing_details else 0
-
-    is_scrollbar_visible = len(lines) > height - details_offset - 1
-    scroll_offset = 2 if is_scrollbar_visible else 0
-    scroll_location = self._scroller.get_scroll_location(lines, height - details_offset - 1)
-
-    if self.is_title_visible():
-      self._draw_title(entries, self._show_details)
-
-    if is_showing_details:
-      self._draw_details(selected, width, is_scrollbar_visible)
-
-    if is_scrollbar_visible:
-      self.add_scroll_bar(scroll_location, scroll_location + height - details_offset - 1, len(lines), 1 + details_offset)
-
-    for line_number in range(scroll_location, len(lines)):
-      y = line_number + details_offset + 1 - scroll_location
-      self._draw_line(scroll_offset, y, lines[line_number], lines[line_number] == selected, width - scroll_offset, current_time)
-
-      if y >= height:
-        break
-
-  def _draw_title(self, entries, showing_details):
-    """
-    Panel title with the number of connections we presently have.
-    """
-
-    if showing_details:
-      self.addstr(0, 0, 'Connection Details:', curses.A_STANDOUT)
-    elif not entries:
-      self.addstr(0, 0, 'Connections:', curses.A_STANDOUT)
-    else:
-      counts = collections.Counter([entry.get_type() for entry in entries])
-      count_labels = ['%i %s' % (counts[category], category.lower()) for category in Category if counts[category]]
-      self.addstr(0, 0, 'Connections (%s):' % ', '.join(count_labels), curses.A_STANDOUT)
-
-  def _draw_details(self, selected, width, is_scrollbar_visible):
-    """
-    Shows detailed information about the selected connection.
-    """
-
-    attr = (CONFIG['attr.connection.category_color'].get(selected.entry.get_type(), 'white'), curses.A_BOLD)
-
-    if selected.line_type == LineType.CIRCUIT_HEADER and selected.circuit.status != 'BUILT':
-      self.addstr(1, 2, 'Building Circuit...', *attr)
-    else:
-      address = '<scrubbed>' if selected.entry.is_private() else selected.connection.remote_address
-      self.addstr(1, 2, 'address: %s:%s' % (address, selected.connection.remote_port), *attr)
-      self.addstr(2, 2, 'locale: %s' % ('??' if selected.entry.is_private() else (selected.locale if selected.locale else '??')), *attr)
-
-      matches = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(selected.connection.remote_address)
-
-      if not matches:
-        self.addstr(3, 2, 'No consensus data found', *attr)
-      elif len(matches) == 1 or selected.connection.remote_port in matches:
-        controller = tor_controller()
-        fingerprint = matches.values()[0] if len(matches) == 1 else matches[selected.connection.remote_port]
-        router_status_entry = controller.get_network_status(fingerprint, None)
-
-        self.addstr(2, 15, 'fingerprint: %s' % fingerprint, *attr)
-
-        if router_status_entry:
-          dir_port_label = 'dirport: %s' % router_status_entry.dir_port if router_status_entry.dir_port else ''
-          self.addstr(3, 2, 'nickname: %-25s orport: %-10s %s' % (router_status_entry.nickname, router_status_entry.or_port, dir_port_label), *attr)
-          self.addstr(4, 2, 'published: %s' % router_status_entry.published.strftime("%H:%M %m/%d/%Y"), *attr)
-          self.addstr(5, 2, 'flags: %s' % ', '.join(router_status_entry.flags), *attr)
-
-          server_descriptor = controller.get_server_descriptor(fingerprint, None)
-
-          if server_descriptor:
-            policy_label = server_descriptor.exit_policy.summary() if server_descriptor.exit_policy else 'unknown'
-            self.addstr(6, 2, 'exit policy: %s' % policy_label, *attr)
-            self.addstr(4, 38, 'os: %-14s version: %s' % (server_descriptor.operating_system, server_descriptor.tor_version), *attr)
-
-            if server_descriptor.contact:
-              self.addstr(7, 2, 'contact: %s' % server_descriptor.contact, *attr)
-      else:
-        self.addstr(3, 2, 'Multiple matches, possible fingerprints are:', *attr)
-
-        for i, port in enumerate(sorted(matches.keys())):
-          is_last_line, remaining_relays = i == 3, len(matches) - i
-
-          if not is_last_line or remaining_relays == 1:
-            self.addstr(4 + i, 2, '%i. or port: %-5s fingerprint: %s' % (i + 1, port, matches[port]), *attr)
-          else:
-            self.addstr(4 + i, 2, '... %i more' % remaining_relays, *attr)
-
-          if is_last_line:
-            break
-
-    # draw the border, with a 'T' pipe if connecting with the scrollbar
-
-    ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT + 2)
-
-    if is_scrollbar_visible:
-      self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
-
-  def _draw_line(self, x, y, line, is_selected, width, current_time):
-    attr = nyx.util.ui_tools.get_color(CONFIG['attr.connection.category_color'].get(line.entry.get_type(), 'white'))
-    attr |= curses.A_STANDOUT if is_selected else curses.A_NORMAL
-
-    self.addstr(y, x, ' ' * (width - x), attr)
-
-    if line.line_type == LineType.CIRCUIT:
-      if line.circuit.path[-1][0] == line.fingerprint:
-        prefix = (ord(' '), curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' '))
-      else:
-        prefix = (ord(' '), curses.ACS_VLINE, ord(' '), ord(' '))
-
-      for char in prefix:
-        x = self.addch(y, x, char)
-    else:
-      x += 1  # offset from edge
-
-    self._draw_address_column(x, y, line, attr)
-    self._draw_line_details(57, y, line, width - 57 - 20, attr)
-    self._draw_right_column(width - 18, y, line, current_time, attr)
-
-  def _draw_address_column(self, x, y, line, attr):
-    src = tor_controller().get_info('address', line.connection.local_address)
-    src += ':%s' % line.connection.local_port if line.line_type == LineType.CONNECTION else ''
-
-    if line.line_type == LineType.CIRCUIT_HEADER and line.circuit.status != 'BUILT':
-      dst = 'Building...'
-    else:
-      dst = '<scrubbed>' if line.entry.is_private() else line.connection.remote_address
-      dst += ':%s' % line.connection.remote_port
-
-      if line.entry.get_type() == Category.EXIT:
-        purpose = connection.port_usage(line.connection.remote_port)
-
-        if purpose:
-          dst += ' (%s)' % str_tools.crop(purpose, 26 - len(dst) - 3)
-      elif not tor_controller().is_geoip_unavailable() and not line.entry.is_private():
-        dst += ' (%s)' % (line.locale if line.locale else '??')
-
-    if line.entry.get_type() in (Category.INBOUND, Category.SOCKS, Category.CONTROL):
-      dst, src = src, dst
-
-    if line.line_type == LineType.CIRCUIT:
-      self.addstr(y, x, dst, attr)
-    else:
-      self.addstr(y, x, '%-21s  -->  %-26s' % (src, dst), attr)
-
-  def _draw_line_details(self, x, y, line, width, attr):
-    if line.line_type == LineType.CIRCUIT_HEADER:
-      comp = ['Purpose: %s' % line.circuit.purpose.capitalize(), ', Circuit ID: %s' % line.circuit.id]
-    elif line.entry.get_type() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL):
-      try:
-        port = line.connection.local_port if line.entry.get_type() == Category.HIDDEN else line.connection.remote_port
-        process = nyx.util.tracker.get_port_usage_tracker().fetch(port)
-        comp = ['%s (%s)' % (process.name, process.pid) if process.pid else process.name]
-      except nyx.util.tracker.UnresolvedResult:
-        comp = ['resolving...']
-      except nyx.util.tracker.UnknownApplication:
-        comp = ['UNKNOWN']
-    else:
-      comp = ['%-40s' % (line.fingerprint if line.fingerprint else 'UNKNOWN'), '  ' + (line.nickname if line.nickname else 'UNKNOWN')]
-
-    for entry in comp:
-      if width >= x + len(entry):
-        x = self.addstr(y, x, entry, attr)
-      else:
-        return
-
-  def _draw_right_column(self, x, y, line, current_time, attr):
-    if line.line_type == LineType.CIRCUIT:
-      circ_path = [fp for fp, _ in line.circuit.path]
-      circ_index = circ_path.index(line.fingerprint)
-
-      if circ_index == len(circ_path) - 1:
-        placement_type = 'Exit' if line.circuit.status == 'BUILT' else 'Extending'
-      elif circ_index == 0:
-        placement_type = 'Guard'
-      else:
-        placement_type = 'Middle'
-
-      self.addstr(y, x + 4, '%i / %s' % (circ_index + 1, placement_type), attr)
-    else:
-      x = self.addstr(y, x, '+' if line.connection.is_legacy else ' ', attr)
-      x = self.addstr(y, x, '%5s' % str_tools.time_label(current_time - line.connection.start_time, 1), attr)
-      x = self.addstr(y, x, ' (', attr)
-      x = self.addstr(y, x, line.entry.get_type().upper(), attr | curses.A_BOLD)
-      x = self.addstr(y, x, ')', attr)
-
-  def stop(self):
-    """
-    Halts further resolutions and terminates the thread.
-    """
-
-    with self._pause_condition:
-      self._halt = True
-      self._pause_condition.notifyAll()
-
-  def _update(self):
-    """
-    Fetches the newest resolved connections.
-    """
-
-    global LAST_RETRIEVED_CIRCUITS, LAST_RETRIEVED_HS_CONF
-
-    controller = tor_controller()
-    LAST_RETRIEVED_CIRCUITS = controller.get_circuits([])
-    LAST_RETRIEVED_HS_CONF = controller.get_hidden_service_conf({})
-
-    conn_resolver = nyx.util.tracker.get_connection_tracker()
-    current_resolution_count = conn_resolver.run_counter()
-
-    if not conn_resolver.is_alive():
-      return  # if we're not fetching connections then this is a no-op
-    elif current_resolution_count == self._last_resource_fetch:
-      return  # no new connections to process
-
-    new_entries = [Entry.from_connection(conn) for conn in conn_resolver.get_value()]
-
-    for circ in LAST_RETRIEVED_CIRCUITS:
-      # Skips established single-hop circuits (these are for directory
-      # fetches, not client circuits)
-
-      if not (circ.status == 'BUILT' and len(circ.path) == 1):
-        new_entries.append(Entry.from_circuit(circ))
-
-    # update stats for client and exit connections
-
-    for entry in new_entries:
-      line = entry.get_lines()[0]
-
-      if entry.is_private() and line.connection not in self._counted_connections:
-        if entry.get_type() == Category.INBOUND and line.locale:
-          self._client_locale_usage[line.locale] = self._client_locale_usage.get(line.locale, 0) + 1
-        elif entry.get_type() == Category.EXIT:
-          self._exit_port_usage[line.connection.remote_port] = self._exit_port_usage.get(line.connection.remote_port, 0) + 1
-
-        self._counted_connections.add(line.connection)
-
-    self._entries = sorted(new_entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
-    self._last_resource_fetch = current_resolution_count
-
-    if CONFIG['features.connection.resolveApps']:
-      local_ports, remote_ports = [], []
-
-      for entry in new_entries:
-        line = entry.get_lines()[0]
-
-        if entry.get_type() in (Category.SOCKS, Category.CONTROL):
-          local_ports.append(line.connection.remote_port)
-        elif entry.get_type() == Category.HIDDEN:
-          remote_ports.append(line.connection.local_port)
-
-      nyx.util.tracker.get_port_usage_tracker().query(local_ports, remote_ports)
diff --git a/nyx/controller.py b/nyx/controller.py
index e143b49..318df4b 100644
--- a/nyx/controller.py
+++ b/nyx/controller.py
@@ -9,14 +9,15 @@ import threading
 
 import nyx.menu.menu
 import nyx.popups
-import nyx.header_panel
-import nyx.log_panel
-import nyx.config_panel
-import nyx.torrc_panel
-import nyx.graph_panel
-import nyx.connection_panel
 import nyx.util.tracker
 
+import nyx.panel.config
+import nyx.panel.connection
+import nyx.panel.graph
+import nyx.panel.header
+import nyx.panel.log
+import nyx.panel.torrc
+
 import stem
 
 from stem.util import conf, log
@@ -102,29 +103,29 @@ class Controller:
     self._screen = stdscr
 
     self._sticky_panels = [
-      nyx.header_panel.HeaderPanel(stdscr),
+      nyx.panel.header.HeaderPanel(stdscr),
       LabelPanel(stdscr),
     ]
 
     self._page_panels, first_page_panels = [], []
 
     if CONFIG['features.panels.show.graph']:
-      first_page_panels.append(nyx.graph_panel.GraphPanel(stdscr))
+      first_page_panels.append(nyx.panel.graph.GraphPanel(stdscr))
 
     if CONFIG['features.panels.show.log']:
-      first_page_panels.append(nyx.log_panel.LogPanel(stdscr))
+      first_page_panels.append(nyx.panel.log.LogPanel(stdscr))
 
     if first_page_panels:
       self._page_panels.append(first_page_panels)
 
     if CONFIG['features.panels.show.connection']:
-      self._page_panels.append([nyx.connection_panel.ConnectionPanel(stdscr)])
+      self._page_panels.append([nyx.panel.connection.ConnectionPanel(stdscr)])
 
     if CONFIG['features.panels.show.config']:
-      self._page_panels.append([nyx.config_panel.ConfigPanel(stdscr)])
+      self._page_panels.append([nyx.panel.config.ConfigPanel(stdscr)])
 
     if CONFIG['features.panels.show.torrc']:
-      self._page_panels.append([nyx.torrc_panel.TorrcPanel(stdscr)])
+      self._page_panels.append([nyx.panel.torrc.TorrcPanel(stdscr)])
 
     self.quit_signal = False
     self._page = 0
diff --git a/nyx/graph_panel.py b/nyx/graph_panel.py
deleted file mode 100644
index 086d04d..0000000
--- a/nyx/graph_panel.py
+++ /dev/null
@@ -1,753 +0,0 @@
-"""
-Graphs of tor related statistics. For example...
-
-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 collections
-import copy
-import curses
-import time
-
-import nyx.controller
-import nyx.popups
-import nyx.util.tracker
-
-from stem.control import EventType, Listener
-from stem.util import conf, enum, log, str_tools, system
-from nyx.util import join, msg, panel, tor_controller
-
-GraphStat = enum.Enum(('BANDWIDTH', 'bandwidth'), ('CONNECTIONS', 'connections'), ('SYSTEM_RESOURCES', 'resources'))
-Interval = enum.Enum(('EACH_SECOND', 'each second'), ('FIVE_SECONDS', '5 seconds'), ('THIRTY_SECONDS', '30 seconds'), ('MINUTELY', 'minutely'), ('FIFTEEN_MINUTE', '15 minute'), ('THIRTY_MINUTE', '30 minute'), ('HOURLY', 'hourly'), ('DAILY', 'daily'))
-Bounds = enum.Enum(('GLOBAL_MAX', 'global_max'), ('LOCAL_MAX', 'local_max'), ('TIGHT', 'tight'))
-
-DrawAttributes = collections.namedtuple('DrawAttributes', ('stat', 'subgraph_height', 'subgraph_width', 'interval', 'bounds_type', 'accounting'))
-
-INTERVAL_SECONDS = {
-  Interval.EACH_SECOND: 1,
-  Interval.FIVE_SECONDS: 5,
-  Interval.THIRTY_SECONDS: 30,
-  Interval.MINUTELY: 60,
-  Interval.FIFTEEN_MINUTE: 900,
-  Interval.THIRTY_MINUTE: 1800,
-  Interval.HOURLY: 3600,
-  Interval.DAILY: 86400,
-}
-
-PRIMARY_COLOR, SECONDARY_COLOR = 'green', 'cyan'
-
-ACCOUNTING_RATE = 5
-DEFAULT_CONTENT_HEIGHT = 4  # space needed for labeling above and below the graph
-WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
-COLLAPSE_WIDTH = 135  # width at which to move optional stats from the title to x-axis label
-
-
-def conf_handler(key, value):
-  if key == 'features.graph.height':
-    return max(1, value)
-  elif key == 'features.graph.max_width':
-    return max(1, value)
-  elif key == 'features.graph.type':
-    if value != 'none' and value not in GraphStat:
-      log.warn("'%s' isn't a valid graph type, options are: none, %s" % (CONFIG['features.graph.type'], ', '.join(GraphStat)))
-      return CONFIG['features.graph.type']  # keep the default
-  elif key == 'features.graph.interval':
-    if value not in Interval:
-      log.warn("'%s' isn't a valid graphing interval, options are: %s" % (value, ', '.join(Interval)))
-      return CONFIG['features.graph.interval']  # keep the default
-  elif key == 'features.graph.bound':
-    if value not in Bounds:
-      log.warn("'%s' isn't a valid graph bounds, options are: %s" % (value, ', '.join(Bounds)))
-      return CONFIG['features.graph.bound']  # keep the default
-
-
-CONFIG = conf.config_dict('nyx', {
-  'attr.hibernate_color': {},
-  'attr.graph.title': {},
-  'attr.graph.header.primary': {},
-  'attr.graph.header.secondary': {},
-  'features.graph.height': 7,
-  'features.graph.type': GraphStat.BANDWIDTH,
-  'features.graph.interval': Interval.EACH_SECOND,
-  'features.graph.bound': Bounds.LOCAL_MAX,
-  'features.graph.max_width': 300,  # we need some sort of max size so we know how much graph data to retain
-  'features.panels.show.connection': True,
-  'features.graph.bw.transferInBytes': False,
-  'features.graph.bw.accounting.show': True,
-}, conf_handler)
-
-
-class GraphData(object):
-  """
-  Graphable statistical information.
-
-  :var int latest_value: last value we recorded
-  :var int total: sum of all values we've recorded
-  :var int tick: number of events we've processed
-  :var dict values: mapping of intervals to an array of samplings from newest to oldest
-  :var dict max_value: mapping of intervals to the maximum value it has had
-  """
-
-  def __init__(self, clone = None, category = None, is_primary = True):
-    if clone:
-      self.latest_value = clone.latest_value
-      self.total = clone.total
-      self.tick = clone.tick
-      self.values = copy.deepcopy(clone.values)
-      self.max_value = dict(clone.max_value)
-
-      self._category = clone._category
-      self._is_primary = clone._is_primary
-      self._in_process_value = dict(clone._in_process_value)
-    else:
-      self.latest_value = 0
-      self.total = 0
-      self.tick = 0
-      self.values = dict([(i, CONFIG['features.graph.max_width'] * [0]) for i in Interval])
-      self.max_value = dict([(i, 0) for i in Interval])
-
-      self._category = category
-      self._is_primary = is_primary
-      self._in_process_value = dict([(i, 0) for i in Interval])
-
-  def average(self):
-    return self.total / max(1, self.tick)
-
-  def update(self, new_value):
-    self.latest_value = new_value
-    self.total += new_value
-    self.tick += 1
-
-    for interval in Interval:
-      interval_seconds = INTERVAL_SECONDS[interval]
-      self._in_process_value[interval] += new_value
-
-      if self.tick % interval_seconds == 0:
-        new_entry = self._in_process_value[interval] / interval_seconds
-        self.values[interval] = [new_entry] + self.values[interval][:-1]
-        self.max_value[interval] = max(self.max_value[interval], new_entry)
-        self._in_process_value[interval] = 0
-
-  def header(self, width):
-    """
-    Provides the description above a subgraph.
-
-    :param int width: maximum length of the header
-
-    :returns: **str** with our graph header
-    """
-
-    return self._category._header(width, self._is_primary)
-
-  def y_axis_label(self, value):
-    """
-    Provides the label we should display on our y-axis.
-
-    :param int value: value being shown on the y-axis
-
-    :returns: **str** with our y-axis label
-    """
-
-    return self._category._y_axis_label(value, self._is_primary)
-
-
-class GraphCategory(object):
-  """
-  Category for the graph. This maintains two subgraphs, updating them each
-  second with updated stats.
-
-  :var GraphData primary: first subgraph
-  :var GraphData secondary: second subgraph
-  :var float start_time: unix timestamp for when we started
-  """
-
-  def __init__(self, clone = None):
-    if clone:
-      self.primary = GraphData(clone.primary)
-      self.secondary = GraphData(clone.secondary)
-      self.start_time = clone.start_time
-      self._title_stats = list(clone._title_stats)
-      self._primary_header_stats = list(clone._primary_header_stats)
-      self._secondary_header_stats = list(clone._secondary_header_stats)
-    else:
-      self.primary = GraphData(category = self, is_primary = True)
-      self.secondary = GraphData(category = self, is_primary = False)
-      self.start_time = time.time()
-      self._title_stats = []
-      self._primary_header_stats = []
-      self._secondary_header_stats = []
-
-  def stat_type(self):
-    """
-    Provides the GraphStat this graph is for.
-
-    :returns: **GraphStat** of this graph
-    """
-
-    raise NotImplementedError('Should be implemented by subclasses')
-
-  def title(self, width):
-    """
-    Provides a graph title that fits in the given width.
-
-    :param int width: maximum length of the title
-
-    :returns: **str** with our title
-    """
-
-    title = CONFIG['attr.graph.title'].get(self.stat_type(), '')
-    title_stats = join(self._title_stats, ', ', width - len(title) - 4)
-    return '%s (%s):' % (title, title_stats) if title_stats else title + ':'
-
-  def bandwidth_event(self, event):
-    """
-    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 per second).
-    """
-
-    pass
-
-  def _header(self, is_primary, width):
-    if is_primary:
-      header = CONFIG['attr.graph.header.primary'].get(self.stat_type(), '')
-      header_stats = self._primary_header_stats
-    else:
-      header = CONFIG['attr.graph.header.secondary'].get(self.stat_type(), '')
-      header_stats = self._secondary_header_stats
-
-    header_stats = join(header_stats, '', width - len(header) - 4)
-    return '%s (%s):' % (header, header_stats) if header_stats else '%s:' % header
-
-  def _y_axis_label(self, value, is_primary):
-    return str(value)
-
-
-class BandwidthStats(GraphCategory):
-  """
-  Tracks tor's bandwidth usage.
-  """
-
-  def __init__(self, clone = None):
-    GraphCategory.__init__(self, clone)
-
-    if not clone:
-      # fill in past bandwidth information
-
-      controller = tor_controller()
-      bw_entries, is_successful = controller.get_info('bw-event-cache', None), True
-
-      if bw_entries:
-        for entry in bw_entries.split():
-          entry_comp = entry.split(',')
-
-          if len(entry_comp) != 2 or not entry_comp[0].isdigit() or not entry_comp[1].isdigit():
-            log.warn(msg('panel.graphing.bw_event_cache_malformed', response = bw_entries))
-            is_successful = False
-            break
-
-          self.primary.update(int(entry_comp[0]))
-          self.secondary.update(int(entry_comp[1]))
-
-        if is_successful:
-          log.info(msg('panel.graphing.prepopulation_successful', duration = str_tools.time_label(len(bw_entries.split()), is_long = True)))
-
-      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)
-        self.secondary.total = int(write_total)
-        self.start_time = start_time
-
-  def stat_type(self):
-    return GraphStat.BANDWIDTH
-
-  def _y_axis_label(self, value, is_primary):
-    return _size_label(value, 0)
-
-  def bandwidth_event(self, event):
-    self.primary.update(event.read)
-    self.secondary.update(event.written)
-
-    self._primary_header_stats = [
-      '%-14s' % ('%s/sec' % _size_label(self.primary.latest_value)),
-      '- avg: %s/sec' % _size_label(self.primary.total / (time.time() - self.start_time)),
-      ', total: %s' % _size_label(self.primary.total),
-    ]
-
-    self._secondary_header_stats = [
-      '%-14s' % ('%s/sec' % _size_label(self.secondary.latest_value)),
-      '- avg: %s/sec' % _size_label(self.secondary.total / (time.time() - self.start_time)),
-      ', total: %s' % _size_label(self.secondary.total),
-    ]
-
-    controller = tor_controller()
-
-    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 ConnectionStats(GraphCategory):
-  """
-  Tracks number of inbound and outbound connections.
-  """
-
-  def stat_type(self):
-    return GraphStat.CONNECTIONS
-
-  def bandwidth_event(self, event):
-    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 nyx.util.tracker.get_connection_tracker().get_value():
-      if entry.local_port in or_ports or entry.local_port in dir_ports:
-        inbound_count += 1
-      elif entry.local_port in control_ports:
-        pass  # control connection
-      else:
-        outbound_count += 1
-
-    self.primary.update(inbound_count)
-    self.secondary.update(outbound_count)
-
-    self._primary_header_stats = [str(self.primary.latest_value), ', avg: %s' % self.primary.average()]
-    self._secondary_header_stats = [str(self.secondary.latest_value), ', avg: %s' % self.secondary.average()]
-
-
-class ResourceStats(GraphCategory):
-  """
-  Tracks cpu and memory usage of the tor process.
-  """
-
-  def stat_type(self):
-    return GraphStat.SYSTEM_RESOURCES
-
-  def _y_axis_label(self, value, is_primary):
-    return '%i%%' % value if is_primary else str_tools.size_label(value)
-
-  def bandwidth_event(self, event):
-    resources = nyx.util.tracker.get_resource_tracker().get_value()
-    self.primary.update(resources.cpu_sample * 100)  # decimal percentage to whole numbers
-    self.secondary.update(resources.memory_bytes)
-
-    self._primary_header_stats = ['%0.1f%%' % self.primary.latest_value, ', avg: %0.1f%%' % self.primary.average()]
-    self._secondary_header_stats = [str_tools.size_label(self.secondary.latest_value, 1), ', avg: %s' % str_tools.size_label(self.secondary.average(), 1)]
-
-
-class GraphPanel(panel.Panel):
-  """
-  Panel displaying graphical information of GraphCategory instances.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'graph', 0)
-
-    self._displayed_stat = None if CONFIG['features.graph.type'] == 'none' else CONFIG['features.graph.type']
-    self._update_interval = CONFIG['features.graph.interval']
-    self._bounds = CONFIG['features.graph.bound']
-    self._graph_height = CONFIG['features.graph.height']
-
-    self._accounting_stats = None
-
-    self._stats = {
-      GraphStat.BANDWIDTH: BandwidthStats(),
-      GraphStat.SYSTEM_RESOURCES: ResourceStats(),
-    }
-
-    if CONFIG['features.panels.show.connection']:
-      self._stats[GraphStat.CONNECTIONS] = ConnectionStats()
-    elif self._displayed_stat == GraphStat.CONNECTIONS:
-      log.warn("The connection graph is unavailble when you set 'features.panels.show.connection false'.")
-      self._displayed_stat = GraphStat.BANDWIDTH
-
-    self.set_pause_attr('_stats')
-    self.set_pause_attr('_accounting_stats')
-
-    controller = tor_controller()
-    controller.add_event_listener(self._update_accounting, EventType.BW)
-    controller.add_event_listener(self._update_stats, EventType.BW)
-    controller.add_status_listener(lambda *args: self.redraw(True))
-
-  @property
-  def displayed_stat(self):
-    return self._displayed_stat
-
-  @displayed_stat.setter
-  def displayed_stat(self, value):
-    if value is not None and value not in self._stats.keys():
-      raise ValueError("%s isn't a graphed statistic" % value)
-
-    self._displayed_stat = value
-
-  def stat_options(self):
-    return self._stats.keys()
-
-  @property
-  def update_interval(self):
-    return self._update_interval
-
-  @update_interval.setter
-  def update_interval(self, value):
-    if value not in Interval:
-      raise ValueError("%s isn't a valid graphing update interval" % value)
-
-    self._update_interval = value
-
-  @property
-  def bounds_type(self):
-    return self._bounds
-
-  @bounds_type.setter
-  def bounds_type(self, value):
-    if value not in Bounds:
-      raise ValueError("%s isn't a valid type of bounds" % value)
-
-    self._bounds = value
-
-  def get_height(self):
-    """
-    Provides the height of the content.
-    """
-
-    if not self.displayed_stat:
-      return 0
-
-    height = DEFAULT_CONTENT_HEIGHT + self._graph_height
-
-    if self.displayed_stat == GraphStat.BANDWIDTH and self._accounting_stats:
-      height += 3
-
-    return height
-
-  def set_graph_height(self, new_graph_height):
-    self._graph_height = max(1, 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 = nyx.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_type = Bounds.next(self.bounds_type)
-      self.redraw(True)
-    elif key.match('s'):
-      # provides a menu to pick the graphed stats
-
-      available_stats = sorted(self.stat_options())
-      options = ['None'] + [stat.capitalize() for stat in available_stats]
-      initial_selection = available_stats.index(self.displayed_stat) + 1 if self.displayed_stat else 0
-
-      selection = nyx.popups.show_menu('Graphed Stats:', options, initial_selection)
-
-      # applies new setting
-
-      if selection == 0:
-        self.displayed_stat = None
-      elif selection != -1:
-        self.displayed_stat = available_stats[selection - 1]
-    elif key.match('i'):
-      # provides menu to pick graph panel update interval
-
-      selection = nyx.popups.show_menu('Update Interval:', list(Interval), list(Interval).index(self.update_interval))
-
-      if selection != -1:
-        self.update_interval = list(Interval)[selection]
-    else:
-      return False
-
-    return True
-
-  def get_help(self):
-    return [
-      ('r', 'resize graph', None),
-      ('s', 'graphed stats', self.displayed_stat if self.displayed_stat else 'none'),
-      ('b', 'graph bounds', self.bounds_type.replace('_', ' ')),
-      ('i', 'graph update interval', self.update_interval),
-    ]
-
-  def draw(self, width, height):
-    if not self.displayed_stat:
-      return
-
-    stat = self.get_attr('_stats')[self.displayed_stat]
-
-    attr = DrawAttributes(
-      stat = type(stat)(stat),  # clone the GraphCategory
-      subgraph_height = self._graph_height + 2,  # graph rows + header + x-axis label
-      subgraph_width = min(width / 2, CONFIG['features.graph.max_width']),
-      interval = self.update_interval,
-      bounds_type = self.bounds_type,
-      accounting = self.get_attr('_accounting_stats'),
-    )
-
-    if self.is_title_visible():
-      self.addstr(0, 0, attr.stat.title(width), curses.A_STANDOUT)
-
-    self._draw_subgraph(attr, attr.stat.primary, 0, PRIMARY_COLOR)
-    self._draw_subgraph(attr, attr.stat.secondary, attr.subgraph_width, SECONDARY_COLOR)
-
-    if attr.stat.stat_type() == GraphStat.BANDWIDTH:
-      if width <= COLLAPSE_WIDTH:
-        self._draw_bandwidth_stats(attr, width)
-
-      if attr.accounting:
-        self._draw_accounting_stats(attr)
-
-  def _draw_subgraph(self, attr, data, x, color):
-    # Concering our subgraph colums, the y-axis label can be at most six
-    # characters, with two spaces of padding on either side of the graph.
-    # Starting with the smallest size, then possibly raise it after determing
-    # the y_axis_labels.
-
-    subgraph_columns = attr.subgraph_width - 8
-    min_bound, max_bound = self._get_graph_bounds(attr, data, subgraph_columns)
-
-    x_axis_labels = self._get_x_axis_labels(attr, subgraph_columns)
-    y_axis_labels = self._get_y_axis_labels(attr, data, min_bound, max_bound)
-    subgraph_columns = max(subgraph_columns, attr.subgraph_width - max([len(label) for label in y_axis_labels.values()]) - 2)
-    axis_offset = max([len(label) for label in y_axis_labels.values()])
-
-    self.addstr(1, x, data.header(attr.subgraph_width), curses.A_BOLD, color)
-
-    for x_offset, label in x_axis_labels.items():
-      self.addstr(attr.subgraph_height, x + x_offset + axis_offset, label, color)
-
-    for y, label in y_axis_labels.items():
-      self.addstr(y, x, label, color)
-
-    for col in range(subgraph_columns):
-      column_count = int(data.values[attr.interval][col]) - min_bound
-      column_height = int(min(attr.subgraph_height - 2, (attr.subgraph_height - 2) * column_count / (max(1, max_bound) - min_bound)))
-
-      for row in range(column_height):
-        self.addstr(attr.subgraph_height - 1 - row, x + col + axis_offset + 1, ' ', curses.A_STANDOUT, color)
-
-  def _get_graph_bounds(self, attr, data, subgraph_columns):
-    """
-    Provides the range the graph shows (ie, its minimum and maximum value).
-    """
-
-    min_bound, max_bound = 0, 0
-    values = data.values[attr.interval][:subgraph_columns]
-
-    if attr.bounds_type == Bounds.GLOBAL_MAX:
-      max_bound = data.max_value[attr.interval]
-    elif subgraph_columns > 0:
-      max_bound = max(values)  # local maxima
-
-    if attr.bounds_type == Bounds.TIGHT and subgraph_columns > 0:
-      min_bound = min(values)
-
-      # if the max = min pick zero so we still display something
-
-      if min_bound == max_bound:
-        min_bound = 0
-
-    return min_bound, max_bound
-
-  def _get_y_axis_labels(self, attr, data, min_bound, max_bound):
-    """
-    Provides the labels for the y-axis. This is a mapping of the position it
-    should be drawn at to its text.
-    """
-
-    y_axis_labels = {
-      2: data.y_axis_label(max_bound),
-      attr.subgraph_height - 1: data.y_axis_label(min_bound),
-    }
-
-    ticks = (attr.subgraph_height - 5) / 2
-
-    for i in range(ticks):
-      row = attr.subgraph_height - (2 * i) - 5
-
-      if attr.subgraph_height % 2 == 0 and i >= (ticks / 2):
-        row -= 1  # make extra gap be in the middle when we're an even size
-
-      val = (max_bound - min_bound) * (attr.subgraph_height - row - 3) / (attr.subgraph_height - 3)
-
-      if val not in (min_bound, max_bound):
-        y_axis_labels[row + 2] = data.y_axis_label(val)
-
-    return y_axis_labels
-
-  def _get_x_axis_labels(self, attr, subgraph_columns):
-    """
-    Provides the labels for the x-axis. We include the units for only its first
-    value, then bump the precision for subsequent units. For example...
-
-      10s, 20, 30, 40, 50, 1m, 1.1, 1.3, 1.5
-    """
-
-    x_axis_labels = {}
-
-    interval_sec = INTERVAL_SECONDS[attr.interval]
-    interval_spacing = 10 if subgraph_columns >= WIDE_LABELING_GRAPH_COL else 5
-    units_label, decimal_precision = None, 0
-
-    for i in range((subgraph_columns - 4) / interval_spacing):
-      x = (i + 1) * interval_spacing
-      time_label = str_tools.time_label(x * 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]
-
-      x_axis_labels[x] = time_label
-
-    return x_axis_labels
-
-  def _draw_bandwidth_stats(self, attr, width):
-    """
-    Replaces the x-axis labeling with bandwidth stats. This is done on small
-    screens since this information otherwise wouldn't fit.
-    """
-
-    labeling_line = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 4
-    self.addstr(labeling_line, 0, ' ' * width)  # clear line
-
-    runtime = time.time() - attr.stat.start_time
-    primary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.primary.total), _size_label(attr.stat.primary.total / runtime))
-    secondary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.secondary.total), _size_label(attr.stat.secondary.total / runtime))
-
-    self.addstr(labeling_line, 1, primary_footer, PRIMARY_COLOR)
-    self.addstr(labeling_line, attr.subgraph_width + 1, secondary_footer, SECONDARY_COLOR)
-
-  def _draw_accounting_stats(self, attr):
-    y = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 2
-
-    if tor_controller().is_alive():
-      hibernate_color = CONFIG['attr.hibernate_color'].get(attr.accounting.status, 'red')
-
-      x = self.addstr(y, 0, 'Accounting (', curses.A_BOLD)
-      x = self.addstr(y, x, attr.accounting.status, curses.A_BOLD, hibernate_color)
-      x = self.addstr(y, x, ')', curses.A_BOLD)
-
-      self.addstr(y, 35, 'Time to reset: %s' % str_tools.short_time_label(attr.accounting.time_until_reset))
-
-      self.addstr(y + 1, 2, '%s / %s' % (attr.accounting.read_bytes, attr.accounting.read_limit), PRIMARY_COLOR)
-      self.addstr(y + 1, 37, '%s / %s' % (attr.accounting.written_bytes, attr.accounting.write_limit), SECONDARY_COLOR)
-    else:
-      self.addstr(y, 0, 'Accounting:', curses.A_BOLD)
-      self.addstr(y, 12, 'Connection Closed...')
-
-  def copy_attr(self, attr):
-    if attr == '_stats':
-      return dict([(key, type(self._stats[key])(self._stats[key])) for key in self._stats])
-    else:
-      return panel.Panel.copy_attr(self, attr)
-
-  def _update_accounting(self, event):
-    if not CONFIG['features.graph.bw.accounting.show']:
-      self._accounting_stats = None
-    elif not self._accounting_stats or time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
-      old_accounting_stats = self._accounting_stats
-      self._accounting_stats = tor_controller().get_accounting_stats(None)
-
-      # if we either added or removed accounting info then redraw the whole
-      # screen to account for resizing
-
-      if bool(old_accounting_stats) != bool(self._accounting_stats):
-        nyx.controller.get_controller().redraw()
-
-  def _update_stats(self, event):
-    for stat in self._stats.values():
-      stat.bandwidth_event(event)
-
-    if self.displayed_stat:
-      param = self.get_attr('_stats')[self.displayed_stat]
-      update_rate = INTERVAL_SECONDS[self.update_interval]
-
-      if param.primary.tick % update_rate == 0:
-        self.redraw(True)
-
-
-def _size_label(byte_count, decimal = 1):
-  """
-  Alias for str_tools.size_label() that accounts for if the user prefers bits
-  or bytes.
-  """
-
-  return str_tools.size_label(byte_count, decimal, is_bytes = CONFIG['features.graph.bw.transferInBytes'])
diff --git a/nyx/header_panel.py b/nyx/header_panel.py
deleted file mode 100644
index 0d6591a..0000000
--- a/nyx/header_panel.py
+++ /dev/null
@@ -1,496 +0,0 @@
-"""
-Top panel for every page, containing basic system and tor related information.
-This expands the information it presents to two columns if there's room
-available.
-"""
-
-import collections
-import os
-import time
-import curses
-import threading
-
-import stem
-
-import nyx.controller
-import nyx.popups
-
-from stem.control import Listener, State
-from stem.util import conf, log, proc, str_tools, system
-from nyx.util import msg, tor_controller, panel, tracker
-
-MIN_DUAL_COL_WIDTH = 141  # minimum width where we'll show two columns
-SHOW_FD_THRESHOLD = 60  # show file descriptor usage if usage is over this percentage
-UPDATE_RATE = 5  # rate in seconds at which we refresh
-
-CONFIG = conf.config_dict('nyx', {
-  'attr.flag_colors': {},
-  'attr.version_status_colors': {},
-})
-
-
-class HeaderPanel(panel.Panel, threading.Thread):
-  """
-  Top area containing tor settings and system information.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'header', 0)
-    threading.Thread.__init__(self)
-    self.setDaemon(True)
-
-    self._vals = get_sampling()
-
-    self._pause_condition = threading.Condition()
-    self._halt = False  # terminates thread if true
-    self._reported_inactive = False
-
-    tor_controller().add_status_listener(self.reset_listener)
-
-  def is_wide(self, width = None):
-    """
-    True if we should show two columns of information, False otherwise.
-    """
-
-    if width is None:
-      width = self.get_parent().getmaxyx()[1]
-
-    return width >= MIN_DUAL_COL_WIDTH
-
-  def get_height(self):
-    """
-    Provides the height of the content, which is dynamically determined by the
-    panel's maximum width.
-    """
-
-    if self._vals.is_relay:
-      return 4 if self.is_wide() else 6
-    else:
-      return 3 if self.is_wide() else 4
-
-  def send_newnym(self):
-    """
-    Requests a new identity and provides a visual queue.
-    """
-
-    controller = tor_controller()
-
-    if not controller.is_newnym_available():
-      return
-
-    controller.signal(stem.Signal.NEWNYM)
-
-    # If we're wide then the newnym label in this panel will give an
-    # indication that the signal was sent. Otherwise use a msg.
-
-    if not self.is_wide():
-      nyx.popups.show_msg('Requesting a new identity', 1)
-
-  def handle_key(self, key):
-    if key.match('n'):
-      self.send_newnym()
-    elif key.match('r') and not self._vals.is_connected:
-      # TODO: This is borked. Not quite sure why but our attempt to call
-      # PROTOCOLINFO fails with a socket error, followed by completely freezing
-      # nyx. This is exposing two bugs...
-      #
-      # * This should be working. That's a stem issue.
-      # * Our interface shouldn't be locking up. That's an nyx issue.
-
-      return True
-
-      controller = tor_controller()
-
-      try:
-        controller.connect()
-
-        try:
-          controller.authenticate()  # TODO: should account for our chroot
-        except stem.connection.MissingPassword:
-          password = nyx.popups.input_prompt('Controller Password: ')
-
-          if password:
-            controller.authenticate(password)
-
-        log.notice("Reconnected to Tor's control port")
-        nyx.popups.show_msg('Tor reconnected', 1)
-      except Exception as exc:
-        nyx.popups.show_msg('Unable to reconnect (%s)' % exc, 3)
-        controller.close()
-    else:
-      return False
-
-    return True
-
-  def draw(self, width, height):
-    vals = self._vals  # local reference to avoid concurrency concerns
-    is_wide = self.is_wide(width)
-
-    # space available for content
-
-    left_width = max(width / 2, 77) if is_wide else width
-    right_width = width - left_width
-
-    self._draw_platform_section(0, 0, left_width, vals)
-
-    if vals.is_connected:
-      self._draw_ports_section(0, 1, left_width, vals)
-    else:
-      self._draw_disconnected(0, 1, left_width, vals)
-
-    if is_wide:
-      self._draw_resource_usage(left_width, 0, right_width, vals)
-
-      if vals.is_relay:
-        self._draw_fingerprint_and_fd_usage(left_width, 1, right_width, vals)
-        self._draw_flags(0, 2, left_width, vals)
-        self._draw_exit_policy(left_width, 2, right_width, vals)
-      elif vals.is_connected:
-        self._draw_newnym_option(left_width, 1, right_width, vals)
-    else:
-      self._draw_resource_usage(0, 2, left_width, vals)
-
-      if vals.is_relay:
-        self._draw_fingerprint_and_fd_usage(0, 3, left_width, vals)
-        self._draw_flags(0, 4, left_width, vals)
-
-  def _draw_platform_section(self, x, y, width, vals):
-    """
-    Section providing the user's hostname, platform, and version information...
-
-      nyx - odin (Linux 3.5.0-52-generic)        Tor 0.2.5.1-alpha-dev (unrecommended)
-      |------ platform (40 characters) ------|   |----------- tor version -----------|
-    """
-
-    initial_x, space_left = x, min(width, 40)
-
-    x = self.addstr(y, x, vals.format('nyx - {hostname}', space_left))
-    space_left -= x - initial_x
-
-    if space_left >= 10:
-      self.addstr(y, x, ' (%s)' % vals.format('{platform}', space_left - 3))
-
-    x, space_left = initial_x + 43, width - 43
-
-    if vals.version != 'Unknown' and space_left >= 10:
-      x = self.addstr(y, x, vals.format('Tor {version}', space_left))
-      space_left -= x - 43 - initial_x
-
-      if space_left >= 7 + len(vals.version_status):
-        version_color = CONFIG['attr.version_status_colors'].get(vals.version_status, 'white')
-
-        x = self.addstr(y, x, ' (')
-        x = self.addstr(y, x, vals.version_status, version_color)
-        self.addstr(y, x, ')')
-
-  def _draw_ports_section(self, x, y, width, vals):
-    """
-    Section providing our nickname, address, and port information...
-
-      Unnamed - 0.0.0.0:7000, Control Port (cookie): 9051
-    """
-
-    if not vals.is_relay:
-      x = self.addstr(y, x, 'Relaying Disabled', 'cyan')
-    else:
-      x = self.addstr(y, x, vals.format('{nickname} - {address}:{or_port}'))
-
-      if vals.dir_port != '0':
-        x = self.addstr(y, x, vals.format(', Dir Port: {dir_port}'))
-
-    if vals.control_port:
-      if width >= x + 19 + len(vals.control_port) + len(vals.auth_type):
-        auth_color = 'red' if vals.auth_type == 'open' else 'green'
-
-        x = self.addstr(y, x, ', Control Port (')
-        x = self.addstr(y, x, vals.auth_type, auth_color)
-        self.addstr(y, x, vals.format('): {control_port}'))
-      else:
-        self.addstr(y, x, vals.format(', Control Port: {control_port}'))
-    elif vals.socket_path:
-      self.addstr(y, x, vals.format(', Control Socket: {socket_path}'))
-
-  def _draw_disconnected(self, x, y, width, vals):
-    """
-    Message indicating that tor is disconnected...
-
-      Tor Disconnected (15:21 07/13/2014, press r to reconnect)
-    """
-
-    x = self.addstr(y, x, 'Tor Disconnected', curses.A_BOLD, 'red')
-    last_heartbeat = time.strftime('%H:%M %m/%d/%Y', time.localtime(vals.last_heartbeat))
-    self.addstr(y, x, ' (%s, press r to reconnect)' % last_heartbeat)
-
-  def _draw_resource_usage(self, x, y, width, vals):
-    """
-    System resource usage of the tor process...
-
-      cpu: 0.0% tor, 1.0% nyx    mem: 0 (0.0%)       pid: 16329  uptime: 12-20:42:07
-    """
-
-    if vals.start_time:
-      if not vals.is_connected:
-        now = vals.connection_time
-      elif self.is_paused():
-        now = self.get_pause_time()
-      else:
-        now = time.time()
-
-      uptime = str_tools.short_time_label(now - vals.start_time)
-    else:
-      uptime = ''
-
-    sys_fields = (
-      (0, vals.format('cpu: {tor_cpu}% tor, {nyx_cpu}% nyx')),
-      (27, vals.format('mem: {memory} ({memory_percent}%)')),
-      (47, vals.format('pid: {pid}')),
-      (59, 'uptime: %s' % uptime),
-    )
-
-    for (start, label) in sys_fields:
-      if width >= start + len(label):
-        self.addstr(y, x + start, label)
-      else:
-        break
-
-  def _draw_fingerprint_and_fd_usage(self, x, y, width, vals):
-    """
-    Presents our fingerprint, and our file descriptor usage if we're running
-    out...
-
-      fingerprint: 1A94D1A794FCB2F8B6CBC179EF8FDD4008A98D3B, file desc: 900 / 1000 (90%)
-    """
-
-    initial_x, space_left = x, width
-
-    x = self.addstr(y, x, vals.format('fingerprint: {fingerprint}', width))
-    space_left -= x - initial_x
-
-    if space_left >= 30 and vals.fd_used and vals.fd_limit != -1:
-      fd_percent = 100 * vals.fd_used / vals.fd_limit
-
-      if fd_percent >= SHOW_FD_THRESHOLD:
-        if fd_percent >= 95:
-          percentage_format = (curses.A_BOLD, 'red')
-        elif fd_percent >= 90:
-          percentage_format = ('red',)
-        elif fd_percent >= 60:
-          percentage_format = ('yellow',)
-        else:
-          percentage_format = ()
-
-        x = self.addstr(y, x, ', file descriptors' if space_left >= 37 else ', file desc')
-        x = self.addstr(y, x, vals.format(': {fd_used} / {fd_limit} ('))
-        x = self.addstr(y, x, '%i%%' % fd_percent, *percentage_format)
-        self.addstr(y, x, ')')
-
-  def _draw_flags(self, x, y, width, vals):
-    """
-    Presents flags held by our relay...
-
-      flags: Running, Valid
-    """
-
-    x = self.addstr(y, x, 'flags: ')
-
-    if vals.flags:
-      for i, flag in enumerate(vals.flags):
-        flag_color = CONFIG['attr.flag_colors'].get(flag, 'white')
-        x = self.addstr(y, x, flag, curses.A_BOLD, flag_color)
-
-        if i < len(vals.flags) - 1:
-          x = self.addstr(y, x, ', ')
-    else:
-      self.addstr(y, x, 'none', curses.A_BOLD, 'cyan')
-
-  def _draw_exit_policy(self, x, y, width, vals):
-    """
-    Presents our exit policy...
-
-      exit policy: reject *:*
-    """
-
-    x = self.addstr(y, x, 'exit policy: ')
-
-    if not vals.exit_policy:
-      return
-
-    rules = list(vals.exit_policy.strip_private().strip_default())
-
-    for i, rule in enumerate(rules):
-      policy_color = 'green' if rule.is_accept else 'red'
-      x = self.addstr(y, x, str(rule), curses.A_BOLD, policy_color)
-
-      if i < len(rules) - 1:
-        x = self.addstr(y, x, ', ')
-
-    if vals.exit_policy.has_default():
-      if rules:
-        x = self.addstr(y, x, ', ')
-
-      self.addstr(y, x, '<default>', curses.A_BOLD, 'cyan')
-
-  def _draw_newnym_option(self, x, y, width, vals):
-    """
-    Provide a notice for requiesting a new identity, and time until it's next
-    available if in the process of building circuits.
-    """
-
-    if vals.newnym_wait == 0:
-      self.addstr(y, x, "press 'n' for a new identity")
-    else:
-      plural = 's' if vals.newnym_wait > 1 else ''
-      self.addstr(y, x, 'building circuits, available again in %i second%s' % (vals.newnym_wait, plural))
-
-  def run(self):
-    """
-    Keeps stats updated, checking for new information at a set rate.
-    """
-
-    last_ran = -1
-
-    while not self._halt:
-      if self.is_paused() or not self._vals.is_connected or (time.time() - last_ran) < UPDATE_RATE:
-        with self._pause_condition:
-          if not self._halt:
-            self._pause_condition.wait(0.2)
-
-        continue  # done waiting, try again
-
-      self._update()
-      last_ran = time.time()
-
-  def stop(self):
-    """
-    Halts further resolutions and terminates the thread.
-    """
-
-    with self._pause_condition:
-      self._halt = True
-      self._pause_condition.notifyAll()
-
-  def reset_listener(self, controller, event_type, _):
-    self._update()
-
-    if event_type == State.CLOSED:
-      log.notice('Tor control port closed')
-
-  def _update(self):
-    previous_height = self.get_height()
-    self._vals = get_sampling(self._vals)
-
-    if self._vals.fd_used and self._vals.fd_limit != -1:
-      fd_percent = 100 * self._vals.fd_used / self._vals.fd_limit
-
-      if fd_percent >= 90:
-        log_msg = msg('panel.header.fd_used_at_ninety_percent', percentage = fd_percent)
-        log.log_once('fd_used_at_ninety_percent', log.WARN, log_msg)
-        log.DEDUPLICATION_MESSAGE_IDS.add('fd_used_at_sixty_percent')
-      elif fd_percent >= 60:
-        log_msg = msg('panel.header.fd_used_at_sixty_percent', percentage = fd_percent)
-        log.log_once('fd_used_at_sixty_percent', log.NOTICE, log_msg)
-
-    if self._vals.is_connected:
-      if not self._reported_inactive and (time.time() - self._vals.last_heartbeat) >= 10:
-        self._reported_inactive = True
-        log.notice('Relay unresponsive (last heartbeat: %s)' % time.ctime(self._vals.last_heartbeat))
-      elif self._reported_inactive and (time.time() - self._vals.last_heartbeat) < 10:
-        self._reported_inactive = False
-        log.notice('Relay resumed')
-
-    if previous_height != self.get_height():
-      # We're toggling between being a relay and client, causing the height
-      # of this panel to change. Redraw all content so we don't get
-      # overlapping content.
-
-      nyx.controller.get_controller().redraw()
-    else:
-      self.redraw(True)  # just need to redraw ourselves
-
-
-def get_sampling(last_sampling = None):
-  controller = tor_controller()
-  retrieved = time.time()
-
-  pid = controller.get_pid('')
-  tor_resources = tracker.get_resource_tracker().get_value()
-  nyx_total_cpu_time = sum(os.times()[:3])
-
-  or_listeners = controller.get_listeners(Listener.OR, [])
-  control_listeners = controller.get_listeners(Listener.CONTROL, [])
-
-  if controller.get_conf('HashedControlPassword', None):
-    auth_type = 'password'
-  elif controller.get_conf('CookieAuthentication', None) == '1':
-    auth_type = 'cookie'
-  else:
-    auth_type = 'open'
-
-  try:
-    fd_used = proc.file_descriptors_used(pid)
-  except IOError:
-    fd_used = None
-
-  if last_sampling:
-    nyx_cpu_delta = nyx_total_cpu_time - last_sampling.nyx_total_cpu_time
-    nyx_time_delta = retrieved - last_sampling.retrieved
-
-    python_cpu_time = nyx_cpu_delta / nyx_time_delta
-    sys_call_cpu_time = 0.0  # TODO: add a wrapper around call() to get this
-
-    nyx_cpu = python_cpu_time + sys_call_cpu_time
-  else:
-    nyx_cpu = 0.0
-
-  attr = {
-    'retrieved': retrieved,
-    'is_connected': controller.is_alive(),
-    'connection_time': controller.connection_time(),
-    'last_heartbeat': controller.get_latest_heartbeat(),
-
-    'fingerprint': controller.get_info('fingerprint', 'Unknown'),
-    'nickname': controller.get_conf('Nickname', ''),
-    'newnym_wait': controller.get_newnym_wait(),
-    'exit_policy': controller.get_exit_policy(None),
-    'flags': getattr(controller.get_network_status(default = None), 'flags', []),
-
-    'version': str(controller.get_version('Unknown')).split()[0],
-    'version_status': controller.get_info('status/version/current', 'Unknown'),
-
-    'address': or_listeners[0][0] if (or_listeners and or_listeners[0][0] != '0.0.0.0') else controller.get_info('address', 'Unknown'),
-    'or_port': or_listeners[0][1] if or_listeners else '',
-    'dir_port': controller.get_conf('DirPort', '0'),
-    'control_port': str(control_listeners[0][1]) if control_listeners else None,
-    'socket_path': controller.get_conf('ControlSocket', None),
-    'is_relay': bool(or_listeners),
-
-    'auth_type': auth_type,
-    'pid': pid,
-    'start_time': system.start_time(pid),
-    'fd_limit': int(controller.get_info('process/descriptor-limit', '-1')),
-    'fd_used': fd_used,
-
-    'nyx_total_cpu_time': nyx_total_cpu_time,
-    'tor_cpu': '%0.1f' % (100 * tor_resources.cpu_sample),
-    'nyx_cpu': nyx_cpu,
-    'memory': str_tools.size_label(tor_resources.memory_bytes) if tor_resources.memory_bytes > 0 else 0,
-    'memory_percent': '%0.1f' % (100 * tor_resources.memory_percent),
-
-    'hostname': os.uname()[1],
-    'platform': '%s %s' % (os.uname()[0], os.uname()[2]),  # [platform name] [version]
-  }
-
-  class Sampling(collections.namedtuple('Sampling', attr.keys())):
-    def __init__(self, **attr):
-      super(Sampling, self).__init__(**attr)
-      self._attr = attr
-
-    def format(self, message, crop_width = None):
-      formatted_msg = message.format(**self._attr)
-
-      if crop_width:
-        formatted_msg = str_tools.crop(formatted_msg, crop_width)
-
-      return formatted_msg
-
-  return Sampling(**attr)
diff --git a/nyx/log_panel.py b/nyx/log_panel.py
deleted file mode 100644
index 708a679..0000000
--- a/nyx/log_panel.py
+++ /dev/null
@@ -1,454 +0,0 @@
-"""
-Panel providing a chronological log of events its been configured to listen
-for. This provides prepopulation from the log file and supports filtering by
-regular expressions.
-"""
-
-import os
-import time
-import curses
-import threading
-
-import stem.response.events
-
-import nyx.arguments
-import nyx.popups
-import nyx.util.log
-
-from nyx.util import join, panel, tor_controller, ui_tools
-from stem.util import conf, log
-
-
-def conf_handler(key, value):
-  if key == 'features.log.prepopulateReadLimit':
-    return max(0, value)
-  elif key == 'features.log.maxRefreshRate':
-    return max(10, value)
-  elif key == 'cache.log_panel.size':
-    return max(1000, value)
-
-
-CONFIG = conf.config_dict('nyx', {
-  'attr.log_color': {},
-  'cache.log_panel.size': 1000,
-  'features.logFile': '',
-  'features.log.showDuplicateEntries': False,
-  'features.log.prepopulate': True,
-  'features.log.prepopulateReadLimit': 5000,
-  'features.log.maxRefreshRate': 300,
-  'features.log.regex': [],
-  'msg.misc.event_types': '',
-  'startup.events': 'N3',
-}, conf_handler)
-
-# The height of the drawn content is estimated based on the last time we redrew
-# the panel. It's chiefly used for scrolling and the bar indicating its
-# position. Letting the estimate be too inaccurate results in a display bug, so
-# redraws the display if it's off by this threshold.
-
-CONTENT_HEIGHT_REDRAW_THRESHOLD = 3
-
-# Log buffer so we start collecting stem/nyx events when imported. This is used
-# to make our LogPanel when curses initializes.
-
-stem_logger = log.get_logger()
-NYX_LOGGER = log.LogBuffer(log.Runlevel.DEBUG, yield_records = True)
-stem_logger.addHandler(NYX_LOGGER)
-
-
-class LogPanel(panel.Panel, threading.Thread):
-  """
-  Listens for and displays tor, nyx, and stem events. This prepopulates
-  from tor's log file if it exists.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'log', 0)
-    threading.Thread.__init__(self)
-    self.setDaemon(True)
-
-    logged_events = nyx.arguments.expand_events(CONFIG['startup.events'])
-    self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
-    self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, logged_events)
-    self._log_file = nyx.util.log.LogFileOutput(CONFIG['features.logFile'])
-    self._filter = nyx.util.log.LogFilters(initial_filters = CONFIG['features.log.regex'])
-    self._show_duplicates = CONFIG['features.log.showDuplicateEntries']
-
-    self.set_pause_attr('_event_log')
-
-    self._halt = False  # terminates thread if true
-    self._pause_condition = threading.Condition()
-    self._has_new_event = False
-
-    # fetches past tor events from log file, if available
-
-    if CONFIG['features.log.prepopulate']:
-      log_location = nyx.util.log.log_file_path(tor_controller())
-
-      if log_location:
-        try:
-          for entry in reversed(list(nyx.util.log.read_tor_log(log_location, CONFIG['features.log.prepopulateReadLimit']))):
-            if entry.type in self._event_types:
-              self._event_log.add(entry)
-        except IOError as exc:
-          log.info('Unable to read log located at %s: %s' % (log_location, exc))
-        except ValueError as exc:
-          log.info(str(exc))
-
-    self._last_content_height = len(self._event_log)  # height of the rendered content when last drawn
-    self._scroll = 0
-
-    # merge NYX_LOGGER into us, and listen for its future events
-
-    for event in NYX_LOGGER:
-      self._register_nyx_event(event)
-
-    NYX_LOGGER.emit = self._register_nyx_event
-
-  def set_duplicate_visability(self, is_visible):
-    """
-    Sets if duplicate log entries are collaped or expanded.
-
-    :param bool is_visible: if **True** all log entries are shown, otherwise
-      they're deduplicated
-    """
-
-    self._show_duplicates = is_visible
-
-  def get_filter(self):
-    """
-    Provides our currently selected regex filter.
-    """
-
-    return self._filter
-
-  def show_filter_prompt(self):
-    """
-    Prompts the user to add a new regex filter.
-    """
-
-    regex_input = nyx.popups.input_prompt('Regular expression: ')
-
-    if regex_input:
-      self._filter.select(regex_input)
-
-  def show_event_selection_prompt(self):
-    """
-    Prompts the user to select the events being listened for.
-    """
-
-    # allow user to enter new types of events to log - unchanged if left blank
-
-    with nyx.popups.popup_window(16, 80) as (popup, width, height):
-      if popup:
-        # displays the available flags
-
-        popup.win.box()
-        popup.addstr(0, 0, 'Event Types:', curses.A_STANDOUT)
-        event_lines = CONFIG['msg.misc.event_types'].split('\n')
-
-        for i in range(len(event_lines)):
-          popup.addstr(i + 1, 1, event_lines[i][6:])
-
-        popup.win.refresh()
-
-        user_input = nyx.popups.input_prompt('Events to log: ')
-
-        if user_input:
-          try:
-            user_input = user_input.replace(' ', '')  # strip spaces
-            event_types = nyx.arguments.expand_events(user_input)
-
-            if event_types != self._event_types:
-              self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, event_types)
-              self.redraw(True)
-          except ValueError as exc:
-            nyx.popups.show_msg('Invalid flags: %s' % str(exc), 2)
-
-  def show_snapshot_prompt(self):
-    """
-    Lets user enter a path to take a snapshot, canceling if left blank.
-    """
-
-    path_input = nyx.popups.input_prompt('Path to save log snapshot: ')
-
-    if path_input:
-      try:
-        self.save_snapshot(path_input)
-        nyx.popups.show_msg('Saved: %s' % path_input, 2)
-      except IOError as exc:
-        nyx.popups.show_msg('Unable to save snapshot: %s' % exc, 2)
-
-  def clear(self):
-    """
-    Clears the contents of the event log.
-    """
-
-    self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
-    self.redraw(True)
-
-  def save_snapshot(self, path):
-    """
-    Saves the log events currently being displayed to the given path. This
-    takes filers into account. This overwrites the file if it already exists.
-
-    :param str path: path where to save the log snapshot
-
-    :raises: **IOError** if unsuccessful
-    """
-
-    path = os.path.abspath(os.path.expanduser(path))
-
-    # make dir if the path doesn't already exist
-
-    base_dir = os.path.dirname(path)
-
-    try:
-      if not os.path.exists(base_dir):
-        os.makedirs(base_dir)
-    except OSError as exc:
-      raise IOError("unable to make directory '%s'" % base_dir)
-
-    event_log = list(self._event_log)
-    event_filter = self._filter.clone()
-
-    with open(path, 'w') as snapshot_file:
-      try:
-        for entry in reversed(event_log):
-          if event_filter.match(entry.display_message):
-            snapshot_file.write(entry.display_message + '\n')
-      except Exception as exc:
-        raise IOError("unable to write to '%s': %s" % (path, exc))
-
-  def handle_key(self, key):
-    if key.is_scroll():
-      page_height = self.get_preferred_size()[0] - 1
-      new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
-
-      if self._scroll != new_scroll:
-        self._scroll = new_scroll
-        self.redraw(True)
-    elif key.match('u'):
-      self.set_duplicate_visability(not self._show_duplicates)
-      self.redraw(True)
-    elif key.match('c'):
-      msg = 'This will clear the log. Are you sure (c again to confirm)?'
-      key_press = nyx.popups.show_msg(msg, attr = curses.A_BOLD)
-
-      if key_press.match('c'):
-        self.clear()
-    elif key.match('f'):
-      with panel.CURSES_LOCK:
-        initial_selection = 1 if self._filter.selection() else 0
-        options = ['None'] + self._filter.latest_selections() + ['New...']
-        selection = nyx.popups.show_menu('Log Filter:', options, initial_selection)
-
-        if selection == 0:
-          self._filter.select(None)
-        elif selection == len(options) - 1:
-          # selected 'New...' option - prompt user to input regular expression
-          self.show_filter_prompt()
-        elif selection != -1:
-          self._filter.select(self._filter.latest_selections()[selection - 1])
-    elif key.match('e'):
-      self.show_event_selection_prompt()
-    elif key.match('a'):
-      self.show_snapshot_prompt()
-    else:
-      return False
-
-    return True
-
-  def get_help(self):
-    return [
-      ('up arrow', 'scroll log up a line', None),
-      ('down arrow', 'scroll log down a line', None),
-      ('a', 'save snapshot of the log', None),
-      ('e', 'change logged events', None),
-      ('f', 'log regex filter', 'enabled' if self._filter.selection() else 'disabled'),
-      ('u', 'duplicate log entries', 'visible' if self._show_duplicates else 'hidden'),
-      ('c', 'clear event log', None),
-    ]
-
-  def draw(self, width, height):
-    self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
-
-    event_log = list(self.get_attr('_event_log'))
-    event_filter = self._filter.clone()
-    event_types = list(self._event_types)
-    scroll = int(self._scroll)
-    last_content_height = self._last_content_height
-    show_duplicates = self._show_duplicates
-
-    is_scrollbar_visible = last_content_height > height - 1
-
-    if is_scrollbar_visible:
-      self.add_scroll_bar(scroll, scroll + height - 1, last_content_height, 1)
-
-    x, y = 3 if is_scrollbar_visible else 1, 1 - scroll
-
-    # group entries by date, filtering out those that aren't visible
-
-    day_to_entries, today = {}, nyx.util.log.day_count(time.time())
-
-    for entry in event_log:
-      if entry.is_duplicate and not show_duplicates:
-        continue  # deduplicated message
-      elif not event_filter.match(entry.display_message):
-        continue  # filter doesn't match log message
-
-      day_to_entries.setdefault(entry.day_count(), []).append(entry)
-
-    for day in sorted(day_to_entries.keys(), reverse = True):
-      if day == today:
-        for entry in day_to_entries[day]:
-          y = self._draw_entry(x, y, width, entry, show_duplicates)
-      else:
-        original_y, y = y, y + 1
-
-        for entry in day_to_entries[day]:
-          y = self._draw_entry(x, y, width, entry, show_duplicates)
-
-        ui_tools.draw_box(self, original_y, x - 1, width - x + 1, y - original_y + 1, curses.A_BOLD, 'yellow')
-        time_label = time.strftime(' %B %d, %Y ', time.localtime(day_to_entries[day][0].timestamp))
-        self.addstr(original_y, x + 1, time_label, curses.A_BOLD, curses.A_BOLD, 'yellow')
-
-        y += 1
-
-    # drawing the title after the content, so we'll clear content from the top line
-
-    if self.is_title_visible():
-      self._draw_title(width, event_types, event_filter)
-
-    # redraw the display if...
-    # - last_content_height was off by too much
-    # - we're off the bottom of the page
-
-    new_content_height = y + scroll - 1
-    content_height_delta = abs(last_content_height - new_content_height)
-    force_redraw, force_redraw_reason = True, ''
-
-    if content_height_delta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
-      force_redraw_reason = 'estimate was off by %i' % content_height_delta
-    elif new_content_height > height and self._scroll + height - 1 > new_content_height:
-      force_redraw_reason = 'scrolled off the bottom of the page'
-    elif not is_scrollbar_visible and new_content_height > height - 1:
-      force_redraw_reason = "scroll bar wasn't previously visible"
-    elif is_scrollbar_visible and new_content_height <= height - 1:
-      force_redraw_reason = "scroll bar shouldn't be visible"
-    else:
-      force_redraw = False
-
-    self._last_content_height = new_content_height
-    self._has_new_event = False
-
-    if force_redraw:
-      log.debug('redrawing the log panel with the corrected content height (%s)' % force_redraw_reason)
-      self.redraw(True)
-
-  def _draw_title(self, width, event_types, event_filter):
-    """
-    Panel title with the event types we're logging and our regex filter if set.
-    """
-
-    self.addstr(0, 0, ' ' * width)  # clear line
-    title_comp = list(nyx.util.log.condense_runlevels(*event_types))
-
-    if event_filter.selection():
-      title_comp.append('filter: %s' % event_filter.selection())
-
-    title_comp_str = join(title_comp, ', ', width - 10)
-    title = 'Events (%s):' % title_comp_str if title_comp_str else 'Events:'
-
-    self.addstr(0, 0, title, curses.A_STANDOUT)
-
-  def _draw_entry(self, x, y, width, entry, show_duplicates):
-    """
-    Presents a log entry with line wrapping.
-    """
-
-    min_x, msg = x + 2, entry.display_message
-    boldness = curses.A_BOLD if 'ERR' in entry.type else curses.A_NORMAL  # emphasize ERR messages
-    color = CONFIG['attr.log_color'].get(entry.type, 'white')
-
-    for line in msg.splitlines():
-      x, y = self.addstr_wrap(y, x, line, width, min_x, boldness, color)
-
-    if entry.duplicates and not show_duplicates:
-      duplicate_count = len(entry.duplicates) - 1
-      plural = 's' if duplicate_count > 1 else ''
-      duplicate_msg = ' [%i duplicate%s hidden]' % (duplicate_count, plural)
-      x, y = self.addstr_wrap(y, x, duplicate_msg, width, min_x, curses.A_BOLD, 'green')
-
-    return y + 1
-
-  def run(self):
-    """
-    Redraws the display, coalescing updates if events are rapidly logged (for
-    instance running at the DEBUG runlevel) while also being immediately
-    responsive if additions are less frequent.
-    """
-
-    last_ran, last_day = -1, nyx.util.log.day_count(time.time())
-
-    while not self._halt:
-      current_day = nyx.util.log.day_count(time.time())
-      time_since_reset = time.time() - last_ran
-      max_log_update_rate = CONFIG['features.log.maxRefreshRate'] / 1000.0
-
-      sleep_time = 0
-
-      if (not self._has_new_event and last_day == current_day) or self.is_paused():
-        sleep_time = 5
-      elif time_since_reset < max_log_update_rate:
-        sleep_time = max(0.05, max_log_update_rate - time_since_reset)
-
-      if sleep_time:
-        with self._pause_condition:
-          if not self._halt:
-            self._pause_condition.wait(sleep_time)
-
-        continue
-
-      last_ran, last_day = time.time(), current_day
-      self.redraw(True)
-
-  def stop(self):
-    """
-    Halts further updates and terminates the thread.
-    """
-
-    with self._pause_condition:
-      self._halt = True
-      self._pause_condition.notifyAll()
-
-  def _register_tor_event(self, event):
-    msg = ' '.join(str(event).split(' ')[1:])
-
-    if isinstance(event, stem.response.events.BandwidthEvent):
-      msg = 'READ: %i, WRITTEN: %i' % (event.read, event.written)
-    elif isinstance(event, stem.response.events.LogEvent):
-      msg = event.message
-
-    self._register_event(nyx.util.log.LogEntry(event.arrived_at, event.type, msg))
-
-  def _register_nyx_event(self, record):
-    if record.levelname == 'WARNING':
-      record.levelname = 'WARN'
-
-    self._register_event(nyx.util.log.LogEntry(int(record.created), 'NYX_%s' % record.levelname, record.msg))
-
-  def _register_event(self, event):
-    if event.type not in self._event_types:
-      return
-
-    self._event_log.add(event)
-    self._log_file.write(event.display_message)
-
-    # notifies the display that it has new content
-
-    if self._filter.match(event.display_message):
-      self._has_new_event = True
-
-      with self._pause_condition:
-        self._pause_condition.notifyAll()
diff --git a/nyx/menu/actions.py b/nyx/menu/actions.py
index 0105615..222e3e7 100644
--- a/nyx/menu/actions.py
+++ b/nyx/menu/actions.py
@@ -4,10 +4,10 @@ Generates the menu for nyx, binding options with their related actions.
 
 import functools
 
-import nyx.popups
 import nyx.controller
+import nyx.panel.graph
+import nyx.popups
 import nyx.menu.item
-import nyx.graph_panel
 import nyx.util.tracker
 
 from nyx.util import tor_controller, ui_tools
@@ -167,7 +167,7 @@ def make_graph_menu(graph_panel):
   interval_menu = nyx.menu.item.Submenu('Interval')
   interval_group = nyx.menu.item.SelectionGroup(functools.partial(setattr, graph_panel, 'update_interval'), graph_panel.update_interval)
 
-  for interval in nyx.graph_panel.Interval:
+  for interval in nyx.panel.graph.Interval:
     interval_menu.add(nyx.menu.item.SelectionMenuItem(interval, interval_group, interval))
 
   graph_menu.add(interval_menu)
@@ -177,7 +177,7 @@ def make_graph_menu(graph_panel):
   bounds_menu = nyx.menu.item.Submenu('Bounds')
   bounds_group = nyx.menu.item.SelectionGroup(functools.partial(setattr, graph_panel, 'bounds_type'), graph_panel.bounds_type)
 
-  for bounds_type in nyx.graph_panel.Bounds:
+  for bounds_type in nyx.panel.graph.Bounds:
     bounds_menu.add(nyx.menu.item.SelectionMenuItem(bounds_type, bounds_group, bounds_type))
 
   graph_menu.add(bounds_menu)
diff --git a/nyx/panel/__init__.py b/nyx/panel/__init__.py
new file mode 100644
index 0000000..0f25fac
--- /dev/null
+++ b/nyx/panel/__init__.py
@@ -0,0 +1,11 @@
+"""
+Panels consisting the nyx interface.
+"""
+
+__all__ = [
+  'config',
+  'connection',
+  'header',
+  'log',
+  'torrc',
+]
diff --git a/nyx/panel/config.py b/nyx/panel/config.py
new file mode 100644
index 0000000..2352fe7
--- /dev/null
+++ b/nyx/panel/config.py
@@ -0,0 +1,357 @@
+"""
+Panel presenting the configuration state for tor or nyx. Options can be edited
+and the resulting configuration files saved.
+"""
+
+import curses
+import os
+
+import nyx.controller
+import nyx.popups
+
+import stem.control
+import stem.manual
+
+from nyx.util import DATA_DIR, panel, tor_controller, ui_tools
+
+from stem.util import conf, enum, log, str_tools
+
+SortAttr = enum.Enum('NAME', 'VALUE', 'VALUE_TYPE', 'CATEGORY', 'USAGE', 'SUMMARY', 'DESCRIPTION', 'MAN_PAGE_ENTRY', 'IS_SET')
+
+DETAILS_HEIGHT = 8
+NAME_WIDTH = 25
+VALUE_WIDTH = 15
+
+
+def conf_handler(key, value):
+  if key == 'features.config.order':
+    return conf.parse_enum_csv(key, value[0], SortAttr, 3)
+
+
+CONFIG = conf.config_dict('nyx', {
+  'attr.config.category_color': {},
+  'attr.config.sort_color': {},
+  'features.config.order': [SortAttr.MAN_PAGE_ENTRY, SortAttr.NAME, SortAttr.IS_SET],
+  'features.config.state.showPrivateOptions': False,
+  'features.config.state.showVirtualOptions': False,
+}, conf_handler)
+
+
+class ConfigEntry(object):
+  """
+  Configuration option presented in the panel.
+
+  :var str name: name of the configuration option
+  :var str value_type: type of value
+  :var stem.manual.ConfigOption manual: manual information about the option
+  """
+
+  def __init__(self, name, value_type, manual):
+    self.name = name
+    self.value_type = value_type
+    self.manual = manual.config_options.get(name, stem.manual.ConfigOption(name))
+    self._index = manual.config_options.keys().index(name) if name in manual.config_options else 99999
+
+  def value(self):
+    """
+    Provides the value of this configuration option.
+
+    :returns: **str** representation of the current config value
+    """
+
+    values = tor_controller().get_conf(self.name, [], True)
+
+    if not values:
+      return '<none>'
+    elif self.value_type == 'Boolean' and values[0] in ('0', '1'):
+      return 'False' if values[0] == '0' else 'True'
+    elif self.value_type == 'DataSize' and values[0].isdigit():
+      return str_tools.size_label(int(values[0]))
+    elif self.value_type == 'TimeInterval' and values[0].isdigit():
+      return str_tools.time_label(int(values[0]), is_long = True)
+    else:
+      return ', '.join(values)
+
+  def is_set(self):
+    """
+    Checks if the configuration option has a custom value.
+
+    :returns: **True** if the option has a custom value, **False** otherwise
+    """
+
+    return tor_controller().is_set(self.name, False)
+
+  def sort_value(self, attr):
+    """
+    Provides a heuristic for sorting by a given value.
+
+    :param SortAttr attr: sort attribute to provide a heuristic for
+
+    :returns: comparable value for sorting
+    """
+
+    if attr == SortAttr.CATEGORY:
+      return self.manual.category
+    elif attr == SortAttr.NAME:
+      return self.name
+    elif attr == SortAttr.VALUE:
+      return self.value()
+    elif attr == SortAttr.VALUE_TYPE:
+      return self.value_type
+    elif attr == SortAttr.USAGE:
+      return self.manual.usage
+    elif attr == SortAttr.SUMMARY:
+      return self.manual.summary
+    elif attr == SortAttr.DESCRIPTION:
+      return self.manual.description
+    elif attr == SortAttr.MAN_PAGE_ENTRY:
+      return self._index
+    elif attr == SortAttr.IS_SET:
+      return not self.is_set()
+
+
+class ConfigPanel(panel.Panel):
+  """
+  Editor for tor's configuration.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'configuration', 0)
+
+    self._contents = []
+    self._scroller = ui_tools.Scroller(True)
+    self._sort_order = CONFIG['features.config.order']
+    self._show_all = False  # show all options, or just the important ones
+
+    cached_manual_path = os.path.join(DATA_DIR, 'manual')
+
+    if os.path.exists(cached_manual_path):
+      manual = stem.manual.Manual.from_cache(cached_manual_path)
+    else:
+      try:
+        manual = stem.manual.Manual.from_man()
+
+        try:
+          manual.save(cached_manual_path)
+        except IOError as exc:
+          log.debug("Unable to cache manual information to '%s'. This is fine, but means starting Nyx takes a little longer than usual: " % (cached_manual_path, exc))
+      except IOError as exc:
+        log.debug("Unable to use 'man tor' to get information about config options (%s), using bundled information instead" % exc)
+        manual = stem.manual.Manual.from_cache()
+
+    try:
+      for line in tor_controller().get_info('config/names').splitlines():
+        # Lines of the form "<option> <type>[ <documentation>]". Documentation
+        # was apparently only in old tor versions like 0.2.1.25.
+
+        if ' ' not in line:
+          continue
+
+        line_comp = line.split()
+        name, value_type = line_comp[0], line_comp[1]
+
+        # skips private and virtual entries if not configured to show them
+
+        if name.startswith('__') and not CONFIG['features.config.state.showPrivateOptions']:
+          continue
+        elif value_type == 'Virtual' and not CONFIG['features.config.state.showVirtualOptions']:
+          continue
+
+        self._contents.append(ConfigEntry(name, value_type, manual))
+
+      self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
+    except stem.ControllerError as exc:
+      log.warn('Unable to determine the configuration options tor supports: %s' % exc)
+
+  def show_sort_dialog(self):
+    """
+    Provides the dialog for sorting our configuration options.
+    """
+
+    sort_colors = dict([(attr, CONFIG['attr.config.sort_color'].get(attr, 'white')) for attr in SortAttr])
+    results = nyx.popups.show_sort_dialog('Config Option Ordering:', SortAttr, self._sort_order, sort_colors)
+
+    if results:
+      self._sort_order = results
+      self._contents = sorted(self._contents, key = lambda entry: [entry.sort_value(field) for field in self._sort_order])
+
+  def show_write_dialog(self):
+    """
+    Confirmation dialog for saving tor's configuration.
+    """
+
+    selection, controller = 1, tor_controller()
+    config_text = controller.get_info('config-text', None)
+    config_lines = config_text.splitlines() if config_text else []
+
+    with nyx.popups.popup_window(len(config_lines) + 2) as (popup, width, height):
+      if not popup or height <= 2:
+        return
+
+      while True:
+        height, width = popup.get_preferred_size()  # allow us to be resized
+        popup.win.erase()
+
+        for i, full_line in enumerate(config_lines):
+          line = str_tools.crop(full_line, width - 2)
+          option, arg = line.split(' ', 1) if ' ' in line else (line, '')
+
+          popup.addstr(i + 1, 1, option, curses.A_BOLD, 'green')
+          popup.addstr(i + 1, len(option) + 2, arg, curses.A_BOLD, 'cyan')
+
+        x = width - 16
+
+        for i, option in enumerate(['Save', 'Cancel']):
+          x = popup.addstr(height - 2, x, '[')
+          x = popup.addstr(height - 2, x, option, curses.A_BOLD, curses.A_STANDOUT if i == selection else curses.A_NORMAL)
+          x = popup.addstr(height - 2, x, '] ')
+
+        popup.win.box()
+        popup.addstr(0, 0, 'Torrc to save:', curses.A_STANDOUT)
+        popup.win.refresh()
+
+        key = nyx.controller.get_controller().key_input()
+
+        if key.match('left'):
+          selection = max(0, selection - 1)
+        elif key.match('right'):
+          selection = min(1, selection + 1)
+        elif key.is_selection():
+          if selection == 0:
+            try:
+              controller.save_conf()
+              nyx.popups.show_msg('Saved configuration to %s' % controller.get_info('config-file', '<unknown>'), 2)
+            except IOError as exc:
+              nyx.popups.show_msg('Unable to save configuration (%s)' % exc.strerror, 2)
+
+          break
+        elif key.match('esc'):
+          break  # esc - cancel
+
+  def handle_key(self, key):
+    if key.is_scroll():
+      page_height = self.get_preferred_size()[0] - DETAILS_HEIGHT
+      is_changed = self._scroller.handle_key(key, self._get_config_options(), page_height)
+
+      if is_changed:
+        self.redraw(True)
+    elif key.is_selection():
+      selection = self._scroller.get_cursor_selection(self._get_config_options())
+      initial_value = selection.value() if selection.is_set() else ''
+      new_value = nyx.popups.input_prompt('%s Value (esc to cancel): ' % selection.name, initial_value)
+
+      if new_value != initial_value:
+        try:
+          if selection.value_type == 'Boolean':
+            # if the value's a boolean then allow for 'true' and 'false' inputs
+
+            if new_value.lower() == 'true':
+              new_value = '1'
+            elif new_value.lower() == 'false':
+              new_value = '0'
+          elif selection.value_type == 'LineList':
+            new_value = new_value.split(',')  # set_conf accepts list inputs
+
+          tor_controller().set_conf(selection.name, new_value)
+          self.redraw(True)
+        except Exception as exc:
+          nyx.popups.show_msg('%s (press any key)' % exc)
+    elif key.match('a'):
+      self._show_all = not self._show_all
+      self.redraw(True)
+    elif key.match('s'):
+      self.show_sort_dialog()
+    elif key.match('w'):
+      self.show_write_dialog()
+    else:
+      return False
+
+    return True
+
+  def get_help(self):
+    return [
+      ('up arrow', 'scroll up a line', None),
+      ('down arrow', 'scroll down a line', None),
+      ('page up', 'scroll up a page', None),
+      ('page down', 'scroll down a page', None),
+      ('enter', 'edit configuration option', None),
+      ('w', 'write torrc', None),
+      ('a', 'toggle filtering', None),
+      ('s', 'sort ordering', None),
+    ]
+
+  def draw(self, width, height):
+    contents = self._get_config_options()
+    selection = self._scroller.get_cursor_selection(contents)
+    scroll_location = self._scroller.get_scroll_location(contents, height - DETAILS_HEIGHT)
+    is_scrollbar_visible = len(contents) > height - DETAILS_HEIGHT
+
+    if selection is not None:
+      self._draw_selection_details(selection, width)
+
+    if self.is_title_visible():
+      hidden_msg = "press 'a' to hide most options" if self._show_all else "press 'a' to show all options"
+      self.addstr(0, 0, 'Tor Configuration (%s):' % hidden_msg, curses.A_STANDOUT)
+
+    scroll_offset = 1
+
+    if is_scrollbar_visible:
+      scroll_offset = 3
+      self.add_scroll_bar(scroll_location, scroll_location + height - DETAILS_HEIGHT, len(contents), DETAILS_HEIGHT)
+
+      if selection is not None:
+        self.addch(DETAILS_HEIGHT - 1, 1, curses.ACS_TTEE)
+
+    # Description column can grow up to eighty characters. After that any extra
+    # space goes to the value.
+
+    description_width = max(0, width - scroll_offset - NAME_WIDTH - VALUE_WIDTH - 2)
+
+    if description_width > 80:
+      value_width = VALUE_WIDTH + (description_width - 80)
+      description_width = 80
+    else:
+      value_width = VALUE_WIDTH
+
+    for i, entry in enumerate(contents[scroll_location:]):
+      attr = nyx.util.ui_tools.get_color(CONFIG['attr.config.category_color'].get(entry.manual.category, 'white'))
+      attr |= curses.A_BOLD if entry.is_set() else curses.A_NORMAL
+      attr |= curses.A_STANDOUT if entry == selection else curses.A_NORMAL
+
+      option_label = str_tools.crop(entry.name, NAME_WIDTH).ljust(NAME_WIDTH + 1)
+      value_label = str_tools.crop(entry.value(), value_width).ljust(value_width + 1)
+      summary_label = str_tools.crop(entry.manual.summary, description_width).ljust(description_width)
+
+      self.addstr(DETAILS_HEIGHT + i, scroll_offset, option_label + value_label + summary_label, attr)
+
+      if DETAILS_HEIGHT + i >= height:
+        break
+
+  def _get_config_options(self):
+    return self._contents if self._show_all else filter(lambda entry: stem.manual.is_important(entry.name) or entry.is_set(), self._contents)
+
+  def _draw_selection_details(self, selection, width):
+    """
+    Shows details of the currently selected option.
+    """
+
+    description = 'Description: %s' % (selection.manual.description)
+    attr = ', '.join(('custom' if selection.is_set() else 'default', selection.value_type, 'usage: %s' % selection.manual.usage))
+    selected_color = CONFIG['attr.config.category_color'].get(selection.manual.category, 'white')
+    ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT)
+
+    self.addstr(1, 2, '%s (%s Option)' % (selection.name, selection.manual.category), curses.A_BOLD, selected_color)
+    self.addstr(2, 2, 'Value: %s (%s)' % (selection.value(), str_tools.crop(attr, width - len(selection.value()) - 13)), curses.A_BOLD, selected_color)
+
+    for i in range(DETAILS_HEIGHT - 4):
+      if not description:
+        break  # done writing description
+
+      line, description = description.split('\n', 1) if '\n' in description else (description, '')
+
+      if i < DETAILS_HEIGHT - 5:
+        line, remainder = str_tools.crop(line, width - 3, 4, 4, str_tools.Ending.HYPHEN, True)
+        description = '  ' + remainder.strip() + description
+        self.addstr(3 + i, 2, line, curses.A_BOLD, selected_color)
+      else:
+        self.addstr(3 + i, 2, str_tools.crop(line, width - 3, 4, 4), curses.A_BOLD, selected_color)
diff --git a/nyx/panel/connection.py b/nyx/panel/connection.py
new file mode 100644
index 0000000..e28f531
--- /dev/null
+++ b/nyx/panel/connection.py
@@ -0,0 +1,706 @@
+"""
+Listing of the currently established connections tor has made.
+"""
+
+import re
+import time
+import collections
+import curses
+import itertools
+import threading
+
+import nyx.popups
+import nyx.util.tracker
+import nyx.util.ui_tools
+
+from nyx.util import panel, tor_controller, ui_tools
+
+from stem.control import Listener
+from stem.util import datetime_to_unix, conf, connection, enum, str_tools
+
+try:
+  # added in python 3.2
+  from functools import lru_cache
+except ImportError:
+  from stem.util.lru_cache import lru_cache
+
+# height of the detail panel content, not counting top and bottom border
+
+DETAILS_HEIGHT = 7
+
+EXIT_USAGE_WIDTH = 15
+UPDATE_RATE = 5  # rate in seconds at which we refresh
+
+# cached information from our last _update() call
+
+LAST_RETRIEVED_HS_CONF = None
+LAST_RETRIEVED_CIRCUITS = None
+
+# Connection Categories:
+#   Inbound      Relay connection, coming to us.
+#   Outbound     Relay connection, leaving us.
+#   Exit         Outbound relay connection leaving the Tor network.
+#   Hidden       Connections to a hidden service we're providing.
+#   Socks        Socks connections for applications using Tor.
+#   Circuit      Circuits our tor client has created.
+#   Directory    Fetching tor consensus information.
+#   Control      Tor controller (nyx, vidalia, etc).
+
+Category = enum.Enum('INBOUND', 'OUTBOUND', 'EXIT', 'HIDDEN', 'SOCKS', 'CIRCUIT', 'DIRECTORY', 'CONTROL')
+SortAttr = enum.Enum('CATEGORY', 'UPTIME', 'IP_ADDRESS', 'PORT', 'FINGERPRINT', 'NICKNAME', 'COUNTRY')
+LineType = enum.Enum('CONNECTION', 'CIRCUIT_HEADER', 'CIRCUIT')
+
+Line = collections.namedtuple('Line', [
+  'entry',
+  'line_type',
+  'connection',
+  'circuit',
+  'fingerprint',
+  'nickname',
+  'locale',
+])
+
+
+def conf_handler(key, value):
+  if key == 'features.connection.order':
+    return conf.parse_enum_csv(key, value[0], SortAttr, 3)
+
+
+CONFIG = conf.config_dict('nyx', {
+  'attr.connection.category_color': {},
+  'attr.connection.sort_color': {},
+  'features.connection.resolveApps': True,
+  'features.connection.order': [SortAttr.CATEGORY, SortAttr.IP_ADDRESS, SortAttr.UPTIME],
+  'features.connection.showIps': True,
+}, conf_handler)
+
+
+class Entry(object):
+  @staticmethod
+  @lru_cache()
+  def from_connection(connection):
+    return ConnectionEntry(connection)
+
+  @staticmethod
+  @lru_cache()
+  def from_circuit(circuit):
+    return CircuitEntry(circuit)
+
+  def get_lines(self):
+    """
+    Provides individual lines of connection information.
+
+    :returns: **list** of **ConnectionLine** concerning this entry
+    """
+
+    raise NotImplementedError('should be implemented by subclasses')
+
+  def get_type(self):
+    """
+    Provides our best guess at the type of connection this is.
+
+    :returns: **Category** for the connection's type
+    """
+
+    raise NotImplementedError('should be implemented by subclasses')
+
+  def is_private(self):
+    """
+    Checks if information about this endpoint should be scrubbed. Relaying
+    etiquette (and wiretapping laws) say these are bad things to look at so
+    DON'T CHANGE THIS UNLESS YOU HAVE A DAMN GOOD REASON!
+
+    :returns: **bool** indicating if connection information is sensive or not
+    """
+
+    raise NotImplementedError('should be implemented by subclasses')
+
+  def sort_value(self, attr):
+    """
+    Provides a heuristic for sorting by a given value.
+
+    :param SortAttr attr: sort attribute to provide a heuristic for
+
+    :returns: comparable value for sorting
+    """
+
+    line = self.get_lines()[0]
+    at_end = 'z' * 20
+
+    if attr == SortAttr.IP_ADDRESS:
+      if self.is_private():
+        return 255 ** 4  # orders at the end
+
+      ip_value = 0
+
+      for octet in line.connection.remote_address.split('.'):
+        ip_value = ip_value * 255 + int(octet)
+
+      return ip_value * 65536 + line.connection.remote_port
+    elif attr == SortAttr.PORT:
+      return line.connection.remote_port
+    elif attr == SortAttr.FINGERPRINT:
+      return line.fingerprint if line.fingerprint else at_end
+    elif attr == SortAttr.NICKNAME:
+      return line.nickname if line.nickname else at_end
+    elif attr == SortAttr.CATEGORY:
+      return Category.index_of(self.get_type())
+    elif attr == SortAttr.UPTIME:
+      return line.connection.start_time
+    elif attr == SortAttr.COUNTRY:
+      return line.locale if (line.locale and not self.is_private()) else at_end
+    else:
+      return ''
+
+
+class ConnectionEntry(Entry):
+  def __init__(self, connection):
+    self._connection = connection
+
+  @lru_cache()
+  def get_lines(self):
+    fingerprint, nickname, locale = None, None, None
+
+    if self.get_type() in (Category.OUTBOUND, Category.CIRCUIT, Category.DIRECTORY, Category.EXIT):
+      fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
+
+      if fingerprint:
+        nickname = nyx.util.tracker.get_consensus_tracker().get_relay_nickname(fingerprint)
+        locale = tor_controller().get_info('ip-to-country/%s' % self._connection.remote_address, None)
+
+    return [Line(self, LineType.CONNECTION, self._connection, None, fingerprint, nickname, locale)]
+
+  @lru_cache()
+  def get_type(self):
+    controller = tor_controller()
+
+    if self._connection.local_port in controller.get_ports(Listener.OR, []):
+      return Category.INBOUND
+    elif self._connection.local_port in controller.get_ports(Listener.DIR, []):
+      return Category.INBOUND
+    elif self._connection.local_port in controller.get_ports(Listener.SOCKS, []):
+      return Category.SOCKS
+    elif self._connection.local_port in controller.get_ports(Listener.CONTROL, []):
+      return Category.CONTROL
+
+    if LAST_RETRIEVED_HS_CONF:
+      for hs_config in LAST_RETRIEVED_HS_CONF.values():
+        if self._connection.remote_port == hs_config['HiddenServicePort']:
+          return Category.HIDDEN
+
+    fingerprint = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address).get(self._connection.remote_port)
+
+    if fingerprint and LAST_RETRIEVED_CIRCUITS:
+      for circ in LAST_RETRIEVED_CIRCUITS:
+        if circ.path and len(circ.path) == 1 and circ.path[0][0] == fingerprint and circ.status == 'BUILT':
+          return Category.DIRECTORY  # one-hop circuit to retrieve directory information
+    else:
+      # not a known relay, might be an exit connection
+
+      exit_policy = controller.get_exit_policy(None)
+
+      if exit_policy and exit_policy.can_exit_to(self._connection.remote_address, self._connection.remote_port):
+        return Category.EXIT
+
+    return Category.OUTBOUND
+
+  @lru_cache()
+  def is_private(self):
+    if not CONFIG['features.connection.showIps']:
+      return True
+
+    if self.get_type() == Category.INBOUND:
+      controller = tor_controller()
+
+      if controller.is_user_traffic_allowed().inbound:
+        return len(nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(self._connection.remote_address)) == 0
+    elif self.get_type() == Category.EXIT:
+      # DNS connections exiting us aren't private (since they're hitting our
+      # resolvers). Everything else is.
+
+      return not (self._connection.remote_port == 53 and self._connection.protocol == 'udp')
+
+    return False  # for everything else this isn't a concern
+
+
+class CircuitEntry(Entry):
+  def __init__(self, circuit):
+    self._circuit = circuit
+
+  @lru_cache()
+  def get_lines(self):
+    def line(fingerprint, line_type):
+      address, port, nickname, locale = '0.0.0.0', 0, None, None
+      consensus_tracker = nyx.util.tracker.get_consensus_tracker()
+
+      if fingerprint is not None:
+        address, port = consensus_tracker.get_relay_address(fingerprint, ('192.168.0.1', 0))
+        nickname = consensus_tracker.get_relay_nickname(fingerprint)
+        locale = tor_controller().get_info('ip-to-country/%s' % address, None)
+
+      connection = nyx.util.tracker.Connection(datetime_to_unix(self._circuit.created), False, '127.0.0.1', 0, address, port, 'tcp', False)
+      return Line(self, line_type, connection, self._circuit, fingerprint, nickname, locale)
+
+    header_line = line(self._circuit.path[-1][0] if self._circuit.status == 'BUILT' else None, LineType.CIRCUIT_HEADER)
+    return [header_line] + [line(fp, LineType.CIRCUIT) for fp, _ in self._circuit.path]
+
+  def get_type(self):
+    return Category.CIRCUIT
+
+  def is_private(self):
+    return False
+
+
+class ConnectionPanel(panel.Panel, threading.Thread):
+  """
+  Listing of connections tor is making, with information correlated against
+  the current consensus and other data sources.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'connections', 0)
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+
+    self._scroller = ui_tools.Scroller(True)
+    self._entries = []            # last fetched display entries
+    self._show_details = False    # presents the details panel if true
+    self._sort_order = CONFIG['features.connection.order']
+
+    self._last_resource_fetch = -1  # timestamp of the last ConnectionResolver results used
+
+    self._pause_condition = threading.Condition()
+    self._halt = False  # terminates thread if true
+
+    # Tracks exiting port and client country statistics
+
+    self._client_locale_usage = {}
+    self._exit_port_usage = {}
+    self._counted_connections = set()
+
+    # If we're a bridge and been running over a day then prepopulates with the
+    # last day's clients.
+
+    bridge_clients = tor_controller().get_info('status/clients-seen', None)
+
+    if bridge_clients:
+      # Response has a couple arguments...
+      # TimeStarted="2011-08-17 15:50:49" CountrySummary=us=16,de=8,uk=8
+
+      country_summary = None
+
+      for line in bridge_clients.split():
+        if line.startswith('CountrySummary='):
+          country_summary = line[15:]
+          break
+
+      if country_summary:
+        for entry in country_summary.split(','):
+          if re.match('^..=[0-9]+$', entry):
+            locale, count = entry.split('=', 1)
+            self._client_locale_usage[locale] = int(count)
+
+  def show_sort_dialog(self):
+    """
+    Provides a dialog for sorting our connections.
+    """
+
+    sort_colors = dict([(attr, CONFIG['attr.connection.sort_color'].get(attr, 'white')) for attr in SortAttr])
+    results = nyx.popups.show_sort_dialog('Connection Ordering:', SortAttr, self._sort_order, sort_colors)
+
+    if results:
+      self._sort_order = results
+      self._entries = sorted(self._entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
+
+  def handle_key(self, key):
+    user_traffic_allowed = tor_controller().is_user_traffic_allowed()
+
+    if key.is_scroll():
+      page_height = self.get_preferred_size()[0] - 1
+
+      if self._show_details:
+        page_height -= (DETAILS_HEIGHT + 1)
+
+      lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in self._entries]))
+      is_changed = self._scroller.handle_key(key, lines, page_height)
+
+      if is_changed:
+        self.redraw(True)
+    elif key.is_selection():
+      self._show_details = not self._show_details
+      self.redraw(True)
+    elif key.match('s'):
+      self.show_sort_dialog()
+    elif key.match('r'):
+      connection_tracker = nyx.util.tracker.get_connection_tracker()
+      options = ['auto'] + list(connection.Resolver) + list(nyx.util.tracker.CustomResolver)
+
+      resolver = connection_tracker.get_custom_resolver()
+      selected_index = 0 if resolver is None else options.index(resolver)
+      selection = nyx.popups.show_menu('Connection Resolver:', options, selected_index)
+
+      if selection != -1:
+        connection_tracker.set_custom_resolver(None if selection == 0 else options[selection])
+    elif key.match('d'):
+      self.set_title_visible(False)
+      self.redraw(True)
+      entries = self._entries
+
+      while True:
+        lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
+        selection = self._scroller.get_cursor_selection(lines)
+
+        if not selection:
+          break
+
+        def is_close_key(key):
+          return key.is_selection() or key.match('d') or key.match('left') or key.match('right')
+
+        color = CONFIG['attr.connection.category_color'].get(selection.entry.get_type(), 'white')
+        key = nyx.popups.show_descriptor_popup(selection.fingerprint, color, self.max_x, is_close_key)
+
+        if not key or key.is_selection() or key.match('d'):
+          break  # closes popup
+        elif key.match('left'):
+          self.handle_key(panel.KeyInput(curses.KEY_UP))
+        elif key.match('right'):
+          self.handle_key(panel.KeyInput(curses.KEY_DOWN))
+
+      self.set_title_visible(True)
+      self.redraw(True)
+    elif key.match('c') and user_traffic_allowed.inbound:
+      nyx.popups.show_count_dialog('Client Locales', self._client_locale_usage)
+    elif key.match('e') and user_traffic_allowed.outbound:
+      counts = {}
+      key_width = max(map(len, self._exit_port_usage.keys()))
+
+      for k, v in self._exit_port_usage.items():
+        usage = connection.port_usage(k)
+
+        if usage:
+          k = k.ljust(key_width + 3) + usage.ljust(EXIT_USAGE_WIDTH)
+
+        counts[k] = v
+
+      nyx.popups.show_count_dialog('Exiting Port Usage', counts)
+    else:
+      return False
+
+    return True
+
+  def run(self):
+    """
+    Keeps connections listing updated, checking for new entries at a set rate.
+    """
+
+    last_ran = -1
+
+    while not self._halt:
+      if self.is_paused() or not tor_controller().is_alive() or (time.time() - last_ran) < UPDATE_RATE:
+        with self._pause_condition:
+          if not self._halt:
+            self._pause_condition.wait(0.2)
+
+        continue  # done waiting, try again
+
+      self._update()
+      self.redraw(True)
+
+      # If this is our first run then fill in our fingerprint tracker. This
+      # requires fetching all the router status entries which takes a few
+      # seconds, so best done when we're finished with the rest of the first
+      # iteration to hide the lag.
+
+      if last_ran == -1:
+        nyx.util.tracker.get_consensus_tracker().update(tor_controller().get_network_statuses([]))
+
+      last_ran = time.time()
+
+  def get_help(self):
+    resolver = nyx.util.tracker.get_connection_tracker().get_custom_resolver()
+    user_traffic_allowed = tor_controller().is_user_traffic_allowed()
+
+    options = [
+      ('up arrow', 'scroll up a line', None),
+      ('down arrow', 'scroll down a line', None),
+      ('page up', 'scroll up a page', None),
+      ('page down', 'scroll down a page', None),
+      ('enter', 'show connection details', None),
+      ('d', 'raw consensus descriptor', None),
+      ('s', 'sort ordering', None),
+      ('r', 'connection resolver', 'auto' if resolver is None else resolver),
+    ]
+
+    if user_traffic_allowed.inbound:
+      options.append(('c', 'client locale usage summary', None))
+
+    if user_traffic_allowed.outbound:
+      options.append(('e', 'exit port usage summary', None))
+
+    return options
+
+  def draw(self, width, height):
+    controller = tor_controller()
+    entries = self._entries
+
+    lines = list(itertools.chain.from_iterable([entry.get_lines() for entry in entries]))
+    selected = self._scroller.get_cursor_selection(lines)
+
+    if self.is_paused():
+      current_time = self.get_pause_time()
+    elif not controller.is_alive():
+      current_time = controller.connection_time()
+    else:
+      current_time = time.time()
+
+    is_showing_details = self._show_details and selected
+    details_offset = DETAILS_HEIGHT + 1 if is_showing_details else 0
+
+    is_scrollbar_visible = len(lines) > height - details_offset - 1
+    scroll_offset = 2 if is_scrollbar_visible else 0
+    scroll_location = self._scroller.get_scroll_location(lines, height - details_offset - 1)
+
+    if self.is_title_visible():
+      self._draw_title(entries, self._show_details)
+
+    if is_showing_details:
+      self._draw_details(selected, width, is_scrollbar_visible)
+
+    if is_scrollbar_visible:
+      self.add_scroll_bar(scroll_location, scroll_location + height - details_offset - 1, len(lines), 1 + details_offset)
+
+    for line_number in range(scroll_location, len(lines)):
+      y = line_number + details_offset + 1 - scroll_location
+      self._draw_line(scroll_offset, y, lines[line_number], lines[line_number] == selected, width - scroll_offset, current_time)
+
+      if y >= height:
+        break
+
+  def _draw_title(self, entries, showing_details):
+    """
+    Panel title with the number of connections we presently have.
+    """
+
+    if showing_details:
+      self.addstr(0, 0, 'Connection Details:', curses.A_STANDOUT)
+    elif not entries:
+      self.addstr(0, 0, 'Connections:', curses.A_STANDOUT)
+    else:
+      counts = collections.Counter([entry.get_type() for entry in entries])
+      count_labels = ['%i %s' % (counts[category], category.lower()) for category in Category if counts[category]]
+      self.addstr(0, 0, 'Connections (%s):' % ', '.join(count_labels), curses.A_STANDOUT)
+
+  def _draw_details(self, selected, width, is_scrollbar_visible):
+    """
+    Shows detailed information about the selected connection.
+    """
+
+    attr = (CONFIG['attr.connection.category_color'].get(selected.entry.get_type(), 'white'), curses.A_BOLD)
+
+    if selected.line_type == LineType.CIRCUIT_HEADER and selected.circuit.status != 'BUILT':
+      self.addstr(1, 2, 'Building Circuit...', *attr)
+    else:
+      address = '<scrubbed>' if selected.entry.is_private() else selected.connection.remote_address
+      self.addstr(1, 2, 'address: %s:%s' % (address, selected.connection.remote_port), *attr)
+      self.addstr(2, 2, 'locale: %s' % ('??' if selected.entry.is_private() else (selected.locale if selected.locale else '??')), *attr)
+
+      matches = nyx.util.tracker.get_consensus_tracker().get_relay_fingerprints(selected.connection.remote_address)
+
+      if not matches:
+        self.addstr(3, 2, 'No consensus data found', *attr)
+      elif len(matches) == 1 or selected.connection.remote_port in matches:
+        controller = tor_controller()
+        fingerprint = matches.values()[0] if len(matches) == 1 else matches[selected.connection.remote_port]
+        router_status_entry = controller.get_network_status(fingerprint, None)
+
+        self.addstr(2, 15, 'fingerprint: %s' % fingerprint, *attr)
+
+        if router_status_entry:
+          dir_port_label = 'dirport: %s' % router_status_entry.dir_port if router_status_entry.dir_port else ''
+          self.addstr(3, 2, 'nickname: %-25s orport: %-10s %s' % (router_status_entry.nickname, router_status_entry.or_port, dir_port_label), *attr)
+          self.addstr(4, 2, 'published: %s' % router_status_entry.published.strftime("%H:%M %m/%d/%Y"), *attr)
+          self.addstr(5, 2, 'flags: %s' % ', '.join(router_status_entry.flags), *attr)
+
+          server_descriptor = controller.get_server_descriptor(fingerprint, None)
+
+          if server_descriptor:
+            policy_label = server_descriptor.exit_policy.summary() if server_descriptor.exit_policy else 'unknown'
+            self.addstr(6, 2, 'exit policy: %s' % policy_label, *attr)
+            self.addstr(4, 38, 'os: %-14s version: %s' % (server_descriptor.operating_system, server_descriptor.tor_version), *attr)
+
+            if server_descriptor.contact:
+              self.addstr(7, 2, 'contact: %s' % server_descriptor.contact, *attr)
+      else:
+        self.addstr(3, 2, 'Multiple matches, possible fingerprints are:', *attr)
+
+        for i, port in enumerate(sorted(matches.keys())):
+          is_last_line, remaining_relays = i == 3, len(matches) - i
+
+          if not is_last_line or remaining_relays == 1:
+            self.addstr(4 + i, 2, '%i. or port: %-5s fingerprint: %s' % (i + 1, port, matches[port]), *attr)
+          else:
+            self.addstr(4 + i, 2, '... %i more' % remaining_relays, *attr)
+
+          if is_last_line:
+            break
+
+    # draw the border, with a 'T' pipe if connecting with the scrollbar
+
+    ui_tools.draw_box(self, 0, 0, width, DETAILS_HEIGHT + 2)
+
+    if is_scrollbar_visible:
+      self.addch(DETAILS_HEIGHT + 1, 1, curses.ACS_TTEE)
+
+  def _draw_line(self, x, y, line, is_selected, width, current_time):
+    attr = nyx.util.ui_tools.get_color(CONFIG['attr.connection.category_color'].get(line.entry.get_type(), 'white'))
+    attr |= curses.A_STANDOUT if is_selected else curses.A_NORMAL
+
+    self.addstr(y, x, ' ' * (width - x), attr)
+
+    if line.line_type == LineType.CIRCUIT:
+      if line.circuit.path[-1][0] == line.fingerprint:
+        prefix = (ord(' '), curses.ACS_LLCORNER, curses.ACS_HLINE, ord(' '))
+      else:
+        prefix = (ord(' '), curses.ACS_VLINE, ord(' '), ord(' '))
+
+      for char in prefix:
+        x = self.addch(y, x, char)
+    else:
+      x += 1  # offset from edge
+
+    self._draw_address_column(x, y, line, attr)
+    self._draw_line_details(57, y, line, width - 57 - 20, attr)
+    self._draw_right_column(width - 18, y, line, current_time, attr)
+
+  def _draw_address_column(self, x, y, line, attr):
+    src = tor_controller().get_info('address', line.connection.local_address)
+    src += ':%s' % line.connection.local_port if line.line_type == LineType.CONNECTION else ''
+
+    if line.line_type == LineType.CIRCUIT_HEADER and line.circuit.status != 'BUILT':
+      dst = 'Building...'
+    else:
+      dst = '<scrubbed>' if line.entry.is_private() else line.connection.remote_address
+      dst += ':%s' % line.connection.remote_port
+
+      if line.entry.get_type() == Category.EXIT:
+        purpose = connection.port_usage(line.connection.remote_port)
+
+        if purpose:
+          dst += ' (%s)' % str_tools.crop(purpose, 26 - len(dst) - 3)
+      elif not tor_controller().is_geoip_unavailable() and not line.entry.is_private():
+        dst += ' (%s)' % (line.locale if line.locale else '??')
+
+    if line.entry.get_type() in (Category.INBOUND, Category.SOCKS, Category.CONTROL):
+      dst, src = src, dst
+
+    if line.line_type == LineType.CIRCUIT:
+      self.addstr(y, x, dst, attr)
+    else:
+      self.addstr(y, x, '%-21s  -->  %-26s' % (src, dst), attr)
+
+  def _draw_line_details(self, x, y, line, width, attr):
+    if line.line_type == LineType.CIRCUIT_HEADER:
+      comp = ['Purpose: %s' % line.circuit.purpose.capitalize(), ', Circuit ID: %s' % line.circuit.id]
+    elif line.entry.get_type() in (Category.SOCKS, Category.HIDDEN, Category.CONTROL):
+      try:
+        port = line.connection.local_port if line.entry.get_type() == Category.HIDDEN else line.connection.remote_port
+        process = nyx.util.tracker.get_port_usage_tracker().fetch(port)
+        comp = ['%s (%s)' % (process.name, process.pid) if process.pid else process.name]
+      except nyx.util.tracker.UnresolvedResult:
+        comp = ['resolving...']
+      except nyx.util.tracker.UnknownApplication:
+        comp = ['UNKNOWN']
+    else:
+      comp = ['%-40s' % (line.fingerprint if line.fingerprint else 'UNKNOWN'), '  ' + (line.nickname if line.nickname else 'UNKNOWN')]
+
+    for entry in comp:
+      if width >= x + len(entry):
+        x = self.addstr(y, x, entry, attr)
+      else:
+        return
+
+  def _draw_right_column(self, x, y, line, current_time, attr):
+    if line.line_type == LineType.CIRCUIT:
+      circ_path = [fp for fp, _ in line.circuit.path]
+      circ_index = circ_path.index(line.fingerprint)
+
+      if circ_index == len(circ_path) - 1:
+        placement_type = 'Exit' if line.circuit.status == 'BUILT' else 'Extending'
+      elif circ_index == 0:
+        placement_type = 'Guard'
+      else:
+        placement_type = 'Middle'
+
+      self.addstr(y, x + 4, '%i / %s' % (circ_index + 1, placement_type), attr)
+    else:
+      x = self.addstr(y, x, '+' if line.connection.is_legacy else ' ', attr)
+      x = self.addstr(y, x, '%5s' % str_tools.time_label(current_time - line.connection.start_time, 1), attr)
+      x = self.addstr(y, x, ' (', attr)
+      x = self.addstr(y, x, line.entry.get_type().upper(), attr | curses.A_BOLD)
+      x = self.addstr(y, x, ')', attr)
+
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+
+    with self._pause_condition:
+      self._halt = True
+      self._pause_condition.notifyAll()
+
+  def _update(self):
+    """
+    Fetches the newest resolved connections.
+    """
+
+    global LAST_RETRIEVED_CIRCUITS, LAST_RETRIEVED_HS_CONF
+
+    controller = tor_controller()
+    LAST_RETRIEVED_CIRCUITS = controller.get_circuits([])
+    LAST_RETRIEVED_HS_CONF = controller.get_hidden_service_conf({})
+
+    conn_resolver = nyx.util.tracker.get_connection_tracker()
+    current_resolution_count = conn_resolver.run_counter()
+
+    if not conn_resolver.is_alive():
+      return  # if we're not fetching connections then this is a no-op
+    elif current_resolution_count == self._last_resource_fetch:
+      return  # no new connections to process
+
+    new_entries = [Entry.from_connection(conn) for conn in conn_resolver.get_value()]
+
+    for circ in LAST_RETRIEVED_CIRCUITS:
+      # Skips established single-hop circuits (these are for directory
+      # fetches, not client circuits)
+
+      if not (circ.status == 'BUILT' and len(circ.path) == 1):
+        new_entries.append(Entry.from_circuit(circ))
+
+    # update stats for client and exit connections
+
+    for entry in new_entries:
+      line = entry.get_lines()[0]
+
+      if entry.is_private() and line.connection not in self._counted_connections:
+        if entry.get_type() == Category.INBOUND and line.locale:
+          self._client_locale_usage[line.locale] = self._client_locale_usage.get(line.locale, 0) + 1
+        elif entry.get_type() == Category.EXIT:
+          self._exit_port_usage[line.connection.remote_port] = self._exit_port_usage.get(line.connection.remote_port, 0) + 1
+
+        self._counted_connections.add(line.connection)
+
+    self._entries = sorted(new_entries, key = lambda entry: [entry.sort_value(attr) for attr in self._sort_order])
+    self._last_resource_fetch = current_resolution_count
+
+    if CONFIG['features.connection.resolveApps']:
+      local_ports, remote_ports = [], []
+
+      for entry in new_entries:
+        line = entry.get_lines()[0]
+
+        if entry.get_type() in (Category.SOCKS, Category.CONTROL):
+          local_ports.append(line.connection.remote_port)
+        elif entry.get_type() == Category.HIDDEN:
+          remote_ports.append(line.connection.local_port)
+
+      nyx.util.tracker.get_port_usage_tracker().query(local_ports, remote_ports)
diff --git a/nyx/panel/graph.py b/nyx/panel/graph.py
new file mode 100644
index 0000000..086d04d
--- /dev/null
+++ b/nyx/panel/graph.py
@@ -0,0 +1,753 @@
+"""
+Graphs of tor related statistics. For example...
+
+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 collections
+import copy
+import curses
+import time
+
+import nyx.controller
+import nyx.popups
+import nyx.util.tracker
+
+from stem.control import EventType, Listener
+from stem.util import conf, enum, log, str_tools, system
+from nyx.util import join, msg, panel, tor_controller
+
+GraphStat = enum.Enum(('BANDWIDTH', 'bandwidth'), ('CONNECTIONS', 'connections'), ('SYSTEM_RESOURCES', 'resources'))
+Interval = enum.Enum(('EACH_SECOND', 'each second'), ('FIVE_SECONDS', '5 seconds'), ('THIRTY_SECONDS', '30 seconds'), ('MINUTELY', 'minutely'), ('FIFTEEN_MINUTE', '15 minute'), ('THIRTY_MINUTE', '30 minute'), ('HOURLY', 'hourly'), ('DAILY', 'daily'))
+Bounds = enum.Enum(('GLOBAL_MAX', 'global_max'), ('LOCAL_MAX', 'local_max'), ('TIGHT', 'tight'))
+
+DrawAttributes = collections.namedtuple('DrawAttributes', ('stat', 'subgraph_height', 'subgraph_width', 'interval', 'bounds_type', 'accounting'))
+
+INTERVAL_SECONDS = {
+  Interval.EACH_SECOND: 1,
+  Interval.FIVE_SECONDS: 5,
+  Interval.THIRTY_SECONDS: 30,
+  Interval.MINUTELY: 60,
+  Interval.FIFTEEN_MINUTE: 900,
+  Interval.THIRTY_MINUTE: 1800,
+  Interval.HOURLY: 3600,
+  Interval.DAILY: 86400,
+}
+
+PRIMARY_COLOR, SECONDARY_COLOR = 'green', 'cyan'
+
+ACCOUNTING_RATE = 5
+DEFAULT_CONTENT_HEIGHT = 4  # space needed for labeling above and below the graph
+WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
+COLLAPSE_WIDTH = 135  # width at which to move optional stats from the title to x-axis label
+
+
+def conf_handler(key, value):
+  if key == 'features.graph.height':
+    return max(1, value)
+  elif key == 'features.graph.max_width':
+    return max(1, value)
+  elif key == 'features.graph.type':
+    if value != 'none' and value not in GraphStat:
+      log.warn("'%s' isn't a valid graph type, options are: none, %s" % (CONFIG['features.graph.type'], ', '.join(GraphStat)))
+      return CONFIG['features.graph.type']  # keep the default
+  elif key == 'features.graph.interval':
+    if value not in Interval:
+      log.warn("'%s' isn't a valid graphing interval, options are: %s" % (value, ', '.join(Interval)))
+      return CONFIG['features.graph.interval']  # keep the default
+  elif key == 'features.graph.bound':
+    if value not in Bounds:
+      log.warn("'%s' isn't a valid graph bounds, options are: %s" % (value, ', '.join(Bounds)))
+      return CONFIG['features.graph.bound']  # keep the default
+
+
+CONFIG = conf.config_dict('nyx', {
+  'attr.hibernate_color': {},
+  'attr.graph.title': {},
+  'attr.graph.header.primary': {},
+  'attr.graph.header.secondary': {},
+  'features.graph.height': 7,
+  'features.graph.type': GraphStat.BANDWIDTH,
+  'features.graph.interval': Interval.EACH_SECOND,
+  'features.graph.bound': Bounds.LOCAL_MAX,
+  'features.graph.max_width': 300,  # we need some sort of max size so we know how much graph data to retain
+  'features.panels.show.connection': True,
+  'features.graph.bw.transferInBytes': False,
+  'features.graph.bw.accounting.show': True,
+}, conf_handler)
+
+
+class GraphData(object):
+  """
+  Graphable statistical information.
+
+  :var int latest_value: last value we recorded
+  :var int total: sum of all values we've recorded
+  :var int tick: number of events we've processed
+  :var dict values: mapping of intervals to an array of samplings from newest to oldest
+  :var dict max_value: mapping of intervals to the maximum value it has had
+  """
+
+  def __init__(self, clone = None, category = None, is_primary = True):
+    if clone:
+      self.latest_value = clone.latest_value
+      self.total = clone.total
+      self.tick = clone.tick
+      self.values = copy.deepcopy(clone.values)
+      self.max_value = dict(clone.max_value)
+
+      self._category = clone._category
+      self._is_primary = clone._is_primary
+      self._in_process_value = dict(clone._in_process_value)
+    else:
+      self.latest_value = 0
+      self.total = 0
+      self.tick = 0
+      self.values = dict([(i, CONFIG['features.graph.max_width'] * [0]) for i in Interval])
+      self.max_value = dict([(i, 0) for i in Interval])
+
+      self._category = category
+      self._is_primary = is_primary
+      self._in_process_value = dict([(i, 0) for i in Interval])
+
+  def average(self):
+    return self.total / max(1, self.tick)
+
+  def update(self, new_value):
+    self.latest_value = new_value
+    self.total += new_value
+    self.tick += 1
+
+    for interval in Interval:
+      interval_seconds = INTERVAL_SECONDS[interval]
+      self._in_process_value[interval] += new_value
+
+      if self.tick % interval_seconds == 0:
+        new_entry = self._in_process_value[interval] / interval_seconds
+        self.values[interval] = [new_entry] + self.values[interval][:-1]
+        self.max_value[interval] = max(self.max_value[interval], new_entry)
+        self._in_process_value[interval] = 0
+
+  def header(self, width):
+    """
+    Provides the description above a subgraph.
+
+    :param int width: maximum length of the header
+
+    :returns: **str** with our graph header
+    """
+
+    return self._category._header(width, self._is_primary)
+
+  def y_axis_label(self, value):
+    """
+    Provides the label we should display on our y-axis.
+
+    :param int value: value being shown on the y-axis
+
+    :returns: **str** with our y-axis label
+    """
+
+    return self._category._y_axis_label(value, self._is_primary)
+
+
+class GraphCategory(object):
+  """
+  Category for the graph. This maintains two subgraphs, updating them each
+  second with updated stats.
+
+  :var GraphData primary: first subgraph
+  :var GraphData secondary: second subgraph
+  :var float start_time: unix timestamp for when we started
+  """
+
+  def __init__(self, clone = None):
+    if clone:
+      self.primary = GraphData(clone.primary)
+      self.secondary = GraphData(clone.secondary)
+      self.start_time = clone.start_time
+      self._title_stats = list(clone._title_stats)
+      self._primary_header_stats = list(clone._primary_header_stats)
+      self._secondary_header_stats = list(clone._secondary_header_stats)
+    else:
+      self.primary = GraphData(category = self, is_primary = True)
+      self.secondary = GraphData(category = self, is_primary = False)
+      self.start_time = time.time()
+      self._title_stats = []
+      self._primary_header_stats = []
+      self._secondary_header_stats = []
+
+  def stat_type(self):
+    """
+    Provides the GraphStat this graph is for.
+
+    :returns: **GraphStat** of this graph
+    """
+
+    raise NotImplementedError('Should be implemented by subclasses')
+
+  def title(self, width):
+    """
+    Provides a graph title that fits in the given width.
+
+    :param int width: maximum length of the title
+
+    :returns: **str** with our title
+    """
+
+    title = CONFIG['attr.graph.title'].get(self.stat_type(), '')
+    title_stats = join(self._title_stats, ', ', width - len(title) - 4)
+    return '%s (%s):' % (title, title_stats) if title_stats else title + ':'
+
+  def bandwidth_event(self, event):
+    """
+    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 per second).
+    """
+
+    pass
+
+  def _header(self, is_primary, width):
+    if is_primary:
+      header = CONFIG['attr.graph.header.primary'].get(self.stat_type(), '')
+      header_stats = self._primary_header_stats
+    else:
+      header = CONFIG['attr.graph.header.secondary'].get(self.stat_type(), '')
+      header_stats = self._secondary_header_stats
+
+    header_stats = join(header_stats, '', width - len(header) - 4)
+    return '%s (%s):' % (header, header_stats) if header_stats else '%s:' % header
+
+  def _y_axis_label(self, value, is_primary):
+    return str(value)
+
+
+class BandwidthStats(GraphCategory):
+  """
+  Tracks tor's bandwidth usage.
+  """
+
+  def __init__(self, clone = None):
+    GraphCategory.__init__(self, clone)
+
+    if not clone:
+      # fill in past bandwidth information
+
+      controller = tor_controller()
+      bw_entries, is_successful = controller.get_info('bw-event-cache', None), True
+
+      if bw_entries:
+        for entry in bw_entries.split():
+          entry_comp = entry.split(',')
+
+          if len(entry_comp) != 2 or not entry_comp[0].isdigit() or not entry_comp[1].isdigit():
+            log.warn(msg('panel.graphing.bw_event_cache_malformed', response = bw_entries))
+            is_successful = False
+            break
+
+          self.primary.update(int(entry_comp[0]))
+          self.secondary.update(int(entry_comp[1]))
+
+        if is_successful:
+          log.info(msg('panel.graphing.prepopulation_successful', duration = str_tools.time_label(len(bw_entries.split()), is_long = True)))
+
+      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)
+        self.secondary.total = int(write_total)
+        self.start_time = start_time
+
+  def stat_type(self):
+    return GraphStat.BANDWIDTH
+
+  def _y_axis_label(self, value, is_primary):
+    return _size_label(value, 0)
+
+  def bandwidth_event(self, event):
+    self.primary.update(event.read)
+    self.secondary.update(event.written)
+
+    self._primary_header_stats = [
+      '%-14s' % ('%s/sec' % _size_label(self.primary.latest_value)),
+      '- avg: %s/sec' % _size_label(self.primary.total / (time.time() - self.start_time)),
+      ', total: %s' % _size_label(self.primary.total),
+    ]
+
+    self._secondary_header_stats = [
+      '%-14s' % ('%s/sec' % _size_label(self.secondary.latest_value)),
+      '- avg: %s/sec' % _size_label(self.secondary.total / (time.time() - self.start_time)),
+      ', total: %s' % _size_label(self.secondary.total),
+    ]
+
+    controller = tor_controller()
+
+    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 ConnectionStats(GraphCategory):
+  """
+  Tracks number of inbound and outbound connections.
+  """
+
+  def stat_type(self):
+    return GraphStat.CONNECTIONS
+
+  def bandwidth_event(self, event):
+    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 nyx.util.tracker.get_connection_tracker().get_value():
+      if entry.local_port in or_ports or entry.local_port in dir_ports:
+        inbound_count += 1
+      elif entry.local_port in control_ports:
+        pass  # control connection
+      else:
+        outbound_count += 1
+
+    self.primary.update(inbound_count)
+    self.secondary.update(outbound_count)
+
+    self._primary_header_stats = [str(self.primary.latest_value), ', avg: %s' % self.primary.average()]
+    self._secondary_header_stats = [str(self.secondary.latest_value), ', avg: %s' % self.secondary.average()]
+
+
+class ResourceStats(GraphCategory):
+  """
+  Tracks cpu and memory usage of the tor process.
+  """
+
+  def stat_type(self):
+    return GraphStat.SYSTEM_RESOURCES
+
+  def _y_axis_label(self, value, is_primary):
+    return '%i%%' % value if is_primary else str_tools.size_label(value)
+
+  def bandwidth_event(self, event):
+    resources = nyx.util.tracker.get_resource_tracker().get_value()
+    self.primary.update(resources.cpu_sample * 100)  # decimal percentage to whole numbers
+    self.secondary.update(resources.memory_bytes)
+
+    self._primary_header_stats = ['%0.1f%%' % self.primary.latest_value, ', avg: %0.1f%%' % self.primary.average()]
+    self._secondary_header_stats = [str_tools.size_label(self.secondary.latest_value, 1), ', avg: %s' % str_tools.size_label(self.secondary.average(), 1)]
+
+
+class GraphPanel(panel.Panel):
+  """
+  Panel displaying graphical information of GraphCategory instances.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'graph', 0)
+
+    self._displayed_stat = None if CONFIG['features.graph.type'] == 'none' else CONFIG['features.graph.type']
+    self._update_interval = CONFIG['features.graph.interval']
+    self._bounds = CONFIG['features.graph.bound']
+    self._graph_height = CONFIG['features.graph.height']
+
+    self._accounting_stats = None
+
+    self._stats = {
+      GraphStat.BANDWIDTH: BandwidthStats(),
+      GraphStat.SYSTEM_RESOURCES: ResourceStats(),
+    }
+
+    if CONFIG['features.panels.show.connection']:
+      self._stats[GraphStat.CONNECTIONS] = ConnectionStats()
+    elif self._displayed_stat == GraphStat.CONNECTIONS:
+      log.warn("The connection graph is unavailble when you set 'features.panels.show.connection false'.")
+      self._displayed_stat = GraphStat.BANDWIDTH
+
+    self.set_pause_attr('_stats')
+    self.set_pause_attr('_accounting_stats')
+
+    controller = tor_controller()
+    controller.add_event_listener(self._update_accounting, EventType.BW)
+    controller.add_event_listener(self._update_stats, EventType.BW)
+    controller.add_status_listener(lambda *args: self.redraw(True))
+
+  @property
+  def displayed_stat(self):
+    return self._displayed_stat
+
+  @displayed_stat.setter
+  def displayed_stat(self, value):
+    if value is not None and value not in self._stats.keys():
+      raise ValueError("%s isn't a graphed statistic" % value)
+
+    self._displayed_stat = value
+
+  def stat_options(self):
+    return self._stats.keys()
+
+  @property
+  def update_interval(self):
+    return self._update_interval
+
+  @update_interval.setter
+  def update_interval(self, value):
+    if value not in Interval:
+      raise ValueError("%s isn't a valid graphing update interval" % value)
+
+    self._update_interval = value
+
+  @property
+  def bounds_type(self):
+    return self._bounds
+
+  @bounds_type.setter
+  def bounds_type(self, value):
+    if value not in Bounds:
+      raise ValueError("%s isn't a valid type of bounds" % value)
+
+    self._bounds = value
+
+  def get_height(self):
+    """
+    Provides the height of the content.
+    """
+
+    if not self.displayed_stat:
+      return 0
+
+    height = DEFAULT_CONTENT_HEIGHT + self._graph_height
+
+    if self.displayed_stat == GraphStat.BANDWIDTH and self._accounting_stats:
+      height += 3
+
+    return height
+
+  def set_graph_height(self, new_graph_height):
+    self._graph_height = max(1, 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 = nyx.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_type = Bounds.next(self.bounds_type)
+      self.redraw(True)
+    elif key.match('s'):
+      # provides a menu to pick the graphed stats
+
+      available_stats = sorted(self.stat_options())
+      options = ['None'] + [stat.capitalize() for stat in available_stats]
+      initial_selection = available_stats.index(self.displayed_stat) + 1 if self.displayed_stat else 0
+
+      selection = nyx.popups.show_menu('Graphed Stats:', options, initial_selection)
+
+      # applies new setting
+
+      if selection == 0:
+        self.displayed_stat = None
+      elif selection != -1:
+        self.displayed_stat = available_stats[selection - 1]
+    elif key.match('i'):
+      # provides menu to pick graph panel update interval
+
+      selection = nyx.popups.show_menu('Update Interval:', list(Interval), list(Interval).index(self.update_interval))
+
+      if selection != -1:
+        self.update_interval = list(Interval)[selection]
+    else:
+      return False
+
+    return True
+
+  def get_help(self):
+    return [
+      ('r', 'resize graph', None),
+      ('s', 'graphed stats', self.displayed_stat if self.displayed_stat else 'none'),
+      ('b', 'graph bounds', self.bounds_type.replace('_', ' ')),
+      ('i', 'graph update interval', self.update_interval),
+    ]
+
+  def draw(self, width, height):
+    if not self.displayed_stat:
+      return
+
+    stat = self.get_attr('_stats')[self.displayed_stat]
+
+    attr = DrawAttributes(
+      stat = type(stat)(stat),  # clone the GraphCategory
+      subgraph_height = self._graph_height + 2,  # graph rows + header + x-axis label
+      subgraph_width = min(width / 2, CONFIG['features.graph.max_width']),
+      interval = self.update_interval,
+      bounds_type = self.bounds_type,
+      accounting = self.get_attr('_accounting_stats'),
+    )
+
+    if self.is_title_visible():
+      self.addstr(0, 0, attr.stat.title(width), curses.A_STANDOUT)
+
+    self._draw_subgraph(attr, attr.stat.primary, 0, PRIMARY_COLOR)
+    self._draw_subgraph(attr, attr.stat.secondary, attr.subgraph_width, SECONDARY_COLOR)
+
+    if attr.stat.stat_type() == GraphStat.BANDWIDTH:
+      if width <= COLLAPSE_WIDTH:
+        self._draw_bandwidth_stats(attr, width)
+
+      if attr.accounting:
+        self._draw_accounting_stats(attr)
+
+  def _draw_subgraph(self, attr, data, x, color):
+    # Concering our subgraph colums, the y-axis label can be at most six
+    # characters, with two spaces of padding on either side of the graph.
+    # Starting with the smallest size, then possibly raise it after determing
+    # the y_axis_labels.
+
+    subgraph_columns = attr.subgraph_width - 8
+    min_bound, max_bound = self._get_graph_bounds(attr, data, subgraph_columns)
+
+    x_axis_labels = self._get_x_axis_labels(attr, subgraph_columns)
+    y_axis_labels = self._get_y_axis_labels(attr, data, min_bound, max_bound)
+    subgraph_columns = max(subgraph_columns, attr.subgraph_width - max([len(label) for label in y_axis_labels.values()]) - 2)
+    axis_offset = max([len(label) for label in y_axis_labels.values()])
+
+    self.addstr(1, x, data.header(attr.subgraph_width), curses.A_BOLD, color)
+
+    for x_offset, label in x_axis_labels.items():
+      self.addstr(attr.subgraph_height, x + x_offset + axis_offset, label, color)
+
+    for y, label in y_axis_labels.items():
+      self.addstr(y, x, label, color)
+
+    for col in range(subgraph_columns):
+      column_count = int(data.values[attr.interval][col]) - min_bound
+      column_height = int(min(attr.subgraph_height - 2, (attr.subgraph_height - 2) * column_count / (max(1, max_bound) - min_bound)))
+
+      for row in range(column_height):
+        self.addstr(attr.subgraph_height - 1 - row, x + col + axis_offset + 1, ' ', curses.A_STANDOUT, color)
+
+  def _get_graph_bounds(self, attr, data, subgraph_columns):
+    """
+    Provides the range the graph shows (ie, its minimum and maximum value).
+    """
+
+    min_bound, max_bound = 0, 0
+    values = data.values[attr.interval][:subgraph_columns]
+
+    if attr.bounds_type == Bounds.GLOBAL_MAX:
+      max_bound = data.max_value[attr.interval]
+    elif subgraph_columns > 0:
+      max_bound = max(values)  # local maxima
+
+    if attr.bounds_type == Bounds.TIGHT and subgraph_columns > 0:
+      min_bound = min(values)
+
+      # if the max = min pick zero so we still display something
+
+      if min_bound == max_bound:
+        min_bound = 0
+
+    return min_bound, max_bound
+
+  def _get_y_axis_labels(self, attr, data, min_bound, max_bound):
+    """
+    Provides the labels for the y-axis. This is a mapping of the position it
+    should be drawn at to its text.
+    """
+
+    y_axis_labels = {
+      2: data.y_axis_label(max_bound),
+      attr.subgraph_height - 1: data.y_axis_label(min_bound),
+    }
+
+    ticks = (attr.subgraph_height - 5) / 2
+
+    for i in range(ticks):
+      row = attr.subgraph_height - (2 * i) - 5
+
+      if attr.subgraph_height % 2 == 0 and i >= (ticks / 2):
+        row -= 1  # make extra gap be in the middle when we're an even size
+
+      val = (max_bound - min_bound) * (attr.subgraph_height - row - 3) / (attr.subgraph_height - 3)
+
+      if val not in (min_bound, max_bound):
+        y_axis_labels[row + 2] = data.y_axis_label(val)
+
+    return y_axis_labels
+
+  def _get_x_axis_labels(self, attr, subgraph_columns):
+    """
+    Provides the labels for the x-axis. We include the units for only its first
+    value, then bump the precision for subsequent units. For example...
+
+      10s, 20, 30, 40, 50, 1m, 1.1, 1.3, 1.5
+    """
+
+    x_axis_labels = {}
+
+    interval_sec = INTERVAL_SECONDS[attr.interval]
+    interval_spacing = 10 if subgraph_columns >= WIDE_LABELING_GRAPH_COL else 5
+    units_label, decimal_precision = None, 0
+
+    for i in range((subgraph_columns - 4) / interval_spacing):
+      x = (i + 1) * interval_spacing
+      time_label = str_tools.time_label(x * 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]
+
+      x_axis_labels[x] = time_label
+
+    return x_axis_labels
+
+  def _draw_bandwidth_stats(self, attr, width):
+    """
+    Replaces the x-axis labeling with bandwidth stats. This is done on small
+    screens since this information otherwise wouldn't fit.
+    """
+
+    labeling_line = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 4
+    self.addstr(labeling_line, 0, ' ' * width)  # clear line
+
+    runtime = time.time() - attr.stat.start_time
+    primary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.primary.total), _size_label(attr.stat.primary.total / runtime))
+    secondary_footer = 'total: %s, avg: %s/sec' % (_size_label(attr.stat.secondary.total), _size_label(attr.stat.secondary.total / runtime))
+
+    self.addstr(labeling_line, 1, primary_footer, PRIMARY_COLOR)
+    self.addstr(labeling_line, attr.subgraph_width + 1, secondary_footer, SECONDARY_COLOR)
+
+  def _draw_accounting_stats(self, attr):
+    y = DEFAULT_CONTENT_HEIGHT + attr.subgraph_height - 2
+
+    if tor_controller().is_alive():
+      hibernate_color = CONFIG['attr.hibernate_color'].get(attr.accounting.status, 'red')
+
+      x = self.addstr(y, 0, 'Accounting (', curses.A_BOLD)
+      x = self.addstr(y, x, attr.accounting.status, curses.A_BOLD, hibernate_color)
+      x = self.addstr(y, x, ')', curses.A_BOLD)
+
+      self.addstr(y, 35, 'Time to reset: %s' % str_tools.short_time_label(attr.accounting.time_until_reset))
+
+      self.addstr(y + 1, 2, '%s / %s' % (attr.accounting.read_bytes, attr.accounting.read_limit), PRIMARY_COLOR)
+      self.addstr(y + 1, 37, '%s / %s' % (attr.accounting.written_bytes, attr.accounting.write_limit), SECONDARY_COLOR)
+    else:
+      self.addstr(y, 0, 'Accounting:', curses.A_BOLD)
+      self.addstr(y, 12, 'Connection Closed...')
+
+  def copy_attr(self, attr):
+    if attr == '_stats':
+      return dict([(key, type(self._stats[key])(self._stats[key])) for key in self._stats])
+    else:
+      return panel.Panel.copy_attr(self, attr)
+
+  def _update_accounting(self, event):
+    if not CONFIG['features.graph.bw.accounting.show']:
+      self._accounting_stats = None
+    elif not self._accounting_stats or time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
+      old_accounting_stats = self._accounting_stats
+      self._accounting_stats = tor_controller().get_accounting_stats(None)
+
+      # if we either added or removed accounting info then redraw the whole
+      # screen to account for resizing
+
+      if bool(old_accounting_stats) != bool(self._accounting_stats):
+        nyx.controller.get_controller().redraw()
+
+  def _update_stats(self, event):
+    for stat in self._stats.values():
+      stat.bandwidth_event(event)
+
+    if self.displayed_stat:
+      param = self.get_attr('_stats')[self.displayed_stat]
+      update_rate = INTERVAL_SECONDS[self.update_interval]
+
+      if param.primary.tick % update_rate == 0:
+        self.redraw(True)
+
+
+def _size_label(byte_count, decimal = 1):
+  """
+  Alias for str_tools.size_label() that accounts for if the user prefers bits
+  or bytes.
+  """
+
+  return str_tools.size_label(byte_count, decimal, is_bytes = CONFIG['features.graph.bw.transferInBytes'])
diff --git a/nyx/panel/header.py b/nyx/panel/header.py
new file mode 100644
index 0000000..0d6591a
--- /dev/null
+++ b/nyx/panel/header.py
@@ -0,0 +1,496 @@
+"""
+Top panel for every page, containing basic system and tor related information.
+This expands the information it presents to two columns if there's room
+available.
+"""
+
+import collections
+import os
+import time
+import curses
+import threading
+
+import stem
+
+import nyx.controller
+import nyx.popups
+
+from stem.control import Listener, State
+from stem.util import conf, log, proc, str_tools, system
+from nyx.util import msg, tor_controller, panel, tracker
+
+MIN_DUAL_COL_WIDTH = 141  # minimum width where we'll show two columns
+SHOW_FD_THRESHOLD = 60  # show file descriptor usage if usage is over this percentage
+UPDATE_RATE = 5  # rate in seconds at which we refresh
+
+CONFIG = conf.config_dict('nyx', {
+  'attr.flag_colors': {},
+  'attr.version_status_colors': {},
+})
+
+
+class HeaderPanel(panel.Panel, threading.Thread):
+  """
+  Top area containing tor settings and system information.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'header', 0)
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+
+    self._vals = get_sampling()
+
+    self._pause_condition = threading.Condition()
+    self._halt = False  # terminates thread if true
+    self._reported_inactive = False
+
+    tor_controller().add_status_listener(self.reset_listener)
+
+  def is_wide(self, width = None):
+    """
+    True if we should show two columns of information, False otherwise.
+    """
+
+    if width is None:
+      width = self.get_parent().getmaxyx()[1]
+
+    return width >= MIN_DUAL_COL_WIDTH
+
+  def get_height(self):
+    """
+    Provides the height of the content, which is dynamically determined by the
+    panel's maximum width.
+    """
+
+    if self._vals.is_relay:
+      return 4 if self.is_wide() else 6
+    else:
+      return 3 if self.is_wide() else 4
+
+  def send_newnym(self):
+    """
+    Requests a new identity and provides a visual queue.
+    """
+
+    controller = tor_controller()
+
+    if not controller.is_newnym_available():
+      return
+
+    controller.signal(stem.Signal.NEWNYM)
+
+    # If we're wide then the newnym label in this panel will give an
+    # indication that the signal was sent. Otherwise use a msg.
+
+    if not self.is_wide():
+      nyx.popups.show_msg('Requesting a new identity', 1)
+
+  def handle_key(self, key):
+    if key.match('n'):
+      self.send_newnym()
+    elif key.match('r') and not self._vals.is_connected:
+      # TODO: This is borked. Not quite sure why but our attempt to call
+      # PROTOCOLINFO fails with a socket error, followed by completely freezing
+      # nyx. This is exposing two bugs...
+      #
+      # * This should be working. That's a stem issue.
+      # * Our interface shouldn't be locking up. That's an nyx issue.
+
+      return True
+
+      controller = tor_controller()
+
+      try:
+        controller.connect()
+
+        try:
+          controller.authenticate()  # TODO: should account for our chroot
+        except stem.connection.MissingPassword:
+          password = nyx.popups.input_prompt('Controller Password: ')
+
+          if password:
+            controller.authenticate(password)
+
+        log.notice("Reconnected to Tor's control port")
+        nyx.popups.show_msg('Tor reconnected', 1)
+      except Exception as exc:
+        nyx.popups.show_msg('Unable to reconnect (%s)' % exc, 3)
+        controller.close()
+    else:
+      return False
+
+    return True
+
+  def draw(self, width, height):
+    vals = self._vals  # local reference to avoid concurrency concerns
+    is_wide = self.is_wide(width)
+
+    # space available for content
+
+    left_width = max(width / 2, 77) if is_wide else width
+    right_width = width - left_width
+
+    self._draw_platform_section(0, 0, left_width, vals)
+
+    if vals.is_connected:
+      self._draw_ports_section(0, 1, left_width, vals)
+    else:
+      self._draw_disconnected(0, 1, left_width, vals)
+
+    if is_wide:
+      self._draw_resource_usage(left_width, 0, right_width, vals)
+
+      if vals.is_relay:
+        self._draw_fingerprint_and_fd_usage(left_width, 1, right_width, vals)
+        self._draw_flags(0, 2, left_width, vals)
+        self._draw_exit_policy(left_width, 2, right_width, vals)
+      elif vals.is_connected:
+        self._draw_newnym_option(left_width, 1, right_width, vals)
+    else:
+      self._draw_resource_usage(0, 2, left_width, vals)
+
+      if vals.is_relay:
+        self._draw_fingerprint_and_fd_usage(0, 3, left_width, vals)
+        self._draw_flags(0, 4, left_width, vals)
+
+  def _draw_platform_section(self, x, y, width, vals):
+    """
+    Section providing the user's hostname, platform, and version information...
+
+      nyx - odin (Linux 3.5.0-52-generic)        Tor 0.2.5.1-alpha-dev (unrecommended)
+      |------ platform (40 characters) ------|   |----------- tor version -----------|
+    """
+
+    initial_x, space_left = x, min(width, 40)
+
+    x = self.addstr(y, x, vals.format('nyx - {hostname}', space_left))
+    space_left -= x - initial_x
+
+    if space_left >= 10:
+      self.addstr(y, x, ' (%s)' % vals.format('{platform}', space_left - 3))
+
+    x, space_left = initial_x + 43, width - 43
+
+    if vals.version != 'Unknown' and space_left >= 10:
+      x = self.addstr(y, x, vals.format('Tor {version}', space_left))
+      space_left -= x - 43 - initial_x
+
+      if space_left >= 7 + len(vals.version_status):
+        version_color = CONFIG['attr.version_status_colors'].get(vals.version_status, 'white')
+
+        x = self.addstr(y, x, ' (')
+        x = self.addstr(y, x, vals.version_status, version_color)
+        self.addstr(y, x, ')')
+
+  def _draw_ports_section(self, x, y, width, vals):
+    """
+    Section providing our nickname, address, and port information...
+
+      Unnamed - 0.0.0.0:7000, Control Port (cookie): 9051
+    """
+
+    if not vals.is_relay:
+      x = self.addstr(y, x, 'Relaying Disabled', 'cyan')
+    else:
+      x = self.addstr(y, x, vals.format('{nickname} - {address}:{or_port}'))
+
+      if vals.dir_port != '0':
+        x = self.addstr(y, x, vals.format(', Dir Port: {dir_port}'))
+
+    if vals.control_port:
+      if width >= x + 19 + len(vals.control_port) + len(vals.auth_type):
+        auth_color = 'red' if vals.auth_type == 'open' else 'green'
+
+        x = self.addstr(y, x, ', Control Port (')
+        x = self.addstr(y, x, vals.auth_type, auth_color)
+        self.addstr(y, x, vals.format('): {control_port}'))
+      else:
+        self.addstr(y, x, vals.format(', Control Port: {control_port}'))
+    elif vals.socket_path:
+      self.addstr(y, x, vals.format(', Control Socket: {socket_path}'))
+
+  def _draw_disconnected(self, x, y, width, vals):
+    """
+    Message indicating that tor is disconnected...
+
+      Tor Disconnected (15:21 07/13/2014, press r to reconnect)
+    """
+
+    x = self.addstr(y, x, 'Tor Disconnected', curses.A_BOLD, 'red')
+    last_heartbeat = time.strftime('%H:%M %m/%d/%Y', time.localtime(vals.last_heartbeat))
+    self.addstr(y, x, ' (%s, press r to reconnect)' % last_heartbeat)
+
+  def _draw_resource_usage(self, x, y, width, vals):
+    """
+    System resource usage of the tor process...
+
+      cpu: 0.0% tor, 1.0% nyx    mem: 0 (0.0%)       pid: 16329  uptime: 12-20:42:07
+    """
+
+    if vals.start_time:
+      if not vals.is_connected:
+        now = vals.connection_time
+      elif self.is_paused():
+        now = self.get_pause_time()
+      else:
+        now = time.time()
+
+      uptime = str_tools.short_time_label(now - vals.start_time)
+    else:
+      uptime = ''
+
+    sys_fields = (
+      (0, vals.format('cpu: {tor_cpu}% tor, {nyx_cpu}% nyx')),
+      (27, vals.format('mem: {memory} ({memory_percent}%)')),
+      (47, vals.format('pid: {pid}')),
+      (59, 'uptime: %s' % uptime),
+    )
+
+    for (start, label) in sys_fields:
+      if width >= start + len(label):
+        self.addstr(y, x + start, label)
+      else:
+        break
+
+  def _draw_fingerprint_and_fd_usage(self, x, y, width, vals):
+    """
+    Presents our fingerprint, and our file descriptor usage if we're running
+    out...
+
+      fingerprint: 1A94D1A794FCB2F8B6CBC179EF8FDD4008A98D3B, file desc: 900 / 1000 (90%)
+    """
+
+    initial_x, space_left = x, width
+
+    x = self.addstr(y, x, vals.format('fingerprint: {fingerprint}', width))
+    space_left -= x - initial_x
+
+    if space_left >= 30 and vals.fd_used and vals.fd_limit != -1:
+      fd_percent = 100 * vals.fd_used / vals.fd_limit
+
+      if fd_percent >= SHOW_FD_THRESHOLD:
+        if fd_percent >= 95:
+          percentage_format = (curses.A_BOLD, 'red')
+        elif fd_percent >= 90:
+          percentage_format = ('red',)
+        elif fd_percent >= 60:
+          percentage_format = ('yellow',)
+        else:
+          percentage_format = ()
+
+        x = self.addstr(y, x, ', file descriptors' if space_left >= 37 else ', file desc')
+        x = self.addstr(y, x, vals.format(': {fd_used} / {fd_limit} ('))
+        x = self.addstr(y, x, '%i%%' % fd_percent, *percentage_format)
+        self.addstr(y, x, ')')
+
+  def _draw_flags(self, x, y, width, vals):
+    """
+    Presents flags held by our relay...
+
+      flags: Running, Valid
+    """
+
+    x = self.addstr(y, x, 'flags: ')
+
+    if vals.flags:
+      for i, flag in enumerate(vals.flags):
+        flag_color = CONFIG['attr.flag_colors'].get(flag, 'white')
+        x = self.addstr(y, x, flag, curses.A_BOLD, flag_color)
+
+        if i < len(vals.flags) - 1:
+          x = self.addstr(y, x, ', ')
+    else:
+      self.addstr(y, x, 'none', curses.A_BOLD, 'cyan')
+
+  def _draw_exit_policy(self, x, y, width, vals):
+    """
+    Presents our exit policy...
+
+      exit policy: reject *:*
+    """
+
+    x = self.addstr(y, x, 'exit policy: ')
+
+    if not vals.exit_policy:
+      return
+
+    rules = list(vals.exit_policy.strip_private().strip_default())
+
+    for i, rule in enumerate(rules):
+      policy_color = 'green' if rule.is_accept else 'red'
+      x = self.addstr(y, x, str(rule), curses.A_BOLD, policy_color)
+
+      if i < len(rules) - 1:
+        x = self.addstr(y, x, ', ')
+
+    if vals.exit_policy.has_default():
+      if rules:
+        x = self.addstr(y, x, ', ')
+
+      self.addstr(y, x, '<default>', curses.A_BOLD, 'cyan')
+
+  def _draw_newnym_option(self, x, y, width, vals):
+    """
+    Provide a notice for requiesting a new identity, and time until it's next
+    available if in the process of building circuits.
+    """
+
+    if vals.newnym_wait == 0:
+      self.addstr(y, x, "press 'n' for a new identity")
+    else:
+      plural = 's' if vals.newnym_wait > 1 else ''
+      self.addstr(y, x, 'building circuits, available again in %i second%s' % (vals.newnym_wait, plural))
+
+  def run(self):
+    """
+    Keeps stats updated, checking for new information at a set rate.
+    """
+
+    last_ran = -1
+
+    while not self._halt:
+      if self.is_paused() or not self._vals.is_connected or (time.time() - last_ran) < UPDATE_RATE:
+        with self._pause_condition:
+          if not self._halt:
+            self._pause_condition.wait(0.2)
+
+        continue  # done waiting, try again
+
+      self._update()
+      last_ran = time.time()
+
+  def stop(self):
+    """
+    Halts further resolutions and terminates the thread.
+    """
+
+    with self._pause_condition:
+      self._halt = True
+      self._pause_condition.notifyAll()
+
+  def reset_listener(self, controller, event_type, _):
+    self._update()
+
+    if event_type == State.CLOSED:
+      log.notice('Tor control port closed')
+
+  def _update(self):
+    previous_height = self.get_height()
+    self._vals = get_sampling(self._vals)
+
+    if self._vals.fd_used and self._vals.fd_limit != -1:
+      fd_percent = 100 * self._vals.fd_used / self._vals.fd_limit
+
+      if fd_percent >= 90:
+        log_msg = msg('panel.header.fd_used_at_ninety_percent', percentage = fd_percent)
+        log.log_once('fd_used_at_ninety_percent', log.WARN, log_msg)
+        log.DEDUPLICATION_MESSAGE_IDS.add('fd_used_at_sixty_percent')
+      elif fd_percent >= 60:
+        log_msg = msg('panel.header.fd_used_at_sixty_percent', percentage = fd_percent)
+        log.log_once('fd_used_at_sixty_percent', log.NOTICE, log_msg)
+
+    if self._vals.is_connected:
+      if not self._reported_inactive and (time.time() - self._vals.last_heartbeat) >= 10:
+        self._reported_inactive = True
+        log.notice('Relay unresponsive (last heartbeat: %s)' % time.ctime(self._vals.last_heartbeat))
+      elif self._reported_inactive and (time.time() - self._vals.last_heartbeat) < 10:
+        self._reported_inactive = False
+        log.notice('Relay resumed')
+
+    if previous_height != self.get_height():
+      # We're toggling between being a relay and client, causing the height
+      # of this panel to change. Redraw all content so we don't get
+      # overlapping content.
+
+      nyx.controller.get_controller().redraw()
+    else:
+      self.redraw(True)  # just need to redraw ourselves
+
+
+def get_sampling(last_sampling = None):
+  controller = tor_controller()
+  retrieved = time.time()
+
+  pid = controller.get_pid('')
+  tor_resources = tracker.get_resource_tracker().get_value()
+  nyx_total_cpu_time = sum(os.times()[:3])
+
+  or_listeners = controller.get_listeners(Listener.OR, [])
+  control_listeners = controller.get_listeners(Listener.CONTROL, [])
+
+  if controller.get_conf('HashedControlPassword', None):
+    auth_type = 'password'
+  elif controller.get_conf('CookieAuthentication', None) == '1':
+    auth_type = 'cookie'
+  else:
+    auth_type = 'open'
+
+  try:
+    fd_used = proc.file_descriptors_used(pid)
+  except IOError:
+    fd_used = None
+
+  if last_sampling:
+    nyx_cpu_delta = nyx_total_cpu_time - last_sampling.nyx_total_cpu_time
+    nyx_time_delta = retrieved - last_sampling.retrieved
+
+    python_cpu_time = nyx_cpu_delta / nyx_time_delta
+    sys_call_cpu_time = 0.0  # TODO: add a wrapper around call() to get this
+
+    nyx_cpu = python_cpu_time + sys_call_cpu_time
+  else:
+    nyx_cpu = 0.0
+
+  attr = {
+    'retrieved': retrieved,
+    'is_connected': controller.is_alive(),
+    'connection_time': controller.connection_time(),
+    'last_heartbeat': controller.get_latest_heartbeat(),
+
+    'fingerprint': controller.get_info('fingerprint', 'Unknown'),
+    'nickname': controller.get_conf('Nickname', ''),
+    'newnym_wait': controller.get_newnym_wait(),
+    'exit_policy': controller.get_exit_policy(None),
+    'flags': getattr(controller.get_network_status(default = None), 'flags', []),
+
+    'version': str(controller.get_version('Unknown')).split()[0],
+    'version_status': controller.get_info('status/version/current', 'Unknown'),
+
+    'address': or_listeners[0][0] if (or_listeners and or_listeners[0][0] != '0.0.0.0') else controller.get_info('address', 'Unknown'),
+    'or_port': or_listeners[0][1] if or_listeners else '',
+    'dir_port': controller.get_conf('DirPort', '0'),
+    'control_port': str(control_listeners[0][1]) if control_listeners else None,
+    'socket_path': controller.get_conf('ControlSocket', None),
+    'is_relay': bool(or_listeners),
+
+    'auth_type': auth_type,
+    'pid': pid,
+    'start_time': system.start_time(pid),
+    'fd_limit': int(controller.get_info('process/descriptor-limit', '-1')),
+    'fd_used': fd_used,
+
+    'nyx_total_cpu_time': nyx_total_cpu_time,
+    'tor_cpu': '%0.1f' % (100 * tor_resources.cpu_sample),
+    'nyx_cpu': nyx_cpu,
+    'memory': str_tools.size_label(tor_resources.memory_bytes) if tor_resources.memory_bytes > 0 else 0,
+    'memory_percent': '%0.1f' % (100 * tor_resources.memory_percent),
+
+    'hostname': os.uname()[1],
+    'platform': '%s %s' % (os.uname()[0], os.uname()[2]),  # [platform name] [version]
+  }
+
+  class Sampling(collections.namedtuple('Sampling', attr.keys())):
+    def __init__(self, **attr):
+      super(Sampling, self).__init__(**attr)
+      self._attr = attr
+
+    def format(self, message, crop_width = None):
+      formatted_msg = message.format(**self._attr)
+
+      if crop_width:
+        formatted_msg = str_tools.crop(formatted_msg, crop_width)
+
+      return formatted_msg
+
+  return Sampling(**attr)
diff --git a/nyx/panel/log.py b/nyx/panel/log.py
new file mode 100644
index 0000000..708a679
--- /dev/null
+++ b/nyx/panel/log.py
@@ -0,0 +1,454 @@
+"""
+Panel providing a chronological log of events its been configured to listen
+for. This provides prepopulation from the log file and supports filtering by
+regular expressions.
+"""
+
+import os
+import time
+import curses
+import threading
+
+import stem.response.events
+
+import nyx.arguments
+import nyx.popups
+import nyx.util.log
+
+from nyx.util import join, panel, tor_controller, ui_tools
+from stem.util import conf, log
+
+
+def conf_handler(key, value):
+  if key == 'features.log.prepopulateReadLimit':
+    return max(0, value)
+  elif key == 'features.log.maxRefreshRate':
+    return max(10, value)
+  elif key == 'cache.log_panel.size':
+    return max(1000, value)
+
+
+CONFIG = conf.config_dict('nyx', {
+  'attr.log_color': {},
+  'cache.log_panel.size': 1000,
+  'features.logFile': '',
+  'features.log.showDuplicateEntries': False,
+  'features.log.prepopulate': True,
+  'features.log.prepopulateReadLimit': 5000,
+  'features.log.maxRefreshRate': 300,
+  'features.log.regex': [],
+  'msg.misc.event_types': '',
+  'startup.events': 'N3',
+}, conf_handler)
+
+# The height of the drawn content is estimated based on the last time we redrew
+# the panel. It's chiefly used for scrolling and the bar indicating its
+# position. Letting the estimate be too inaccurate results in a display bug, so
+# redraws the display if it's off by this threshold.
+
+CONTENT_HEIGHT_REDRAW_THRESHOLD = 3
+
+# Log buffer so we start collecting stem/nyx events when imported. This is used
+# to make our LogPanel when curses initializes.
+
+stem_logger = log.get_logger()
+NYX_LOGGER = log.LogBuffer(log.Runlevel.DEBUG, yield_records = True)
+stem_logger.addHandler(NYX_LOGGER)
+
+
+class LogPanel(panel.Panel, threading.Thread):
+  """
+  Listens for and displays tor, nyx, and stem events. This prepopulates
+  from tor's log file if it exists.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'log', 0)
+    threading.Thread.__init__(self)
+    self.setDaemon(True)
+
+    logged_events = nyx.arguments.expand_events(CONFIG['startup.events'])
+    self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
+    self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, logged_events)
+    self._log_file = nyx.util.log.LogFileOutput(CONFIG['features.logFile'])
+    self._filter = nyx.util.log.LogFilters(initial_filters = CONFIG['features.log.regex'])
+    self._show_duplicates = CONFIG['features.log.showDuplicateEntries']
+
+    self.set_pause_attr('_event_log')
+
+    self._halt = False  # terminates thread if true
+    self._pause_condition = threading.Condition()
+    self._has_new_event = False
+
+    # fetches past tor events from log file, if available
+
+    if CONFIG['features.log.prepopulate']:
+      log_location = nyx.util.log.log_file_path(tor_controller())
+
+      if log_location:
+        try:
+          for entry in reversed(list(nyx.util.log.read_tor_log(log_location, CONFIG['features.log.prepopulateReadLimit']))):
+            if entry.type in self._event_types:
+              self._event_log.add(entry)
+        except IOError as exc:
+          log.info('Unable to read log located at %s: %s' % (log_location, exc))
+        except ValueError as exc:
+          log.info(str(exc))
+
+    self._last_content_height = len(self._event_log)  # height of the rendered content when last drawn
+    self._scroll = 0
+
+    # merge NYX_LOGGER into us, and listen for its future events
+
+    for event in NYX_LOGGER:
+      self._register_nyx_event(event)
+
+    NYX_LOGGER.emit = self._register_nyx_event
+
+  def set_duplicate_visability(self, is_visible):
+    """
+    Sets if duplicate log entries are collaped or expanded.
+
+    :param bool is_visible: if **True** all log entries are shown, otherwise
+      they're deduplicated
+    """
+
+    self._show_duplicates = is_visible
+
+  def get_filter(self):
+    """
+    Provides our currently selected regex filter.
+    """
+
+    return self._filter
+
+  def show_filter_prompt(self):
+    """
+    Prompts the user to add a new regex filter.
+    """
+
+    regex_input = nyx.popups.input_prompt('Regular expression: ')
+
+    if regex_input:
+      self._filter.select(regex_input)
+
+  def show_event_selection_prompt(self):
+    """
+    Prompts the user to select the events being listened for.
+    """
+
+    # allow user to enter new types of events to log - unchanged if left blank
+
+    with nyx.popups.popup_window(16, 80) as (popup, width, height):
+      if popup:
+        # displays the available flags
+
+        popup.win.box()
+        popup.addstr(0, 0, 'Event Types:', curses.A_STANDOUT)
+        event_lines = CONFIG['msg.misc.event_types'].split('\n')
+
+        for i in range(len(event_lines)):
+          popup.addstr(i + 1, 1, event_lines[i][6:])
+
+        popup.win.refresh()
+
+        user_input = nyx.popups.input_prompt('Events to log: ')
+
+        if user_input:
+          try:
+            user_input = user_input.replace(' ', '')  # strip spaces
+            event_types = nyx.arguments.expand_events(user_input)
+
+            if event_types != self._event_types:
+              self._event_types = nyx.util.log.listen_for_events(self._register_tor_event, event_types)
+              self.redraw(True)
+          except ValueError as exc:
+            nyx.popups.show_msg('Invalid flags: %s' % str(exc), 2)
+
+  def show_snapshot_prompt(self):
+    """
+    Lets user enter a path to take a snapshot, canceling if left blank.
+    """
+
+    path_input = nyx.popups.input_prompt('Path to save log snapshot: ')
+
+    if path_input:
+      try:
+        self.save_snapshot(path_input)
+        nyx.popups.show_msg('Saved: %s' % path_input, 2)
+      except IOError as exc:
+        nyx.popups.show_msg('Unable to save snapshot: %s' % exc, 2)
+
+  def clear(self):
+    """
+    Clears the contents of the event log.
+    """
+
+    self._event_log = nyx.util.log.LogGroup(CONFIG['cache.log_panel.size'], group_by_day = True)
+    self.redraw(True)
+
+  def save_snapshot(self, path):
+    """
+    Saves the log events currently being displayed to the given path. This
+    takes filers into account. This overwrites the file if it already exists.
+
+    :param str path: path where to save the log snapshot
+
+    :raises: **IOError** if unsuccessful
+    """
+
+    path = os.path.abspath(os.path.expanduser(path))
+
+    # make dir if the path doesn't already exist
+
+    base_dir = os.path.dirname(path)
+
+    try:
+      if not os.path.exists(base_dir):
+        os.makedirs(base_dir)
+    except OSError as exc:
+      raise IOError("unable to make directory '%s'" % base_dir)
+
+    event_log = list(self._event_log)
+    event_filter = self._filter.clone()
+
+    with open(path, 'w') as snapshot_file:
+      try:
+        for entry in reversed(event_log):
+          if event_filter.match(entry.display_message):
+            snapshot_file.write(entry.display_message + '\n')
+      except Exception as exc:
+        raise IOError("unable to write to '%s': %s" % (path, exc))
+
+  def handle_key(self, key):
+    if key.is_scroll():
+      page_height = self.get_preferred_size()[0] - 1
+      new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
+
+      if self._scroll != new_scroll:
+        self._scroll = new_scroll
+        self.redraw(True)
+    elif key.match('u'):
+      self.set_duplicate_visability(not self._show_duplicates)
+      self.redraw(True)
+    elif key.match('c'):
+      msg = 'This will clear the log. Are you sure (c again to confirm)?'
+      key_press = nyx.popups.show_msg(msg, attr = curses.A_BOLD)
+
+      if key_press.match('c'):
+        self.clear()
+    elif key.match('f'):
+      with panel.CURSES_LOCK:
+        initial_selection = 1 if self._filter.selection() else 0
+        options = ['None'] + self._filter.latest_selections() + ['New...']
+        selection = nyx.popups.show_menu('Log Filter:', options, initial_selection)
+
+        if selection == 0:
+          self._filter.select(None)
+        elif selection == len(options) - 1:
+          # selected 'New...' option - prompt user to input regular expression
+          self.show_filter_prompt()
+        elif selection != -1:
+          self._filter.select(self._filter.latest_selections()[selection - 1])
+    elif key.match('e'):
+      self.show_event_selection_prompt()
+    elif key.match('a'):
+      self.show_snapshot_prompt()
+    else:
+      return False
+
+    return True
+
+  def get_help(self):
+    return [
+      ('up arrow', 'scroll log up a line', None),
+      ('down arrow', 'scroll log down a line', None),
+      ('a', 'save snapshot of the log', None),
+      ('e', 'change logged events', None),
+      ('f', 'log regex filter', 'enabled' if self._filter.selection() else 'disabled'),
+      ('u', 'duplicate log entries', 'visible' if self._show_duplicates else 'hidden'),
+      ('c', 'clear event log', None),
+    ]
+
+  def draw(self, width, height):
+    self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
+
+    event_log = list(self.get_attr('_event_log'))
+    event_filter = self._filter.clone()
+    event_types = list(self._event_types)
+    scroll = int(self._scroll)
+    last_content_height = self._last_content_height
+    show_duplicates = self._show_duplicates
+
+    is_scrollbar_visible = last_content_height > height - 1
+
+    if is_scrollbar_visible:
+      self.add_scroll_bar(scroll, scroll + height - 1, last_content_height, 1)
+
+    x, y = 3 if is_scrollbar_visible else 1, 1 - scroll
+
+    # group entries by date, filtering out those that aren't visible
+
+    day_to_entries, today = {}, nyx.util.log.day_count(time.time())
+
+    for entry in event_log:
+      if entry.is_duplicate and not show_duplicates:
+        continue  # deduplicated message
+      elif not event_filter.match(entry.display_message):
+        continue  # filter doesn't match log message
+
+      day_to_entries.setdefault(entry.day_count(), []).append(entry)
+
+    for day in sorted(day_to_entries.keys(), reverse = True):
+      if day == today:
+        for entry in day_to_entries[day]:
+          y = self._draw_entry(x, y, width, entry, show_duplicates)
+      else:
+        original_y, y = y, y + 1
+
+        for entry in day_to_entries[day]:
+          y = self._draw_entry(x, y, width, entry, show_duplicates)
+
+        ui_tools.draw_box(self, original_y, x - 1, width - x + 1, y - original_y + 1, curses.A_BOLD, 'yellow')
+        time_label = time.strftime(' %B %d, %Y ', time.localtime(day_to_entries[day][0].timestamp))
+        self.addstr(original_y, x + 1, time_label, curses.A_BOLD, curses.A_BOLD, 'yellow')
+
+        y += 1
+
+    # drawing the title after the content, so we'll clear content from the top line
+
+    if self.is_title_visible():
+      self._draw_title(width, event_types, event_filter)
+
+    # redraw the display if...
+    # - last_content_height was off by too much
+    # - we're off the bottom of the page
+
+    new_content_height = y + scroll - 1
+    content_height_delta = abs(last_content_height - new_content_height)
+    force_redraw, force_redraw_reason = True, ''
+
+    if content_height_delta >= CONTENT_HEIGHT_REDRAW_THRESHOLD:
+      force_redraw_reason = 'estimate was off by %i' % content_height_delta
+    elif new_content_height > height and self._scroll + height - 1 > new_content_height:
+      force_redraw_reason = 'scrolled off the bottom of the page'
+    elif not is_scrollbar_visible and new_content_height > height - 1:
+      force_redraw_reason = "scroll bar wasn't previously visible"
+    elif is_scrollbar_visible and new_content_height <= height - 1:
+      force_redraw_reason = "scroll bar shouldn't be visible"
+    else:
+      force_redraw = False
+
+    self._last_content_height = new_content_height
+    self._has_new_event = False
+
+    if force_redraw:
+      log.debug('redrawing the log panel with the corrected content height (%s)' % force_redraw_reason)
+      self.redraw(True)
+
+  def _draw_title(self, width, event_types, event_filter):
+    """
+    Panel title with the event types we're logging and our regex filter if set.
+    """
+
+    self.addstr(0, 0, ' ' * width)  # clear line
+    title_comp = list(nyx.util.log.condense_runlevels(*event_types))
+
+    if event_filter.selection():
+      title_comp.append('filter: %s' % event_filter.selection())
+
+    title_comp_str = join(title_comp, ', ', width - 10)
+    title = 'Events (%s):' % title_comp_str if title_comp_str else 'Events:'
+
+    self.addstr(0, 0, title, curses.A_STANDOUT)
+
+  def _draw_entry(self, x, y, width, entry, show_duplicates):
+    """
+    Presents a log entry with line wrapping.
+    """
+
+    min_x, msg = x + 2, entry.display_message
+    boldness = curses.A_BOLD if 'ERR' in entry.type else curses.A_NORMAL  # emphasize ERR messages
+    color = CONFIG['attr.log_color'].get(entry.type, 'white')
+
+    for line in msg.splitlines():
+      x, y = self.addstr_wrap(y, x, line, width, min_x, boldness, color)
+
+    if entry.duplicates and not show_duplicates:
+      duplicate_count = len(entry.duplicates) - 1
+      plural = 's' if duplicate_count > 1 else ''
+      duplicate_msg = ' [%i duplicate%s hidden]' % (duplicate_count, plural)
+      x, y = self.addstr_wrap(y, x, duplicate_msg, width, min_x, curses.A_BOLD, 'green')
+
+    return y + 1
+
+  def run(self):
+    """
+    Redraws the display, coalescing updates if events are rapidly logged (for
+    instance running at the DEBUG runlevel) while also being immediately
+    responsive if additions are less frequent.
+    """
+
+    last_ran, last_day = -1, nyx.util.log.day_count(time.time())
+
+    while not self._halt:
+      current_day = nyx.util.log.day_count(time.time())
+      time_since_reset = time.time() - last_ran
+      max_log_update_rate = CONFIG['features.log.maxRefreshRate'] / 1000.0
+
+      sleep_time = 0
+
+      if (not self._has_new_event and last_day == current_day) or self.is_paused():
+        sleep_time = 5
+      elif time_since_reset < max_log_update_rate:
+        sleep_time = max(0.05, max_log_update_rate - time_since_reset)
+
+      if sleep_time:
+        with self._pause_condition:
+          if not self._halt:
+            self._pause_condition.wait(sleep_time)
+
+        continue
+
+      last_ran, last_day = time.time(), current_day
+      self.redraw(True)
+
+  def stop(self):
+    """
+    Halts further updates and terminates the thread.
+    """
+
+    with self._pause_condition:
+      self._halt = True
+      self._pause_condition.notifyAll()
+
+  def _register_tor_event(self, event):
+    msg = ' '.join(str(event).split(' ')[1:])
+
+    if isinstance(event, stem.response.events.BandwidthEvent):
+      msg = 'READ: %i, WRITTEN: %i' % (event.read, event.written)
+    elif isinstance(event, stem.response.events.LogEvent):
+      msg = event.message
+
+    self._register_event(nyx.util.log.LogEntry(event.arrived_at, event.type, msg))
+
+  def _register_nyx_event(self, record):
+    if record.levelname == 'WARNING':
+      record.levelname = 'WARN'
+
+    self._register_event(nyx.util.log.LogEntry(int(record.created), 'NYX_%s' % record.levelname, record.msg))
+
+  def _register_event(self, event):
+    if event.type not in self._event_types:
+      return
+
+    self._event_log.add(event)
+    self._log_file.write(event.display_message)
+
+    # notifies the display that it has new content
+
+    if self._filter.match(event.display_message):
+      self._has_new_event = True
+
+      with self._pause_condition:
+        self._pause_condition.notifyAll()
diff --git a/nyx/panel/torrc.py b/nyx/panel/torrc.py
new file mode 100644
index 0000000..f0f66b4
--- /dev/null
+++ b/nyx/panel/torrc.py
@@ -0,0 +1,171 @@
+"""
+Panel displaying the torrc or nyxrc with the validation done against it.
+"""
+
+import math
+import curses
+
+from nyx.util import expand_path, msg, panel, tor_controller, ui_tools
+
+from stem import ControllerError
+from stem.control import State
+
+
+class TorrcPanel(panel.Panel):
+  """
+  Renders the current torrc or nyxrc with syntax highlighting in a scrollable
+  area.
+  """
+
+  def __init__(self, stdscr):
+    panel.Panel.__init__(self, stdscr, 'torrc', 0)
+
+    self._scroll = 0
+    self._show_line_numbers = True  # shows left aligned line numbers
+    self._show_comments = True  # shows comments and extra whitespace
+    self._last_content_height = 0
+
+    self._torrc_location = None
+    self._torrc_content = None
+    self._torrc_load_error = None
+
+    controller = tor_controller()
+    controller.add_status_listener(self.reset_listener)
+    self.reset_listener(controller, State.RESET, None)
+
+  def reset_listener(self, controller, event_type, _):
+    """
+    Reloads and displays the torrc on tor reload (sighup) events.
+    """
+
+    if event_type == State.RESET:
+      try:
+        self._torrc_location = expand_path(controller.get_info('config-file'))
+
+        with open(self._torrc_location) as torrc_file:
+          self._torrc_content = [ui_tools.get_printable(line.replace('\t', '   ')).rstrip() for line in torrc_file.readlines()]
+      except ControllerError as exc:
+        self._torrc_load_error = msg('panel.torrc.unable_to_find_torrc', error = exc)
+        self._torrc_location = None
+        self._torrc_content = None
+      except Exception as exc:
+        self._torrc_load_error = msg('panel.torrc.unable_to_load_torrc', error = exc.strerror)
+        self._torrc_content = None
+
+  def set_comments_visible(self, is_visible):
+    """
+    Sets if comments and blank lines are shown or stripped.
+
+    :var bool is_visible: shows comments if true, strips otherwise
+    """
+
+    self._show_comments = is_visible
+    self.redraw(True)
+
+  def set_line_number_visible(self, is_visible):
+    """
+    Sets if line numbers are shown or hidden.
+
+    :var bool is_visible: displays line numbers if true, hides otherwise
+    """
+
+    self._show_line_numbers = is_visible
+    self.redraw(True)
+
+  def handle_key(self, key):
+    if key.is_scroll():
+      page_height = self.get_preferred_size()[0] - 1
+      new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
+
+      if self._scroll != new_scroll:
+        self._scroll = new_scroll
+        self.redraw(True)
+    elif key.match('l'):
+      self.set_line_number_visible(not self._show_line_numbers)
+    elif key.match('s'):
+      self.set_comments_visible(not self._show_comments)
+    else:
+      return False
+
+    return True
+
+  def get_help(self):
+    return [
+      ('up arrow', 'scroll up a line', None),
+      ('down arrow', 'scroll down a line', None),
+      ('page up', 'scroll up a page', None),
+      ('page down', 'scroll down a page', None),
+      ('s', 'comment stripping', 'off' if self._show_comments else 'on'),
+      ('l', 'line numbering', 'on' if self._show_line_numbers else 'off'),
+      ('x', 'reset tor (issue sighup)', None),
+    ]
+
+  def draw(self, width, height):
+    if self._torrc_content is None:
+      self.addstr(1, 0, self._torrc_load_error, 'red', curses.A_BOLD)
+      new_content_height = 1
+    else:
+      self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
+
+      if not self._show_line_numbers:
+        line_number_offset = 0
+      elif len(self._torrc_content) == 0:
+        line_number_offset = 2
+      else:
+        line_number_offset = int(math.log10(len(self._torrc_content))) + 2
+
+      scroll_offset = 0
+
+      if self._last_content_height > height - 1:
+        scroll_offset = 3
+        self.add_scroll_bar(self._scroll, self._scroll + height - 1, self._last_content_height, 1)
+
+      y = 1 - self._scroll
+      is_multiline = False  # true if we're in the middle of a multiline torrc entry
+
+      for line_number, line in enumerate(self._torrc_content):
+        if not self._show_comments:
+          line = line[:line.find('#')].rstrip() if '#' in line else line
+
+          if not line:
+            continue  # skip blank lines
+
+        if '#' in line:
+          line, comment = line.split('#', 1)
+          comment = '#' + comment
+        else:
+          comment = ''
+
+        if is_multiline:
+          option, argument = '', line  # previous line ended with a '\'
+        elif ' ' not in line.strip():
+          option, argument = line, ''  # no argument
+        else:
+          whitespace = ' ' * (len(line) - len(line.strip()))
+          option, argument = line.strip().split(' ', 1)
+          option = whitespace + option + ' '
+
+        is_multiline = line.endswith('\\')  # next line's part of a multi-line entry
+
+        if self._show_line_numbers:
+          self.addstr(y, scroll_offset, str(line_number + 1).rjust(line_number_offset - 1), curses.A_BOLD, 'yellow')
+
+        x = line_number_offset + scroll_offset
+        min_x = line_number_offset + scroll_offset
+
+        x, y = self.addstr_wrap(y, x, option, width, min_x, curses.A_BOLD, 'green')
+        x, y = self.addstr_wrap(y, x, argument, width, min_x, curses.A_BOLD, 'cyan')
+        x, y = self.addstr_wrap(y, x, comment, width, min_x, 'white')
+
+        y += 1
+
+      new_content_height = y + self._scroll - 1
+
+    if self.is_title_visible():
+      self.addstr(0, 0, ' ' * width)  # clear line
+      location = ' (%s)' % self._torrc_location if self._torrc_location else ''
+      self.addstr(0, 0, 'Tor Configuration File%s:' % location, curses.A_STANDOUT)
+
+    if self._last_content_height != new_content_height:
+      self._last_content_height = new_content_height
+      self.redraw(True)
diff --git a/nyx/torrc_panel.py b/nyx/torrc_panel.py
deleted file mode 100644
index f0f66b4..0000000
--- a/nyx/torrc_panel.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-Panel displaying the torrc or nyxrc with the validation done against it.
-"""
-
-import math
-import curses
-
-from nyx.util import expand_path, msg, panel, tor_controller, ui_tools
-
-from stem import ControllerError
-from stem.control import State
-
-
-class TorrcPanel(panel.Panel):
-  """
-  Renders the current torrc or nyxrc with syntax highlighting in a scrollable
-  area.
-  """
-
-  def __init__(self, stdscr):
-    panel.Panel.__init__(self, stdscr, 'torrc', 0)
-
-    self._scroll = 0
-    self._show_line_numbers = True  # shows left aligned line numbers
-    self._show_comments = True  # shows comments and extra whitespace
-    self._last_content_height = 0
-
-    self._torrc_location = None
-    self._torrc_content = None
-    self._torrc_load_error = None
-
-    controller = tor_controller()
-    controller.add_status_listener(self.reset_listener)
-    self.reset_listener(controller, State.RESET, None)
-
-  def reset_listener(self, controller, event_type, _):
-    """
-    Reloads and displays the torrc on tor reload (sighup) events.
-    """
-
-    if event_type == State.RESET:
-      try:
-        self._torrc_location = expand_path(controller.get_info('config-file'))
-
-        with open(self._torrc_location) as torrc_file:
-          self._torrc_content = [ui_tools.get_printable(line.replace('\t', '   ')).rstrip() for line in torrc_file.readlines()]
-      except ControllerError as exc:
-        self._torrc_load_error = msg('panel.torrc.unable_to_find_torrc', error = exc)
-        self._torrc_location = None
-        self._torrc_content = None
-      except Exception as exc:
-        self._torrc_load_error = msg('panel.torrc.unable_to_load_torrc', error = exc.strerror)
-        self._torrc_content = None
-
-  def set_comments_visible(self, is_visible):
-    """
-    Sets if comments and blank lines are shown or stripped.
-
-    :var bool is_visible: shows comments if true, strips otherwise
-    """
-
-    self._show_comments = is_visible
-    self.redraw(True)
-
-  def set_line_number_visible(self, is_visible):
-    """
-    Sets if line numbers are shown or hidden.
-
-    :var bool is_visible: displays line numbers if true, hides otherwise
-    """
-
-    self._show_line_numbers = is_visible
-    self.redraw(True)
-
-  def handle_key(self, key):
-    if key.is_scroll():
-      page_height = self.get_preferred_size()[0] - 1
-      new_scroll = ui_tools.get_scroll_position(key, self._scroll, page_height, self._last_content_height)
-
-      if self._scroll != new_scroll:
-        self._scroll = new_scroll
-        self.redraw(True)
-    elif key.match('l'):
-      self.set_line_number_visible(not self._show_line_numbers)
-    elif key.match('s'):
-      self.set_comments_visible(not self._show_comments)
-    else:
-      return False
-
-    return True
-
-  def get_help(self):
-    return [
-      ('up arrow', 'scroll up a line', None),
-      ('down arrow', 'scroll down a line', None),
-      ('page up', 'scroll up a page', None),
-      ('page down', 'scroll down a page', None),
-      ('s', 'comment stripping', 'off' if self._show_comments else 'on'),
-      ('l', 'line numbering', 'on' if self._show_line_numbers else 'off'),
-      ('x', 'reset tor (issue sighup)', None),
-    ]
-
-  def draw(self, width, height):
-    if self._torrc_content is None:
-      self.addstr(1, 0, self._torrc_load_error, 'red', curses.A_BOLD)
-      new_content_height = 1
-    else:
-      self._scroll = max(0, min(self._scroll, self._last_content_height - height + 1))
-
-      if not self._show_line_numbers:
-        line_number_offset = 0
-      elif len(self._torrc_content) == 0:
-        line_number_offset = 2
-      else:
-        line_number_offset = int(math.log10(len(self._torrc_content))) + 2
-
-      scroll_offset = 0
-
-      if self._last_content_height > height - 1:
-        scroll_offset = 3
-        self.add_scroll_bar(self._scroll, self._scroll + height - 1, self._last_content_height, 1)
-
-      y = 1 - self._scroll
-      is_multiline = False  # true if we're in the middle of a multiline torrc entry
-
-      for line_number, line in enumerate(self._torrc_content):
-        if not self._show_comments:
-          line = line[:line.find('#')].rstrip() if '#' in line else line
-
-          if not line:
-            continue  # skip blank lines
-
-        if '#' in line:
-          line, comment = line.split('#', 1)
-          comment = '#' + comment
-        else:
-          comment = ''
-
-        if is_multiline:
-          option, argument = '', line  # previous line ended with a '\'
-        elif ' ' not in line.strip():
-          option, argument = line, ''  # no argument
-        else:
-          whitespace = ' ' * (len(line) - len(line.strip()))
-          option, argument = line.strip().split(' ', 1)
-          option = whitespace + option + ' '
-
-        is_multiline = line.endswith('\\')  # next line's part of a multi-line entry
-
-        if self._show_line_numbers:
-          self.addstr(y, scroll_offset, str(line_number + 1).rjust(line_number_offset - 1), curses.A_BOLD, 'yellow')
-
-        x = line_number_offset + scroll_offset
-        min_x = line_number_offset + scroll_offset
-
-        x, y = self.addstr_wrap(y, x, option, width, min_x, curses.A_BOLD, 'green')
-        x, y = self.addstr_wrap(y, x, argument, width, min_x, curses.A_BOLD, 'cyan')
-        x, y = self.addstr_wrap(y, x, comment, width, min_x, 'white')
-
-        y += 1
-
-      new_content_height = y + self._scroll - 1
-
-    if self.is_title_visible():
-      self.addstr(0, 0, ' ' * width)  # clear line
-      location = ' (%s)' % self._torrc_location if self._torrc_location else ''
-      self.addstr(0, 0, 'Tor Configuration File%s:' % location, curses.A_STANDOUT)
-
-    if self._last_content_height != new_content_height:
-      self._last_content_height = new_content_height
-      self.redraw(True)
diff --git a/nyx/util/log.py b/nyx/util/log.py
index bf9fe82..e4f962b 100644
--- a/nyx/util/log.py
+++ b/nyx/util/log.py
@@ -4,7 +4,7 @@ runlevels.
 
 ::
 
-  trace - logs a message at the TRACE runlevel 
+  trace - logs a message at the TRACE runlevel
   debug - logs a message at the DEBUG runlevel
   info - logs a message at the INFO runlevel
   notice - logs a message at the NOTICE runlevel
diff --git a/setup.py b/setup.py
index 13ae6af..326cc61 100644
--- a/setup.py
+++ b/setup.py
@@ -99,7 +99,7 @@ setup(
   author = nyx.__author__,
   author_email = nyx.__contact__,
   url = nyx.__url__,
-  packages = ['nyx', 'nyx.menu', 'nyx.util'],
+  packages = ['nyx', 'nyx.panel', 'nyx.menu', 'nyx.util'],
   keywords = 'tor onion controller',
   install_requires = ['stem>=1.4.1'],
   package_data = {'nyx': ['settings/*', 'resources/*']},



More information about the tor-commits mailing list