1# Copyright 2010-2019, Damian Johnson and The Tor Project
2# See LICENSE for licensing information
3
4"""
5Graphs of tor related statistics. For example...
6
7Downloaded (0.0 B/sec):           Uploaded (0.0 B/sec):
8  34                                30
9                            *                                 *
10                    **  *   *                          *      **
11      *   *  *      ** **   **          ***  **       ** **   **
12     *********      ******  ******     *********      ******  ******
13   0 ************ ****************   0 ************ ****************
14         25s  50   1m   1.6  2.0           25s  50   1m   1.6  2.0
15"""
16
17import copy
18import functools
19import threading
20import time
21
22import nyx.curses
23import nyx.panel
24import nyx.popups
25import nyx.tracker
26
27from nyx import nyx_interface, tor_controller, join, show_message
28from nyx.curses import RED, GREEN, CYAN, BOLD, HIGHLIGHT
29from nyx.menu import MenuItem, Submenu, RadioMenuItem, RadioGroup
30from stem.control import EventType, Listener
31from stem.util import conf, enum, log, str_tools, system
32
33GraphStat = enum.Enum(('BANDWIDTH', 'bandwidth'), ('CONNECTIONS', 'connections'), ('SYSTEM_RESOURCES', 'resources'))
34Interval = 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'))
35Bounds = enum.Enum(('GLOBAL_MAX', 'global_max'), ('LOCAL_MAX', 'local_max'), ('TIGHT', 'tight'))
36
37INTERVAL_SECONDS = {
38  Interval.EACH_SECOND: 1,
39  Interval.FIVE_SECONDS: 5,
40  Interval.THIRTY_SECONDS: 30,
41  Interval.MINUTELY: 60,
42  Interval.FIFTEEN_MINUTE: 900,
43  Interval.THIRTY_MINUTE: 1800,
44  Interval.HOURLY: 3600,
45  Interval.DAILY: 86400,
46}
47
48PRIMARY_COLOR, SECONDARY_COLOR = GREEN, CYAN
49
50ACCOUNTING_RATE = 5
51DEFAULT_CONTENT_HEIGHT = 4  # space needed for labeling above and below the graph
52WIDE_LABELING_GRAPH_COL = 50  # minimum graph columns to use wide spacing for x-axis labels
53TITLE_UPDATE_RATE = 30
54
55
56def conf_handler(key, value):
57  if key == 'graph_height':
58    return max(1, value)
59  elif key == 'max_graph_width':
60    return max(1, value)
61  elif key == 'graph_stat':
62    if value != 'none' and value not in GraphStat:
63      log.warn("'%s' isn't a valid graph type, options are: none, %s" % (CONFIG['graph_stat'], ', '.join(GraphStat)))
64      return CONFIG['graph_stat']  # keep the default
65  elif key == 'graph_interval':
66    if value not in Interval:
67      log.warn("'%s' isn't a valid graphing interval, options are: %s" % (value, ', '.join(Interval)))
68      return CONFIG['graph_interval']  # keep the default
69  elif key == 'graph_bound':
70    if value not in Bounds:
71      log.warn("'%s' isn't a valid graph bounds, options are: %s" % (value, ', '.join(Bounds)))
72      return CONFIG['graph_bound']  # keep the default
73
74
75CONFIG = conf.config_dict('nyx', {
76  'attr.hibernate_color': {},
77  'attr.graph.title': {},
78  'attr.graph.header.primary': {},
79  'attr.graph.header.secondary': {},
80  'graph_bound': Bounds.LOCAL_MAX,
81  'graph_height': 7,
82  'graph_interval': Interval.EACH_SECOND,
83  'graph_stat': GraphStat.BANDWIDTH,
84  'max_graph_width': 300,  # we need some sort of max size so we know how much graph data to retain
85  'show_accounting': True,
86  'show_bits': False,
87  'show_connections': True,
88}, conf_handler)
89
90
91def _bandwidth_title_stats():
92  controller = tor_controller()
93
94  stats = []
95  bw_rate = controller.get_effective_rate(None)
96  bw_burst = controller.get_effective_rate(None, burst = True)
97
98  if bw_rate and bw_burst:
99    bw_rate_label = _size_label(bw_rate)
100    bw_burst_label = _size_label(bw_burst)
101
102    # if both are using rounded values then strip off the '.0' decimal
103
104    if '.0' in bw_rate_label and '.0' in bw_burst_label:
105      bw_rate_label = bw_rate_label.replace('.0', '')
106      bw_burst_label = bw_burst_label.replace('.0', '')
107
108    stats.append('limit: %s/s' % bw_rate_label)
109    stats.append('burst: %s/s' % bw_burst_label)
110
111  my_server_descriptor = controller.get_server_descriptor(default = None)
112  observed_bw = getattr(my_server_descriptor, 'observed_bandwidth', None)
113
114  if observed_bw:
115    stats.append('observed: %s/s' % _size_label(observed_bw))
116
117  return stats
118
119
120class GraphData(object):
121  """
122  Graphable statistical information.
123
124  :var int latest_value: last value we recorded
125  :var int total: sum of all values we've recorded
126  :var int tick: number of events we've processed
127  :var dict values: mapping of intervals to an array of samplings from newest to oldest
128  """
129
130  def __init__(self, clone = None, category = None, is_primary = True):
131    if clone:
132      self.latest_value = clone.latest_value
133      self.total = clone.total
134      self.tick = clone.tick
135      self.values = copy.deepcopy(clone.values)
136
137      self._category = category
138      self._is_primary = clone._is_primary
139      self._in_process_value = dict(clone._in_process_value)
140      self._max_value = dict(clone._max_value)
141    else:
142      self.latest_value = 0
143      self.total = 0
144      self.tick = 0
145      self.values = dict([(i, CONFIG['max_graph_width'] * [0]) for i in Interval])
146
147      self._category = category
148      self._is_primary = is_primary
149      self._in_process_value = dict([(i, 0) for i in Interval])
150      self._max_value = dict([(i, 0) for i in Interval])  # interval => maximum value it's had
151
152  def average(self):
153    return self.total / max(1, self.tick)
154
155  def update(self, new_value):
156    self.latest_value = new_value
157    self.total += new_value
158    self.tick += 1
159
160    for interval in Interval:
161      interval_seconds = INTERVAL_SECONDS[interval]
162      self._in_process_value[interval] += new_value
163
164      if self.tick % interval_seconds == 0:
165        new_entry = self._in_process_value[interval] / interval_seconds
166        self.values[interval] = [new_entry] + self.values[interval][:-1]
167        self._max_value[interval] = max(self._max_value[interval], new_entry)
168        self._in_process_value[interval] = 0
169
170  def header(self, width):
171    """
172    Provides the description above a subgraph.
173
174    :param int width: maximum length of the header
175
176    :returns: **str** with our graph header
177    """
178
179    return self._category._header(width, self._is_primary)
180
181  def bounds(self, bounds, interval, columns):
182    """
183    Range of values for the graph.
184
185    :param Bounds bounds: boundary type for the range we want
186    :param Interval interval: timing interval of the values
187    :param int columns: number of values to take into account
188
189    :returns: **tuple** of the form (min, max)
190    """
191
192    min_bound, max_bound = 0, 0
193    values = self.values[interval][:columns]
194
195    if bounds == Bounds.GLOBAL_MAX:
196      max_bound = self._max_value[interval]
197    elif columns > 0:
198      max_bound = max(values)  # local maxima
199
200    if bounds == Bounds.TIGHT and columns > 0:
201      min_bound = min(values)
202
203      # if the max = min pick zero so we still display something
204
205      if min_bound == max_bound:
206        min_bound = 0
207
208    return min_bound, max_bound
209
210  def y_axis_label(self, value):
211    """
212    Provides the label we should display on our y-axis.
213
214    :param int value: value being shown on the y-axis
215
216    :returns: **str** with our y-axis label
217    """
218
219    return self._category._y_axis_label(value, self._is_primary)
220
221
222class GraphCategory(object):
223  """
224  Category for the graph. This maintains two subgraphs, updating them each
225  second with updated stats.
226
227  :var GraphData primary: first subgraph
228  :var GraphData secondary: second subgraph
229  :var float start_time: unix timestamp for when we started
230  """
231
232  def __init__(self, clone = None):
233    if clone:
234      self.primary = GraphData(clone.primary, category = self)
235      self.secondary = GraphData(clone.secondary, category = self)
236      self.start_time = clone.start_time
237      self._title_stats = list(clone._title_stats)
238      self._primary_header_stats = list(clone._primary_header_stats)
239      self._secondary_header_stats = list(clone._secondary_header_stats)
240    else:
241      self.primary = GraphData(category = self, is_primary = True)
242      self.secondary = GraphData(category = self, is_primary = False)
243      self.start_time = time.time()
244      self._title_stats = []
245      self._primary_header_stats = []
246      self._secondary_header_stats = []
247
248  def stat_type(self):
249    """
250    Provides the GraphStat this graph is for.
251
252    :returns: **GraphStat** of this graph
253    """
254
255    raise NotImplementedError('Should be implemented by subclasses')
256
257  def title(self, width):
258    """
259    Provides a graph title that fits in the given width.
260
261    :param int width: maximum length of the title
262
263    :returns: **str** with our title
264    """
265
266    title = CONFIG['attr.graph.title'].get(self.stat_type(), '')
267    title_stats = join(self._title_stats, ', ', width - len(title) - 4)
268    return '%s (%s):' % (title, title_stats) if title_stats else title + ':'
269
270  def bandwidth_event(self, event):
271    """
272    Called when it's time to process another event. All graphs use tor BW
273    events to keep in sync with each other (this happens once per second).
274    """
275
276    pass
277
278  def _header(self, width, is_primary):
279    if is_primary:
280      header = CONFIG['attr.graph.header.primary'].get(self.stat_type(), '')
281      header_stats = self._primary_header_stats
282    else:
283      header = CONFIG['attr.graph.header.secondary'].get(self.stat_type(), '')
284      header_stats = self._secondary_header_stats
285
286    header_stats = join(header_stats, '', width - len(header) - 4).rstrip()
287    return '%s (%s):' % (header, header_stats) if header_stats else '%s:' % header
288
289  def _y_axis_label(self, value, is_primary):
290    return str(value)
291
292
293class BandwidthStats(GraphCategory):
294  """
295  Tracks tor's bandwidth usage.
296  """
297
298  def __init__(self, clone = None):
299    GraphCategory.__init__(self, clone)
300    self._title_last_updated = None
301
302    if not clone:
303      # fill in past bandwidth information
304
305      controller = tor_controller()
306      bw_entries, is_successful = controller.get_info('bw-event-cache', None), True
307
308      if bw_entries:
309        for entry in bw_entries.split():
310          entry_comp = entry.split(',')
311
312          if len(entry_comp) != 2 or not entry_comp[0].isdigit() or not entry_comp[1].isdigit():
313            log.warn("Tor's 'GETINFO bw-event-cache' provided malformed output: %s" % bw_entries)
314            is_successful = False
315            break
316
317          self.primary.update(int(entry_comp[0]))
318          self.secondary.update(int(entry_comp[1]))
319
320        if is_successful:
321          log.info('Bandwidth graph has information for the last %s' % str_tools.time_label(len(bw_entries.split()), is_long = True))
322
323      read_total = controller.get_info('traffic/read', None)
324      write_total = controller.get_info('traffic/written', None)
325      start_time = system.start_time(controller.get_pid(None))
326
327      if read_total and write_total and start_time:
328        self.primary.total = int(read_total)
329        self.secondary.total = int(write_total)
330        self.start_time = start_time
331
332  def stat_type(self):
333    return GraphStat.BANDWIDTH
334
335  def _y_axis_label(self, value, is_primary):
336    return _size_label(value, 0)
337
338  def bandwidth_event(self, event):
339    self.primary.update(event.read)
340    self.secondary.update(event.written)
341
342    self._primary_header_stats = [
343      '%-14s' % ('%s/sec' % _size_label(self.primary.latest_value)),
344      '- avg: %s/sec' % _size_label(self.primary.total / (time.time() - self.start_time)),
345      ', total: %s' % _size_label(self.primary.total),
346    ]
347
348    self._secondary_header_stats = [
349      '%-14s' % ('%s/sec' % _size_label(self.secondary.latest_value)),
350      '- avg: %s/sec' % _size_label(self.secondary.total / (time.time() - self.start_time)),
351      ', total: %s' % _size_label(self.secondary.total),
352    ]
353
354    if not self._title_last_updated or time.time() - self._title_last_updated > TITLE_UPDATE_RATE:
355      self._title_stats = _bandwidth_title_stats()
356      self._title_last_updated = time.time()
357
358
359class ConnectionStats(GraphCategory):
360  """
361  Tracks number of inbound and outbound connections.
362  """
363
364  def stat_type(self):
365    return GraphStat.CONNECTIONS
366
367  def bandwidth_event(self, event):
368    inbound_count, outbound_count = 0, 0
369
370    controller = tor_controller()
371    or_ports = controller.get_ports(Listener.OR, [])
372    dir_ports = controller.get_ports(Listener.DIR, [])
373    control_ports = controller.get_ports(Listener.CONTROL, [])
374
375    for entry in nyx.tracker.get_connection_tracker().get_value():
376      if entry.local_port in or_ports or entry.local_port in dir_ports:
377        inbound_count += 1
378      elif entry.local_port in control_ports:
379        pass  # control connection
380      else:
381        outbound_count += 1
382
383    self.primary.update(inbound_count)
384    self.secondary.update(outbound_count)
385
386    self._primary_header_stats = [str(self.primary.latest_value), ', avg: %i' % self.primary.average()]
387    self._secondary_header_stats = [str(self.secondary.latest_value), ', avg: %i' % self.secondary.average()]
388
389
390class ResourceStats(GraphCategory):
391  """
392  Tracks cpu and memory usage of the tor process.
393  """
394
395  def stat_type(self):
396    return GraphStat.SYSTEM_RESOURCES
397
398  def _y_axis_label(self, value, is_primary):
399    return '%i%%' % value if is_primary else str_tools.size_label(value)
400
401  def bandwidth_event(self, event):
402    resources = nyx.tracker.get_resource_tracker().get_value()
403    self.primary.update(resources.cpu_sample * 100)  # decimal percentage to whole numbers
404    self.secondary.update(resources.memory_bytes)
405
406    self._primary_header_stats = ['%0.1f%%' % self.primary.latest_value, ', avg: %0.1f%%' % self.primary.average()]
407    self._secondary_header_stats = [str_tools.size_label(self.secondary.latest_value, 1), ', avg: %s' % str_tools.size_label(self.secondary.average(), 1)]
408
409
410class GraphPanel(nyx.panel.Panel):
411  """
412  Panel displaying graphical information of GraphCategory instances.
413  """
414
415  def __init__(self):
416    nyx.panel.Panel.__init__(self)
417
418    self._displayed_stat = None if CONFIG['graph_stat'] == 'none' else CONFIG['graph_stat']
419    self._update_interval = CONFIG['graph_interval']
420    self._bounds_type = CONFIG['graph_bound']
421    self._graph_height = CONFIG['graph_height']
422
423    self._accounting_stats = None
424    self._accounting_stats_paused = None
425
426    self._stats = {
427      GraphStat.BANDWIDTH: BandwidthStats(),
428      GraphStat.SYSTEM_RESOURCES: ResourceStats(),
429    }
430
431    self._stats_lock = threading.RLock()
432    self._stats_paused = None
433
434    if CONFIG['show_connections']:
435      self._stats[GraphStat.CONNECTIONS] = ConnectionStats()
436    elif self._displayed_stat == GraphStat.CONNECTIONS:
437      log.warn("The connection graph is unavailble when you set 'show_connections false'.")
438      self._displayed_stat = GraphStat.BANDWIDTH
439
440    controller = tor_controller()
441    controller.add_event_listener(self._update_accounting, EventType.BW)
442    controller.add_event_listener(self._update_stats, EventType.BW)
443    controller.add_status_listener(lambda *args: self.redraw())
444
445  def stat_options(self):
446    return self._stats.keys()
447
448  def get_height(self):
449    """
450    Provides the height of the content.
451    """
452
453    max_height = nyx.panel.Panel.get_height(self)
454
455    if not self._displayed_stat:
456      return 0
457
458    height = DEFAULT_CONTENT_HEIGHT + self._graph_height
459    accounting_stats = self._accounting_stats if not nyx_interface().is_paused() else self._accounting_stats_paused
460
461    if self._displayed_stat == GraphStat.BANDWIDTH and accounting_stats:
462      height += 3
463
464    return min(max_height, height)
465
466  def set_graph_height(self, new_graph_height):
467    self._graph_height = max(1, new_graph_height)
468
469  def _resize_graph(self):
470    """
471    Prompts for user input to resize the graph panel. Options include...
472
473      * down arrow - grow graph
474      * up arrow - shrink graph
475      * enter / space - set size
476    """
477
478    with nyx.curses.CURSES_LOCK:
479      try:
480        while True:
481          show_message('press the down/up to resize the graph, and enter when done', BOLD)
482          key = nyx.curses.key_input()
483
484          if key.match('down'):
485            # don't grow the graph if it's already consuming the whole display
486            # (plus an extra line for the graph/log gap)
487
488            max_height = nyx.curses.screen_size().height - self.get_top()
489            current_height = self.get_height()
490
491            if current_height < max_height + 1:
492              self.set_graph_height(self._graph_height + 1)
493          elif key.match('up'):
494            self.set_graph_height(self._graph_height - 1)
495          elif key.is_selection():
496            break
497
498          nyx_interface().redraw()
499      finally:
500        show_message()
501
502  def set_paused(self, is_pause):
503    if is_pause:
504      self._accounting_stats_paused = copy.copy(self._accounting_stats)
505      self._stats_paused = dict([(key, type(self._stats[key])(self._stats[key])) for key in self._stats])
506
507  def key_handlers(self):
508    def _pick_stats():
509      available_stats = sorted(self.stat_options())
510      options = ['None'] + [stat.capitalize() for stat in available_stats]
511      previous_selection = options[available_stats.index(self._displayed_stat) + 1] if self._displayed_stat else 'None'
512
513      selection = nyx.popups.select_from_list('Graphed Stats:', options, previous_selection)
514      self._displayed_stat = None if selection == 'None' else available_stats[options.index(selection) - 1]
515
516    def _next_bounds():
517      self._bounds_type = Bounds.next(self._bounds_type)
518      self.redraw()
519
520    def _pick_interval():
521      self._update_interval = nyx.popups.select_from_list('Update Interval:', list(Interval), self._update_interval)
522      self.redraw()
523
524    return (
525      nyx.panel.KeyHandler('g', 'resize graph', self._resize_graph),
526      nyx.panel.KeyHandler('s', 'graphed stats', _pick_stats, self._displayed_stat if self._displayed_stat else 'none'),
527      nyx.panel.KeyHandler('b', 'graph bounds', _next_bounds, self._bounds_type.replace('_', ' ')),
528      nyx.panel.KeyHandler('i', 'graph update interval', _pick_interval, self._update_interval),
529    )
530
531  def submenu(self):
532    """
533    Submenu consisting of...
534
535      [X] <Stat 1>
536      [ ] <Stat 2>
537      [ ] <Stat 2>
538          Resize...
539          Interval (Submenu)
540          Bounds (Submenu)
541    """
542
543    stat_group = RadioGroup(functools.partial(setattr, self, '_displayed_stat'), self._displayed_stat)
544    interval_group = RadioGroup(functools.partial(setattr, self, '_update_interval'), self._update_interval)
545    bounds_group = RadioGroup(functools.partial(setattr, self, '_bounds_type'), self._bounds_type)
546
547    return Submenu('Graph', [
548      RadioMenuItem('None', stat_group, None),
549      [RadioMenuItem(str_tools._to_camel_case(opt, divider = ' '), stat_group, opt) for opt in sorted(self.stat_options())],
550      MenuItem('Resize...', self._resize_graph),
551      Submenu('Interval', [RadioMenuItem(opt, interval_group, opt) for opt in Interval]),
552      Submenu('Bounds', [RadioMenuItem(opt, bounds_group, opt) for opt in Bounds]),
553    ])
554
555  def _draw(self, subwindow):
556    if not self._displayed_stat:
557      return
558
559    if not nyx_interface().is_paused():
560      stat = self._stats[self._displayed_stat]
561      accounting_stats = self._accounting_stats
562    else:
563      if not self._stats_paused:
564        return  # when first paused concurrency could mean this isn't set yet
565
566      stat = self._stats_paused[self._displayed_stat]
567      accounting_stats = self._accounting_stats_paused
568
569    with self._stats_lock:
570      subgraph_height = self._graph_height + 2  # graph rows + header + x-axis label
571      subgraph_width = min(subwindow.width // 2, CONFIG['max_graph_width'])
572      interval, bounds_type = self._update_interval, self._bounds_type
573
574      subwindow.addstr(0, 0, stat.title(subwindow.width), HIGHLIGHT)
575
576      _draw_subgraph(subwindow, stat.primary, 0, subgraph_width, subgraph_height, bounds_type, interval, PRIMARY_COLOR)
577      _draw_subgraph(subwindow, stat.secondary, subgraph_width, subgraph_width, subgraph_height, bounds_type, interval, SECONDARY_COLOR)
578
579      if stat.stat_type() == GraphStat.BANDWIDTH and accounting_stats:
580        _draw_accounting_stats(subwindow, DEFAULT_CONTENT_HEIGHT + subgraph_height - 2, accounting_stats)
581
582  def _update_accounting(self, event):
583    if not CONFIG['show_accounting']:
584      self._accounting_stats = None
585    elif not self._accounting_stats or time.time() - self._accounting_stats.retrieved >= ACCOUNTING_RATE:
586      old_accounting_stats = self._accounting_stats
587      self._accounting_stats = tor_controller().get_accounting_stats(None)
588
589      if not nyx_interface().is_paused():
590        # if we either added or removed accounting info then redraw the whole
591        # screen to account for resizing
592
593        if bool(old_accounting_stats) != bool(self._accounting_stats):
594          nyx_interface().redraw()
595
596  def _update_stats(self, event):
597    with self._stats_lock:
598      for stat in self._stats.values():
599        stat.bandwidth_event(event)
600
601    if self._displayed_stat:
602      param = self._stats[self._displayed_stat]
603      update_rate = INTERVAL_SECONDS[self._update_interval]
604
605      if param.primary.tick % update_rate == 0:
606        self.redraw()
607
608
609def _draw_subgraph(subwindow, data, x, width, height, bounds_type, interval, color, fill_char = ' '):
610  """
611  Renders subgraph including its title, labeled axis, and content.
612  """
613
614  columns = width - 8  # y-axis labels can be at most six characters wide with a space on either side
615  min_bound, max_bound = data.bounds(bounds_type, interval, columns)
616
617  x_axis_labels = _x_axis_labels(interval, columns)
618  y_axis_labels = _y_axis_labels(height, data, min_bound, max_bound)
619
620  x_axis_offset = max([len(label) for label in y_axis_labels.values()])
621  columns = max(columns, width - x_axis_offset - 2)
622
623  subwindow.addstr(x, 1, data.header(width), color, BOLD)
624
625  for x_offset, label in x_axis_labels.items():
626    subwindow.addstr(x + x_offset + x_axis_offset, height, label, color)
627
628  for y, label in y_axis_labels.items():
629    subwindow.addstr(x, y, label, color)
630
631  for col in range(columns):
632    column_count = int(data.values[interval][col]) - min_bound
633    column_height = int(min(height - 2, (height - 2) * column_count / (max(1, max_bound) - min_bound)))
634    subwindow.vline(x + col + x_axis_offset + 1, height - column_height, column_height, color, HIGHLIGHT, char = fill_char)
635
636
637def _x_axis_labels(interval, columns):
638  """
639  Provides the labels for the x-axis. We include the units for only its first
640  value, then bump the precision for subsequent units. For example...
641
642    10s, 20, 30, 40, 50, 1m, 1.1, 1.3, 1.5
643  """
644
645  x_axis_labels = {}
646
647  interval_sec = INTERVAL_SECONDS[interval]
648  interval_spacing = 10 if columns >= WIDE_LABELING_GRAPH_COL else 5
649  previous_units, decimal_precision = None, 0
650
651  for i in range((columns - 4) // interval_spacing):
652    x = (i + 1) * interval_spacing
653    time_label = str_tools.time_label(x * interval_sec, decimal_precision)
654
655    if not previous_units:
656      previous_units = time_label[-1]
657    elif previous_units != time_label[-1]:
658      previous_units = time_label[-1]
659      decimal_precision = 1  # raised precision for future measurements
660    else:
661      time_label = time_label[:-1]  # strip units since already provided
662
663    x_axis_labels[x] = time_label
664
665  return x_axis_labels
666
667
668def _y_axis_labels(subgraph_height, data, min_bound, max_bound):
669  """
670  Provides the labels for the y-axis. This is a mapping of the position it
671  should be drawn at to its text.
672  """
673
674  y_axis_labels = {
675    2: data.y_axis_label(max_bound),
676    subgraph_height - 1: data.y_axis_label(min_bound),
677  }
678
679  ticks = (subgraph_height - 5) // 2
680
681  for i in range(ticks):
682    row = subgraph_height - (2 * i) - 5
683
684    if subgraph_height % 2 == 0 and i >= (ticks // 2):
685      row -= 1  # make extra gap be in the middle when we're an even size
686
687    val = (max_bound - min_bound) * (subgraph_height - row - 3) // (subgraph_height - 3)
688
689    if val not in (min_bound, max_bound):
690      y_axis_labels[row + 2] = data.y_axis_label(val)
691
692  return y_axis_labels
693
694
695def _draw_accounting_stats(subwindow, y, accounting):
696  if tor_controller().is_alive():
697    hibernate_color = CONFIG['attr.hibernate_color'].get(accounting.status, RED)
698
699    x = subwindow.addstr(0, y, 'Accounting (', BOLD)
700    x = subwindow.addstr(x, y, accounting.status, BOLD, hibernate_color)
701    x = subwindow.addstr(x, y, ')', BOLD)
702
703    subwindow.addstr(35, y, 'Time to reset: %s' % str_tools.short_time_label(accounting.time_until_reset))
704
705    subwindow.addstr(2, y + 1, '%s / %s' % (_size_label(accounting.read_bytes), _size_label(accounting.read_limit)), PRIMARY_COLOR)
706    subwindow.addstr(37, y + 1, '%s / %s' % (_size_label(accounting.written_bytes), _size_label(accounting.write_limit)), SECONDARY_COLOR)
707  else:
708    subwindow.addstr(0, y, 'Accounting:', BOLD)
709    subwindow.addstr(12, y, 'Connection Closed...')
710
711
712def _size_label(byte_count, decimal = 1):
713  """
714  Alias for str_tools.size_label() that accounts for if the user prefers bits
715  or bytes.
716  """
717
718  return str_tools.size_label(byte_count, decimal, is_bytes = not CONFIG['show_bits'], round = True)
719