Test Failed
Pull Request — develop (#2900)
by
unknown
02:07
created

_GlancesCurses.get_stats_display_height()   A

Complexity

Conditions 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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