mirror of https://github.com/nicolargo/glances.git
Glances textual layout definition
This commit is contained in:
parent
c8d70fb343
commit
30ab859244
6
Makefile
6
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -35,5 +35,6 @@ requests
|
|||
six
|
||||
sparklines
|
||||
statsd
|
||||
textual
|
||||
uvicorn
|
||||
zeroconf
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue