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