From d0487521846892f2f8de418c45681c2f19839f73 Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Mon, 4 Dec 2023 00:36:02 +0100 Subject: [PATCH] [#301] Exposing hosts as entities in the `ping` plugin. Closes: #301 --- .../backend/http/webapp/src/assets/icons.json | 3 + .../components/panels/Entities/PingHost.vue | 86 +++++++++++++++++++ .../src/components/panels/Entities/meta.json | 8 ++ platypush/entities/ping.py | 34 ++++++++ platypush/plugins/ping/__init__.py | 86 ++++++++++++++----- 5 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 platypush/backend/http/webapp/src/components/panels/Entities/PingHost.vue create mode 100644 platypush/entities/ping.py diff --git a/platypush/backend/http/webapp/src/assets/icons.json b/platypush/backend/http/webapp/src/assets/icons.json index 9ae3321d..97cb2fc5 100644 --- a/platypush/backend/http/webapp/src/assets/icons.json +++ b/platypush/backend/http/webapp/src/assets/icons.json @@ -71,6 +71,9 @@ "music.spotify": { "class": "fab fa-spotify" }, + "ping": { + "class": "fas fa-server" + }, "torrent": { "class": "fa fa-magnet" }, diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/PingHost.vue b/platypush/backend/http/webapp/src/components/panels/Entities/PingHost.vue new file mode 100644 index 00000000..91612bd2 --- /dev/null +++ b/platypush/backend/http/webapp/src/components/panels/Entities/PingHost.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json index 0c986e28..5b69b966 100644 --- a/platypush/backend/http/webapp/src/components/panels/Entities/meta.json +++ b/platypush/backend/http/webapp/src/components/panels/Entities/meta.json @@ -327,6 +327,14 @@ } }, + "ping_host": { + "name": "Host", + "name_plural": "Hosts", + "icon": { + "class": "fas fa-server" + } + }, + "time_duration_sensor": { "name": "Sensor", "name_plural": "Sensors", diff --git a/platypush/entities/ping.py b/platypush/entities/ping.py new file mode 100644 index 00000000..fcbb71ee --- /dev/null +++ b/platypush/entities/ping.py @@ -0,0 +1,34 @@ +from sqlalchemy import ( + Column, + ForeignKey, + Float, + Integer, +) + +from platypush.common.db import is_defined + +from .devices import Device + + +if not is_defined('ping_host'): + + class PingHost(Device): + """ + Entity that maps a generic host that can be pinged. + """ + + __tablename__ = 'ping_host' + + id = Column( + Integer, ForeignKey(Device.id, ondelete='CASCADE'), primary_key=True + ) + + min = Column(Float) + max = Column(Float) + avg = Column(Float) + mdev = Column(Float) + + __table_args__ = {'extend_existing': True} + __mapper_args__ = { + 'polymorphic_identity': __tablename__, + } diff --git a/platypush/plugins/ping/__init__.py b/platypush/plugins/ping/__init__.py index 8663f084..d42fabc6 100644 --- a/platypush/plugins/ping/__init__.py +++ b/platypush/plugins/ping/__init__.py @@ -3,8 +3,11 @@ import re import subprocess import sys from concurrent.futures import ProcessPoolExecutor -from typing import Dict, Optional, List +from concurrent.futures.process import BrokenProcessPool +from typing import Collection, Dict, Iterable, Optional, List +from platypush.entities import EntityManager +from platypush.entities.ping import PingHost from platypush.message.event.ping import HostUpEvent, HostDownEvent, PingResponseEvent from platypush.plugins import RunnablePlugin, action from platypush.schemas.ping import PingResponseSchema @@ -19,6 +22,8 @@ WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict: + out = None + pinger = None err_response = dict( PingResponseSchema().dump( { @@ -28,12 +33,12 @@ def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict: ) ) - with subprocess.Popen( - ping_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) as pinger: - try: + try: + with subprocess.Popen( + ping_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) as pinger: out = pinger.communicate() if sys.platform == "win32": match = WIN32_PING_MATCHER.search(str(out).rsplit("\n", maxsplit=1)[-1]) @@ -43,12 +48,12 @@ def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict: match = PING_MATCHER_BUSYBOX.search( str(out).rsplit("\n", maxsplit=1)[-1] ) - assert match is not None, f"No match found in {out}" + assert match is not None, out min_val, avg_val, max_val = match.groups() mdev_val = None else: match = PING_MATCHER.search(str(out).rsplit("\n", maxsplit=1)[-1]) - assert match is not None, f"No match found in {out}" + assert match is not None, out min_val, avg_val, max_val, mdev_val = match.groups() return dict( @@ -63,17 +68,22 @@ def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict: } ) ) - except Exception as e: - if not isinstance(e, (subprocess.CalledProcessError, KeyboardInterrupt)): - logger.warning("Error while pinging host %s: %s", host, e) - logger.exception(e) + except Exception as e: + err = ( + '\n'.join(line.decode().strip() for line in out) + if isinstance(out, (tuple, list)) + else str(e) + ) + logger.warning("Error while pinging host %s: %s", host, err) + if pinger and pinger.poll() is None: pinger.kill() pinger.wait() - return err_response + + return err_response -class PingPlugin(RunnablePlugin): +class PingPlugin(RunnablePlugin, EntityManager): """ This integration allows you to: @@ -88,7 +98,7 @@ class PingPlugin(RunnablePlugin): count: int = 1, timeout: float = 5.0, hosts: Optional[List[str]] = None, - poll_interval: float = 10.0, + poll_interval: float = 20.0, **kwargs, ): """ @@ -103,7 +113,7 @@ class PingPlugin(RunnablePlugin): self.executable = executable self.count = count self.timeout = timeout - self.hosts: Dict[str, Optional[bool]] = {h: None for h in (hosts or [])} + self.hosts: Dict[str, Optional[dict]] = {h: None for h in (hosts or [])} def _get_ping_cmd(self, host: str, count: int, timeout: float) -> List[str]: if sys.platform == 'win32': @@ -162,17 +172,46 @@ class PingPlugin(RunnablePlugin): if success is None: return + prev_result = self.hosts.get(host) if success: - if self.hosts.get(host) in (False, None): + if not (prev_result and prev_result.get("success")): self._bus.post(HostUpEvent(host=host)) - result.pop("success", None) self._bus.post(PingResponseEvent(**result)) - self.hosts[host] = True + self.hosts[host] = result else: - if self.hosts.get(host) in (True, None): + if not prev_result or prev_result.get("success"): self._bus.post(HostDownEvent(host=host)) - self.hosts[host] = False + self.hosts[host] = result + + @action + def status(self) -> Collection[PingHost]: + """ + Get the status of the monitored hosts. + :return: Dictionary of monitored hosts and their status. + """ + return self.publish_entities() + + def publish_entities(self, *_, **__) -> Collection[PingHost]: + return super().publish_entities(self.hosts.values()) + + def transform_entities( + self, entities: Collection[Optional[dict]], **_ + ) -> Iterable[PingHost]: + return super().transform_entities( + [ + PingHost( + id=status.get("host"), + name=status.get("host"), + reachable=status.get("success"), + min=status.get("min"), + max=status.get("max"), + avg=status.get("avg"), + ) + for status in entities + if status + ] + ) def main(self): # Don't start the thread if no monitored hosts are configured @@ -193,12 +232,13 @@ class PingPlugin(RunnablePlugin): [self.logger] * len(self.hosts), ): self._process_ping_result(result) - except KeyboardInterrupt: + except (KeyboardInterrupt, BrokenProcessPool): break except Exception as e: self.logger.warning("Error while pinging hosts: %s", e) self.logger.exception(e) finally: + self.publish_entities() self.wait_stop(self.poll_interval)