Test Failed
Pull Request — develop (#2932)
by
unknown
02:14
created

_GlancesCurses.x_overbound()   A

Complexity

Conditions 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nop 3
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
#
2
# This file is part of Glances.
3
#
4
# SPDX-FileCopyrightText: 2024 Nicolas Hennion <[email protected]>
5
#
6
# SPDX-License-Identifier: LGPL-3.0-only
7
#
8
9
"""Curses interface class."""
10
11
import getpass
12
import sys
13
14
from glances.events_list import glances_events
15
from glances.globals import MACOS, WINDOWS, disable, enable, itervalues, nativestr, u
16
from glances.logger import logger
17
from glances.outputs.glances_colors import GlancesColors
18
from glances.outputs.glances_unicode import unicode_message
19
from glances.processes import glances_processes, sort_processes_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
            sub_pop.addnstr(0, 0, '', 0)
911
            # Display the popup
912
            popup.refresh()
913
            sub_pop.refresh()
914
            # Create the textbox inside the sub-windows
915
            self.set_cursor(2)
916
            self.term_window.keypad(1)
917
            textbox = GlancesTextboxYesNo(sub_pop, insert_mode=False)
918
            textbox.edit()
919
            self.set_cursor(0)
920
            # self.term_window.keypad(0)
921
            return textbox.gather()
922
923
        return None
924
925
    def set_upper_left_pos(self, plugin_stats):
926
        screen_x = self.term_window.getmaxyx()[1]
927
        screen_y = self.term_window.getmaxyx()[0]
928
929
        if plugin_stats['align'] == 'right':
930
            # Right align (last column)
931
            display_x = screen_x - self.get_stats_display_width(plugin_stats)
932
        else:
933
            display_x = self.column
934
        if plugin_stats['align'] == 'bottom':
935
            # Bottom (last line)
936
            display_y = screen_y - self.get_stats_display_height(plugin_stats)
937
        else:
938
            display_y = self.line
939
940
        return display_y, display_x
941
942
    def check_opt_and_add(self, display_optional, display_additional):
943
        def neither_optional_nor_additional(m):
944
            has_optional = not display_optional and m['optional']
945
            has_additional = not display_additional and m['additional']
946
947
            return any([has_optional, has_additional])
948
949
        return neither_optional_nor_additional
950
951
    def display_plugin(self, plugin_stats, display_optional=True, display_additional=True, max_y=65535, add_space=0):
952
        """Display the plugin_stats on the screen.
953
954
        :param plugin_stats:
955
        :param display_optional: display the optional stats if True
956
        :param display_additional: display additional stats if True
957
        :param max_y: do not display line > max_y
958
        :param add_space: add x space (line) after the plugin
959
        """
960
        # Exit if:
961
        # - the plugin_stats message is empty
962
        # - the display tag = False
963
        conditions = [plugin_stats is None, not plugin_stats['msgdict'], not plugin_stats['display']]
964
        if any(conditions):
965
            # Exit
966
            return 0
967
968
        # Set the upper/left position of the message
969
        display_y, display_x = self.set_upper_left_pos(plugin_stats)
970
971
        helper = {
972
            'goto next and ret first col': self.goto_next_and_ret_first_col(display_x),
973
            'neither opt nor add?': self.check_opt_and_add(display_optional, display_additional),
974
            'x overbound?': self.x_overbound,
975
            'y overbound?': self.y_overbound(max_y),
976
        }
977
978
        init = {'x': display_x, 'x max': display_x, 'y': display_y}
979
        y, x, x_max = self.display_msg(plugin_stats, init, helper)
980
981
        # Compute the next Glances column/line position
982
        self.next_column = max(self.next_column, x_max + self.space_between_column)
983
        self.next_line = max(self.next_line, y + self.space_between_line)
984
985
        # Have empty lines after the plugins
986
        self.next_line += add_space
987
        return None
988
989
    def y_overbound(self, max_y):
990
        screen_y = self.term_window.getmaxyx()[0]
991
992
        return lambda y: y < 0 or (y + 1 > screen_y) or (y > max_y)
993
994
    def x_overbound(self, m, x):
995
        screen_x = self.term_window.getmaxyx()[1]
996
997
        return x < 0 or not m['splittable'] and (x + len(m['msg']) > screen_x)
998
999
    def goto_next_and_ret_first_col(self, display_x):
1000
        return lambda y: (y + 1, display_x)
1001
1002
    def display_msg(self, plugin_stats, init, helper):
1003
        y, x, x_max = init['y'], init['x'], init['x max']
1004
        for m in plugin_stats['msgdict']:
1005
            # New line
1006
            try:
1007
                if m['msg'].startswith('\n'):
1008
                    y, x = helper['goto next and ret first col'](y)
1009
                    continue
1010
            except Exception:
1011
                # Avoid exception (see issue #1692)
1012
                pass
1013
            if helper['x overbound?'](m, x) or helper['neither opt nor add?'](m):
1014
                continue
1015
            if helper['y overbound?'](y):
1016
                break
1017
            x, x_max = self.display_stats_with_current_size(m, y, x, x_max)
1018
1019
        return y, x, x_max
1020
1021
    def display_stats_with_current_size(self, m, y, x, x_max):
1022
        # Is it possible to display the stat with the current screen size
1023
        # !!! Crash if not try/except... Why ???
1024
        screen_x = self.term_window.getmaxyx()[1]
1025
        try:
1026
            self.term_window.addnstr(
1027
                y,
1028
                x,
1029
                m['msg'],
1030
                # Do not display outside the screen
1031
                screen_x - x,
1032
                self.colors_list[m['decoration']],
1033
            )
1034
        except Exception:
1035
            pass
1036
        else:
1037
            return self.add_new_colum(m, x, x_max)
1038
1039
    def add_new_colum(self, m, x, x_max):
1040
        # New column
1041
        # Python 2: we need to decode to get real screen size because
1042
        # UTF-8 special tree chars occupy several bytes.
1043
        # Python 3: strings are strings and bytes are bytes, all is
1044
        # good.
1045
        try:
1046
            x += len(u(m['msg']))
1047
        except UnicodeDecodeError:
1048
            # Quick and dirty hack for issue #745
1049
            pass
1050
        if x > x_max:
1051
            x_max = x
1052
1053
        return x, x_max
1054
1055
    def clear(self):
1056
        """Erase the content of the screen.
1057
        The difference is that clear() also calls clearok(). clearok()
1058
        basically tells ncurses to forget whatever it knows about the current
1059
        terminal contents, so that when refresh() is called, it will actually
1060
        begin by clearing the entire terminal screen before redrawing any of it."""
1061
        self.term_window.clear()
1062
1063
    def erase(self):
1064
        """Erase the content of the screen.
1065
        erase() on the other hand, just clears the screen (the internal
1066
        object, not the terminal screen). When refresh() is later called,
1067
        ncurses will still compute the minimum number of characters to send to
1068
        update the terminal."""
1069
        self.term_window.erase()
1070
1071
    def refresh(self):
1072
        """Refresh the windows"""
1073
        self.term_window.refresh()
1074
1075
    def flush(self, stats, cs_status=None):
1076
        """Erase and update the screen.
1077
1078
        :param stats: Stats database to display
1079
        :param cs_status:
1080
            "None": standalone or server mode
1081
            "Connected": Client is connected to the server
1082
            "Disconnected": Client is disconnected from the server
1083
        """
1084
        # See https://stackoverflow.com/a/43486979/1919431
1085
        self.erase()
1086
        self.display(stats, cs_status=cs_status)
1087
        self.refresh()
1088
1089
    def update(self, stats, duration=3, cs_status=None, return_to_browser=False):
1090
        """Update the screen.
1091
1092
        :param stats: Stats database to display
1093
        :param duration: duration of the loop
1094
        :param cs_status:
1095
            "None": standalone or server mode
1096
            "Connected": Client is connected to the server
1097
            "Disconnected": Client is disconnected from the server
1098
        :param return_to_browser:
1099
            True: Do not exist, return to the browser list
1100
            False: Exit and return to the shell
1101
1102
        :return: True if exit key has been pressed else False
1103
        """
1104
        # Flush display
1105
        self.flush(stats, cs_status=cs_status)
1106
1107
        # If the duration is < 0 (update + export time > refresh_time)
1108
        # Then display the interface and log a message
1109
        if duration <= 0:
1110
            logger.warning('Update and export time higher than refresh_time.')
1111
            duration = 0.1
1112
1113
        # Wait duration (in s) time
1114
        isexitkey = False
1115
        countdown = Timer(duration)
1116
        # Set the default timeout (in ms) between two getch
1117
        self.term_window.timeout(100)
1118
        while not countdown.finished() and not isexitkey:
1119
            # Getkey
1120
            pressedkey = self.__catch_key(return_to_browser=return_to_browser)
1121
            isexitkey = pressedkey == ord('\x1b') or pressedkey == ord('q')
1122
1123
            if pressedkey == curses.KEY_F5 or self.pressedkey == 18:
1124
                # Were asked to refresh (F5 or Ctrl-R)
1125
                self.clear()
1126
                return isexitkey
1127
1128
            if pressedkey in (curses.KEY_UP, 65, curses.KEY_DOWN, 66):
1129
                # Up of won key pressed, reset the countdown
1130
                # Better for user experience
1131
                countdown.reset()
1132
1133
            if isexitkey and self.args.help_tag:
1134
                # Quit from help should return to main screen, not exit #1874
1135
                self.args.help_tag = not self.args.help_tag
1136
                return False
1137
1138
            if not isexitkey and pressedkey > -1:
1139
                # Redraw display
1140
                self.flush(stats, cs_status=cs_status)
1141
                # Overwrite the timeout with the countdown
1142
                self.wait(delay=int(countdown.get() * 1000))
1143
1144
        return isexitkey
1145
1146
    def wait(self, delay=100):
1147
        """Wait delay in ms"""
1148
        curses.napms(delay)
1149
1150
    def get_stats_display_width(self, curse_msg, without_option=False):
1151
        """Return the width of the formatted curses message."""
1152
        try:
1153
            if without_option:
1154
                # Size without options
1155
                c = len(
1156
                    max(
1157
                        ''.join(
1158
                            [
1159
                                (u(u(nativestr(i['msg'])).encode('ascii', 'replace')) if not i['optional'] else "")
1160
                                for i in curse_msg['msgdict']
1161
                            ]
1162
                        ).split('\n'),
1163
                        key=len,
1164
                    )
1165
                )
1166
            else:
1167
                # Size with all options
1168
                c = len(
1169
                    max(
1170
                        ''.join(
1171
                            [u(u(nativestr(i['msg'])).encode('ascii', 'replace')) for i in curse_msg['msgdict']]
1172
                        ).split('\n'),
1173
                        key=len,
1174
                    )
1175
                )
1176
        except Exception as e:
1177
            logger.debug(f'ERROR: Can not compute plugin width ({e})')
1178
            return 0
1179
        else:
1180
            return c
1181
1182
    def get_stats_display_height(self, curse_msg):
1183
        """Return the height of the formatted curses message.
1184
1185
        The height is defined by the number of '\n' (new line).
1186
        """
1187
        try:
1188
            c = [i['msg'] for i in curse_msg['msgdict']].count('\n')
1189
        except Exception as e:
1190
            logger.debug(f'ERROR: Can not compute plugin height ({e})')
1191
            return 0
1192
        else:
1193
            return c + 1
1194
1195
1196
class GlancesCursesStandalone(_GlancesCurses):
1197
    """Class for the Glances curse standalone."""
1198
1199
1200
class GlancesCursesClient(_GlancesCurses):
1201
    """Class for the Glances curse client."""
1202
1203
1204
class GlancesTextbox(Textbox):
1205
    def __init__(self, *args, **kwargs):
1206
        super().__init__(*args, **kwargs)
1207
1208
    def do_command(self, ch):
1209
        if ch == 10:  # Enter
1210
            return 0
1211
        if ch == 127:  # Back
1212
            return 8
1213
        return super().do_command(ch)
1214
1215
1216
class GlancesTextboxYesNo(Textbox):
1217
    def __init__(self, *args, **kwargs):
1218
        super().__init__(*args, **kwargs)
1219
1220
    def do_command(self, ch):
1221
        return super().do_command(ch)
1222