Test Failed
Push — develop ( 5f3e1d...89e9ad )
by Nicolas
02:49 queued 17s
created

_GlancesCurses._handle_diskio_latency()   A

Complexity

Conditions 2

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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