diff --git a/server/api/ha.py b/server/api/ha.py
new file mode 100644
index 0000000..3b96ea8
--- /dev/null
+++ b/server/api/ha.py
@@ -0,0 +1,115 @@
+"""Home Assistant monitoring API endpoints."""
+
+import json
+from datetime import datetime
+from typing import Dict
+
+from fastapi import APIRouter, Request, Depends
+
+from server.auth import require_auth
+from server.config import DATA_DIR
+
+router = APIRouter(prefix="/api/ha", tags=["homeassistant"])
+
+# In-memory cache
+ha_metrics: Dict[str, dict] = {}
+
+HA_FILE = DATA_DIR / "homeassistant.json"
+
+
+def _load_ha():
+ if HA_FILE.exists():
+ with open(HA_FILE) as f:
+ return json.load(f)
+ return []
+
+
+def _save_ha(data):
+ with open(HA_FILE, "w") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+
+@router.get("/list")
+async def ha_list(request: Request, user: str = Depends(require_auth)):
+ """List all Home Assistant instances with metrics."""
+ instances = _load_ha()
+ for inst in instances:
+ name = inst["name"]
+ if name in ha_metrics:
+ inst["metrics"] = ha_metrics[name]
+ return instances
+
+
+@router.post("/add")
+async def ha_add(request: Request, user: str = Depends(require_auth)):
+ """Add a Home Assistant instance."""
+ body = await request.json()
+ name = body.get("name", "").strip()
+ url = body.get("url", "").strip()
+ token = body.get("token", "").strip()
+
+ if not name or not url or not token:
+ return {"status": "error", "detail": "name, url, and token required"}
+
+ instances = _load_ha()
+ instance = {
+ "name": name,
+ "url": url,
+ "token": token,
+ "added": datetime.now().isoformat(),
+ }
+ instances.append(instance)
+ _save_ha(instances)
+
+ return {"status": "ok"}
+
+
+@router.delete("/{name}")
+async def ha_delete(name: str, request: Request, user: str = Depends(require_auth)):
+ """Remove a Home Assistant instance."""
+ instances = _load_ha()
+ instances = [i for i in instances if i["name"] != name]
+ _save_ha(instances)
+ ha_metrics.pop(name, None)
+ return {"status": "ok"}
+
+
+@router.post("/refresh/{name}")
+async def ha_refresh(name: str, request: Request, user: str = Depends(require_auth)):
+ """Force refresh metrics for a Home Assistant instance."""
+ from server.services.ha_client import HomeAssistantClient
+
+ instances = _load_ha()
+ inst = next((i for i in instances if i["name"] == name), None)
+ if not inst:
+ return {"status": "error", "detail": "instance not found"}
+
+ client = HomeAssistantClient(url=inst["url"], token=inst["token"])
+ try:
+ metrics = await client.collect_all_metrics()
+ metrics["last_updated"] = datetime.now().isoformat()
+ ha_metrics[name] = metrics
+ return {"status": "ok", "metrics": metrics}
+ except Exception as e:
+ return {"status": "error", "detail": str(e)}
+
+
+@router.post("/refresh-all")
+async def ha_refresh_all(request: Request, user: str = Depends(require_auth)):
+ """Refresh metrics for all Home Assistant instances."""
+ from server.services.ha_client import HomeAssistantClient
+
+ instances = _load_ha()
+ results = []
+
+ for inst in instances:
+ client = HomeAssistantClient(url=inst["url"], token=inst["token"])
+ try:
+ metrics = await client.collect_all_metrics()
+ metrics["last_updated"] = datetime.now().isoformat()
+ ha_metrics[inst["name"]] = metrics
+ results.append({"name": inst["name"], "online": metrics["online"]})
+ except Exception as e:
+ results.append({"name": inst["name"], "online": False, "error": str(e)})
+
+ return {"status": "ok", "results": results}
diff --git a/server/api/notifications.py b/server/api/notifications.py
new file mode 100644
index 0000000..1f06e1c
--- /dev/null
+++ b/server/api/notifications.py
@@ -0,0 +1,111 @@
+"""Notification settings and test endpoints."""
+
+from fastapi import APIRouter, Request, Depends
+
+from server.auth import require_auth
+from server.config import load_settings, save_settings
+
+router = APIRouter(prefix="/api/notifications", tags=["notifications"])
+
+
+@router.get("/settings")
+async def get_notification_settings(request: Request, user: str = Depends(require_auth)):
+ """Get notification preferences per category."""
+ settings = load_settings()
+ return {
+ "notifications": settings.get("notifications", {
+ "servers": {"telegram": True, "email": False, "whatsapp": False},
+ "pc": {"telegram": True, "email": False, "whatsapp": False},
+ "synology": {"telegram": True, "email": False, "whatsapp": False},
+ "ha": {"telegram": True, "email": False, "whatsapp": False},
+ }),
+ "email": {
+ "smtp_host": settings.get("smtp_host", ""),
+ "smtp_port": settings.get("smtp_port", 587),
+ "smtp_user": settings.get("smtp_user", ""),
+ "email_to": settings.get("email_to", ""),
+ "configured": bool(settings.get("smtp_host") and settings.get("smtp_user")),
+ },
+ "whatsapp": {
+ "phone": settings.get("whatsapp_phone", ""),
+ "configured": bool(settings.get("whatsapp_phone") and settings.get("whatsapp_apikey")),
+ },
+ "telegram": {
+ "configured": bool(settings.get("telegram_bot_token") and settings.get("telegram_chat_id")),
+ },
+ }
+
+
+@router.put("/settings")
+async def save_notification_settings(request: Request, user: str = Depends(require_auth)):
+ """Save notification preferences."""
+ body = await request.json()
+ settings = load_settings()
+
+ # Notification toggles per category
+ if "notifications" in body:
+ settings["notifications"] = body["notifications"]
+
+ # Email settings
+ if "smtp_host" in body:
+ settings["smtp_host"] = body["smtp_host"]
+ if "smtp_port" in body:
+ settings["smtp_port"] = int(body["smtp_port"])
+ if "smtp_user" in body:
+ settings["smtp_user"] = body["smtp_user"]
+ if "smtp_password" in body:
+ settings["smtp_password"] = body["smtp_password"]
+ if "email_to" in body:
+ settings["email_to"] = body["email_to"]
+
+ # WhatsApp settings
+ if "whatsapp_phone" in body:
+ settings["whatsapp_phone"] = body["whatsapp_phone"]
+ if "whatsapp_apikey" in body:
+ settings["whatsapp_apikey"] = body["whatsapp_apikey"]
+
+ save_settings(settings)
+ return {"status": "ok"}
+
+
+@router.post("/test/{channel}")
+async def test_notification(channel: str, request: Request, user: str = Depends(require_auth)):
+ """Send test notification via specified channel."""
+ from server.services.notifier import send_telegram, send_email, send_whatsapp
+
+ message = "🧪 Тест уведомления VPS Monitoring"
+ ok = False
+
+ if channel == "telegram":
+ ok = await send_telegram(message)
+ elif channel == "email":
+ ok = await send_email("Тест", message)
+ elif channel == "whatsapp":
+ ok = await send_whatsapp(message)
+ else:
+ return {"status": "error", "detail": f"unknown channel: {channel}"}
+
+ return {"status": "ok" if ok else "error", "channel": channel}
+
+
+@router.post("/generate-pc-agent")
+async def generate_pc_agent(request: Request, user: str = Depends(require_auth)):
+ """Generate personalized PC agent install command."""
+ body = await request.json()
+ agent_name = body.get("agent_name", "MyPC").strip()
+ server_url = body.get("server_url", "").strip()
+
+ if not server_url:
+ # Auto-detect
+ host = request.headers.get("host", "localhost:7272")
+ proto = "http"
+ server_url = f"{proto}://{host}"
+
+ cmd = (
+ f'powershell -ExecutionPolicy Bypass -Command '
+ f'"Invoke-WebRequest -Uri \'{server_url}/static/downloads/install_agent.ps1\' '
+ f'-OutFile install_agent.ps1; .\\install_agent.ps1 '
+ f'-ServerUrl \'{server_url}\' -AgentName \'{agent_name}\'"'
+ )
+
+ return {"status": "ok", "command": cmd, "agent_name": agent_name}
diff --git a/server/api/synology.py b/server/api/synology.py
new file mode 100644
index 0000000..d713f8c
--- /dev/null
+++ b/server/api/synology.py
@@ -0,0 +1,132 @@
+"""Synology NAS monitoring API endpoints."""
+
+import json
+from datetime import datetime
+from typing import Dict, List
+
+from fastapi import APIRouter, Request, Depends
+
+from server.auth import require_auth
+from server.config import DATA_DIR
+
+router = APIRouter(prefix="/api/synology", tags=["synology"])
+
+# In-memory cache
+synology_metrics: Dict[str, dict] = {}
+
+SYNOLOGY_FILE = DATA_DIR / "synology.json"
+
+
+def _load_synology():
+ if SYNOLOGY_FILE.exists():
+ with open(SYNOLOGY_FILE) as f:
+ return json.load(f)
+ return []
+
+
+def _save_synology(data):
+ with open(SYNOLOGY_FILE, "w") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+
+@router.get("/list")
+async def synology_list(request: Request, user: str = Depends(require_auth)):
+ """List all Synology NAS devices with metrics."""
+ devices = _load_synology()
+ # Merge with in-memory cache
+ for dev in devices:
+ name = dev["name"]
+ if name in synology_metrics:
+ dev["metrics"] = synology_metrics[name]
+ return devices
+
+
+@router.post("/add")
+async def synology_add(request: Request, user: str = Depends(require_auth)):
+ """Add a Synology NAS device."""
+ body = await request.json()
+ name = body.get("name", "").strip()
+ host = body.get("host", "").strip()
+ if not name or not host:
+ return {"status": "error", "detail": "name and host required"}
+
+ devices = _load_synology()
+ device = {
+ "name": name,
+ "host": host,
+ "port": int(body.get("port", 5000)),
+ "https": body.get("https", False),
+ "username": body.get("username", ""),
+ "password": body.get("password", ""),
+ "added": datetime.now().isoformat(),
+ }
+ devices.append(device)
+ _save_synology(devices)
+
+ return {"status": "ok"}
+
+
+@router.delete("/{name}")
+async def synology_delete(name: str, request: Request, user: str = Depends(require_auth)):
+ """Remove a Synology NAS device."""
+ devices = _load_synology()
+ devices = [d for d in devices if d["name"] != name]
+ _save_synology(devices)
+ synology_metrics.pop(name, None)
+ return {"status": "ok"}
+
+
+@router.post("/refresh/{name}")
+async def synology_refresh(name: str, request: Request, user: str = Depends(require_auth)):
+ """Force refresh metrics for a Synology device."""
+ from server.services.synology_client import SynologyClient
+
+ devices = _load_synology()
+ dev = next((d for d in devices if d["name"] == name), None)
+ if not dev:
+ return {"status": "error", "detail": "device not found"}
+
+ client = SynologyClient(
+ host=dev["host"],
+ port=dev.get("port", 5000),
+ https=dev.get("https", False),
+ username=dev.get("username", ""),
+ password=dev.get("password", ""),
+ )
+
+ try:
+ metrics = await client.collect_all_metrics()
+ metrics["last_updated"] = datetime.now().isoformat()
+ synology_metrics[name] = metrics
+ await client.logout()
+ return {"status": "ok", "metrics": metrics}
+ except Exception as e:
+ return {"status": "error", "detail": str(e)}
+
+
+@router.post("/refresh-all")
+async def synology_refresh_all(request: Request, user: str = Depends(require_auth)):
+ """Refresh metrics for all Synology devices."""
+ from server.services.synology_client import SynologyClient
+
+ devices = _load_synology()
+ results = []
+
+ for dev in devices:
+ client = SynologyClient(
+ host=dev["host"],
+ port=dev.get("port", 5000),
+ https=dev.get("https", False),
+ username=dev.get("username", ""),
+ password=dev.get("password", ""),
+ )
+ try:
+ metrics = await client.collect_all_metrics()
+ metrics["last_updated"] = datetime.now().isoformat()
+ synology_metrics[dev["name"]] = metrics
+ await client.logout()
+ results.append({"name": dev["name"], "online": metrics["online"]})
+ except Exception as e:
+ results.append({"name": dev["name"], "online": False, "error": str(e)})
+
+ return {"status": "ok", "results": results}
diff --git a/server/main.py b/server/main.py
index 2c7171d..d933313 100644
--- a/server/main.py
+++ b/server/main.py
@@ -17,6 +17,9 @@ from server.api.servers import router as servers_router
from server.api.auth_routes import router as auth_router
from server.api.ssh_ws import router as ssh_router
from server.api.pc import router as pc_router
+from server.api.synology import router as synology_router
+from server.api.ha import router as ha_router
+from server.api.notifications import router as notifications_router
from server.services.monitor import monitor_loop
from server.services.telegram_bot import start_bot, stop_bot
from server.services.alerter import check_alerts
@@ -74,6 +77,9 @@ app.include_router(servers_router)
app.include_router(auth_router)
app.include_router(ssh_router)
app.include_router(pc_router)
+app.include_router(synology_router)
+app.include_router(ha_router)
+app.include_router(notifications_router)
@app.get("/", response_class=HTMLResponse)
diff --git a/server/services/alerter.py b/server/services/alerter.py
index 06859dd..af5a322 100644
--- a/server/services/alerter.py
+++ b/server/services/alerter.py
@@ -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"
+ )
diff --git a/server/services/ha_client.py b/server/services/ha_client.py
new file mode 100644
index 0000000..bd80c6c
--- /dev/null
+++ b/server/services/ha_client.py
@@ -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
diff --git a/server/services/notifier.py b/server/services/notifier.py
new file mode 100644
index 0000000..725a2ed
--- /dev/null
+++ b/server/services/notifier.py
@@ -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"""
+
+
🖥 VPS Monitoring
+
+ {body.replace(chr(10), ' ')}
+
+
Автоматическое уведомление VPS Monitoring
+
+ """
+ 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)
diff --git a/server/services/synology_client.py b/server/services/synology_client.py
new file mode 100644
index 0000000..b348bbd
--- /dev/null
+++ b/server/services/synology_client.py
@@ -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"
diff --git a/server/static/css/style.css b/server/static/css/style.css
index 5e178bc..4aa8e3e 100644
--- a/server/static/css/style.css
+++ b/server/static/css/style.css
@@ -633,6 +633,75 @@ main {
color: white;
}
+/* HA Summary */
+.ha-summary {
+ font-size: 12px;
+ line-height: 1.6;
+ margin-bottom: 8px;
+}
+
+.ha-stat {
+ padding: 2px 0;
+ color: var(--text-secondary);
+}
+
+/* Notification Matrix */
+.notify-channels {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.notify-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 14px;
+}
+
+.notify-table th,
+.notify-table td {
+ padding: 10px 12px;
+ text-align: center;
+ border-bottom: 1px solid var(--border);
+}
+
+.notify-table th {
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.notify-table td:first-child,
+.notify-table th:first-child {
+ text-align: left;
+}
+
+.notify-table input[type="checkbox"] {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+ accent-color: var(--accent);
+}
+
+/* Small button */
+.btn-sm {
+ padding: 6px 14px;
+ font-size: 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ cursor: pointer;
+ transition: background 0.2s;
+}
+
+.btn-sm:hover {
+ background: var(--accent);
+ color: white;
+}
+
/* Footer */
.app-footer {
text-align: center;
@@ -660,4 +729,7 @@ main {
.stats-bar { flex-direction: column; gap: 8px; }
header { padding: 12px 16px; }
.header-right { gap: 4px; }
+ .tabs { flex-wrap: wrap; }
+ .tab { flex: 0 1 auto; padding: 8px 12px; font-size: 12px; }
+ .notify-channels { grid-template-columns: 1fr; }
}
diff --git a/server/static/js/app.js b/server/static/js/app.js
index d00bb66..0915323 100644
--- a/server/static/js/app.js
+++ b/server/static/js/app.js
@@ -197,9 +197,10 @@ function showInstructions() {
POST /api/servers — add server
DELETE /api/servers/{id} — remove
POST /api/servers/{id}/reboot — reboot
- POST /api/servers/{id}/exec — run command
- GET /api/settings — get settings
- PUT /api/settings — update settings
+ GET /api/synology/list — Synology NAS
+ GET /api/ha/list — Home Assistant
+ GET /api/pc/list — PC agents
+ GET /api/notifications/settings
WS /ws/ssh/{id} — SSH terminal
@@ -224,18 +225,13 @@ function openSSH(id, name) {
const term = new Terminal({
cursorBlink: true,
- theme: {
- background: '#000000',
- foreground: '#e8e8ff',
- cursor: '#6366f1',
- },
+ theme: { background: '#000000', foreground: '#e8e8ff', cursor: '#6366f1' },
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
});
term.open(container);
- // Fit addon
if (typeof FitAddon !== 'undefined') {
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
@@ -249,38 +245,16 @@ function openSSH(id, name) {
const ws = new WebSocket(`${protocol}//${location.host}/ws/ssh/${id}`);
currentWs = ws;
- ws.onopen = () => {
- term.write('\r\n\x1b[32mConnecting...\x1b[0m\r\n');
- };
-
- ws.onmessage = (event) => {
- term.write(event.data);
- };
-
- ws.onerror = () => {
- term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
- };
-
- ws.onclose = () => {
- term.write('\r\n\x1b[33mConnection closed\x1b[0m\r\n');
- };
-
- term.onData(data => {
- if (ws.readyState === WebSocket.OPEN) {
- ws.send(data);
- }
- });
+ ws.onopen = () => term.write('\r\n\x1b[32mConnecting...\x1b[0m\r\n');
+ ws.onmessage = (event) => term.write(event.data);
+ ws.onerror = () => term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n');
+ ws.onclose = () => term.write('\r\n\x1b[33mConnection closed\x1b[0m\r\n');
+ term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(data); });
}
function closeSSH() {
- if (currentWs) {
- currentWs.close();
- currentWs = null;
- }
- if (currentTerminal) {
- currentTerminal.dispose();
- currentTerminal = null;
- }
+ if (currentWs) { currentWs.close(); currentWs = null; }
+ if (currentTerminal) { currentTerminal.dispose(); currentTerminal = null; }
closeModal('sshModal');
}
@@ -290,9 +264,7 @@ async function rebootServer(id) {
try {
await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'});
alert(t('rebootSent'));
- } catch (e) {
- alert('Error: ' + e.message);
- }
+ } catch (e) { alert('Error: ' + e.message); }
}
async function deleteServer(id) {
@@ -300,9 +272,7 @@ async function deleteServer(id) {
try {
await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'});
loadServers();
- } catch (e) {
- alert('Error: ' + e.message);
- }
+ } catch (e) { alert('Error: ' + e.message); }
}
// Add server
@@ -337,9 +307,7 @@ document.getElementById('addServerForm')?.addEventListener('submit', async (e) =
const err = await resp.json();
alert(err.detail || 'Error');
}
- } catch (e) {
- alert('Error: ' + e.message);
- }
+ } catch (e) { alert('Error: ' + e.message); }
});
// Settings
@@ -383,9 +351,7 @@ document.getElementById('settingsForm')?.addEventListener('submit', async (e) =>
});
closeModal('settingsModal');
alert(t('settingsSaved'));
- } catch (e) {
- alert('Error: ' + e.message);
- }
+ } catch (e) { alert('Error: ' + e.message); }
});
// Utils
@@ -394,7 +360,10 @@ function closeModal(id) {
}
function refreshAll() {
- loadServers();
+ if (activeTab === 'servers') loadServers();
+ else if (activeTab === 'pc') loadPCs();
+ else if (activeTab === 'synology') loadSynology();
+ else if (activeTab === 'ha') loadHA();
}
async function logout() {
@@ -402,31 +371,43 @@ async function logout() {
window.location.href = '/login';
}
+function copyToClipboard(text) {
+ navigator.clipboard.writeText(text).then(() => {
+ // Brief visual feedback
+ const el = event.target;
+ const orig = el.style.color;
+ el.style.color = 'var(--accent)';
+ setTimeout(() => el.style.color = orig, 500);
+ });
+}
+
// Click outside modal to close
document.addEventListener('click', (e) => {
if (e.target.classList.contains('modal')) {
- if (e.target.id === 'sshModal') {
- closeSSH();
- } else {
- e.target.style.display = 'none';
- }
+ if (e.target.id === 'sshModal') closeSSH();
+ else e.target.style.display = 'none';
}
});
-// Tabs
+// ===================== TABS =====================
let activeTab = 'servers';
+const allTabs = ['servers', 'pc', 'synology', 'ha'];
function switchTab(tab) {
activeTab = tab;
- document.getElementById('tab-servers').classList.toggle('active', tab === 'servers');
- document.getElementById('tab-pc').classList.toggle('active', tab === 'pc');
- document.getElementById('panel-servers').style.display = tab === 'servers' ? '' : 'none';
- document.getElementById('panel-pc').style.display = tab === 'pc' ? '' : 'none';
+ allTabs.forEach(t => {
+ const tabBtn = document.getElementById('tab-' + t);
+ const panel = document.getElementById('panel-' + t);
+ if (tabBtn) tabBtn.classList.toggle('active', t === tab);
+ if (panel) panel.style.display = t === tab ? '' : 'none';
+ });
if (tab === 'pc') loadPCs();
+ else if (tab === 'synology') loadSynology();
+ else if (tab === 'ha') loadHA();
}
-// PC monitoring
+// ===================== PC MONITORING =====================
let pcAgents = [];
async function loadPCs() {
@@ -435,16 +416,14 @@ async function loadPCs() {
if (resp.status === 401) return;
pcAgents = await resp.json();
renderPCs();
- } catch (e) {
- console.error('PC load error:', e);
- }
+ } catch (e) { console.error('PC load error:', e); }
}
function renderPCs() {
const grid = document.getElementById('pc-grid');
if (!pcAgents.length) {
- grid.innerHTML = 'Нет подключённых ПК. Установите агент.
';
+ grid.innerHTML = 'Нет подключённых ПК. Нажмите "📥 Скачать агент".
';
return;
}
@@ -522,7 +501,556 @@ function showPCSetup() {
document.getElementById('pcSetupModal').style.display = 'flex';
}
-// Initial load
+function showPCDownload() {
+ document.getElementById('pc-agent-name').value = '';
+ document.getElementById('pc-generated-cmd').style.display = 'none';
+ document.getElementById('pcDownloadModal').style.display = 'flex';
+}
+
+async function generatePCCommand() {
+ const name = document.getElementById('pc-agent-name').value.trim() || 'MyPC';
+ try {
+ const resp = await fetch('/api/notifications/generate-pc-agent', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ credentials: 'include',
+ body: JSON.stringify({agent_name: name}),
+ });
+ const data = await resp.json();
+ if (data.command) {
+ document.getElementById('pc-gen-code').textContent = data.command;
+ document.getElementById('pc-generated-cmd').style.display = 'block';
+ }
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+// ===================== SYNOLOGY =====================
+let synologyDevices = [];
+
+async function loadSynology() {
+ try {
+ const resp = await fetch('/api/synology/list', {credentials: 'include'});
+ if (resp.status === 401) return;
+ synologyDevices = await resp.json();
+ renderSynology();
+ } catch (e) { console.error('Synology load error:', e); }
+}
+
+function renderSynology() {
+ const grid = document.getElementById('synology-grid');
+
+ if (!synologyDevices.length) {
+ grid.innerHTML = 'Нет Synology NAS. Нажмите "+ Добавить NAS".
';
+ return;
+ }
+
+ grid.innerHTML = synologyDevices.map(dev => {
+ const m = dev.metrics || {};
+ const isOnline = m.online;
+ const cpu = m.cpu_percent || 0;
+ const ram = m.ram_percent || 0;
+ const temp = m.temperature || 0;
+
+ const cpuClass = cpu > 90 ? 'crit' : cpu > 70 ? 'warn' : '';
+ const ramClass = ram > 90 ? 'crit' : ram > 70 ? 'warn' : '';
+ const tempClass = temp > 60 ? 'crit' : temp > 50 ? 'warn' : '';
+
+ const vmsCount = (m.vms || []).length;
+ const dockerCount = (m.docker || []).length;
+ const volsHtml = (m.volumes || []).map(v => {
+ const vc = v.percent > 90 ? 'crit' : v.percent > 70 ? 'warn' : '';
+ return `
+ ${v.name}: ${v.used_gb}/${v.total_gb} GB
+
+
`;
+ }).join('');
+
+ return `
+
+
+ ${isOnline ? `
+
+
+
+
+ 🌡️
+ ${temp}°C
+
+
+ ${m.dsm_version ? `
${m.dsm_version} • ${m.uptime || ''}
` : ''}
+ ${vmsCount ? `
🖥 ${vmsCount} VM
` : ''}
+ ${dockerCount ? `
🐳 ${dockerCount} контейнеров
` : ''}
+ ${volsHtml}
+ ` : '
Нет данных — нажмите 🔄
'}
+
+ 🔄 Обновить
+ 🗑
+
+
+ `;
+ }).join('');
+}
+
+function showSynologyDetail(name) {
+ const dev = synologyDevices.find(d => d.name === name);
+ if (!dev || !dev.metrics) return;
+
+ const m = dev.metrics;
+ const modal = document.getElementById('synDetailModal');
+ document.getElementById('syn-detail-title').textContent = `📦 ${name}`;
+
+ let html = ``;
+
+ // System
+ html += `
💻 Система
+
Модель: ${m.model || 'N/A'} ${m.dsm_version || ''} Uptime: ${m.uptime || 'N/A'}
+ Температура: ${m.temperature || 0}°C CPU: ${m.cpu_percent || 0}% | RAM: ${m.ram_percent || 0}% (${m.ram_used_mb || 0}/${m.ram_total_mb || 0} MB)
`;
+
+ // Volumes
+ if (m.volumes?.length) {
+ html += `
💾 Тома `;
+ m.volumes.forEach(v => {
+ const vc = v.percent > 90 ? 'crit' : v.percent > 70 ? 'warn' : 'ok';
+ html += `
${v.name} — ${v.used_gb}/${v.total_gb} GB (${v.percent}%) ${v.status ? '• ' + v.status : ''}
+
`;
+ });
+ html += `
`;
+ }
+
+ // Disks
+ if (m.disks?.length) {
+ html += `
🔩 Диски `;
+ m.disks.forEach(d => {
+ html += `
${d.name} — ${d.model || ''} (${d.size_gb} GB) 🌡${d.temp}°C ${d.smart_status ? '• SMART: ' + d.smart_status : ''} ${d.status || ''}
`;
+ });
+ html += `
`;
+ }
+
+ // VMs
+ if (m.vms?.length) {
+ html += `
🖥 Виртуальные машины `;
+ m.vms.forEach(vm => {
+ const st = vm.status === 'running' ? '🟢' : '🔴';
+ html += `
${st} ${vm.name} — ${vm.vcpu} vCPU, ${vm.ram_mb} MB RAM ${vm.autorun ? '• AutoStart' : ''}
`;
+ });
+ html += `
`;
+ }
+
+ // Docker
+ if (m.docker?.length) {
+ html += `
🐳 Docker контейнеры `;
+ m.docker.forEach(c => {
+ const st = c.state === 'running' ? '🟢' : '🔴';
+ html += `
${st} ${c.name} — ${c.image || ''}
`;
+ });
+ html += `
`;
+ }
+
+ html += `
`;
+ document.getElementById('syn-detail-content').innerHTML = html;
+ modal.style.display = 'flex';
+}
+
+function showAddSynology() {
+ document.getElementById('addSynologyModal').style.display = 'flex';
+}
+
+document.getElementById('addSynologyForm')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ name: form.name.value,
+ host: form.host.value,
+ port: parseInt(form.port.value) || 5000,
+ https: form.querySelector('[name=https]').checked,
+ username: form.username.value,
+ password: form.password.value,
+ };
+
+ try {
+ const resp = await fetch('/api/synology/add', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ credentials: 'include',
+ body: JSON.stringify(data),
+ });
+ if (resp.ok) {
+ closeModal('addSynologyModal');
+ form.reset();
+ loadSynology();
+ }
+ } catch (e) { alert('Error: ' + e.message); }
+});
+
+async function refreshSynology(name) {
+ try {
+ await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
+ loadSynology();
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+async function refreshAllSynology() {
+ try {
+ await fetch('/api/synology/refresh-all', {method: 'POST', credentials: 'include'});
+ loadSynology();
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+async function deleteSynology(name) {
+ if (!confirm(`Удалить Synology "${name}"?`)) return;
+ await fetch(`/api/synology/${encodeURIComponent(name)}`, {method: 'DELETE', credentials: 'include'});
+ loadSynology();
+}
+
+// ===================== HOME ASSISTANT =====================
+let haInstances = [];
+
+async function loadHA() {
+ try {
+ const resp = await fetch('/api/ha/list', {credentials: 'include'});
+ if (resp.status === 401) return;
+ haInstances = await resp.json();
+ renderHA();
+ } catch (e) { console.error('HA load error:', e); }
+}
+
+function renderHA() {
+ const grid = document.getElementById('ha-grid');
+
+ if (!haInstances.length) {
+ grid.innerHTML = 'Нет Home Assistant. Нажмите "+ Добавить HA".
';
+ return;
+ }
+
+ grid.innerHTML = haInstances.map(inst => {
+ const m = inst.metrics || {};
+ const isOnline = m.online;
+ const temps = (m.sensors?.temperature || []).filter(s => s.value !== null).slice(0, 4);
+ const humidity = (m.sensors?.humidity || []).filter(s => s.value !== null).slice(0, 4);
+ const doors = m.sensors?.door || [];
+ const motion = m.sensors?.motion || [];
+ const battery = (m.sensors?.battery || []).filter(s => s.value !== null && s.value < 20);
+ const problems = (m.problem_entities || []).length;
+ const updates = (m.updates_available || []).length;
+ const persons = m.persons || [];
+
+ return `
+
+
+ ${isOnline ? `
+
+
📡 ${m.entities_count || 0} сущностей
+ ${persons.length ? `
👤 ${persons.map(p => `${p.name}: ${p.state === 'home' ? '🏠' : '🚗'}`).join(', ')}
` : ''}
+ ${temps.length ? `
🌡 ${temps.map(s => s.name.substring(0, 15) + ': ' + s.value + s.unit).join(' | ')}
` : ''}
+ ${humidity.length ? `
💧 ${humidity.map(s => s.name.substring(0, 15) + ': ' + s.value + '%').join(' | ')}
` : ''}
+ ${doors.length ? `
🚪 ${doors.filter(d => d.state === 'on').length ? '' + doors.filter(d => d.state === 'on').length + ' открыто ' : 'все закрыты ✅'}
` : ''}
+ ${motion.length ? `
🏃 ${motion.filter(m => m.state === 'on').length ? 'движение! ' : 'нет движения'}
` : ''}
+ ${battery.length ? `
🔋 ${battery.length} устройств с низким зарядом
` : ''}
+ ${problems ? `
⚠️ ${problems} проблемных сущностей
` : ''}
+ ${updates ? `
📦 ${updates} обновлений
` : ''}
+
+ ` : '
Нет данных — нажмите 🔄
'}
+
+ 🔄 Обновить
+ 🗑
+
+
+ `;
+ }).join('');
+}
+
+function showHADetail(name) {
+ const inst = haInstances.find(i => i.name === name);
+ if (!inst || !inst.metrics) return;
+
+ const m = inst.metrics;
+ const modal = document.getElementById('haDetailModal');
+ document.getElementById('ha-detail-title').textContent = `🏠 ${name}`;
+
+ let html = ``;
+
+ // System
+ html += `
🔧 Система
+
Версия: ${m.version || 'N/A'} Локация: ${m.location_name || 'N/A'}
+ Компоненты: ${m.components_count || 0} Сущности: ${m.entities_count || 0}
`;
+
+ // Temperatures
+ const temps = m.sensors?.temperature || [];
+ if (temps.length) {
+ html += `
🌡️ Температура `;
+ temps.forEach(s => {
+ if (s.value === null) return;
+ const cls = s.value > 30 ? 'style="color:var(--danger)"' : s.value < 10 ? 'style="color:var(--accent)"' : '';
+ html += `
${s.name}: ${s.value}${s.unit}
`;
+ });
+ html += `
`;
+ }
+
+ // Humidity
+ const humid = m.sensors?.humidity || [];
+ if (humid.length) {
+ html += `
💧 Влажность `;
+ humid.forEach(s => { if (s.value !== null) html += `
${s.name}: ${s.value}%
`; });
+ html += `
`;
+ }
+
+ // Doors/Windows
+ const doors = m.sensors?.door || [];
+ if (doors.length) {
+ html += `
🚪 Двери / Окна `;
+ doors.forEach(d => {
+ const icon = d.state === 'on' ? '🔓 Открыто' : '🔒 Закрыто';
+ const cls = d.state === 'on' ? 'style="color:var(--warning)"' : '';
+ html += `
${d.name}: ${icon}
`;
+ });
+ html += `
`;
+ }
+
+ // Motion
+ const motion = m.sensors?.motion || [];
+ if (motion.length) {
+ html += `
🏃 Движение `;
+ motion.forEach(s => {
+ const cls = s.state === 'on' ? 'style="color:var(--warning)"' : '';
+ html += `
${s.name}: ${s.state === 'on' ? '⚡ Обнаружено' : '—'}
`;
+ });
+ html += `
`;
+ }
+
+ // Battery
+ const battery = m.sensors?.battery || [];
+ if (battery.length) {
+ html += `
🔋 Батареи `;
+ battery.forEach(b => {
+ if (b.value === null) return;
+ const cls = b.value < 20 ? 'crit' : b.value < 50 ? 'warn' : '';
+ html += `
${b.value}% ${b.name}
`;
+ });
+ html += `
`;
+ }
+
+ // Climate
+ if (m.climate?.length) {
+ html += `
🌡 Климат `;
+ m.climate.forEach(c => {
+ html += `
${c.name}: ${c.state} (${c.current_temp || '?'}° → ${c.target_temp || '?'}°) ${c.hvac_action}
`;
+ });
+ html += `
`;
+ }
+
+ // Persons
+ if (m.persons?.length) {
+ html += `
👤 Люди `;
+ m.persons.forEach(p => {
+ const icon = p.state === 'home' ? '🏠 Дома' : '🚗 Не дома';
+ html += `
${p.name}: ${icon}
`;
+ });
+ html += `
`;
+ }
+
+ // Automations
+ if (m.automations?.length) {
+ html += `
⚙️ Автоматизации (${m.automations.length}) `;
+ m.automations.slice(0, 20).forEach(a => {
+ const st = a.state === 'on' ? '🟢' : '🔴';
+ html += `
${st} ${a.name}
`;
+ });
+ html += `
`;
+ }
+
+ // Updates
+ if (m.updates_available?.length) {
+ html += `
📦 Доступные обновления `;
+ m.updates_available.forEach(u => {
+ html += `
${u.name}: ${u.installed} → ${u.latest}
`;
+ });
+ html += `
`;
+ }
+
+ // Problems
+ if (m.problem_entities?.length) {
+ html += `
⚠️ Проблемные сущности `;
+ m.problem_entities.slice(0, 20).forEach(p => {
+ html += `
${p.entity_id}: ${p.state}
`;
+ });
+ html += `
`;
+ }
+
+ html += `
`;
+ document.getElementById('ha-detail-content').innerHTML = html;
+ modal.style.display = 'flex';
+}
+
+function showAddHA() {
+ document.getElementById('addHAModal').style.display = 'flex';
+}
+
+document.getElementById('addHAForm')?.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const form = e.target;
+ const data = {
+ name: form.name.value,
+ url: form.url.value,
+ token: form.token.value,
+ };
+
+ try {
+ const resp = await fetch('/api/ha/add', {
+ method: 'POST',
+ headers: {'Content-Type': 'application/json'},
+ credentials: 'include',
+ body: JSON.stringify(data),
+ });
+ if (resp.ok) {
+ closeModal('addHAModal');
+ form.reset();
+ loadHA();
+ }
+ } catch (e) { alert('Error: ' + e.message); }
+});
+
+async function refreshHA(name) {
+ try {
+ await fetch(`/api/ha/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
+ loadHA();
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+async function refreshAllHA() {
+ try {
+ await fetch('/api/ha/refresh-all', {method: 'POST', credentials: 'include'});
+ loadHA();
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+async function deleteHA(name) {
+ if (!confirm(`Удалить Home Assistant "${name}"?`)) return;
+ await fetch(`/api/ha/${encodeURIComponent(name)}`, {method: 'DELETE', credentials: 'include'});
+ loadHA();
+}
+
+// ===================== NOTIFICATIONS =====================
+async function showNotificationSettings() {
+ try {
+ const resp = await fetch('/api/notifications/settings', {credentials: 'include'});
+ const data = await resp.json();
+
+ const n = data.notifications || {};
+ // Set checkboxes
+ ['servers', 'pc', 'synology', 'ha'].forEach(cat => {
+ const prefs = n[cat] || {};
+ const tgEl = document.getElementById(`n-${cat}-tg`);
+ const emEl = document.getElementById(`n-${cat}-email`);
+ const waEl = document.getElementById(`n-${cat}-wa`);
+ if (tgEl) tgEl.checked = prefs.telegram !== false;
+ if (emEl) emEl.checked = !!prefs.email;
+ if (waEl) waEl.checked = !!prefs.whatsapp;
+ });
+
+ // Email fields
+ document.getElementById('n-smtp-host').value = data.email?.smtp_host || '';
+ document.getElementById('n-smtp-port').value = data.email?.smtp_port || 587;
+ document.getElementById('n-smtp-user').value = data.email?.smtp_user || '';
+ document.getElementById('n-email-to').value = data.email?.email_to || '';
+
+ // WhatsApp
+ document.getElementById('n-wa-phone').value = data.whatsapp?.phone || '';
+
+ // Status indicators
+ document.getElementById('tg-status').innerHTML = data.telegram?.configured
+ ? '✅ Настроен '
+ : '❌ Не настроен (укажите Bot Token и Chat ID в ⚙️) ';
+
+ } catch (e) { console.error(e); }
+
+ document.getElementById('notifyModal').style.display = 'flex';
+}
+
+async function saveNotificationSettings() {
+ const notifications = {};
+ ['servers', 'pc', 'synology', 'ha'].forEach(cat => {
+ notifications[cat] = {
+ telegram: document.getElementById(`n-${cat}-tg`)?.checked || false,
+ email: document.getElementById(`n-${cat}-email`)?.checked || false,
+ whatsapp: document.getElementById(`n-${cat}-wa`)?.checked || false,
+ };
+ });
+
+ const data = {
+ notifications,
+ smtp_host: document.getElementById('n-smtp-host').value,
+ smtp_port: parseInt(document.getElementById('n-smtp-port').value) || 587,
+ smtp_user: document.getElementById('n-smtp-user').value,
+ smtp_password: document.getElementById('n-smtp-pass').value,
+ email_to: document.getElementById('n-email-to').value,
+ whatsapp_phone: document.getElementById('n-wa-phone').value,
+ whatsapp_apikey: document.getElementById('n-wa-apikey').value,
+ };
+
+ // Don't send empty password
+ if (!data.smtp_password) delete data.smtp_password;
+ if (!data.whatsapp_apikey) delete data.whatsapp_apikey;
+
+ try {
+ await fetch('/api/notifications/settings', {
+ method: 'PUT',
+ headers: {'Content-Type': 'application/json'},
+ credentials: 'include',
+ body: JSON.stringify(data),
+ });
+ closeModal('notifyModal');
+ alert('Настройки уведомлений сохранены');
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+async function testNotification(channel) {
+ try {
+ // Save first if email/whatsapp have new values
+ if (channel !== 'telegram') {
+ await saveNotificationSettings();
+ // Re-open modal
+ document.getElementById('notifyModal').style.display = 'flex';
+ }
+
+ const resp = await fetch(`/api/notifications/test/${channel}`, {
+ method: 'POST', credentials: 'include'
+ });
+ const data = await resp.json();
+ if (data.status === 'ok') {
+ alert(`✅ Тестовое уведомление отправлено через ${channel}`);
+ } else {
+ alert(`❌ Ошибка отправки через ${channel}. Проверьте настройки.`);
+ }
+ } catch (e) { alert('Error: ' + e.message); }
+}
+
+// ===================== INIT =====================
document.addEventListener('DOMContentLoaded', () => {
applyTheme(currentTheme);
renderPage();
@@ -531,5 +1059,7 @@ document.addEventListener('DOMContentLoaded', () => {
setInterval(() => {
if (activeTab === 'servers') loadServers();
- else loadPCs();
+ else if (activeTab === 'pc') loadPCs();
+ else if (activeTab === 'synology') loadSynology();
+ else if (activeTab === 'ha') loadHA();
}, 30000);
diff --git a/server/static/js/i18n.js b/server/static/js/i18n.js
index f14beb4..58dbbcc 100644
--- a/server/static/js/i18n.js
+++ b/server/static/js/i18n.js
@@ -73,6 +73,8 @@ const translations = {
instr_api: "API endpoints",
instr_thresholds: "Пороги алертов",
instr_thresholds_text: "Настройки → Мониторинг. По умолчанию: CPU 90%, RAM 90%, Disk 90%",
+ tabServers: "Серверы",
+ tabPC: "ПК",
},
en: {
title: "VPS Monitoring",
@@ -148,6 +150,8 @@ const translations = {
instr_api: "API endpoints",
instr_thresholds: "Alert thresholds",
instr_thresholds_text: "Settings → Monitoring. Default: CPU 90%, RAM 90%, Disk 90%",
+ tabServers: "Servers",
+ tabPC: "PC",
}
};
diff --git a/server/templates/dashboard.html b/server/templates/dashboard.html
index b4cf9bf..187b033 100644
--- a/server/templates/dashboard.html
+++ b/server/templates/dashboard.html
@@ -19,6 +19,7 @@
🌙
🔄
📖
+ 🔔
⚙️
🚪
@@ -42,8 +43,10 @@
- 🖥 Серверы
- 💻 ПК
+ 🖥 Серверы
+ 💻 ПК
+ 📦 Synology
+ 🏠 Home Assistant
@@ -57,11 +60,30 @@
- 📋 Инструкция установки
+ 📥 Скачать агент
+ 📋 Инструкция
🔄 Обновить
+
+
+
+
+ + Добавить NAS
+ 🔄 Обновить все
+
+
+
+
+
+
+
+ + Добавить HA
+ 🔄 Обновить все
+
+
+
@@ -101,6 +123,195 @@
+
+
+
+
+
+
+
+
+
+
+
+ Имя компьютера (агента)
+
+
+
🔧 Сгенерировать команду
+
+
+
📋 Команда установки (PowerShell от Администратора)
+
+
Нажмите на команду чтобы скопировать
+
+
+
+
+
+
+
+
+
+
+
+
📨 Каналы доставки
+
+
+
📱 Telegram
+
Настраивается в Настройках (⚙️) — Bot Token и Chat ID
+
+
🧪 Тест
+
+
+
+
💬 WhatsApp (CallMeBot)
+
+ 1. Отправьте "I allow callmebot to send me messages" на +34 644 71 85 23 в WhatsApp
+ 2. Получите apikey в ответном сообщении
+ 3. Введите данные ниже
+
+
+ Телефон (с кодом страны)
+
+
+
+ API Key
+
+
+
🧪 Тест
+
+
+
+
+
📊 Какие уведомления отправлять
+
+
+
💾 Сохранить
+
+
+
+
+
+
+
+
+
@@ -213,11 +446,6 @@
Unregister-ScheduledTask 'VPS-Monitor-Agent'
-
-
📁 Файлы
-
Агент: C:\VPS-Monitor\vps_monitor_agent.ps1
- Конфиг: C:\VPS-Monitor\agent_config.json
-