Issues (45)

glances/outputs/glances_curses.py (1 issue)

1
# -*- coding: utf-8 -*-
2
#
3
# This file is part of Glances.
4
#
5
# SPDX-FileCopyrightText: 2023 Nicolas Hennion <[email protected]>
6
#
7
# SPDX-License-Identifier: LGPL-3.0-only
8
#
9
10
"""Curses interface class."""
11
from __future__ import unicode_literals
12
13
import sys
14
15
from glances.globals import MACOS, WINDOWS, nativestr, u, itervalues, enable, disable
16
from glances.logger import logger
17
from glances.events_list import glances_events
18
from glances.processes import glances_processes, sort_processes_key_list
19
from glances.outputs.glances_unicode import unicode_message
20
from glances.timer import Timer
21
22
# Import curses library for "normal" operating system
23
try:
24
    import curses
25
    import curses.panel
26
    from curses.textpad import Textbox
27
except ImportError:
28
    logger.critical("Curses module not found. Glances cannot start in standalone mode.")
29
    if WINDOWS:
30
        logger.critical("For Windows you can try installing windows-curses with pip install.")
31
    sys.exit(1)
32
33
34
class _GlancesCurses(object):
35
36
    """This class manages the curses display (and key pressed).
37
38
    Note: It is a private class, use GlancesCursesClient or GlancesCursesBrowser.
39
    """
40
41
    _hotkeys = {
42
        '\n': {'handler': '_handle_enter'},
43
        '0': {'switch': 'disable_irix'},
44
        '1': {'switch': 'percpu'},
45
        '2': {'switch': 'disable_left_sidebar'},
46
        '3': {'switch': 'disable_quicklook'},
47
        '4': {'handler': '_handle_quicklook'},
48
        '5': {'handler': '_handle_top_menu'},
49
        '6': {'switch': 'meangpu'},
50
        '/': {'switch': 'process_short_name'},
51
        'a': {'sort_key': 'auto'},
52
        'A': {'switch': 'disable_amps'},
53
        'b': {'switch': 'byte'},
54
        'B': {'switch': 'diskio_iops'},
55
        'c': {'sort_key': 'cpu_percent'},
56
        'C': {'switch': 'disable_cloud'},
57
        'd': {'switch': 'disable_diskio'},
58
        'D': {'switch': 'disable_containers'},
59
        # 'e' > Enable/Disable process extended
60
        'E': {'handler': '_handle_erase_filter'},
61
        'f': {'handler': '_handle_fs_stats'},
62
        'F': {'switch': 'fs_free_space'},
63
        'g': {'switch': 'generate_graph'},
64
        'G': {'switch': 'disable_gpu'},
65
        'h': {'switch': 'help_tag'},
66
        'i': {'sort_key': 'io_counters'},
67
        'I': {'switch': 'disable_ip'},
68
        'j': {'switch': 'programs'},
69
        # 'k' > Kill selected process
70
        'K': {'switch': 'disable_connections'},
71
        'l': {'switch': 'disable_alert'},
72
        'm': {'sort_key': 'memory_percent'},
73
        'M': {'switch': 'reset_minmax_tag'},
74
        'n': {'switch': 'disable_network'},
75
        'N': {'switch': 'disable_now'},
76
        'p': {'sort_key': 'name'},
77
        'P': {'switch': 'disable_ports'},
78
        # 'q' or ESCAPE > Quit
79
        'Q': {'switch': 'enable_irq'},
80
        'r': {'switch': 'disable_smart'},
81
        'R': {'switch': 'disable_raid'},
82
        's': {'switch': 'disable_sensors'},
83
        'S': {'switch': 'sparkline'},
84
        't': {'sort_key': 'cpu_times'},
85
        'T': {'switch': 'network_sum'},
86
        'u': {'sort_key': 'username'},
87
        'U': {'switch': 'network_cumul'},
88
        'w': {'handler': '_handle_clean_logs'},
89
        'W': {'switch': 'disable_wifi'},
90
        'x': {'handler': '_handle_clean_critical_logs'},
91
        'z': {'handler': '_handle_disable_process'},
92
        '+': {'handler': '_handle_increase_nice'},
93
        '-': {'handler': '_handle_decrease_nice'},
94
        # "<" (left arrow) navigation through process sort
95
        # ">" (right arrow) navigation through process sort
96
        # 'UP' > Up in the server list
97
        # 'DOWN' > Down in the server list
98
    }
99
100
    _sort_loop = sort_processes_key_list
101
102
    # Define top menu
103
    _top = ['quicklook', 'cpu', 'percpu', 'gpu', 'mem', 'memswap', 'load']
104
    _quicklook_max_width = 68
105
106
    # Define left sidebar
107
    # This default list is also defined in the glances/outputs/static/js/uiconfig.json
108
    # file for the web interface
109
    # Both can be overwritten by the configuration file ([outputs] left_menu option)
110
    _left_sidebar = [
111
        'network',
112
        'ports',
113
        'wifi',
114
        'connections',
115
        'diskio',
116
        'fs',
117
        'irq',
118
        'folders',
119
        'raid',
120
        'smart',
121
        'sensors',
122
        'now',
123
    ]
124
    _left_sidebar_min_width = 23
125
    _left_sidebar_max_width = 34
126
127
    # Define right sidebar
128
    _right_sidebar = ['containers', 'processcount', 'amps', 'processlist', 'alert']
129
130
    def __init__(self, config=None, args=None):
131
        # Init
132
        self.config = config
133
        self.args = args
134
135
        # Init windows positions
136
        self.term_w = 80
137
        self.term_h = 24
138
139
        # Space between stats
140
        self.space_between_column = 3
141
        self.space_between_line = 2
142
143
        # Init the curses screen
144
        try:
145
            self.screen = curses.initscr()
146
            if not self.screen:
147
                logger.critical("Cannot init the curses library.\n")
148
                sys.exit(1)
149
            else:
150
                logger.debug("Curses library initialized with term: {}".format(curses.longname()))
151
        except Exception as e:
152
            if args.export:
153
                logger.info("Cannot init the curses library, quiet mode on and export.")
154
                args.quiet = True
155
                return
156
            else:
157
                logger.critical("Cannot init the curses library ({})".format(e))
158
                sys.exit(1)
159
160
        # Load configuration file
161
        self.load_config(config)
162
163
        # Init cursor
164
        self._init_cursor()
165
166
        # Init the colors
167
        self._init_colors()
168
169
        # Init main window
170
        self.term_window = self.screen.subwin(0, 0)
171
172
        # Init edit filter tag
173
        self.edit_filter = False
174
175
        # Init nice increase/decrease tag
176
        self.increase_nice_process = False
177
        self.decrease_nice_process = False
178
179
        # Init kill process tag
180
        self.kill_process = False
181
182
        # Init the process min/max reset
183
        self.args.reset_minmax_tag = False
184
185
        # Init cursor
186
        self.args.cursor_position = 0
187
188
        # Catch key pressed with non blocking mode
189
        self.term_window.keypad(1)
190
        self.term_window.nodelay(1)
191
        self.pressedkey = -1
192
193
        # History tag
194
        self._init_history()
195
196
    def load_config(self, config):
197
        """Load the outputs section of the configuration file."""
198
        if config is not None and config.has_section('outputs'):
199
            logger.debug('Read the outputs section in the configuration file')
200
            # Separator ?
201
            self.args.enable_separator = config.get_bool_value('outputs', 'separator', default=True)
202
            # Set the left sidebar list
203
            self._left_sidebar = config.get_list_value('outputs',
204
                                                       'left_menu',
205
                                                       default=self._left_sidebar)
206
207
    def _init_history(self):
208
        """Init the history option."""
209
210
        self.reset_history_tag = False
211
212
    def _init_cursor(self):
213
        """Init cursors."""
214
215
        if hasattr(curses, 'noecho'):
216
            curses.noecho()
217
        if hasattr(curses, 'cbreak'):
218
            curses.cbreak()
219
        self.set_cursor(0)
220
221
    def _init_colors(self):
222
        """Init the Curses color layout."""
223
224
        # Set curses options
225
        try:
226
            if hasattr(curses, 'start_color'):
227
                curses.start_color()
228
                logger.debug('Curses interface compatible with {} colors'.format(curses.COLORS))
229
            if hasattr(curses, 'use_default_colors'):
230
                curses.use_default_colors()
231
        except Exception as e:
232
            logger.warning('Error initializing terminal color ({})'.format(e))
233
234
        # Init colors
235
        if self.args.disable_bold:
236
            A_BOLD = 0
237
            self.args.disable_bg = True
238
        else:
239
            A_BOLD = curses.A_BOLD
240
241
        self.title_color = A_BOLD
242
        self.title_underline_color = A_BOLD | curses.A_UNDERLINE
243
        self.help_color = A_BOLD
244
245
        if curses.has_colors():
246
            # The screen is compatible with a colored design
247
            # ex: export TERM=xterm-256color
248
            #     export TERM=xterm-color
249
250
            curses.init_pair(1, -1, -1)
251
            if self.args.disable_bg:
252
                curses.init_pair(2, curses.COLOR_RED, -1)
253
                curses.init_pair(3, curses.COLOR_GREEN, -1)
254
                curses.init_pair(5, curses.COLOR_MAGENTA, -1)
255
            else:
256
                curses.init_pair(2, -1, curses.COLOR_RED)
257
                curses.init_pair(3, -1, curses.COLOR_GREEN)
258
                curses.init_pair(5, -1, curses.COLOR_MAGENTA)
259
            curses.init_pair(4, curses.COLOR_BLUE, -1)
260
            curses.init_pair(6, curses.COLOR_RED, -1)
261
            curses.init_pair(7, curses.COLOR_GREEN, -1)
262
            curses.init_pair(8, curses.COLOR_MAGENTA, -1)
263
264
            # Colors text styles
265
            self.no_color = curses.color_pair(1)
266
            self.default_color = curses.color_pair(3) | A_BOLD
267
            self.nice_color = curses.color_pair(8)
268
            self.cpu_time_color = curses.color_pair(8)
269
            self.ifCAREFUL_color = curses.color_pair(4) | A_BOLD
270
            self.ifWARNING_color = curses.color_pair(5) | A_BOLD
271
            self.ifCRITICAL_color = curses.color_pair(2) | A_BOLD
272
            self.default_color2 = curses.color_pair(7)
273
            self.ifCAREFUL_color2 = curses.color_pair(4)
274
            self.ifWARNING_color2 = curses.color_pair(8) | A_BOLD
275
            self.ifCRITICAL_color2 = curses.color_pair(6) | A_BOLD
276
            self.ifINFO_color = curses.color_pair(4)
277
            self.filter_color = A_BOLD
278
            self.selected_color = A_BOLD
279
            self.separator = curses.color_pair(1)
280
281
            if curses.COLORS > 8:
282
                # ex: export TERM=xterm-256color
283
                colors_list = [curses.COLOR_CYAN, curses.COLOR_YELLOW]
284
                for i in range(0, 3):
285
                    try:
286
                        curses.init_pair(i + 9, colors_list[i], -1)
287
                    except Exception:
288
                        curses.init_pair(i + 9, -1, -1)
289
                self.filter_color = curses.color_pair(9) | A_BOLD
290
                self.selected_color = curses.color_pair(10) | A_BOLD
291
                # Define separator line style
292
                curses.init_color(11, 500, 500, 500)
293
                curses.init_pair(11, curses.COLOR_BLACK, -1)
294
                self.separator = curses.color_pair(11)
295
296
        else:
297
            # The screen is NOT compatible with a colored design
298
            # switch to B&W text styles
299
            # ex: export TERM=xterm-mono
300
            self.no_color = -1
301
            self.default_color = -1
302
            self.nice_color = A_BOLD
303
            self.cpu_time_color = A_BOLD
304
            self.ifCAREFUL_color = A_BOLD
305
            self.ifWARNING_color = curses.A_UNDERLINE
306
            self.ifCRITICAL_color = curses.A_REVERSE
307
            self.default_color2 = -1
308
            self.ifCAREFUL_color2 = A_BOLD
309
            self.ifWARNING_color2 = curses.A_UNDERLINE
310
            self.ifCRITICAL_color2 = curses.A_REVERSE
311
            self.ifINFO_color = A_BOLD
312
            self.filter_color = A_BOLD
313
            self.selected_color = A_BOLD
314
            self.separator = -1
315
316
        # Define the colors list (hash table) for stats
317
        self.colors_list = {
318
            'DEFAULT': self.no_color,
319
            'UNDERLINE': curses.A_UNDERLINE,
320
            'BOLD': A_BOLD,
321
            'SORT': curses.A_UNDERLINE | A_BOLD,
322
            'OK': self.default_color2,
323
            'MAX': self.default_color2 | A_BOLD,
324
            'FILTER': self.filter_color,
325
            'TITLE': self.title_color,
326
            'PROCESS': self.default_color2,
327
            'PROCESS_SELECTED': self.default_color2 | curses.A_UNDERLINE,
328
            'STATUS': self.default_color2,
329
            'NICE': self.nice_color,
330
            'CPU_TIME': self.cpu_time_color,
331
            'CAREFUL': self.ifCAREFUL_color2,
332
            'WARNING': self.ifWARNING_color2,
333
            'CRITICAL': self.ifCRITICAL_color2,
334
            'OK_LOG': self.default_color,
335
            'CAREFUL_LOG': self.ifCAREFUL_color,
336
            'WARNING_LOG': self.ifWARNING_color,
337
            'CRITICAL_LOG': self.ifCRITICAL_color,
338
            'PASSWORD': curses.A_PROTECT,
339
            'SELECTED': self.selected_color,
340
            'INFO': self.ifINFO_color,
341
            'ERROR': self.selected_color,
342
            'SEPARATOR': self.separator,
343
        }
344
345
    def set_cursor(self, value):
346
        """Configure the curse cursor appearance.
347
348
        0: invisible
349
        1: visible
350
        2: very visible
351
        """
352
        if hasattr(curses, 'curs_set'):
353
            try:
354
                curses.curs_set(value)
355
            except Exception:
356
                pass
357
358
    def get_key(self, window):
359
        # TODO: Check issue #163
360
        ret = window.getch()
361
        return ret
362
363
    def __catch_key(self, return_to_browser=False):
364
        # Catch the pressed key
365
        self.pressedkey = self.get_key(self.term_window)
366
        if self.pressedkey == -1:
367
            return -1
368
369
        # Actions (available in the global hotkey dict)...
370
        logger.debug("Keypressed (code: {})".format(self.pressedkey))
371
        for hotkey in self._hotkeys:
372
            if self.pressedkey == ord(hotkey) and 'switch' in self._hotkeys[hotkey]:
373
                self._handle_switch(hotkey)
374
            elif self.pressedkey == ord(hotkey) and 'sort_key' in self._hotkeys[hotkey]:
375
                self._handle_sort_key(hotkey)
376
            if self.pressedkey == ord(hotkey) and 'handler' in self._hotkeys[hotkey]:
377
                action = getattr(self, self._hotkeys[hotkey]['handler'])
378
                action()
379
380
        # Other actions with key > 255 (ord will not work) and/or additional test...
381
        if self.pressedkey == ord('e') and not self.args.programs:
382
            self._handle_process_extended()
383
        elif self.pressedkey == ord('k') and not self.args.disable_cursor:
384
            self._handle_kill_process()
385
        elif self.pressedkey == curses.KEY_LEFT:
386
            self._handle_sort_left()
387
        elif self.pressedkey == curses.KEY_RIGHT:
388
            self._handle_sort_right()
389
        elif self.pressedkey == curses.KEY_UP or self.pressedkey == 65 and not self.args.disable_cursor:
390
            self._handle_cursor_up()
391
        elif self.pressedkey == curses.KEY_DOWN or self.pressedkey == 66 and not self.args.disable_cursor:
392
            self._handle_cursor_down()
393
        elif self.pressedkey == ord('\x1b') or self.pressedkey == ord('q'):
394
            self._handle_quit(return_to_browser)
395
        elif self.pressedkey == curses.KEY_F5 or self.pressedkey == 18:
396
            self._handle_refresh()
397
398
        # Return the key code
399
        return self.pressedkey
400
401
    def _handle_switch(self, hotkey):
402
        option = '_'.join(self._hotkeys[hotkey]['switch'].split('_')[1:])
403
        if self._hotkeys[hotkey]['switch'].startswith('disable_'):
404
            if getattr(self.args, self._hotkeys[hotkey]['switch']):
405
                enable(self.args, option)
406
            else:
407
                disable(self.args, option)
408
        elif self._hotkeys[hotkey]['switch'].startswith('enable_'):
409
            if getattr(self.args, self._hotkeys[hotkey]['switch']):
410
                disable(self.args, option)
411
            else:
412
                enable(self.args, option)
413
        else:
414
            setattr(
415
                self.args,
416
                self._hotkeys[hotkey]['switch'],
417
                not getattr(self.args, self._hotkeys[hotkey]['switch']),
418
            )
419
420
    def _handle_sort_key(self, hotkey):
421
        glances_processes.set_sort_key(self._hotkeys[hotkey]['sort_key'], self._hotkeys[hotkey]['sort_key'] == 'auto')
422
423
    def _handle_enter(self):
424
        self.edit_filter = not self.edit_filter
425
426
    def _handle_quicklook(self):
427
        self.args.full_quicklook = not self.args.full_quicklook
428
        if self.args.full_quicklook:
429
            self.enable_fullquicklook()
430
        else:
431
            self.disable_fullquicklook()
432
433
    def _handle_top_menu(self):
434
        self.args.disable_top = not self.args.disable_top
435
        if self.args.disable_top:
436
            self.disable_top()
437
        else:
438
            self.enable_top()
439
440
    def _handle_process_extended(self):
441
        self.args.enable_process_extended = not self.args.enable_process_extended
442
        if not self.args.enable_process_extended:
443
            glances_processes.disable_extended()
444
        else:
445
            glances_processes.enable_extended()
446
        self.args.disable_cursor = self.args.enable_process_extended and self.args.is_standalone
447
448
    def _handle_erase_filter(self):
449
        glances_processes.process_filter = None
450
451
    def _handle_fs_stats(self):
452
        self.args.disable_fs = not self.args.disable_fs
453
        self.args.disable_folders = not self.args.disable_folders
454
455
    def _handle_increase_nice(self):
456
        self.increase_nice_process = not self.increase_nice_process
457
458
    def _handle_decrease_nice(self):
459
        self.decrease_nice_process = not self.decrease_nice_process
460
461
    def _handle_kill_process(self):
462
        self.kill_process = not self.kill_process
463
464
    def _handle_clean_logs(self):
465
        glances_events.clean()
466
467
    def _handle_clean_critical_logs(self):
468
        glances_events.clean(critical=True)
469
470
    def _handle_disable_process(self):
471
        self.args.disable_process = not self.args.disable_process
472
        if self.args.disable_process:
473
            glances_processes.disable()
474
        else:
475
            glances_processes.enable()
476
477
    def _handle_sort_left(self):
478
        next_sort = (self.loop_position() - 1) % len(self._sort_loop)
479
        glances_processes.set_sort_key(self._sort_loop[next_sort], False)
480
481
    def _handle_sort_right(self):
482
        next_sort = (self.loop_position() + 1) % len(self._sort_loop)
483
        glances_processes.set_sort_key(self._sort_loop[next_sort], False)
484
485
    def _handle_cursor_up(self):
486
        if self.args.cursor_position > 0:
487
            self.args.cursor_position -= 1
488
489
    def _handle_cursor_down(self):
490
        if self.args.cursor_position < glances_processes.processes_count:
491
            self.args.cursor_position += 1
492
493
    def _handle_quit(self, return_to_browser):
494
        if return_to_browser:
495
            logger.info("Stop Glances client and return to the browser")
496
        else:
497
            logger.info("Stop Glances (keypressed: {})".format(self.pressedkey))
498
499
    def _handle_refresh(self):
500
        pass
501
502
    def loop_position(self):
503
        """Return the current sort in the loop"""
504
        for i, v in enumerate(self._sort_loop):
505
            if v == glances_processes.sort_key:
506
                return i
507
        return 0
508
509
    def disable_top(self):
510
        """Disable the top panel"""
511
        for p in ['quicklook', 'cpu', 'gpu', 'mem', 'memswap', 'load']:
512
            setattr(self.args, 'disable_' + p, True)
513
514
    def enable_top(self):
515
        """Enable the top panel"""
516
        for p in ['quicklook', 'cpu', 'gpu', 'mem', 'memswap', 'load']:
517
            setattr(self.args, 'disable_' + p, False)
518
519
    def disable_fullquicklook(self):
520
        """Disable the full quicklook mode"""
521
        for p in ['quicklook', 'cpu', 'gpu', 'mem', 'memswap']:
522
            setattr(self.args, 'disable_' + p, False)
523
524
    def enable_fullquicklook(self):
525
        """Disable the full quicklook mode"""
526
        self.args.disable_quicklook = False
527
        for p in ['cpu', 'gpu', 'mem', 'memswap']:
528
            setattr(self.args, 'disable_' + p, True)
529
530
    def end(self):
531
        """Shutdown the curses window."""
532
        if hasattr(curses, 'echo'):
533
            curses.echo()
534
        if hasattr(curses, 'nocbreak'):
535
            curses.nocbreak()
536
        try:
537
            curses.curs_set(1)
538
        except Exception:
539
            pass
540
        try:
541
            curses.endwin()
542
        except Exception:
543
            pass
544
545
    def init_line_column(self):
546
        """Init the line and column position for the curses interface."""
547
        self.init_line()
548
        self.init_column()
549
550
    def init_line(self):
551
        """Init the line position for the curses interface."""
552
        self.line = 0
553
        self.next_line = 0
554
555
    def init_column(self):
556
        """Init the column position for the curses interface."""
557
        self.column = 0
558
        self.next_column = 0
559
560
    def new_line(self, separator=False):
561
        """New line in the curses interface."""
562
        self.line = self.next_line
563
564
    def new_column(self):
565
        """New column in the curses interface."""
566
        self.column = self.next_column
567
568
    def separator_line(self, color='SEPARATOR'):
569
        """Add a separator line in the curses interface."""
570
        if not self.args.enable_separator:
571
            return
572
        self.new_line()
573
        self.line -= 1
574
        line_width = self.term_window.getmaxyx()[1] - self.column
575
        self.term_window.addnstr(
576
            self.line,
577
            self.column,
578
            unicode_message('MEDIUM_LINE', self.args) * line_width,
579
            line_width,
580
            self.colors_list[color],
581
        )
582
583
    def __get_stat_display(self, stats, layer):
584
        """Return a dict of dict with all the stats display.
585
        # TODO: Drop extra parameter
586
587
        :param stats: Global stats dict
588
        :param layer: ~ cs_status
589
            "None": standalone or server mode
590
            "Connected": Client is connected to a Glances server
591
            "SNMP": Client is connected to a SNMP server
592
            "Disconnected": Client is disconnected from the server
593
594
        :returns: dict of dict
595
            * key: plugin name
596
            * value: dict returned by the get_stats_display Plugin method
597
        """
598
        ret = {}
599
600
        for p in stats.getPluginsList(enable=False):
601
            # Compute the plugin max size for the left sidebar
602
            plugin_max_width = None
603
            if p in self._left_sidebar:
604
                plugin_max_width = min(self._left_sidebar_max_width,
605
                                       max(self._left_sidebar_min_width,
606
                                           self.term_window.getmaxyx()[1] - 105))
607
608
            # Get the view
609
            ret[p] = stats.get_plugin(p).get_stats_display(args=self.args,
610
                                                           max_width=plugin_max_width)
611
612
        return ret
613
614
    def display(self, stats, cs_status=None):
615
        """Display stats on the screen.
616
617
        :param stats: Stats database to display
618
        :param cs_status:
619
            "None": standalone or server mode
620
            "Connected": Client is connected to a Glances server
621
            "SNMP": Client is connected to a SNMP server
622
            "Disconnected": Client is disconnected from the server
623
624
        :return: True if the stats have been displayed else False if the help have been displayed
625
        """
626
        # Init the internal line/column for Glances Curses
627
        self.init_line_column()
628
629
        # Update the stats messages
630
        ###########################
631
632
        # Get all the plugins view
633
        self.args.cs_status = cs_status
634
        __stat_display = self.__get_stat_display(stats, layer=cs_status)
635
636
        # Display the stats on the curses interface
637
        ###########################################
638
639
        # Help screen (on top of the other stats)
640
        if self.args.help_tag:
641
            # Display the stats...
642
            self.display_plugin(stats.get_plugin('help').get_stats_display(args=self.args))
643
            # ... and exit
644
            return False
645
646
        # =======================================
647
        # Display first line (system+ip+uptime)
648
        # Optionally: Cloud is on the second line
649
        # =======================================
650
        self.__display_header(__stat_display)
651
        self.separator_line()
652
653
        # ==============================================================
654
        # Display second line (<SUMMARY>+CPU|PERCPU+<GPU>+LOAD+MEM+SWAP)
655
        # ==============================================================
656
        self.__display_top(__stat_display, stats)
657
        self.init_column()
658
        self.separator_line()
659
660
        # ==================================================================
661
        # Display left sidebar (NETWORK+PORTS+DISKIO+FS+SENSORS+Current time)
662
        # ==================================================================
663
        self.__display_left(__stat_display)
664
665
        # ====================================
666
        # Display right stats (process and co)
667
        # ====================================
668
        self.__display_right(__stat_display)
669
670
        # =====================
671
        # Others popup messages
672
        # =====================
673
674
        # Display edit filter popup
675
        # Only in standalone mode (cs_status is None)
676
        if self.edit_filter and cs_status is None:
677
            new_filter = self.display_popup(
678
                'Process filter pattern: \n\n'
679
                + 'Examples:\n'
680
                + '- .*python.*\n'
681
                + '- /usr/lib.*\n'
682
                + '- name:.*nautilus.*\n'
683
                + '- cmdline:.*glances.*\n'
684
                + '- username:nicolargo\n'
685
                + '- username:^root        ',
686
                popup_type='input',
687
                input_value=glances_processes.process_filter_input,
688
            )
689
            glances_processes.process_filter = new_filter
690
        elif self.edit_filter and cs_status is not None:
691
            self.display_popup('Process filter only available in standalone mode')
692
        self.edit_filter = False
693
694
        # Manage increase/decrease nice level of the selected process
695
        # Only in standalone mode (cs_status is None)
696
        if self.increase_nice_process and cs_status is None:
697
            self.nice_increase(stats.get_plugin('processlist').get_raw()[self.args.cursor_position])
698
        self.increase_nice_process = False
699
        if self.decrease_nice_process and cs_status is None:
700
            self.nice_decrease(stats.get_plugin('processlist').get_raw()[self.args.cursor_position])
701
        self.decrease_nice_process = False
702
703
        # Display kill process confirmation popup
704
        # Only in standalone mode (cs_status is None)
705
        if self.kill_process and cs_status is None:
706
            self.kill(stats.get_plugin('processlist').get_raw()[self.args.cursor_position])
707
        elif self.kill_process and cs_status is not None:
708
            self.display_popup('Kill process only available for local processes')
709
        self.kill_process = False
710
711
        # Display graph generation popup
712
        if self.args.generate_graph:
713
            if 'graph' in stats.getExportsList():
714
                self.display_popup('Generate graph in {}'.format(self.args.export_graph_path))
715
            else:
716
                logger.warning('Graph export module is disable. Run Glances with --export graph to enable it.')
717
                self.args.generate_graph = False
718
719
        return True
720
721
    def nice_increase(self, process):
722
        glances_processes.nice_increase(process['pid'])
723
724
    def nice_decrease(self, process):
725
        glances_processes.nice_decrease(process['pid'])
726
727
    def kill(self, process):
728
        """Kill a process, or a list of process if the process has a childrens field.
729
730
        :param process
731
        :return: None
732
        """
733
        logger.debug("Selected process to kill: {}".format(process))
734
735
        if 'childrens' in process:
736
            pid_to_kill = process['childrens']
737
        else:
738
            pid_to_kill = [process['pid']]
739
740
        confirm = self.display_popup(
741
            'Kill process: {} (pid: {}) ?\n\nConfirm ([y]es/[n]o): '.format(
742
                process['name'],
743
                ', '.join(map(str, pid_to_kill)),
744
            ),
745
            popup_type='yesno',
746
        )
747
748
        if confirm.lower().startswith('y'):
749
            for pid in pid_to_kill:
750
                try:
751
                    ret_kill = glances_processes.kill(pid)
752
                except Exception as e:
753
                    logger.error('Can not kill process {} ({})'.format(pid, e))
754
                else:
755
                    logger.info('Kill signal has been sent to process {} (return code: {})'.format(pid, ret_kill))
756
757
    def __display_header(self, stat_display):
758
        """Display the firsts lines (header) in the Curses interface.
759
760
        system + ip + uptime
761
        (cloud)
762
        """
763
        # First line
764
        self.new_line()
765
        self.space_between_column = 0
766
        l_uptime = 1
767
        for i in ['system', 'ip', 'uptime']:
768
            if i in stat_display:
769
                l_uptime += self.get_stats_display_width(stat_display[i])
770
        self.display_plugin(stat_display["system"], display_optional=(self.term_window.getmaxyx()[1] >= l_uptime))
771
        self.space_between_column = 3
772
        if 'ip' in stat_display:
773
            self.new_column()
774
            self.display_plugin(stat_display["ip"], display_optional=(self.term_window.getmaxyx()[1] >= 100))
775
        self.new_column()
776
        self.display_plugin(
777
            stat_display["uptime"], add_space=-(self.get_stats_display_width(stat_display["cloud"]) != 0)
778
        )
779
        self.init_column()
780
        if self.get_stats_display_width(stat_display["cloud"]) != 0:
781
            # Second line (optional)
782
            self.new_line()
783
            self.display_plugin(stat_display["cloud"])
784
785
    def __display_top(self, stat_display, stats):
786
        """Display the second line in the Curses interface.
787
788
        <QUICKLOOK> + CPU|PERCPU + <GPU> + MEM + SWAP + LOAD
789
        """
790
        self.init_column()
791
        self.new_line()
792
793
        # Init quicklook
794
        stat_display['quicklook'] = {'msgdict': []}
795
796
        # Dict for plugins width
797
        plugin_widths = {}
798
        for p in self._top:
799
            plugin_widths[p] = (
800
                self.get_stats_display_width(stat_display.get(p, 0)) if hasattr(self.args, 'disable_' + p) else 0
801
            )
802
803
        # Width of all plugins
804
        stats_width = sum(itervalues(plugin_widths))
805
806
        # Number of plugin but quicklook
807
        stats_number = sum(
808
            [int(stat_display[p]['msgdict'] != []) for p in self._top if not getattr(self.args, 'disable_' + p)]
809
        )
810
811
        if not self.args.disable_quicklook:
812
            # Quick look is in the place !
813
            if self.args.full_quicklook:
814
                quicklook_width = self.term_window.getmaxyx()[1] - (
815
                    stats_width + 8 + stats_number * self.space_between_column
816
                )
817
            else:
818
                quicklook_width = min(
819
                    self.term_window.getmaxyx()[1] - (stats_width + 8 + stats_number * self.space_between_column),
820
                    self._quicklook_max_width - 5,
821
                )
822
            try:
823
                stat_display["quicklook"] = stats.get_plugin('quicklook').get_stats_display(
824
                    max_width=quicklook_width, args=self.args
825
                )
826
            except AttributeError as e:
827
                logger.debug("Quicklook plugin not available (%s)" % e)
828
            else:
829
                plugin_widths['quicklook'] = self.get_stats_display_width(stat_display["quicklook"])
830
                stats_width = sum(itervalues(plugin_widths)) + 1
831
            self.space_between_column = 1
832
            self.display_plugin(stat_display["quicklook"])
833
            self.new_column()
834
835
        # Compute spaces between plugins
836
        # Note: Only one space between Quicklook and others
837
        plugin_display_optional = {}
838
        for p in self._top:
839
            plugin_display_optional[p] = True
840
        if stats_number > 1:
841
            self.space_between_column = max(1, int((self.term_window.getmaxyx()[1] - stats_width) / (stats_number - 1)))
842
            for p in ['mem', 'cpu']:
843
                # No space ? Remove optional stats
844
                if self.space_between_column < 3:
845
                    plugin_display_optional[p] = False
846
                    plugin_widths[p] = (
847
                        self.get_stats_display_width(stat_display[p], without_option=True)
848
                        if hasattr(self.args, 'disable_' + p)
849
                        else 0
850
                    )
851
                    stats_width = sum(itervalues(plugin_widths)) + 1
852
                    self.space_between_column = max(
853
                        1, int((self.term_window.getmaxyx()[1] - stats_width) / (stats_number - 1))
854
                    )
855
        else:
856
            self.space_between_column = 0
857
858
        # Display CPU, MEM, SWAP and LOAD
859
        for p in self._top:
860
            if p == 'quicklook':
861
                continue
862
            if p in stat_display:
863
                self.display_plugin(stat_display[p], display_optional=plugin_display_optional[p])
864
            if p != 'load':
865
                # Skip last column
866
                self.new_column()
867
868
        # Space between column
869
        self.space_between_column = 3
870
871
        # Backup line position
872
        self.saved_line = self.next_line
873
874
    def __display_left(self, stat_display):
875
        """Display the left sidebar in the Curses interface."""
876
        self.init_column()
877
878
        if self.args.disable_left_sidebar:
879
            return
880
881
        for p in self._left_sidebar:
882 View Code Duplication
            if (hasattr(self.args, 'enable_' + p) or hasattr(self.args, 'disable_' + p)) and p in stat_display:
883
                self.new_line()
884
                if p == 'sensors':
885
                    self.display_plugin(
886
                        stat_display['sensors'],
887
                        max_y=(
888
                            self.term_window.getmaxyx()[0] - self.get_stats_display_height(stat_display['now']) - 2
889
                        ),
890
                    )
891
                else:
892
                    self.display_plugin(stat_display[p])
893
894
    def __display_right(self, stat_display):
895
        """Display the right sidebar in the Curses interface.
896
897
        docker + processcount + amps + processlist + alert
898
        """
899
        # Do not display anything if space is not available...
900
        if self.term_window.getmaxyx()[1] < self._left_sidebar_min_width:
901
            return
902
903
        # Restore line position
904
        self.next_line = self.saved_line
905
906
        # Display right sidebar
907
        self.new_column()
908
        for p in self._right_sidebar:
909 View Code Duplication
            if (hasattr(self.args, 'enable_' + p) or hasattr(self.args, 'disable_' + p)) and p in stat_display:
910
                self.new_line()
911
                if p == 'processlist':
912
                    self.display_plugin(
913
                        stat_display['processlist'],
914
                        display_optional=(self.term_window.getmaxyx()[1] > 102),
915
                        display_additional=(not MACOS),
916
                        max_y=(
917
                            self.term_window.getmaxyx()[0] - self.get_stats_display_height(stat_display['alert']) - 2
918
                        ),
919
                    )
920
                else:
921
                    self.display_plugin(stat_display[p])
922
923
    def display_popup(
924
        self, message, size_x=None, size_y=None, duration=3, popup_type='info', input_size=30, input_value=None
925
    ):
926
        """
927
        Display a centered popup.
928
929
         popup_type: ='info'
930
         Just an information popup, no user interaction
931
         Display a centered popup with the given message during duration seconds
932
         If size_x and size_y: set the popup size
933
         else set it automatically
934
         Return True if the popup could be displayed
935
936
        popup_type='input'
937
         Display a centered popup with the given message and a input field
938
         If size_x and size_y: set the popup size
939
         else set it automatically
940
         Return the input string or None if the field is empty
941
942
        popup_type='yesno'
943
         Display a centered popup with the given message
944
         If size_x and size_y: set the popup size
945
         else set it automatically
946
         Return True (yes) or False (no)
947
        """
948
        # Center the popup
949
        sentence_list = message.split('\n')
950
        if size_x is None:
951
            size_x = len(max(sentence_list, key=len)) + 4
952
            # Add space for the input field
953
            if popup_type == 'input':
954
                size_x += input_size
955
        if size_y is None:
956
            size_y = len(sentence_list) + 4
957
        screen_x = self.term_window.getmaxyx()[1]
958
        screen_y = self.term_window.getmaxyx()[0]
959
        if size_x > screen_x or size_y > screen_y:
960
            # No size to display the popup => abord
961
            return False
962
        pos_x = int((screen_x - size_x) / 2)
963
        pos_y = int((screen_y - size_y) / 2)
964
965
        # Create the popup
966
        popup = curses.newwin(size_y, size_x, pos_y, pos_x)
967
968
        # Fill the popup
969
        popup.border()
970
971
        # Add the message
972
        for y, m in enumerate(sentence_list):
973
            if len(m) > 0:
974
                popup.addnstr(2 + y, 2, m, len(m))
975
976
        if popup_type == 'info':
977
            # Display the popup
978
            popup.refresh()
979
            self.wait(duration * 1000)
980
            return True
981
        elif popup_type == 'input':
982
            # Create a sub-window for the text field
983
            sub_pop = popup.derwin(1, input_size, 2, 2 + len(m))
0 ignored issues
show
The variable m does not seem to be defined in case the for loop on line 972 is not entered. Are you sure this can never be the case?
Loading history...
984
            sub_pop.attron(self.colors_list['FILTER'])
985
            # Init the field with the current value
986
            if input_value is not None:
987
                sub_pop.addnstr(0, 0, input_value, len(input_value))
988
            # Display the popup
989
            popup.refresh()
990
            sub_pop.refresh()
991
            # Create the textbox inside the sub-windows
992
            self.set_cursor(2)
993
            self.term_window.keypad(1)
994
            textbox = GlancesTextbox(sub_pop, insert_mode=True)
995
            textbox.edit()
996
            self.set_cursor(0)
997
            # self.term_window.keypad(0)
998
            if textbox.gather() != '':
999
                logger.debug("User enters the following string: %s" % textbox.gather())
1000
                return textbox.gather()[:-1]
1001
            else:
1002
                logger.debug("User enters an empty string")
1003
                return None
1004
        elif popup_type == 'yesno':
1005
            # # Create a sub-window for the text field
1006
            sub_pop = popup.derwin(1, 2, len(sentence_list) + 1, len(m) + 2)
1007
            sub_pop.attron(self.colors_list['FILTER'])
1008
            # Init the field with the current value
1009
            sub_pop.addnstr(0, 0, '', 0)
1010
            # Display the popup
1011
            popup.refresh()
1012
            sub_pop.refresh()
1013
            # Create the textbox inside the sub-windows
1014
            self.set_cursor(2)
1015
            self.term_window.keypad(1)
1016
            textbox = GlancesTextboxYesNo(sub_pop, insert_mode=False)
1017
            textbox.edit()
1018
            self.set_cursor(0)
1019
            # self.term_window.keypad(0)
1020
            return textbox.gather()
1021
1022
    def display_plugin(self, plugin_stats, display_optional=True, display_additional=True, max_y=65535, add_space=0):
1023
        """Display the plugin_stats on the screen.
1024
1025
        :param plugin_stats:
1026
        :param display_optional: display the optional stats if True
1027
        :param display_additional: display additional stats if True
1028
        :param max_y: do not display line > max_y
1029
        :param add_space: add x space (line) after the plugin
1030
        """
1031
        # Exit if:
1032
        # - the plugin_stats message is empty
1033
        # - the display tag = False
1034
        if plugin_stats is None or not plugin_stats['msgdict'] or not plugin_stats['display']:
1035
            # Exit
1036
            return 0
1037
1038
        # Get the screen size
1039
        screen_x = self.term_window.getmaxyx()[1]
1040
        screen_y = self.term_window.getmaxyx()[0]
1041
1042
        # Set the upper/left position of the message
1043
        if plugin_stats['align'] == 'right':
1044
            # Right align (last column)
1045
            display_x = screen_x - self.get_stats_display_width(plugin_stats)
1046
        else:
1047
            display_x = self.column
1048
        if plugin_stats['align'] == 'bottom':
1049
            # Bottom (last line)
1050
            display_y = screen_y - self.get_stats_display_height(plugin_stats)
1051
        else:
1052
            display_y = self.line
1053
1054
        # Display
1055
        x = display_x
1056
        x_max = x
1057
        y = display_y
1058
        for m in plugin_stats['msgdict']:
1059
            # New line
1060
            try:
1061
                if m['msg'].startswith('\n'):
1062
                    # Go to the next line
1063
                    y += 1
1064
                    # Return to the first column
1065
                    x = display_x
1066
                    continue
1067
            except Exception:
1068
                # Avoid exception (see issue #1692)
1069
                pass
1070
            # Do not display outside the screen
1071
            if x < 0:
1072
                continue
1073
            if not m['splittable'] and (x + len(m['msg']) > screen_x):
1074
                continue
1075
            if y < 0 or (y + 1 > screen_y) or (y > max_y):
1076
                break
1077
            # If display_optional = False do not display optional stats
1078
            if not display_optional and m['optional']:
1079
                continue
1080
            # If display_additional = False do not display additional stats
1081
            if not display_additional and m['additional']:
1082
                continue
1083
            # Is it possible to display the stat with the current screen size
1084
            # !!! Crash if not try/except... Why ???
1085
            try:
1086
                self.term_window.addnstr(
1087
                    y,
1088
                    x,
1089
                    m['msg'],
1090
                    # Do not display outside the screen
1091
                    screen_x - x,
1092
                    self.colors_list[m['decoration']],
1093
                )
1094
            except Exception:
1095
                pass
1096
            else:
1097
                # New column
1098
                # Python 2: we need to decode to get real screen size because
1099
                # UTF-8 special tree chars occupy several bytes.
1100
                # Python 3: strings are strings and bytes are bytes, all is
1101
                # good.
1102
                try:
1103
                    x += len(u(m['msg']))
1104
                except UnicodeDecodeError:
1105
                    # Quick and dirty hack for issue #745
1106
                    pass
1107
                if x > x_max:
1108
                    x_max = x
1109
1110
        # Compute the next Glances column/line position
1111
        self.next_column = max(self.next_column, x_max + self.space_between_column)
1112
        self.next_line = max(self.next_line, y + self.space_between_line)
1113
1114
        # Have empty lines after the plugins
1115
        self.next_line += add_space
1116
1117
    def clear(self):
1118
        """Erase the content of the screen.
1119
        The difference is that clear() also calls clearok(). clearok()
1120
        basically tells ncurses to forget whatever it knows about the current
1121
        terminal contents, so that when refresh() is called, it will actually
1122
        begin by clearing the entire terminal screen before redrawing any of it."""
1123
        self.term_window.clear()
1124
1125
    def erase(self):
1126
        """Erase the content of the screen.
1127
        erase() on the other hand, just clears the screen (the internal
1128
        object, not the terminal screen). When refresh() is later called,
1129
        ncurses will still compute the minimum number of characters to send to
1130
        update the terminal."""
1131
        self.term_window.erase()
1132
1133
    def flush(self, stats, cs_status=None):
1134
        """Erase and update the screen.
1135
1136
        :param stats: Stats database to display
1137
        :param cs_status:
1138
            "None": standalone or server mode
1139
            "Connected": Client is connected to the server
1140
            "Disconnected": Client is disconnected from the server
1141
        """
1142
        self.erase()
1143
        self.display(stats, cs_status=cs_status)
1144
1145
    def update(self, stats, duration=3, cs_status=None, return_to_browser=False):
1146
        """Update the screen.
1147
1148
        :param stats: Stats database to display
1149
        :param duration: duration of the loop
1150
        :param cs_status:
1151
            "None": standalone or server mode
1152
            "Connected": Client is connected to the server
1153
            "Disconnected": Client is disconnected from the server
1154
        :param return_to_browser:
1155
            True: Do not exist, return to the browser list
1156
            False: Exit and return to the shell
1157
1158
        :return: True if exit key has been pressed else False
1159
        """
1160
        # Flush display
1161
        self.flush(stats, cs_status=cs_status)
1162
1163
        # If the duration is < 0 (update + export time > refresh_time)
1164
        # Then display the interface and log a message
1165
        if duration <= 0:
1166
            logger.warning('Update and export time higher than refresh_time.')
1167
            duration = 0.1
1168
1169
        # Wait duration (in s) time
1170
        isexitkey = False
1171
        countdown = Timer(duration)
1172
        # Set the default timeout (in ms) between two getch
1173
        self.term_window.timeout(100)
1174
        while not countdown.finished() and not isexitkey:
1175
            # Getkey
1176
            pressedkey = self.__catch_key(return_to_browser=return_to_browser)
1177
            isexitkey = pressedkey == ord('\x1b') or pressedkey == ord('q')
1178
1179
            if pressedkey == curses.KEY_F5 or self.pressedkey == 18:
1180
                # Were asked to refresh (F5 or Ctrl-R)
1181
                self.clear()
1182
                return isexitkey
1183
1184
            if pressedkey in (curses.KEY_UP, 65, curses.KEY_DOWN, 66):
1185
                # Up of won key pressed, reset the countdown
1186
                # Better for user experience
1187
                countdown.reset()
1188
1189
            if isexitkey and self.args.help_tag:
1190
                # Quit from help should return to main screen, not exit #1874
1191
                self.args.help_tag = not self.args.help_tag
1192
                isexitkey = False
1193
                return isexitkey
1194
1195
            if not isexitkey and pressedkey > -1:
1196
                # Redraw display
1197
                self.flush(stats, cs_status=cs_status)
1198
                # Overwrite the timeout with the countdown
1199
                self.wait(delay=int(countdown.get() * 1000))
1200
1201
        return isexitkey
1202
1203
    def wait(self, delay=100):
1204
        """Wait delay in ms"""
1205
        curses.napms(delay)
1206
1207
    def get_stats_display_width(self, curse_msg, without_option=False):
1208
        """Return the width of the formatted curses message."""
1209
        try:
1210
            if without_option:
1211
                # Size without options
1212
                c = len(
1213
                    max(
1214
                        ''.join(
1215
                            [
1216
                                (u(u(nativestr(i['msg'])).encode('ascii', 'replace')) if not i['optional'] else "")
1217
                                for i in curse_msg['msgdict']
1218
                            ]
1219
                        ).split('\n'),
1220
                        key=len,
1221
                    )
1222
                )
1223
            else:
1224
                # Size with all options
1225
                c = len(
1226
                    max(
1227
                        ''.join(
1228
                            [u(u(nativestr(i['msg'])).encode('ascii', 'replace')) for i in curse_msg['msgdict']]
1229
                        ).split('\n'),
1230
                        key=len,
1231
                    )
1232
                )
1233
        except Exception as e:
1234
            logger.debug('ERROR: Can not compute plugin width ({})'.format(e))
1235
            return 0
1236
        else:
1237
            return c
1238
1239
    def get_stats_display_height(self, curse_msg):
1240
        """Return the height of the formatted curses message.
1241
1242
        The height is defined by the number of '\n' (new line).
1243
        """
1244
        try:
1245
            c = [i['msg'] for i in curse_msg['msgdict']].count('\n')
1246
        except Exception as e:
1247
            logger.debug('ERROR: Can not compute plugin height ({})'.format(e))
1248
            return 0
1249
        else:
1250
            return c + 1
1251
1252
1253
class GlancesCursesStandalone(_GlancesCurses):
1254
1255
    """Class for the Glances curse standalone."""
1256
1257
1258
class GlancesCursesClient(_GlancesCurses):
1259
1260
    """Class for the Glances curse client."""
1261
1262
1263
class GlancesTextbox(Textbox, object):
1264
    def __init__(self, *args, **kwargs):
1265
        super(GlancesTextbox, self).__init__(*args, **kwargs)
1266
1267
    def do_command(self, ch):
1268
        if ch == 10:  # Enter
1269
            return 0
1270
        if ch == 127:  # Back
1271
            return 8
1272
        return super(GlancesTextbox, self).do_command(ch)
1273
1274
1275
class GlancesTextboxYesNo(Textbox, object):
1276
    def __init__(self, *args, **kwargs):
1277
        super(GlancesTextboxYesNo, self).__init__(*args, **kwargs)
1278
1279
    def do_command(self, ch):
1280
        return super(GlancesTextboxYesNo, self).do_command(ch)
1281