Issues (51)

glances/outputs/glances_curses.py (4 issues)

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