Support for disk entities in the system integration.

This commit is contained in:
Fabio Manganiello 2023-04-20 16:26:51 +02:00
parent 6b03451386
commit 44b8fd4b34
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
6 changed files with 454 additions and 225 deletions

View file

@ -0,0 +1,225 @@
<template>
<div class="entity disk-container">
<div class="head" @click.stop="isCollapsed = !isCollapsed">
<div class="col-1 icon">
<EntityIcon
:entity="value"
:loading="loading"
:error="error" />
</div>
<div class="col-8 label">
<div class="name" v-text="value.name" />
</div>
<div class="col-2 value" v-text="Math.round(value.percent * 100, 1) + '%'" />
<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" v-if="value.mountpoint?.length">
<div class="col-s-12 col-m-6 label">
<div class="name">Mountpoint</div>
</div>
<div class="value">
<div class="name" v-text="value.mountpoint" />
</div>
</div>
<div class="child" v-if="value.fstype?.length">
<div class="col-s-12 col-m-6 label">
<div class="name">Filesystem</div>
</div>
<div class="value">
<div class="name" v-text="value.fstype" />
</div>
</div>
<div class="child" v-if="value.opts?.length">
<div class="col-s-12 col-m-6 label">
<div class="name">Mount options</div>
</div>
<div class="value">
<div class="name" v-text="value.opts" />
</div>
</div>
<div class="child" v-if="value.total != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Total space</div>
</div>
<div class="value">
<div class="name" v-text="convertSize(value.total)" />
</div>
</div>
<div class="child" v-if="value.used != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Used space</div>
</div>
<div class="value">
<div class="name" v-text="convertSize(value.used)" />
</div>
</div>
<div class="child" v-if="value.free != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Available space</div>
</div>
<div class="value">
<div class="name" v-text="convertSize(value.free)" />
</div>
</div>
<div class="child" v-if="value.read_count != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Number of reads</div>
</div>
<div class="value">
<div class="name" v-text="value.read_count" />
</div>
</div>
<div class="child" v-if="value.write_count != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Number of writes</div>
</div>
<div class="value">
<div class="name" v-text="value.write_count" />
</div>
</div>
<div class="child" v-if="value.read_bytes != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Bytes read</div>
</div>
<div class="value">
<div class="name" v-text="convertSize(value.read_bytes)" />
</div>
</div>
<div class="child" v-if="value.write_bytes != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Bytes written</div>
</div>
<div class="value">
<div class="name" v-text="convertSize(value.write_bytes)" />
</div>
</div>
<div class="child" v-if="value.read_time != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Read time</div>
</div>
<div class="value">
<div class="name" v-text="convertTime(value.read_time)" />
</div>
</div>
<div class="child" v-if="value.write_time != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Write time</div>
</div>
<div class="value">
<div class="name" v-text="convertTime(value.write_time)" />
</div>
</div>
<div class="child" v-if="value.busy_time != null">
<div class="col-s-12 col-m-6 label">
<div class="name">Busy time</div>
</div>
<div class="value">
<div class="name" v-text="convertTime(value.busy_time)" />
</div>
</div>
</div>
</div>
</template>
<script>
import EntityMixin from "./EntityMixin"
import EntityIcon from "./EntityIcon"
export default {
name: 'Disk',
components: {EntityIcon},
mixins: [EntityMixin],
data() {
return {
isCollapsed: true,
}
},
}
</script>
<style lang="scss" scoped>
@import "common";
.entity {
.head {
padding: 0.25em;
.value {
text-align: right;
font-weight: bold;
}
.icon {
margin-right: 1em;
}
}
}
.collapse-toggler {
display: flex;
align-items: center;
flex: 1;
min-height: 3em;
cursor: pointer;
@include from($tablet) {
@include until($desktop) {
margin-left: 3.25em;
}
}
&:hover {
color: $default-hover-fg;
}
}
.attributes .child {
margin: 0 -0.5em;
padding: 0.5em 1em;
&:not(:last-child) {
border-bottom: 1px solid $border-color-1;
}
&:hover {
cursor: initial;
}
.label {
font-weight: bold;
@include from($tablet) {
@extend .col-m-6;
}
}
.value {
font-size: 0.95em;
text-align: right;
@include from($tablet) {
@extend .col-m-6;
}
}
}
</style>

View file

@ -63,6 +63,14 @@
}
},
"disk": {
"name": "System",
"name_plural": "System",
"icon": {
"class": "fas fa-hard-drive"
}
},
"current_sensor": {
"name": "Sensor",
"name_plural": "Sensors",

View file

@ -142,3 +142,36 @@ if 'swap_stats' not in Base.metadata:
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}
if 'disk' not in Base.metadata:
class Disk(Entity):
"""
``Disk`` ORM model.
"""
__tablename__ = 'disk'
id = Column(
Integer, ForeignKey(Entity.id, ondelete='CASCADE'), primary_key=True
)
mountpoint = Column(String)
fstype = Column(String)
opts = Column(String)
total = Column(Integer)
used = Column(Integer)
free = Column(Integer)
percent = Column(Float)
read_count = Column(Integer)
write_count = Column(Integer)
read_bytes = Column(Integer)
write_bytes = Column(Integer)
read_time = Column(Float)
write_time = Column(Float)
busy_time = Column(Float)
__mapper_args__ = {
'polymorphic_identity': __tablename__,
}

View file

@ -28,86 +28,6 @@ class SensorResponse(SystemResponse):
pass
class DiskPartitionResponse(DiskResponse):
def __init__(
self,
device: str,
mount_point: str,
fstype: Optional[str] = None,
opts: Optional[str] = None,
*args,
**kwargs
):
super().__init__(
*args,
output={
'device': device,
'mount_point': mount_point,
'fstype': fstype,
'opts': opts,
},
**kwargs
)
class DiskUsageResponse(DiskResponse):
def __init__(
self,
path: str,
total: int,
used: int,
free: int,
percent: float,
*args,
**kwargs
):
super().__init__(
*args,
output={
'path': path,
'total': total,
'used': used,
'free': free,
'percent': percent,
},
**kwargs
)
class DiskIoCountersResponse(DiskResponse):
def __init__(
self,
read_count: int,
write_count: int,
read_bytes: int,
write_bytes: int,
read_time: int,
write_time: int,
read_merged_count: int,
write_merged_count: int,
busy_time: int,
disk: Optional[str] = None,
*args,
**kwargs
):
super().__init__(
*args,
output={
'read_count': read_count,
'write_count': write_count,
'read_bytes': read_bytes,
'write_bytes': write_bytes,
'read_time': read_time,
'write_time': write_time,
'read_merged_count': read_merged_count,
'write_merged_count': write_merged_count,
'busy_time': busy_time,
'disk': disk,
},
**kwargs
)
class NetworkIoCountersResponse(NetworkResponse):
def __init__(
self,
@ -363,11 +283,6 @@ class SystemResponseList(SystemResponse):
super().__init__(output=[r.output for r in responses], *args, **kwargs)
class DiskResponseList(DiskResponse, SystemResponseList):
def __init__(self, responses: List[DiskResponse], *args, **kwargs):
super().__init__(responses=responses, *args, **kwargs)
class NetworkResponseList(NetworkResponse, SystemResponseList):
def __init__(self, responses: List[NetworkResponse], *args, **kwargs):
super().__init__(responses=responses, *args, **kwargs)

View file

@ -1,9 +1,12 @@
import os
import socket
from datetime import datetime
from typing import Tuple, Union, List, Optional, Dict
from typing_extensions import override
import psutil
from platypush.entities import Entity
from platypush.entities.devices import Device
from platypush.entities.managers import EntityManager
@ -13,14 +16,11 @@ from platypush.entities.system import (
CpuInfo as CpuInfoModel,
CpuStats as CpuStatsModel,
CpuTimes as CpuTimesModel,
Disk as DiskModel,
MemoryStats as MemoryStatsModel,
SwapStats as SwapStatsModel,
)
from platypush.message.response.system import (
DiskResponseList,
DiskPartitionResponse,
DiskUsageResponse,
DiskIoCountersResponse,
NetworkIoCountersResponse,
NetworkResponseList,
NetworkConnectionResponse,
@ -46,6 +46,8 @@ from platypush.schemas.system import (
CpuStatsSchema,
CpuTimes,
CpuTimesSchema,
Disk,
DiskSchema,
MemoryStats,
MemoryStatsSchema,
SwapStats,
@ -95,16 +97,12 @@ class SystemPlugin(SensorPlugin, EntityManager):
@classmethod
def _cpu_times_avg(cls, percent=True) -> CpuTimes:
import psutil
method = psutil.cpu_times_percent if percent else psutil.cpu_times
times = method(percpu=False)
return cls._load_cpu_times(times, many=False) # type: ignore
@classmethod
def _cpu_times_per_cpu(cls, percent=True) -> List[CpuTimes]:
import psutil
method = psutil.cpu_times_percent if percent else psutil.cpu_times
times = method(percpu=True)
return cls._load_cpu_times(times, many=True) # type: ignore
@ -143,8 +141,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
function be called with at least 0.1 seconds between calls.
:return: float if ``per_cpu=False``, ``list[float]`` otherwise.
"""
import psutil
percent = psutil.cpu_percent(percpu=per_cpu, interval=interval)
if per_cpu:
@ -152,8 +148,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
return percent
def _cpu_stats(self) -> CpuStats:
import psutil
stats = psutil.cpu_stats()
return CpuStatsSchema().load(stats._asdict()) # type: ignore
@ -167,14 +161,10 @@ class SystemPlugin(SensorPlugin, EntityManager):
return CpuStatsSchema().dump(self._cpu_stats()) # type: ignore
def _cpu_frequency_avg(self) -> CpuFrequency:
import psutil
freq = psutil.cpu_freq(percpu=False)
return CpuFrequencySchema().load(freq._asdict()) # type: ignore
def _cpu_frequency_per_cpu(self) -> List[CpuFrequency]:
import psutil
freq = psutil.cpu_freq(percpu=True)
return CpuFrequencySchema().load(freq._asdict(), many=True) # type: ignore
@ -203,13 +193,9 @@ class SystemPlugin(SensorPlugin, EntityManager):
"""
Get the average load as a vector that represents the load within the last 1, 5 and 15 minutes.
"""
import psutil
return psutil.getloadavg()
def _mem_virtual(self) -> MemoryStats:
import psutil
return MemoryStatsSchema().load(
psutil.virtual_memory()._asdict()
) # type: ignore
@ -224,8 +210,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
return MemoryStatsSchema().dump(self._mem_virtual()) # type: ignore
def _mem_swap(self) -> SwapStats:
import psutil
return SwapStatsSchema().load(psutil.swap_memory()._asdict()) # type: ignore
@action
@ -237,111 +221,41 @@ class SystemPlugin(SensorPlugin, EntityManager):
"""
return SwapStatsSchema().dump(self._mem_swap()) # type: ignore
@action
def disk_partitions(self) -> DiskResponseList:
"""
Get the list of partitions mounted on the system.
:return: list of :class:`platypush.message.response.system.DiskPartitionResponse`
"""
import psutil
def _disk_info(self) -> List[Disk]:
parts = {part.device: part._asdict() for part in psutil.disk_partitions()}
basename_parts = {os.path.basename(part): part for part in parts}
parts = psutil.disk_partitions()
return DiskResponseList(
[
DiskPartitionResponse(
device=p.device,
mount_point=p.mountpoint,
fstype=p.fstype,
opts=p.opts,
)
for p in parts
]
)
@action
def disk_usage(
self, path: Optional[str] = None
) -> Union[DiskUsageResponse, DiskResponseList]:
"""
Get the usage of a mounted disk.
:param path: Path where the device is mounted (default: get stats for all mounted devices).
:return: :class:`platypush.message.response.system.DiskUsageResponse` or list of
:class:`platypush.message.response.system.DiskUsageResponse`.
"""
import psutil
if path:
usage = psutil.disk_usage(path)
return DiskUsageResponse(
path=path,
total=usage.total,
used=usage.used,
free=usage.free,
percent=usage.percent,
)
else:
disks = {
p.mountpoint: psutil.disk_usage(p.mountpoint)
for p in psutil.disk_partitions()
io_stats = {
basename_parts[disk]: stats._asdict()
for disk, stats in psutil.disk_io_counters(perdisk=True).items()
if disk in basename_parts
}
return DiskResponseList(
usage = {
disk: psutil.disk_usage(info['mountpoint'])._asdict()
for disk, info in parts.items()
}
return DiskSchema().load( # type: ignore
[
DiskUsageResponse(
path=path,
total=disk.total,
used=disk.used,
free=disk.free,
percent=disk.percent,
)
for path, disk in disks.items()
]
{
**info,
**io_stats[part],
**usage[part],
}
for part, info in parts.items()
],
many=True,
)
@action
def disk_io_counters(
self, disk: Optional[str] = None, per_disk: bool = False
) -> Union[DiskIoCountersResponse, DiskResponseList]:
def disk_info(self):
"""
Get the I/O counter stats for the mounted disks.
Get information about the detected disks and partitions.
:param disk: Select the stats for a specific disk (e.g. 'sda1'). Default: get stats for all mounted disks.
:param per_disk: Return the stats per disk (default: False).
:return: :class:`platypush.message.response.system.DiskIoCountersResponse` or list of
:class:`platypush.message.response.system.DiskIoCountersResponse`.
:return: .. schema:: system.DiskSchema(many=True)
"""
import psutil
def _expand_response(_disk, _stats):
return DiskIoCountersResponse(
read_count=_stats.read_count,
write_count=_stats.write_count,
read_bytes=_stats.read_bytes,
write_bytes=_stats.write_bytes,
read_time=_stats.read_time,
write_time=_stats.write_time,
read_merged_count=_stats.read_merged_count,
write_merged_count=_stats.write_merged_count,
busy_time=_stats.busy_time,
disk=_disk,
)
if disk:
per_disk = True
io = psutil.disk_io_counters(perdisk=per_disk)
if disk:
stats = [d for name, d in io.items() if name == disk]
assert stats, 'No such disk: {}'.format(disk)
return _expand_response(disk, stats[0])
if not per_disk:
return _expand_response(None, io)
return DiskResponseList(
[_expand_response(disk, stats) for disk, stats in io.items()]
)
return DiskSchema().dump(self._disk_info(), many=True)
@action
def net_io_counters(
@ -355,7 +269,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:return: :class:`platypush.message.response.system.NetIoCountersResponse` or list of
:class:`platypush.message.response.system.NetIoCountersResponse`.
"""
import psutil
def _expand_response(_nic, _stats):
return NetworkIoCountersResponse(
@ -414,8 +327,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:return: List of :class:`platypush.message.response.system.NetworkConnectionResponse`.
"""
import psutil
conns = psutil.net_connections(kind=type)
return NetworkResponseList(
@ -446,8 +357,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:return: :class:`platypush.message.response.system.NetworkAddressResponse` or list of
:class:`platypush.message.response.system.NetworkAddressResponse`.
"""
import psutil
addrs = psutil.net_if_addrs()
def _expand_addresses(_nic, _addrs):
@ -504,8 +413,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:return: :class:`platypush.message.response.system.NetworkInterfaceStatsResponse` or list of
:class:`platypush.message.response.system.NetworkInterfaceStatsResponse`.
"""
import psutil
stats = psutil.net_if_stats()
def _expand_stats(_nic, _stats):
@ -540,8 +447,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:param sensor: Select the sensor name.
:param fahrenheit: Return the temperature in Fahrenheit (default: Celsius).
"""
import psutil
stats = psutil.sensors_temperatures(fahrenheit=fahrenheit)
if sensor:
@ -596,8 +501,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:param sensor: Select the sensor name.
:return: List of :class:`platypush.message.response.system.SensorFanResponse`.
"""
import psutil
stats = psutil.sensors_fans()
def _expand_stats(name, _stats):
@ -627,8 +530,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
Get stats from the battery sensor.
:return: List of :class:`platypush.message.response.system.SensorFanResponse`.
"""
import psutil
stats = psutil.sensors_battery()
return SensorBatteryResponse(
@ -643,8 +544,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
Get the list of connected users.
:return: List of :class:`platypush.message.response.system.ConnectUserResponse`.
"""
import psutil
users = psutil.users()
return ConnectedUserResponseList(
@ -668,8 +567,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:param filter: Filter the list by name.
:return: List of :class:`platypush.message.response.system.ProcessResponse`.
"""
import psutil
processes = [psutil.Process(pid) for pid in psutil.pids()]
p_list = []
@ -720,8 +617,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
@staticmethod
def _get_process(pid: int):
import psutil
return psutil.Process(pid)
@action
@ -730,8 +625,6 @@ class SystemPlugin(SensorPlugin, EntityManager):
:param pid: Process PID.
:return: ``True`` if the process exists, ``False`` otherwise.
"""
import psutil
return psutil.pid_exists(pid)
@action
@ -794,6 +687,7 @@ class SystemPlugin(SensorPlugin, EntityManager):
},
'memory': self._mem_virtual(),
'swap': self._mem_swap(),
'disks': self._disk_info(),
}
)
@ -870,6 +764,14 @@ class SystemPlugin(SensorPlugin, EntityManager):
name='Swap',
**entities['swap'],
),
*[
DiskModel(
id=f'system:disk:{disk["device"]}',
name=disk.pop('device'),
**disk,
)
for disk in entities['disks']
],
]

View file

@ -68,6 +68,25 @@ class CpuTimesBaseSchema(DataClassSchema):
}
class DiskBaseSchema(DataClassSchema):
"""
Base schema for disk stats.
"""
@pre_load
def pre_load(self, data: dict, **_) -> dict:
# Convert read/write/busy times from milliseconds to seconds
for attr in ['read_time', 'write_time', 'busy_time']:
if data.get(attr) is not None:
data[attr] /= 1000
# Normalize the percentage between 0 and 1
if data.get('percent') is not None:
data['percent'] /= 100
return data
@dataclass
class CpuInfo:
"""
@ -356,6 +375,131 @@ class SwapStats:
percent: float = percent_field()
@dataclass
class Disk:
"""
Disk data class.
"""
device: str = field(
metadata={
'metadata': {
'description': 'Path/identifier of the disk/partition',
'example': '/dev/sda1',
}
}
)
mountpoint: Optional[str] = field(
metadata={
'metadata': {
'description': 'Where the disk is mounted',
'example': '/home',
}
}
)
fstype: Optional[str] = field(
metadata={
'metadata': {
'description': 'Filesystem type',
'example': 'ext4',
}
}
)
opts: Optional[str] = field(
metadata={
'metadata': {
'description': 'Extra mount options passed to the partition',
'example': 'rw,relatime,fmask=0022,dmask=0022,utf8',
}
}
)
total: int = field(
metadata={
'metadata': {
'description': 'Total available space, in bytes',
}
}
)
used: int = field(
metadata={
'metadata': {
'description': 'Used disk space, in bytes',
}
}
)
free: int = field(
metadata={
'metadata': {
'description': 'Free disk space, in bytes',
}
}
)
read_count: int = field(
metadata={
'metadata': {
'description': 'Number of recorded read operations',
}
}
)
write_count: int = field(
metadata={
'metadata': {
'description': 'Number of recorded write operations',
}
}
)
read_bytes: int = field(
metadata={
'metadata': {
'description': 'Number of read bytes',
}
}
)
write_bytes: int = field(
metadata={
'metadata': {
'description': 'Number of written bytes',
}
}
)
read_time: float = field(
metadata={
'metadata': {
'description': 'Time spent reading, in seconds',
}
}
)
write_time: float = field(
metadata={
'metadata': {
'description': 'Time spent writing, in seconds',
}
}
)
busy_time: float = field(
metadata={
'metadata': {
'description': 'Total disk busy time, in seconds',
}
}
)
percent: float = percent_field()
@dataclass
class SystemInfo:
"""
@ -365,12 +509,14 @@ class SystemInfo:
cpu: CpuData
memory: MemoryStats
swap: SwapStats
disks: List[Disk]
CpuFrequencySchema = class_schema(CpuFrequency, base_schema=DataClassSchema)
CpuInfoSchema = class_schema(CpuInfo, base_schema=CpuInfoBaseSchema)
CpuTimesSchema = class_schema(CpuTimes, base_schema=CpuTimesBaseSchema)
CpuStatsSchema = class_schema(CpuStats, base_schema=DataClassSchema)
DiskSchema = class_schema(Disk, base_schema=DiskBaseSchema)
MemoryStatsSchema = class_schema(MemoryStats, base_schema=MemoryStatsBaseSchema)
SwapStatsSchema = class_schema(SwapStats, base_schema=MemoryStatsBaseSchema)
SystemInfoSchema = class_schema(SystemInfo, base_schema=DataClassSchema)