diff --git a/Makefile b/Makefile index 2cf40338..11a87959 100644 --- a/Makefile +++ b/Makefile @@ -281,12 +281,18 @@ docker-ubuntu-dev: ## Generate local docker image (Ubuntu dev) run: ## Start Glances in console mode (also called standalone) $(PYTHON) -m glances -C $(CONF) +run-textual: ## Start Glances in textual mode (also called standalone) + $(PYTHON) -m glances -C $(CONF) --textual + run-debug: ## Start Glances in debug console mode (also called standalone) $(PYTHON) -m glances -C $(CONF) -d run-local-conf: ## Start Glances in console mode with the system conf file $(PYTHON) -m glances +run-textual-local-conf: ## Start Glances in textual mode with the system conf file + $(PYTHON) -m glances --textual + run-local-conf-hide-public: ## Start Glances in console mode with the system conf file and hide public information $(PYTHON) -m glances --hide-public-info diff --git a/glances/main.py b/glances/main.py index c7c06317..cc1c3a32 100644 --- a/glances/main.py +++ b/glances/main.py @@ -630,6 +630,14 @@ Examples of use: default='', help='strftime format string for displaying current date in standalone mode', ) + # Experimental option to enable Textual UI + parser.add_argument( + '--textual', + action='store_true', + default=False, + dest='textual', + help='enable Textual UI (experimental)', + ) return parser diff --git a/glances/outputs/glances_textual.py b/glances/outputs/glances_textual.py new file mode 100644 index 00000000..995ad57b --- /dev/null +++ b/glances/outputs/glances_textual.py @@ -0,0 +1,42 @@ +# +# This file is part of Glances. +# +# SPDX-FileCopyrightText: 2025 Nicolas Hennion +# +# SPDX-License-Identifier: LGPL-3.0-only +# + +"""Textual interface class.""" + +from glances.outputs.tui.app import GlancesTuiApp + + +class GlancesTextualStandalone: + """This class manages the Textual user interface.""" + + def __init__(self, stats=None, config=None, args=None): + # Init config + self.config = config + + # Init args + self.args = args + + # Init stats + self.stats = stats + + # Init the textual App + self.app = self.init_app() + + def init_app(self): + """Init the Textual app.""" + return GlancesTuiApp(stats=self.stats, config=self.config, args=self.args) + + def start(self): + """Start the Textual app.""" + self.app.run() + + def end(self): + """End the Textual app.""" + # TODO: check what is done in this function + # Is it realy usefull ? + self.app.exit() diff --git a/glances/outputs/tui/app.py b/glances/outputs/tui/app.py new file mode 100644 index 00000000..23b37494 --- /dev/null +++ b/glances/outputs/tui/app.py @@ -0,0 +1,157 @@ +# +# This file is part of Glances. +# +# SPDX-FileCopyrightText: 2025 Nicolas Hennion +# +# SPDX-License-Identifier: LGPL-3.0-only +# + +"""Textual app for Glances.""" + +from time import monotonic + +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Container, Grid, VerticalScroll +from textual.reactive import reactive +from textual.widgets import Footer, Label, Placeholder + +from glances.plugins.plugin.model import fields_unit_short + + +class GlancesTuiApp(App): + CSS_PATH = "main.tcss" + # BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] + BINDINGS = [Binding(key="q", action="quit", description="Quit the app")] + + start_time = reactive(monotonic) + time = reactive(0.0) + + def __init__(self, stats=None, config=None, args=None): + super().__init__() + + # Init config + self.config = config + + # Init args + self.args = args + + # Init stats + self.stats = stats + + # Init plugins + self.plugins = {} + self.plugins["cpu"] = GlancesPlugin("cpu", stats=stats, config=config, args=args) + + def compose(self) -> ComposeResult: + # yield Header(id="header", show_clock=True) + yield Container( + Grid( + Placeholder(id="system"), + Placeholder(id="ip"), + Placeholder(id="uptime"), + id="header", + ), + Grid( + Placeholder(id="quicklook"), + self.plugins["cpu"], + Placeholder(id="gpu", classes="remove"), + Placeholder(id="mem"), + Placeholder(id="memswap"), + Placeholder(id="load"), + id="top", + ), + Grid( + VerticalScroll( + Placeholder(id="network"), + Placeholder(id="diskio"), + Placeholder(id="fs"), + Placeholder(id="sensors"), + id="sidebar", + ), + VerticalScroll( + Placeholder(id="vms"), + Placeholder(id="containers"), + Placeholder(id="processcount"), + Placeholder(id="processlist"), + id="process", + ), + id="middle", + ), + Grid( + Placeholder(id="now"), + Placeholder(id="alert"), + id="bottom", + ), + id="data", + ) + yield Footer(id="footer") + + def on_mount(self) -> None: + """Event handler called when widget is added to the app.""" + self.set_interval(1, self.update_time) + + def update_time(self) -> None: + """Method to update the time to the current time.""" + self.time = monotonic() - self.start_time + + def watch_time(self, time: float) -> None: + """Called when the time attribute changes.""" + # Start by updating Glances stats + self.stats.update() + + # Solution 1: make the update in the GlancesTuiApp class + self.query_one("#cpu").query_one("#total").update(str(self.stats.getAllAsDict()["cpu"]["total"])) + self.query_one("#cpu").query_one("#system").update(str(self.stats.getAllAsDict()["cpu"]["system"])) + # Solution 2: implement the update method in the CpuTextualPlugin class + # ... (TODO) + + +class GlancesPlugin(Container): + """Plugin to display Glances stats for most of Glances plugins. + It's a simple table with the field name and the value. + Display order: from left to right, top to bottom. + """ + + def __init__(self, plugin, stats=None, config=None, args=None): + super().__init__() + + # Init config + self.config = config + + # Init args + self.args = args + + # Init stats + self.stats = stats + + # Init plugin name (convert by default to lowercase) + self.plugin = plugin.lower() + self.plugin_description = ( + self.stats.getAllFieldsDescriptionAsDict()[self.plugin] + if self.plugin in self.stats.getAllFieldsDescriptionAsDict() + else {} + ) + + def compose(self) -> ComposeResult: + if self.plugin not in self.stats.getAllAsDict(): + # Will generate a NoMatches exception when stats are updated + # TODO: catch it in the main update loop + yield Label(f'{self.plugin.upper()} stats not available', id=f"{self.plugin}-not-available") + + with Grid(id=f"{self.plugin}"): + for field in self.stats.getAllAsDict()[f"{self.plugin}"].keys(): + # Get the field short name + if field in self.plugin_description and 'short_name' in self.plugin_description[field]: + field_name = self.plugin_description[field]['short_name'] + else: + field_name = field + yield Label(field_name, classes="name") + # Display value + yield Label('', id=field, classes="value ok") + # Display unit + if field in self.plugin_description and 'unit' in self.plugin_description[field]: + field_unit = fields_unit_short.get(self.plugin_description[field]['unit'], '') + else: + field_unit = '' + yield Label(field_unit, classes="unit ok") diff --git a/glances/outputs/tui/main.tcss b/glances/outputs/tui/main.tcss new file mode 100644 index 00000000..90b6ffd9 --- /dev/null +++ b/glances/outputs/tui/main.tcss @@ -0,0 +1,132 @@ +# Glances classes +# ############### + +Placeholder.remove { + # self.styles.display = "block" to display the placeholder from Python code + display: none; +} + +* > .sparkline--min-color { + color: $success; +} + +* > .sparkline--max-color { + color: $error; +} + +.title { + text-style: bold; +} + +.name { +} + +.value { + text-align: right; +} + +.unit { + text-align: right; + padding-right: 1; +} + +.default { +} + +.ok { + color: lime; +} + +.careful { + color: cornflowerblue; +} + +.warning { + color: orangered; + background: $background; +} + +.critical { + color: red; +} + +# Glances layout +# ############## + +#data { + layout: vertical; + height: auto; +} + +#header { + grid-size: 3 1; + height: 2; +} + +#top { + grid-size: 5 1; + height: 5; +} + +#middle { + grid-size: 4 1; +} + +#sidebar { + column-span: 1; + grid-size: 1 5; +} + +#process { + column-span: 3; +} + +#bottom { + grid-size: 4 1; + height: 1; +} + + +# Glances plugins +# ############### + +#cpu { + grid-size: 9 4; + grid-columns: 7 5 2; + width: 100%; +} + +#cpu > Label { + width: 100%; +} + +#network { + # self.styles.height = 10 # Explicit cell height can be an int + height: 5; +} + +#diskio { + height: 10; +} + +#vms { +} + +#containers{ +} + +#processcount { + height: 1; +} + +#processlist { +} + +#now { + column-span: 1; +} + +#alert { + column-span: 3; +} + diff --git a/glances/plugins/plugin/model.py b/glances/plugins/plugin/model.py index 45e9805f..335d5e11 100644 --- a/glances/plugins/plugin/model.py +++ b/glances/plugins/plugin/model.py @@ -721,10 +721,11 @@ class GlancesPluginModel: def filter_stats(self, stats): """Filter the stats to keep only the fields we want (the one defined in fields_description).""" + # Iter through the self.fields_description to keep the fields in the order define in the dict if hasattr(stats, '_asdict'): - return {k: v for k, v in stats._asdict().items() if k in self.fields_description} + return {k: stats._asdict()[k] for k in self.fields_description.keys() if k in stats._asdict()} if isinstance(stats, dict): - return {k: v for k, v in stats.items() if k in self.fields_description} + return {k: stats[k] for k in self.fields_description.keys() if k in stats} if isinstance(stats, list): return [self.filter_stats(s) for s in stats] return stats diff --git a/glances/standalone.py b/glances/standalone.py index f4e12ff0..66538b5e 100644 --- a/glances/standalone.py +++ b/glances/standalone.py @@ -20,6 +20,7 @@ from glances.outputs.glances_stdout_apidoc import GlancesStdoutApiDoc from glances.outputs.glances_stdout_csv import GlancesStdoutCsv from glances.outputs.glances_stdout_issue import GlancesStdoutIssue from glances.outputs.glances_stdout_json import GlancesStdoutJson +from glances.outputs.glances_textual import GlancesTextualStandalone from glances.processes import glances_processes from glances.stats import GlancesStats from glances.timer import Counter @@ -102,14 +103,26 @@ class GlancesStandalone: # Init screen self.screen = GlancesStdoutCsv(config=config, args=args) else: - # Default number of processes to displayed is set to 50 - glances_processes.max_processes = 50 + if args.textual: + logger.info("Experimental Textual UI") + # Default number of processes to displayed is set to 50 + glances_processes.max_processes = 50 - # Init screen - self.screen = GlancesCursesStandalone(config=config, args=args) + # Init screen + self.screen = GlancesTextualStandalone(stats=self.stats, config=config, args=args) - # If an error occur during the screen init, continue if export option is set - # It is done in the screen.init function + # If an error occur during the screen init, continue if export option is set + # It is done in the screen.init function + self._quiet = args.quiet + else: + # Default number of processes to displayed is set to 50 + glances_processes.max_processes = 50 + + # Init screen + self.screen = GlancesCursesStandalone(config=config, args=args) + + # If an error occur during the screen init, continue if export option is set + # It is done in the screen.init function self._quiet = args.quiet # Check the latest Glances version @@ -183,8 +196,13 @@ class GlancesStandalone: if self.args.stop_after: self.serve_n(n=self.args.stop_after) else: - while self.__serve_once(): - pass + if self.args.textual: + # Run the Textual app + self.screen.start() + else: + # Run the curses app + while self.__serve_once(): + pass # self.end() def end(self): @@ -195,7 +213,11 @@ class GlancesStandalone: # Exit from export modules self.stats.end() - # Check Glances version versus PyPI one + # Check if a new version is availbale + self.check_new_version() + + def check_new_version(self): + """Check Glances version versus PyPI one""" if self.outdated.is_outdated() and 'unknown' not in self.outdated.installed_version(): latest_version = self.outdated.latest_version() installed_version = self.outdated.installed_version() diff --git a/glances/stats.py b/glances/stats.py index d2cc0946..b667b739 100644 --- a/glances/stats.py +++ b/glances/stats.py @@ -16,7 +16,7 @@ import threading import traceback from importlib import import_module -from glances.globals import exports_path, plugins_path, sys_path +from glances.globals import exports_path, plugins_path, sys_path, weak_lru_cache from glances.logger import logger from glances.timer import Counter @@ -327,6 +327,7 @@ please rename it to "{plugin_path.capitalize()}Plugin"' """Return all fields description (as list).""" return [self._plugins[p].fields_description for p in self.getPluginsList(enable=False)] + @weak_lru_cache(maxsize=32) def getAllFieldsDescriptionAsDict(self, plugin_list=None): """Return all fields description (as dict).""" if plugin_list is None: diff --git a/optional-requirements.txt b/optional-requirements.txt index dc3fc345..a31f1492 100644 --- a/optional-requirements.txt +++ b/optional-requirements.txt @@ -35,5 +35,6 @@ requests six sparklines statsd +textual uvicorn zeroconf diff --git a/pyproject.toml b/pyproject.toml index 271178fe..7d869b77 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "defusedxml", "packaging", "psutil>=5.6.7", + "textual", "windows-curses; platform_system == 'Windows'", "shtab; platform_system != 'Windows'", ] @@ -37,7 +38,7 @@ urls.Homepage = "https://github.com/nicolargo/glances" [project.optional-dependencies] action = ["chevron"] # all but not dev -all = ["glances[action,browser,cloud,containers,export,gpu,graph,ip,raid,sensors,smart,snmp,sparklines,web,wifi]"] +all = ["glances[action,browser,cloud,containers,export,gpu,graph,ip,raid,sensors,smart,snmp,sparklines,textual,web,wifi]"] browser = ["zeroconf"] cloud = ["requests"] containers = [ @@ -92,6 +93,7 @@ sensors = ["batinfo; platform_system == 'Linux'"] smart = ["pySMART.smartx"] snmp = ["pysnmp-lextudio<6.2.0"] sparklines = ["sparklines"] +textual = ["textual"] web = [ "fastapi>=0.82.0", "jinja2",