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