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 ` +
    +
    +
    +
    📦 ${dev.name}
    +
    ${dev.host}:${dev.port || 5000} ${m.model ? '• ' + m.model : ''}
    +
    + + + ${isOnline ? 'Online' : 'Offline'} + +
    + ${isOnline ? ` +
    +
    + CPU + ${cpu}% +
    +
    +
    + RAM + ${ram}% +
    +
    +
    + 🌡️ + ${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 ` +
    +
    +
    +
    🏠 ${inst.name}
    +
    ${m.location_name || inst.url || ''} ${m.version ? '• v' + m.version : ''}
    +
    + + + ${isOnline ? 'Online' : 'Offline'} + +
    + ${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 @@
    - - + + + +
    @@ -57,11 +60,30 @@ + + + + + + @@ -101,6 +123,195 @@ + + + + + + + + + + + + + + + + + +