Glances textual layout definition

This commit is contained in:
nicolargo 2025-05-07 10:24:07 +02:00
parent c8d70fb343
commit 30ab859244
10 changed files with 385 additions and 13 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,42 @@
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2025 Nicolas Hennion <nicolas@nicolargo.com>
#
# 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()

157
glances/outputs/tui/app.py Normal file
View File

@ -0,0 +1,157 @@
#
# This file is part of Glances.
#
# SPDX-FileCopyrightText: 2025 Nicolas Hennion <nicolas@nicolargo.com>
#
# 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")

View File

@ -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;
}

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -35,5 +35,6 @@ requests
six
sparklines
statsd
textual
uvicorn
zeroconf

View File

@ -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",