Test Failed
Push — develop ( c8ed97...067eb9 )
by Nicolas
02:40
created

_GlancesCurses.update()   D

Complexity

Conditions 12

Size

Total Lines 61
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 12
eloc 26
nop 5
dl 0
loc 61
rs 4.8
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

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