Test Failed
Push — master ( cc9054...a8608f )
by Nicolas
03:40
created

_GlancesCurses.display_plugin()   C

Complexity

Conditions 9

Size

Total Lines 41
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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