mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-21 12:01:58 +00:00
feat: add Synology NAS, Home Assistant monitoring, multi-channel notifications
New tabs: - Synology NAS: CPU, RAM, temp, volumes, VMs, Docker containers - Home Assistant: sensors, automations, persons, climate, battery, doors New features: - Personalized PC agent download with custom name - Notification settings: per-category Telegram/Email/WhatsApp toggles - Email via SMTP, WhatsApp via CallMeBot API - Test notification buttons for each channel - Unified notifier replaces direct Telegram calls in alerter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ previous_states: Dict[str, bool] = {}
|
||||
|
||||
async def check_alerts():
|
||||
"""Check metrics against thresholds and send alerts."""
|
||||
from server.services.telegram_bot import send_alert
|
||||
from server.services.notifier import send_notification
|
||||
from server.api.auth_routes import mute_until
|
||||
|
||||
# Check mute
|
||||
@@ -38,9 +38,15 @@ async def check_alerts():
|
||||
# Online/Offline state change
|
||||
if prev_online is not None and prev_online != current_online:
|
||||
if current_online:
|
||||
await send_alert(f"🟢 **{name}** ({host}) — снова онлайн")
|
||||
await send_notification(
|
||||
f"🟢 **{name}** ({host}) — снова онлайн",
|
||||
subject=f"{name} Online", category="servers"
|
||||
)
|
||||
else:
|
||||
await send_alert(f"🔴 **{name}** ({host}) — OFFLINE!")
|
||||
await send_notification(
|
||||
f"🔴 **{name}** ({host}) — OFFLINE!",
|
||||
subject=f"{name} OFFLINE", category="servers"
|
||||
)
|
||||
|
||||
previous_states[host] = current_online
|
||||
|
||||
@@ -50,12 +56,21 @@ async def check_alerts():
|
||||
# Threshold alerts
|
||||
cpu = m.get("cpu_percent", 0)
|
||||
if cpu >= cpu_threshold:
|
||||
await send_alert(f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)")
|
||||
await send_notification(
|
||||
f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)",
|
||||
subject=f"{name} CPU {cpu}%", category="servers"
|
||||
)
|
||||
|
||||
ram = m.get("ram_percent", 0)
|
||||
if ram >= ram_threshold:
|
||||
await send_alert(f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)")
|
||||
await send_notification(
|
||||
f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)",
|
||||
subject=f"{name} RAM {ram}%", category="servers"
|
||||
)
|
||||
|
||||
disk = m.get("disk_percent", 0)
|
||||
if disk >= disk_threshold:
|
||||
await send_alert(f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)")
|
||||
await send_notification(
|
||||
f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)",
|
||||
subject=f"{name} Disk {disk}%", category="servers"
|
||||
)
|
||||
|
||||
226
server/services/ha_client.py
Normal file
226
server/services/ha_client.py
Normal file
@@ -0,0 +1,226 @@
|
||||
"""Home Assistant REST API client for monitoring."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeAssistantClient:
|
||||
"""Client for Home Assistant REST API."""
|
||||
|
||||
def __init__(self, url: str, token: str):
|
||||
"""
|
||||
Args:
|
||||
url: HA base URL, e.g. http://192.168.1.100:8123
|
||||
token: Long-Lived Access Token (from Profile → Security → Long-Lived Access Tokens)
|
||||
"""
|
||||
self.base_url = url.rstrip("/")
|
||||
self.token = token
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
async def _get(self, path: str) -> Optional[Any]:
|
||||
"""GET request to HA API."""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{self.base_url}/api{path}",
|
||||
headers=self.headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
ssl=False,
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return await resp.json()
|
||||
logger.error(f"HA API {path} returned {resp.status}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"HA API error ({path}): {e}")
|
||||
return None
|
||||
|
||||
async def check_connection(self) -> bool:
|
||||
"""Check if HA is reachable."""
|
||||
data = await self._get("/")
|
||||
return data is not None and data.get("message") == "API running."
|
||||
|
||||
async def get_config(self) -> Optional[dict]:
|
||||
"""Get HA configuration (name, version, components, location)."""
|
||||
return await self._get("/config")
|
||||
|
||||
async def get_states(self) -> Optional[List[dict]]:
|
||||
"""Get all entity states."""
|
||||
return await self._get("/states")
|
||||
|
||||
async def get_services(self) -> Optional[List[dict]]:
|
||||
"""Get available services."""
|
||||
return await self._get("/services")
|
||||
|
||||
async def collect_all_metrics(self) -> dict:
|
||||
"""Collect key metrics from Home Assistant."""
|
||||
result = {
|
||||
"online": False,
|
||||
"version": "",
|
||||
"location_name": "",
|
||||
"components_count": 0,
|
||||
"entities_count": 0,
|
||||
"automations": [],
|
||||
"sensors": {
|
||||
"temperature": [],
|
||||
"humidity": [],
|
||||
"pressure": [],
|
||||
"motion": [],
|
||||
"door": [],
|
||||
"light": [],
|
||||
"battery": [],
|
||||
},
|
||||
"climate": [],
|
||||
"media_players": [],
|
||||
"persons": [],
|
||||
"updates_available": [],
|
||||
"problem_entities": [],
|
||||
"uptime": "",
|
||||
}
|
||||
|
||||
# Check connection
|
||||
if not await self.check_connection():
|
||||
return result
|
||||
|
||||
result["online"] = True
|
||||
|
||||
# Config
|
||||
config = await self.get_config()
|
||||
if config:
|
||||
result["version"] = config.get("version", "")
|
||||
result["location_name"] = config.get("location_name", "")
|
||||
result["components_count"] = len(config.get("components", []))
|
||||
|
||||
# States
|
||||
states = await self.get_states()
|
||||
if not states:
|
||||
return result
|
||||
|
||||
result["entities_count"] = len(states)
|
||||
|
||||
for entity in states:
|
||||
eid = entity.get("entity_id", "")
|
||||
state = entity.get("state", "")
|
||||
attrs = entity.get("attributes", {})
|
||||
friendly = attrs.get("friendly_name", eid)
|
||||
|
||||
# Automations
|
||||
if eid.startswith("automation."):
|
||||
result["automations"].append({
|
||||
"name": friendly,
|
||||
"state": state, # on/off
|
||||
"last_triggered": attrs.get("last_triggered", ""),
|
||||
})
|
||||
|
||||
# Temperature sensors
|
||||
elif eid.startswith("sensor.") and attrs.get("device_class") == "temperature":
|
||||
try:
|
||||
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||
result["sensors"]["temperature"].append({
|
||||
"name": friendly,
|
||||
"value": val,
|
||||
"unit": attrs.get("unit_of_measurement", "°C"),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Humidity sensors
|
||||
elif eid.startswith("sensor.") and attrs.get("device_class") == "humidity":
|
||||
try:
|
||||
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||
result["sensors"]["humidity"].append({
|
||||
"name": friendly,
|
||||
"value": val,
|
||||
"unit": attrs.get("unit_of_measurement", "%"),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Battery sensors
|
||||
elif eid.startswith("sensor.") and attrs.get("device_class") == "battery":
|
||||
try:
|
||||
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||
result["sensors"]["battery"].append({
|
||||
"name": friendly,
|
||||
"value": val,
|
||||
"unit": "%",
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Motion / occupancy
|
||||
elif eid.startswith("binary_sensor.") and attrs.get("device_class") in ("motion", "occupancy"):
|
||||
result["sensors"]["motion"].append({
|
||||
"name": friendly,
|
||||
"state": state, # on/off
|
||||
})
|
||||
|
||||
# Door / window
|
||||
elif eid.startswith("binary_sensor.") and attrs.get("device_class") in ("door", "window", "opening"):
|
||||
result["sensors"]["door"].append({
|
||||
"name": friendly,
|
||||
"state": state, # on = open, off = closed
|
||||
})
|
||||
|
||||
# Lights
|
||||
elif eid.startswith("light."):
|
||||
result["sensors"]["light"].append({
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
"brightness": attrs.get("brightness"),
|
||||
})
|
||||
|
||||
# Climate
|
||||
elif eid.startswith("climate."):
|
||||
result["climate"].append({
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
"current_temp": attrs.get("current_temperature"),
|
||||
"target_temp": attrs.get("temperature"),
|
||||
"hvac_action": attrs.get("hvac_action", ""),
|
||||
})
|
||||
|
||||
# Media players
|
||||
elif eid.startswith("media_player."):
|
||||
result["media_players"].append({
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
"source": attrs.get("source", ""),
|
||||
})
|
||||
|
||||
# Person
|
||||
elif eid.startswith("person."):
|
||||
result["persons"].append({
|
||||
"name": friendly,
|
||||
"state": state, # home/not_home/zone
|
||||
})
|
||||
|
||||
# Updates available
|
||||
elif eid.startswith("update.") and state == "on":
|
||||
result["updates_available"].append({
|
||||
"name": friendly,
|
||||
"installed": attrs.get("installed_version", ""),
|
||||
"latest": attrs.get("latest_version", ""),
|
||||
})
|
||||
|
||||
# Problem entities (unavailable/unknown)
|
||||
if state in ("unavailable", "unknown") and not eid.startswith(("update.", "scene.")):
|
||||
result["problem_entities"].append({
|
||||
"entity_id": eid,
|
||||
"name": friendly,
|
||||
"state": state,
|
||||
})
|
||||
|
||||
# HA uptime sensor
|
||||
if eid == "sensor.uptime" or eid.endswith("_uptime"):
|
||||
if attrs.get("device_class") == "timestamp" and state not in ("unknown", "unavailable"):
|
||||
result["uptime"] = state
|
||||
|
||||
return result
|
||||
147
server/services/notifier.py
Normal file
147
server/services/notifier.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Unified notification sender: Telegram, Email, WhatsApp (CallMeBot)."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from server.config import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def send_telegram(message: str, parse_mode: str = "Markdown"):
|
||||
"""Send message via Telegram bot."""
|
||||
settings = load_settings()
|
||||
token = settings.get("telegram_bot_token")
|
||||
chat_id = settings.get("telegram_chat_id")
|
||||
if not token or not chat_id:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||
async with session.post(url, json={
|
||||
"chat_id": chat_id,
|
||||
"text": message,
|
||||
"parse_mode": parse_mode,
|
||||
}) as resp:
|
||||
return resp.status == 200
|
||||
except Exception as e:
|
||||
logger.error(f"Telegram send error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_email(subject: str, body: str):
|
||||
"""Send email via SMTP."""
|
||||
settings = load_settings()
|
||||
smtp_host = settings.get("smtp_host", "")
|
||||
smtp_port = int(settings.get("smtp_port", 587))
|
||||
smtp_user = settings.get("smtp_user", "")
|
||||
smtp_password = settings.get("smtp_password", "")
|
||||
email_to = settings.get("email_to", "")
|
||||
|
||||
if not all([smtp_host, smtp_user, smtp_password, email_to]):
|
||||
return False
|
||||
|
||||
try:
|
||||
msg = MIMEMultipart()
|
||||
msg["From"] = smtp_user
|
||||
msg["To"] = email_to
|
||||
msg["Subject"] = f"🖥 VPS Monitor: {subject}"
|
||||
|
||||
# HTML body
|
||||
html = f"""
|
||||
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;
|
||||
background:#1a1a3e;color:#e8e8ff;padding:24px;border-radius:12px">
|
||||
<h2 style="color:#6366f1;margin-bottom:16px">🖥 VPS Monitoring</h2>
|
||||
<div style="background:#0f0f23;padding:16px;border-radius:8px;
|
||||
border-left:4px solid #6366f1;margin-bottom:16px">
|
||||
{body.replace(chr(10), '<br>')}
|
||||
</div>
|
||||
<p style="color:#a0a0cc;font-size:12px">Автоматическое уведомление VPS Monitoring</p>
|
||||
</div>
|
||||
"""
|
||||
msg.attach(MIMEText(html, "html"))
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(None, _smtp_send, smtp_host, smtp_port, smtp_user, smtp_password, email_to, msg)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Email send error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _smtp_send(host, port, user, password, to, msg):
|
||||
"""Blocking SMTP send (run in executor)."""
|
||||
with smtplib.SMTP(host, port, timeout=15) as server:
|
||||
server.starttls()
|
||||
server.login(user, password)
|
||||
server.sendmail(user, to, msg.as_string())
|
||||
|
||||
|
||||
async def send_whatsapp(message: str):
|
||||
"""Send WhatsApp message via CallMeBot API.
|
||||
|
||||
Setup: User sends "I allow callmebot to send me messages" to
|
||||
+34 644 71 85 23 on WhatsApp, gets apikey.
|
||||
Store phone + apikey in settings.
|
||||
"""
|
||||
settings = load_settings()
|
||||
wa_phone = settings.get("whatsapp_phone", "")
|
||||
wa_apikey = settings.get("whatsapp_apikey", "")
|
||||
|
||||
if not wa_phone or not wa_apikey:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Clean message for URL
|
||||
clean_msg = message.replace("**", "*")
|
||||
async with aiohttp.ClientSession() as session:
|
||||
url = "https://api.callmebot.com/whatsapp.php"
|
||||
params = {
|
||||
"phone": wa_phone,
|
||||
"text": clean_msg,
|
||||
"apikey": wa_apikey,
|
||||
}
|
||||
async with session.get(url, params=params) as resp:
|
||||
return resp.status == 200
|
||||
except Exception as e:
|
||||
logger.error(f"WhatsApp send error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def send_notification(message: str, subject: str = "Alert", category: str = "servers"):
|
||||
"""Send notification via all enabled channels for given category.
|
||||
|
||||
Categories: servers, pc, synology, ha
|
||||
Settings key: notify_{category}_{channel} = true/false
|
||||
Channels: telegram, email, whatsapp
|
||||
"""
|
||||
from datetime import datetime
|
||||
from server.api.auth_routes import mute_until
|
||||
|
||||
# Check global mute
|
||||
if mute_until and datetime.now() < mute_until:
|
||||
return
|
||||
|
||||
settings = load_settings()
|
||||
notify_prefs = settings.get("notifications", {})
|
||||
|
||||
# Default: telegram enabled for all
|
||||
cat_prefs = notify_prefs.get(category, {"telegram": True, "email": False, "whatsapp": False})
|
||||
|
||||
tasks = []
|
||||
if cat_prefs.get("telegram", True):
|
||||
tasks.append(send_telegram(message))
|
||||
if cat_prefs.get("email", False):
|
||||
tasks.append(send_email(subject, message))
|
||||
if cat_prefs.get("whatsapp", False):
|
||||
tasks.append(send_whatsapp(message))
|
||||
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
242
server/services/synology_client.py
Normal file
242
server/services/synology_client.py
Normal file
@@ -0,0 +1,242 @@
|
||||
"""Synology DSM API client for NAS monitoring."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SynologyClient:
|
||||
"""Client for Synology DSM 6/7 REST API."""
|
||||
|
||||
def __init__(self, host: str, port: int = 5000, https: bool = False,
|
||||
username: str = "", password: str = ""):
|
||||
proto = "https" if https else "http"
|
||||
self.base_url = f"{proto}://{host}:{port}"
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.sid: Optional[str] = None
|
||||
|
||||
async def login(self) -> bool:
|
||||
"""Authenticate and get session ID."""
|
||||
try:
|
||||
url = f"{self.base_url}/webapi/auth.cgi"
|
||||
params = {
|
||||
"api": "SYNO.API.Auth",
|
||||
"version": "6",
|
||||
"method": "login",
|
||||
"account": self.username,
|
||||
"passwd": self.password,
|
||||
"session": "VPSMonitor",
|
||||
"format": "sid",
|
||||
}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10),
|
||||
ssl=False) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("success"):
|
||||
self.sid = data["data"]["sid"]
|
||||
return True
|
||||
logger.error(f"Synology login failed: {data}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Synology login error: {e}")
|
||||
return False
|
||||
|
||||
async def _api_call(self, cgi: str, api: str, version: str = "1",
|
||||
method: str = "get", extra_params: dict = None) -> Optional[dict]:
|
||||
"""Make authenticated API call."""
|
||||
if not self.sid:
|
||||
if not await self.login():
|
||||
return None
|
||||
|
||||
url = f"{self.base_url}/webapi/{cgi}"
|
||||
params = {
|
||||
"api": api,
|
||||
"version": version,
|
||||
"method": method,
|
||||
"_sid": self.sid,
|
||||
}
|
||||
if extra_params:
|
||||
params.update(extra_params)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15),
|
||||
ssl=False) as resp:
|
||||
data = await resp.json()
|
||||
if data.get("success"):
|
||||
return data.get("data", {})
|
||||
# Session expired? Re-login once
|
||||
if data.get("error", {}).get("code") == 119:
|
||||
self.sid = None
|
||||
if await self.login():
|
||||
params["_sid"] = self.sid
|
||||
async with session.get(url, params=params, ssl=False) as resp2:
|
||||
data2 = await resp2.json()
|
||||
if data2.get("success"):
|
||||
return data2.get("data", {})
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Synology API error ({api}): {e}")
|
||||
return None
|
||||
|
||||
async def get_system_info(self) -> Optional[dict]:
|
||||
"""Get DSM system info (model, version, uptime, temperature)."""
|
||||
return await self._api_call("entry.cgi", "SYNO.DSM.Info", version="2", method="getinfo")
|
||||
|
||||
async def get_cpu_memory(self) -> Optional[dict]:
|
||||
"""Get CPU and memory utilization."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Core.System.Utilization", version="1")
|
||||
|
||||
async def get_storage(self) -> Optional[dict]:
|
||||
"""Get storage pool and volume info."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Storage.CGI.Storage", version="1",
|
||||
method="load_info")
|
||||
|
||||
async def get_network(self) -> Optional[dict]:
|
||||
"""Get network interface info."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Core.System.Utilization", version="1")
|
||||
|
||||
async def get_vms(self) -> Optional[dict]:
|
||||
"""Get Virtual Machine Manager guests (VMM package required)."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Virtualization.API.Guest", version="1",
|
||||
method="list", extra_params={"offset": "0", "limit": "50"})
|
||||
|
||||
async def get_docker_containers(self) -> Optional[dict]:
|
||||
"""Get Docker/Container Manager containers."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Docker.Container", version="1",
|
||||
method="list", extra_params={"offset": "0", "limit": "50"})
|
||||
|
||||
async def get_disk_info(self) -> Optional[dict]:
|
||||
"""Get physical disk info (SMART, temperature)."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Storage.CGI.Storage", version="1",
|
||||
method="load_info")
|
||||
|
||||
async def get_services(self) -> Optional[dict]:
|
||||
"""Get running services/packages."""
|
||||
return await self._api_call("entry.cgi", "SYNO.Core.Package", version="2",
|
||||
method="list")
|
||||
|
||||
async def collect_all_metrics(self) -> dict:
|
||||
"""Collect all metrics in one go."""
|
||||
result = {
|
||||
"online": False,
|
||||
"system": {},
|
||||
"cpu_percent": 0,
|
||||
"ram_percent": 0,
|
||||
"ram_used_mb": 0,
|
||||
"ram_total_mb": 0,
|
||||
"volumes": [],
|
||||
"disks": [],
|
||||
"vms": [],
|
||||
"docker": [],
|
||||
"temperature": 0,
|
||||
"uptime": "",
|
||||
"model": "",
|
||||
"dsm_version": "",
|
||||
}
|
||||
|
||||
# System info
|
||||
sys_info = await self.get_system_info()
|
||||
if sys_info is None:
|
||||
return result
|
||||
|
||||
result["online"] = True
|
||||
result["model"] = sys_info.get("model", "")
|
||||
result["dsm_version"] = f"DSM {sys_info.get('version_string', '')}"
|
||||
result["temperature"] = sys_info.get("temperature", 0)
|
||||
result["uptime"] = _format_uptime(sys_info.get("up_time", 0))
|
||||
|
||||
# CPU / Memory
|
||||
util = await self.get_cpu_memory()
|
||||
if util:
|
||||
cpu_data = util.get("cpu", {})
|
||||
if cpu_data:
|
||||
# CPU total = user + system
|
||||
user = cpu_data.get("user_load", 0)
|
||||
sys_load = cpu_data.get("system_load", 0)
|
||||
result["cpu_percent"] = user + sys_load
|
||||
|
||||
mem_data = util.get("memory", {})
|
||||
if mem_data:
|
||||
total = mem_data.get("memory_size", 0) / 1024 # KB to MB
|
||||
avail = mem_data.get("avail_swap", 0)
|
||||
real_use = mem_data.get("real_usage", 0)
|
||||
result["ram_total_mb"] = round(total)
|
||||
result["ram_percent"] = real_use
|
||||
result["ram_used_mb"] = round(total * real_use / 100)
|
||||
|
||||
# Storage
|
||||
storage = await self.get_storage()
|
||||
if storage:
|
||||
for vol in storage.get("volumes", []):
|
||||
total_bytes = vol.get("size", {}).get("total", 0)
|
||||
used_bytes = vol.get("size", {}).get("used", 0)
|
||||
total_gb = total_bytes / (1024 ** 3) if total_bytes else 0
|
||||
used_gb = used_bytes / (1024 ** 3) if used_bytes else 0
|
||||
percent = round(used_gb / total_gb * 100, 1) if total_gb > 0 else 0
|
||||
result["volumes"].append({
|
||||
"name": vol.get("display_name", vol.get("id", "")),
|
||||
"total_gb": round(total_gb, 1),
|
||||
"used_gb": round(used_gb, 1),
|
||||
"percent": percent,
|
||||
"status": vol.get("status", ""),
|
||||
})
|
||||
|
||||
for disk in storage.get("disks", []):
|
||||
result["disks"].append({
|
||||
"name": disk.get("name", ""),
|
||||
"model": disk.get("model", ""),
|
||||
"temp": disk.get("temp", 0),
|
||||
"status": disk.get("status", ""),
|
||||
"size_gb": round(int(disk.get("size_total", 0)) / (1024 ** 3), 1),
|
||||
"smart_status": disk.get("smart_status", ""),
|
||||
})
|
||||
|
||||
# VMs
|
||||
vms = await self.get_vms()
|
||||
if vms:
|
||||
for vm in vms.get("guests", []):
|
||||
result["vms"].append({
|
||||
"name": vm.get("guest_name", ""),
|
||||
"status": vm.get("status", ""),
|
||||
"vcpu": vm.get("vcpu_num", 0),
|
||||
"ram_mb": vm.get("vram_size", 0),
|
||||
"autorun": vm.get("autorun", 0),
|
||||
})
|
||||
|
||||
# Docker
|
||||
docker = await self.get_docker_containers()
|
||||
if docker:
|
||||
for c in docker.get("containers", []):
|
||||
result["docker"].append({
|
||||
"name": c.get("name", ""),
|
||||
"status": c.get("status", ""),
|
||||
"image": c.get("image", ""),
|
||||
"state": c.get("state", ""),
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
async def logout(self):
|
||||
"""Close session."""
|
||||
if self.sid:
|
||||
try:
|
||||
await self._api_call("auth.cgi", "SYNO.API.Auth", version="6", method="logout")
|
||||
except Exception:
|
||||
pass
|
||||
self.sid = None
|
||||
|
||||
|
||||
def _format_uptime(seconds: int) -> str:
|
||||
"""Format seconds to human-readable uptime."""
|
||||
if not seconds:
|
||||
return "N/A"
|
||||
days = seconds // 86400
|
||||
hours = (seconds % 86400) // 3600
|
||||
if days > 0:
|
||||
return f"{days}d {hours}h"
|
||||
return f"{hours}h {(seconds % 3600) // 60}m"
|
||||
Reference in New Issue
Block a user