Issues (48)

glances/outputs/glances_curses.py (1 issue)

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