From 36e2397fd16499f1c47c04395a34104a351b0423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Sat, 14 Mar 2026 19:31:32 +0100 Subject: [PATCH 1/5] Add LXD/LXC container engine support - New engine at glances/plugins/containers/engines/lxd.py using pylxd - Polls instance.state() for CPU, memory, disk IO, and network stats - Registers automatically alongside Docker and Podman engines - Add pylxd>=2.3.1 to containers optional dependency Co-Authored-By: Claude Opus 4.6 --- glances/plugins/containers/__init__.py | 7 +- glances/plugins/containers/engines/lxd.py | 287 ++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 glances/plugins/containers/engines/lxd.py diff --git a/glances/plugins/containers/__init__.py b/glances/plugins/containers/__init__.py index 96d50694..c2977779 100644 --- a/glances/plugins/containers/__init__.py +++ b/glances/plugins/containers/__init__.py @@ -17,6 +17,7 @@ from glances.globals import nativestr from glances.logger import logger from glances.plugins.containers.engines import ContainersExtension from glances.plugins.containers.engines.docker import DockerExtension, disable_plugin_docker +from glances.plugins.containers.engines.lxd import LxdExtension, disable_plugin_lxd from glances.plugins.containers.engines.podman import PodmanExtension, disable_plugin_podman from glances.plugins.plugin.model import GlancesPluginModel from glances.processes import glances_processes @@ -86,7 +87,7 @@ fields_description = { 'description': 'Container uptime', }, 'engine': { - 'description': 'Container engine (Docker and Podman are currently supported)', + 'description': 'Container engine (Docker, Podman, and LXD are currently supported)', }, 'pod_name': { 'description': 'Pod name (only with Podman)', @@ -164,6 +165,10 @@ class ContainersPlugin(GlancesPluginModel): if not disable_plugin_podman: self.watchers['podman'] = PodmanExtension(podman_sock=self._podman_sock()) + # Init the LXD API + if not disable_plugin_lxd: + self.watchers['lxd'] = LxdExtension() + # Sort key self.sort_key = None diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py new file mode 100644 index 00000000..318095f4 --- /dev/null +++ b/glances/plugins/containers/engines/lxd.py @@ -0,0 +1,287 @@ +# +# This file is part of Glances. +# +# SPDX-FileCopyrightText: 2024 Nicolas Hennion +# +# SPDX-License-Identifier: LGPL-3.0-only + +"""LXD Extension unit for Glances' Containers plugin.""" + +import threading +import time +from datetime import datetime +from typing import Any + +from glances.globals import nativestr, pretty_date, replace_special_chars +from glances.logger import logger + +# pylxd library (optional and Linux-only) +# https://github.com/canonical/pylxd +try: + from pylxd import Client as LxdClient +except Exception as e: + disable_plugin_lxd = True + logger.warning(f"Error loading LXD deps Lib. LXD feature in the Containers plugin is disabled ({e})") +else: + disable_plugin_lxd = False + + +class LxdStatsFetcher: + """Fetch stats for a single LXD instance by polling its state.""" + + def __init__(self, instance, poll_interval=2): + self._instance = instance + self._poll_interval = poll_interval + + # Store previous stats for rate calculations + self._old_computed_stats = {} + self._last_stats_computed_time = 0 + + # Latest polled state + self._state = None + self._state_lock = threading.Lock() + + # Polling thread + self._stopper = threading.Event() + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + + def _poll_loop(self): + """Poll instance.state() in a background thread.""" + while not self._stopper.is_set(): + try: + state = self._instance.state() + with self._state_lock: + self._state = state + except Exception as e: + logger.debug(f"containers (LXD) Instance({self._instance.name}): Failed to poll state ({e})") + self._stopper.wait(self._poll_interval) + + def stop(self): + self._stopper.set() + + @property + def activity_stats(self) -> dict[str, dict[str, Any]]: + """Compute activity stats from the latest polled state.""" + computed = self._compute_activity_stats() + self._old_computed_stats = computed + self._last_stats_computed_time = time.time() + return computed + + @property + def time_since_update(self) -> float: + return max(1, time.time() - self._last_stats_computed_time) + + def _compute_activity_stats(self) -> dict[str, dict[str, Any]]: + stats = {"cpu": {}, "memory": {}, "io": {}, "network": {}} + + with self._state_lock: + state = self._state + + if state is None: + return stats + + # CPU: state.cpu["usage"] is cumulative nanoseconds + try: + cumulative_cpu_ns = state.cpu["usage"] + stats["cpu"]["cumulative_ns"] = cumulative_cpu_ns + + old_cpu = self._old_computed_stats.get("cpu", {}) + if "cumulative_ns" in old_cpu: + delta_ns = cumulative_cpu_ns - old_cpu["cumulative_ns"] + delta_seconds = self.time_since_update + # Convert ns delta to a percentage (assuming 1 core = 100%) + stats["cpu"]["total"] = (delta_ns / (delta_seconds * 1e9)) * 100.0 + else: + stats["cpu"]["total"] = 0.0 + except (KeyError, TypeError, ZeroDivisionError) as e: + logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab CPU stats ({e})") + + # Memory: state.memory values are in bytes + try: + stats["memory"]["usage"] = state.memory["usage"] + # total is 0 when unlimited; fall back to usage_peak + mem_total = state.memory.get("total", 0) + if mem_total > 0: + stats["memory"]["limit"] = mem_total + else: + stats["memory"]["limit"] = state.memory.get("usage_peak", state.memory["usage"]) + stats["memory"]["inactive_file"] = 0 + except (KeyError, TypeError) as e: + logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab MEM stats ({e})") + + # Disk IO: state.disk["root"]["usage"] is cumulative bytes + try: + if state.disk and "root" in state.disk: + cumulative_disk = state.disk["root"].get("usage", 0) + stats["io"]["cumulative_ior"] = cumulative_disk + stats["io"]["cumulative_iow"] = 0 # LXD doesn't split read/write + + old_io = self._old_computed_stats.get("io", {}) + if "cumulative_ior" in old_io: + stats["io"]["time_since_update"] = round(self.time_since_update) + stats["io"]["ior"] = max(0, cumulative_disk - old_io["cumulative_ior"]) + stats["io"]["iow"] = 0 + except (KeyError, TypeError) as e: + logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab IO stats ({e})") + + # Network: sum counters across all non-loopback interfaces + try: + if state.network: + cumulative_rx = 0 + cumulative_tx = 0 + for iface_name, iface in state.network.items(): + if iface.get("type") == "loopback": + continue + counters = iface.get("counters", {}) + cumulative_rx += counters.get("bytes_received", 0) + cumulative_tx += counters.get("bytes_sent", 0) + + stats["network"]["cumulative_rx"] = cumulative_rx + stats["network"]["cumulative_tx"] = cumulative_tx + + old_net = self._old_computed_stats.get("network", {}) + if "cumulative_rx" in old_net: + stats["network"]["time_since_update"] = round(self.time_since_update) + stats["network"]["rx"] = max(0, cumulative_rx - old_net["cumulative_rx"]) + stats["network"]["tx"] = max(0, cumulative_tx - old_net["cumulative_tx"]) + except (KeyError, TypeError) as e: + logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab NET stats ({e})") + + return stats + + +class LxdExtension: + """Glances' Containers Plugin's LXD Extension unit""" + + CONTAINER_ACTIVE_STATUS = ['Running', 'running'] + + def __init__(self, endpoint=None): + self.disable = disable_plugin_lxd + if self.disable: + raise Exception("Missing libs required to run LXD Extension (Containers)") + + self.display_error = True + self.client = None + self.ext_name = "containers (LXD)" + self.endpoint = endpoint + self.stats_fetchers = {} + + self.connect() + + def connect(self) -> None: + """Connect to the LXD server.""" + try: + if self.endpoint: + self.client = LxdClient(endpoint=self.endpoint) + else: + self.client = LxdClient() + # Verify connectivity + self.client.has_api_extension('instances') + except Exception as e: + logger.debug(f"{self.ext_name} plugin - Can't connect to LXD ({e})") + self.client = None + self.disable = True + + def stop(self) -> None: + for t in self.stats_fetchers.values(): + t.stop() + + def update(self, all_tag) -> tuple[dict, list[dict[str, Any]]]: + """Update LXD stats using the input method.""" + if not self.client or self.disable: + return {}, [] + + # List instances + try: + instances = self.client.instances.all() + if not all_tag: + instances = [i for i in instances if i.status in self.CONTAINER_ACTIVE_STATUS] + self.display_error = True + except Exception as e: + if self.display_error: + logger.error(f"{self.ext_name} plugin - Can't get instances list ({e})") + self.display_error = False + else: + logger.debug(f"{self.ext_name} plugin - Can't get instances list ({e})") + return {}, [] + + # Start new fetcher threads for new instances + for instance in instances: + if instance.name not in self.stats_fetchers: + logger.debug(f"{self.ext_name} plugin - Create thread for instance {instance.name}") + self.stats_fetchers[instance.name] = LxdStatsFetcher(instance) + + # Stop threads for removed instances + current_names = {i.name for i in instances} + absent = set(self.stats_fetchers.keys()) - current_names + for name in absent: + logger.debug(f"{self.ext_name} plugin - Stop thread for old instance {name}") + self.stats_fetchers[name].stop() + del self.stats_fetchers[name] + + # Generate stats + container_stats = [self.generate_stats(instance) for instance in instances] + return {}, container_stats + + @property + def key(self) -> str: + return 'name' + + def generate_stats(self, instance) -> dict[str, Any]: + stats = { + 'key': self.key, + 'name': nativestr(instance.name), + 'id': instance.name, + 'image': instance.config.get('image.description', ''), + 'status': instance.status.lower(), + 'created': instance.created_at, + 'command': None, + 'io': {}, + 'cpu': {}, + 'memory': {}, + 'network': {}, + 'io_rx': None, + 'io_wx': None, + 'cpu_percent': None, + 'memory_percent': None, + 'network_rx': None, + 'network_tx': None, + 'ports': '', + 'uptime': None, + } + + if instance.status not in self.CONTAINER_ACTIVE_STATUS: + return stats + + if instance.name not in self.stats_fetchers: + return stats + + stats_fetcher = self.stats_fetchers[instance.name] + activity_stats = stats_fetcher.activity_stats + stats.update(activity_stats) + + # Additional fields + stats['cpu_percent'] = stats['cpu'].get('total') + stats['memory_usage'] = stats['memory'].get('usage') + stats['memory_inactive_file'] = stats['memory'].get('inactive_file') + stats['memory_limit'] = stats['memory'].get('limit') + + if all(k in stats['io'] for k in ('ior', 'iow', 'time_since_update')): + stats['io_rx'] = stats['io']['ior'] // stats['io']['time_since_update'] + stats['io_wx'] = stats['io']['iow'] // stats['io']['time_since_update'] + + if all(k in stats['network'] for k in ('rx', 'tx', 'time_since_update')): + stats['network_rx'] = stats['network']['rx'] // stats['network']['time_since_update'] + stats['network_tx'] = stats['network']['tx'] // stats['network']['time_since_update'] + + # Uptime from last_used_at + try: + last_used = instance.last_used_at + if last_used and last_used != '1970-01-01T00:00:00Z': + started = datetime.fromisoformat(last_used.replace('Z', '+00:00')).replace(tzinfo=None) + stats['uptime'] = pretty_date(started) + except (ValueError, AttributeError) as e: + logger.debug(f"{self.ext_name} plugin - Can't compute uptime for {instance.name} ({e})") + + return stats diff --git a/pyproject.toml b/pyproject.toml index 959008cc..f621f226 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ containers = [ "docker>=6.1.1", "packaging", "podman", + "pylxd>=2.3.1", "python-dateutil", "six", ] From 1fffb7c5acc009f8b2887dfa56e8cd1afa145cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Sat, 14 Mar 2026 19:38:45 +0100 Subject: [PATCH 2/5] Filter LXD instances to local cluster node and extract proxy ports - In clustered LXD, only show instances on the current node - Extract port mappings from proxy devices for the Ports column Co-Authored-By: Claude Opus 4.6 --- glances/plugins/containers/engines/lxd.py | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py index 318095f4..8b2d4629 100644 --- a/glances/plugins/containers/engines/lxd.py +++ b/glances/plugins/containers/engines/lxd.py @@ -166,6 +166,7 @@ class LxdExtension: self.ext_name = "containers (LXD)" self.endpoint = endpoint self.stats_fetchers = {} + self.local_node = None self.connect() @@ -178,6 +179,12 @@ class LxdExtension: self.client = LxdClient() # Verify connectivity self.client.has_api_extension('instances') + # Determine local cluster member name (for filtering) + try: + env = self.client.host_info.get('environment', {}) + self.local_node = env.get('server_name') + except Exception: + self.local_node = None except Exception as e: logger.debug(f"{self.ext_name} plugin - Can't connect to LXD ({e})") self.client = None @@ -195,6 +202,9 @@ class LxdExtension: # List instances try: instances = self.client.instances.all() + # In a cluster, only show instances running on this node + if self.local_node: + instances = [i for i in instances if getattr(i, 'location', None) == self.local_node] if not all_tag: instances = [i for i in instances if i.status in self.CONTAINER_ACTIVE_STATUS] self.display_error = True @@ -275,6 +285,26 @@ class LxdExtension: stats['network_rx'] = stats['network']['rx'] // stats['network']['time_since_update'] stats['network_tx'] = stats['network']['tx'] // stats['network']['time_since_update'] + # Ports from proxy devices (e.g. listen=tcp:0.0.0.0:80 connect=tcp:127.0.0.1:80) + try: + devices = instance.expanded_devices or {} + port_list = [] + for dev in devices.values(): + if dev.get('type') != 'proxy': + continue + listen = dev.get('listen', '') + connect = dev.get('connect', '') + # Extract port from "tcp:0.0.0.0:80" format + listen_port = listen.rsplit(':', 1)[-1] if listen else '' + connect_port = connect.rsplit(':', 1)[-1] if connect else '' + if listen_port: + proto = listen.split(':')[0] if ':' in listen else 'tcp' + port_list.append(f"{listen_port}->{connect_port}/{proto}") + if port_list: + stats['ports'] = ','.join(port_list) + except (AttributeError, TypeError) as e: + logger.debug(f"{self.ext_name} plugin - Can't get ports for {instance.name} ({e})") + # Uptime from last_used_at try: last_used = instance.last_used_at From 982ed8da3aad40a60958bbd8a8526bd52c60d61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Sun, 15 Mar 2026 14:38:23 +0100 Subject: [PATCH 3/5] #3480 change attribution --- glances/plugins/containers/engines/lxd.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py index 8b2d4629..781d9ab7 100644 --- a/glances/plugins/containers/engines/lxd.py +++ b/glances/plugins/containers/engines/lxd.py @@ -1,7 +1,7 @@ # # This file is part of Glances. # -# SPDX-FileCopyrightText: 2024 Nicolas Hennion +# SPDX-FileCopyrightText: 2026 Christian Rishøj # # SPDX-License-Identifier: LGPL-3.0-only From d6395e8d3e626de28ec48b6f5f26cebcaf0ff720 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Sun, 15 Mar 2026 14:42:12 +0100 Subject: [PATCH 4/5] #3480 address review feedback - Split _compute_activity_stats into sub-functions per review feedback - Pass Glances refresh interval as LXD poll interval - Update containers docs to mention LXD/pylxd Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/aoa/containers.rst | 8 +- glances/plugins/containers/__init__.py | 4 +- glances/plugins/containers/engines/lxd.py | 89 +++++++++++++++-------- 3 files changed, 67 insertions(+), 34 deletions(-) diff --git a/docs/aoa/containers.rst b/docs/aoa/containers.rst index 6c5372e9..a9bda524 100644 --- a/docs/aoa/containers.rst +++ b/docs/aoa/containers.rst @@ -3,8 +3,9 @@ Containers ========== -If you use ``containers``, Glances can help you to monitor your Docker or Podman containers. -Glances uses the containers API through the `docker-py`_ and `podman-py`_ libraries. +If you use ``containers``, Glances can help you to monitor your Docker, Podman, +or LXD containers. Glances uses the containers API through the `docker-py`_, +`podman-py`_, and `pylxd`_ libraries. You can install this dependency using: @@ -75,4 +76,5 @@ order to test your regular expression. .. _regex101: https://regex101.com/ .. _docker-py: https://github.com/containers/containers-py -.. _podman-py: https://github.com/containers/podman-py \ No newline at end of file +.. _podman-py: https://github.com/containers/podman-py +.. _pylxd: https://github.com/canonical/pylxd \ No newline at end of file diff --git a/glances/plugins/containers/__init__.py b/glances/plugins/containers/__init__.py index c2977779..0fb1bc44 100644 --- a/glances/plugins/containers/__init__.py +++ b/glances/plugins/containers/__init__.py @@ -167,7 +167,9 @@ class ContainersPlugin(GlancesPluginModel): # Init the LXD API if not disable_plugin_lxd: - self.watchers['lxd'] = LxdExtension() + self.watchers['lxd'] = LxdExtension( + poll_interval=self.get_refresh() if hasattr(self, 'get_refresh') else 2 + ) # Sort key self.sort_key = None diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py index 781d9ab7..c9eb0daa 100644 --- a/glances/plugins/containers/engines/lxd.py +++ b/glances/plugins/containers/engines/lxd.py @@ -12,7 +12,7 @@ import time from datetime import datetime from typing import Any -from glances.globals import nativestr, pretty_date, replace_special_chars +from glances.globals import nativestr, pretty_date from glances.logger import logger # pylxd library (optional and Linux-only) @@ -73,81 +73,109 @@ class LxdStatsFetcher: return max(1, time.time() - self._last_stats_computed_time) def _compute_activity_stats(self) -> dict[str, dict[str, Any]]: - stats = {"cpu": {}, "memory": {}, "io": {}, "network": {}} - with self._state_lock: state = self._state if state is None: - return stats + return {"cpu": {}, "memory": {}, "io": {}, "network": {}} - # CPU: state.cpu["usage"] is cumulative nanoseconds + return { + "cpu": self._get_cpu_stats(state), + "memory": self._get_memory_stats(state), + "io": self._get_io_stats(state), + "network": self._get_network_stats(state), + } + + def _get_cpu_stats(self, state) -> dict[str, Any]: + """Return CPU usage stats. + + LXD reports cumulative CPU time in nanoseconds. + We compute a percentage from the delta between two polls. + """ + stats = {} try: cumulative_cpu_ns = state.cpu["usage"] - stats["cpu"]["cumulative_ns"] = cumulative_cpu_ns + stats["cumulative_ns"] = cumulative_cpu_ns old_cpu = self._old_computed_stats.get("cpu", {}) if "cumulative_ns" in old_cpu: delta_ns = cumulative_cpu_ns - old_cpu["cumulative_ns"] delta_seconds = self.time_since_update # Convert ns delta to a percentage (assuming 1 core = 100%) - stats["cpu"]["total"] = (delta_ns / (delta_seconds * 1e9)) * 100.0 + stats["total"] = (delta_ns / (delta_seconds * 1e9)) * 100.0 else: - stats["cpu"]["total"] = 0.0 + stats["total"] = 0.0 except (KeyError, TypeError, ZeroDivisionError) as e: logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab CPU stats ({e})") + return stats - # Memory: state.memory values are in bytes + def _get_memory_stats(self, state) -> dict[str, Any]: + """Return memory usage stats. + + LXD reports memory values in bytes. + 'total' is 0 when unlimited; fall back to usage_peak. + """ + stats = {} try: - stats["memory"]["usage"] = state.memory["usage"] - # total is 0 when unlimited; fall back to usage_peak + stats["usage"] = state.memory["usage"] mem_total = state.memory.get("total", 0) if mem_total > 0: - stats["memory"]["limit"] = mem_total + stats["limit"] = mem_total else: - stats["memory"]["limit"] = state.memory.get("usage_peak", state.memory["usage"]) - stats["memory"]["inactive_file"] = 0 + stats["limit"] = state.memory.get("usage_peak", state.memory["usage"]) + stats["inactive_file"] = 0 except (KeyError, TypeError) as e: logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab MEM stats ({e})") + return stats - # Disk IO: state.disk["root"]["usage"] is cumulative bytes + def _get_io_stats(self, state) -> dict[str, Any]: + """Return disk IO stats. + + LXD only exposes cumulative disk usage per device, not separate read/write. + """ + stats = {} try: if state.disk and "root" in state.disk: cumulative_disk = state.disk["root"].get("usage", 0) - stats["io"]["cumulative_ior"] = cumulative_disk - stats["io"]["cumulative_iow"] = 0 # LXD doesn't split read/write + stats["cumulative_ior"] = cumulative_disk + stats["cumulative_iow"] = 0 old_io = self._old_computed_stats.get("io", {}) if "cumulative_ior" in old_io: - stats["io"]["time_since_update"] = round(self.time_since_update) - stats["io"]["ior"] = max(0, cumulative_disk - old_io["cumulative_ior"]) - stats["io"]["iow"] = 0 + stats["time_since_update"] = round(self.time_since_update) + stats["ior"] = max(0, cumulative_disk - old_io["cumulative_ior"]) + stats["iow"] = 0 except (KeyError, TypeError) as e: logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab IO stats ({e})") + return stats - # Network: sum counters across all non-loopback interfaces + def _get_network_stats(self, state) -> dict[str, Any]: + """Return network usage stats. + + Sums counters across all non-loopback interfaces. + """ + stats = {} try: if state.network: cumulative_rx = 0 cumulative_tx = 0 - for iface_name, iface in state.network.items(): + for iface in state.network.values(): if iface.get("type") == "loopback": continue counters = iface.get("counters", {}) cumulative_rx += counters.get("bytes_received", 0) cumulative_tx += counters.get("bytes_sent", 0) - stats["network"]["cumulative_rx"] = cumulative_rx - stats["network"]["cumulative_tx"] = cumulative_tx + stats["cumulative_rx"] = cumulative_rx + stats["cumulative_tx"] = cumulative_tx old_net = self._old_computed_stats.get("network", {}) if "cumulative_rx" in old_net: - stats["network"]["time_since_update"] = round(self.time_since_update) - stats["network"]["rx"] = max(0, cumulative_rx - old_net["cumulative_rx"]) - stats["network"]["tx"] = max(0, cumulative_tx - old_net["cumulative_tx"]) + stats["time_since_update"] = round(self.time_since_update) + stats["rx"] = max(0, cumulative_rx - old_net["cumulative_rx"]) + stats["tx"] = max(0, cumulative_tx - old_net["cumulative_tx"]) except (KeyError, TypeError) as e: logger.debug(f"containers (LXD) Instance({self._instance.name}): Can't grab NET stats ({e})") - return stats @@ -156,7 +184,7 @@ class LxdExtension: CONTAINER_ACTIVE_STATUS = ['Running', 'running'] - def __init__(self, endpoint=None): + def __init__(self, endpoint=None, poll_interval=2): self.disable = disable_plugin_lxd if self.disable: raise Exception("Missing libs required to run LXD Extension (Containers)") @@ -165,6 +193,7 @@ class LxdExtension: self.client = None self.ext_name = "containers (LXD)" self.endpoint = endpoint + self.poll_interval = poll_interval self.stats_fetchers = {} self.local_node = None @@ -220,7 +249,7 @@ class LxdExtension: for instance in instances: if instance.name not in self.stats_fetchers: logger.debug(f"{self.ext_name} plugin - Create thread for instance {instance.name}") - self.stats_fetchers[instance.name] = LxdStatsFetcher(instance) + self.stats_fetchers[instance.name] = LxdStatsFetcher(instance, poll_interval=self.poll_interval) # Stop threads for removed instances current_names = {i.name for i in instances} From c53cb847b941ea0e06ffa5c9cdf96328c3a54cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Rish=C3=B8j?= Date: Sun, 15 Mar 2026 14:50:48 +0100 Subject: [PATCH 5/5] #3480 fix review nits - Remove unnecessary hasattr guard on get_refresh() - Init _last_stats_computed_time to time.time() to avoid stale first reading - Guard against ZeroDivisionError in IO/network rate calculations - Use title-case status from pylxd directly - Add trailing newline to containers.rst Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/aoa/containers.rst | 2 +- glances/plugins/containers/__init__.py | 4 +--- glances/plugins/containers/engines/lxd.py | 12 ++++++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/docs/aoa/containers.rst b/docs/aoa/containers.rst index a9bda524..7bd14688 100644 --- a/docs/aoa/containers.rst +++ b/docs/aoa/containers.rst @@ -77,4 +77,4 @@ order to test your regular expression. .. _regex101: https://regex101.com/ .. _docker-py: https://github.com/containers/containers-py .. _podman-py: https://github.com/containers/podman-py -.. _pylxd: https://github.com/canonical/pylxd \ No newline at end of file +.. _pylxd: https://github.com/canonical/pylxd diff --git a/glances/plugins/containers/__init__.py b/glances/plugins/containers/__init__.py index 0fb1bc44..9cd61c72 100644 --- a/glances/plugins/containers/__init__.py +++ b/glances/plugins/containers/__init__.py @@ -167,9 +167,7 @@ class ContainersPlugin(GlancesPluginModel): # Init the LXD API if not disable_plugin_lxd: - self.watchers['lxd'] = LxdExtension( - poll_interval=self.get_refresh() if hasattr(self, 'get_refresh') else 2 - ) + self.watchers['lxd'] = LxdExtension(poll_interval=self.get_refresh()) # Sort key self.sort_key = None diff --git a/glances/plugins/containers/engines/lxd.py b/glances/plugins/containers/engines/lxd.py index c9eb0daa..ab052c8d 100644 --- a/glances/plugins/containers/engines/lxd.py +++ b/glances/plugins/containers/engines/lxd.py @@ -35,7 +35,7 @@ class LxdStatsFetcher: # Store previous stats for rate calculations self._old_computed_stats = {} - self._last_stats_computed_time = 0 + self._last_stats_computed_time = time.time() # Latest polled state self._state = None @@ -182,7 +182,7 @@ class LxdStatsFetcher: class LxdExtension: """Glances' Containers Plugin's LXD Extension unit""" - CONTAINER_ACTIVE_STATUS = ['Running', 'running'] + CONTAINER_ACTIVE_STATUS = ['Running'] def __init__(self, endpoint=None, poll_interval=2): self.disable = disable_plugin_lxd @@ -307,12 +307,12 @@ class LxdExtension: stats['memory_limit'] = stats['memory'].get('limit') if all(k in stats['io'] for k in ('ior', 'iow', 'time_since_update')): - stats['io_rx'] = stats['io']['ior'] // stats['io']['time_since_update'] - stats['io_wx'] = stats['io']['iow'] // stats['io']['time_since_update'] + stats['io_rx'] = stats['io']['ior'] // max(1, stats['io']['time_since_update']) + stats['io_wx'] = stats['io']['iow'] // max(1, stats['io']['time_since_update']) if all(k in stats['network'] for k in ('rx', 'tx', 'time_since_update')): - stats['network_rx'] = stats['network']['rx'] // stats['network']['time_since_update'] - stats['network_tx'] = stats['network']['tx'] // stats['network']['time_since_update'] + stats['network_rx'] = stats['network']['rx'] // max(1, stats['network']['time_since_update']) + stats['network_tx'] = stats['network']['tx'] // max(1, stats['network']['time_since_update']) # Ports from proxy devices (e.g. listen=tcp:0.0.0.0:80 connect=tcp:127.0.0.1:80) try: