_GlancesCurses.get_key()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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