_GlancesCurses._handle_process_extended()   A
last analyzed

Complexity

Conditions 2

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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