glances.outputs.glances_curses   F
last analyzed

Complexity

Total Complexity 221

Size/Duplication

Total Lines 1230
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 717
dl 0
loc 1230
rs 1.883
c 0
b 0
f 0
wmc 221

69 Methods

Rating   Name   Duplication   Size   Complexity  
A _GlancesCurses.__init__() 0 65 4
B _GlancesCurses.catch_actions_from_hotkey() 0 8 7
A _GlancesCurses._right_sidebar() 0 8 2
A _GlancesCurses._init_cursor() 0 8 3
A _GlancesCurses._init_history() 0 4 1
A _GlancesCurses.get_key() 0 3 1
A _GlancesCurses.load_config() 0 12 3
A _GlancesCurses.set_cursor() 0 12 3
A _GlancesCurses.new_column() 0 3 1
A _GlancesCurses._handle_clean_logs() 0 2 1
A _GlancesCurses._handle_sort_left() 0 3 1
A _GlancesCurses._handle_cursor_up() 0 3 2
A _GlancesCurses.enable_fullquicklook() 0 5 2
A _GlancesCurses.nice_increase() 0 2 1
A _GlancesCurses.disable_top() 0 4 2
A _GlancesCurses._handle_sort_key() 0 2 1
C _GlancesCurses.update() 0 56 11
A _GlancesCurses._handle_quicklook() 0 6 2
A _GlancesCurses.end() 0 14 5
A _GlancesCurses.__get_stat_display() 0 34 4
A _GlancesCurses._handle_disable_process() 0 6 2
A _GlancesCurses.display_stats_with_current_size() 0 9 1
A _GlancesCurses._handle_quit() 0 5 2
A _GlancesCurses.flush() 0 13 1
B _GlancesCurses.get_stats_display_width() 0 31 5
F _GlancesCurses.display() 0 106 16
A _GlancesCurses.nice_decrease() 0 2 1
A _GlancesCurses.catch_other_actions_maybe_return_to_browser() 0 11 2
A _GlancesCurses.erase() 0 7 1
A GlancesTextboxYesNo.__init__() 0 2 1
A _GlancesCurses.setup_upper_left_pos() 0 16 3
A _GlancesCurses.new_line() 0 3 1
A _GlancesCurses._handle_erase_filter() 0 2 1
A _GlancesCurses.refresh() 0 3 1
A _GlancesCurses.clear() 0 7 1
A _GlancesCurses.get_next_x_and_x_max() 0 15 3
F _GlancesCurses.display_popup() 0 119 16
A _GlancesCurses.init_column() 0 4 1
A _GlancesCurses.get_stats_display_height() 0 12 3
B _GlancesCurses.__display_left() 0 17 7
A _GlancesCurses._handle_kill_process() 0 2 1
A _GlancesCurses._handle_cursor_down() 0 3 2
A _GlancesCurses._handle_clean_critical_logs() 0 2 1
A _GlancesCurses.init_line() 0 4 1
A _GlancesCurses.wait() 0 3 1
F _GlancesCurses.__display_top() 0 88 16
A _GlancesCurses.disable_fullquicklook() 0 4 2
A _GlancesCurses._handle_refresh() 0 2 1
A GlancesTextbox.do_command() 0 6 3
B _GlancesCurses.__display_right() 0 36 7
A _GlancesCurses.init_line_column() 0 4 1
A _GlancesCurses._handle_process_extended() 0 7 2
A _GlancesCurses._handle_increase_nice() 0 2 1
A _GlancesCurses.__catch_key() 0 15 2
A _GlancesCurses.enable_top() 0 4 2
A _GlancesCurses.loop_position() 0 6 3
B _GlancesCurses.kill() 0 29 6
A _GlancesCurses._handle_decrease_nice() 0 2 1
A _GlancesCurses._handle_sort_right() 0 3 1
A _GlancesCurses.separator_line() 0 16 5
A GlancesTextboxYesNo.do_command() 0 2 1
B _GlancesCurses.__display_header() 0 26 5
C _GlancesCurses.display_stats() 0 34 11
A GlancesTextbox.__init__() 0 2 1
A _GlancesCurses._handle_switch() 0 17 5
A _GlancesCurses._handle_fs_stats() 0 3 1
C _GlancesCurses.display_plugin() 0 41 9
A _GlancesCurses._handle_top_menu() 0 6 2
A _GlancesCurses._handle_enter() 0 2 1

How to fix   Complexity   

Complexity

Complex classes like glances.outputs.glances_curses often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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