[#301] Exposing hosts as entities in the ping plugin.

Closes: #301
This commit is contained in:
Fabio Manganiello 2023-12-04 00:36:02 +01:00
parent 3bb7c02572
commit d048752184
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
5 changed files with 194 additions and 23 deletions

View file

@ -71,6 +71,9 @@
"music.spotify": { "music.spotify": {
"class": "fab fa-spotify" "class": "fab fa-spotify"
}, },
"ping": {
"class": "fas fa-server"
},
"torrent": { "torrent": {
"class": "fa fa-magnet" "class": "fa fa-magnet"
}, },

View file

@ -0,0 +1,86 @@
<template>
<div class="entity ping-host-container">
<div class="head">
<div class="col-1 icon-container">
<span class="icon" :class="iconClass" />
</div>
<div class="col-10 name" @click.stop="isCollapsed = !isCollapsed">
<div class="name" v-text="value.name" />
</div>
<div class="col-1 collapse-toggler" @click.stop="isCollapsed = !isCollapsed">
<i class="fas" :class="{'fa-chevron-down': isCollapsed, 'fa-chevron-up': !isCollapsed}" />
</div>
</div>
<div class="body children attributes fade-in" v-if="!isCollapsed">
<div class="child">
<div class="col-s-12 col-m-6 label">
<div class="name">Host</div>
</div>
<div class="value">
<div class="name" v-text="value.name" />
</div>
</div>
<div class="child" v-if="value.reachable">
<div class="col-s-12 col-m-6 label">
<div class="name">Ping</div>
</div>
<div class="value">
<span v-text="value.avg" /> ms
</div>
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
export default {
mixins: [EntityMixin],
data() {
return {
isCollapsed: true,
}
},
computed: {
iconClass() {
return this.value.reachable ? "reachable" : "unreachable"
},
},
}
</script>
<style lang="scss" scoped>
@import "common";
.ping-host-container {
.head .icon-container {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2em;
margin-right: 0.5em;
padding-right: 0;
.icon {
width: 1.25em;
height: 1.25em;
border-radius: 50%;
&.reachable {
background-color: $ok-fg;
}
&.unreachable {
background-color: $error-fg;
}
}
}
}
</style>

View file

@ -327,6 +327,14 @@
} }
}, },
"ping_host": {
"name": "Host",
"name_plural": "Hosts",
"icon": {
"class": "fas fa-server"
}
},
"time_duration_sensor": { "time_duration_sensor": {
"name": "Sensor", "name": "Sensor",
"name_plural": "Sensors", "name_plural": "Sensors",

View file

@ -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__,
}

View file

@ -3,8 +3,11 @@ import re
import subprocess import subprocess
import sys import sys
from concurrent.futures import ProcessPoolExecutor 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.message.event.ping import HostUpEvent, HostDownEvent, PingResponseEvent
from platypush.plugins import RunnablePlugin, action from platypush.plugins import RunnablePlugin, action
from platypush.schemas.ping import PingResponseSchema from platypush.schemas.ping import PingResponseSchema
@ -19,6 +22,8 @@ WIN32_PING_MATCHER = re.compile(r"(?P<min>\d+)ms.+(?P<max>\d+)ms.+(?P<avg>\d+)ms
def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict: def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict:
out = None
pinger = None
err_response = dict( err_response = dict(
PingResponseSchema().dump( PingResponseSchema().dump(
{ {
@ -28,12 +33,12 @@ def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict:
) )
) )
try:
with subprocess.Popen( with subprocess.Popen(
ping_cmd, ping_cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
) as pinger: ) as pinger:
try:
out = pinger.communicate() out = pinger.communicate()
if sys.platform == "win32": if sys.platform == "win32":
match = WIN32_PING_MATCHER.search(str(out).rsplit("\n", maxsplit=1)[-1]) 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( match = PING_MATCHER_BUSYBOX.search(
str(out).rsplit("\n", maxsplit=1)[-1] 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() min_val, avg_val, max_val = match.groups()
mdev_val = None mdev_val = None
else: else:
match = PING_MATCHER.search(str(out).rsplit("\n", maxsplit=1)[-1]) 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() min_val, avg_val, max_val, mdev_val = match.groups()
return dict( return dict(
@ -64,16 +69,21 @@ def ping(host: str, ping_cmd: List[str], logger: logging.Logger) -> dict:
) )
) )
except Exception as e: except Exception as e:
if not isinstance(e, (subprocess.CalledProcessError, KeyboardInterrupt)): err = (
logger.warning("Error while pinging host %s: %s", host, e) '\n'.join(line.decode().strip() for line in out)
logger.exception(e) 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.kill()
pinger.wait() pinger.wait()
return err_response return err_response
class PingPlugin(RunnablePlugin): class PingPlugin(RunnablePlugin, EntityManager):
""" """
This integration allows you to: This integration allows you to:
@ -88,7 +98,7 @@ class PingPlugin(RunnablePlugin):
count: int = 1, count: int = 1,
timeout: float = 5.0, timeout: float = 5.0,
hosts: Optional[List[str]] = None, hosts: Optional[List[str]] = None,
poll_interval: float = 10.0, poll_interval: float = 20.0,
**kwargs, **kwargs,
): ):
""" """
@ -103,7 +113,7 @@ class PingPlugin(RunnablePlugin):
self.executable = executable self.executable = executable
self.count = count self.count = count
self.timeout = timeout 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]: def _get_ping_cmd(self, host: str, count: int, timeout: float) -> List[str]:
if sys.platform == 'win32': if sys.platform == 'win32':
@ -162,17 +172,46 @@ class PingPlugin(RunnablePlugin):
if success is None: if success is None:
return return
prev_result = self.hosts.get(host)
if success: 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)) self._bus.post(HostUpEvent(host=host))
result.pop("success", None)
self._bus.post(PingResponseEvent(**result)) self._bus.post(PingResponseEvent(**result))
self.hosts[host] = True self.hosts[host] = result
else: 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._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): def main(self):
# Don't start the thread if no monitored hosts are configured # Don't start the thread if no monitored hosts are configured
@ -193,12 +232,13 @@ class PingPlugin(RunnablePlugin):
[self.logger] * len(self.hosts), [self.logger] * len(self.hosts),
): ):
self._process_ping_result(result) self._process_ping_result(result)
except KeyboardInterrupt: except (KeyboardInterrupt, BrokenProcessPool):
break break
except Exception as e: except Exception as e:
self.logger.warning("Error while pinging hosts: %s", e) self.logger.warning("Error while pinging hosts: %s", e)
self.logger.exception(e) self.logger.exception(e)
finally: finally:
self.publish_entities()
self.wait_stop(self.poll_interval) self.wait_stop(self.poll_interval)