mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-21 12:01:58 +00:00
feat: add Synology NAS, Home Assistant monitoring, multi-channel notifications
New tabs: - Synology NAS: CPU, RAM, temp, volumes, VMs, Docker containers - Home Assistant: sensors, automations, persons, climate, battery, doors New features: - Personalized PC agent download with custom name - Notification settings: per-category Telegram/Email/WhatsApp toggles - Email via SMTP, WhatsApp via CallMeBot API - Test notification buttons for each channel - Unified notifier replaces direct Telegram calls in alerter Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
115
server/api/ha.py
Normal file
115
server/api/ha.py
Normal file
@@ -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}
|
||||||
111
server/api/notifications.py
Normal file
111
server/api/notifications.py
Normal file
@@ -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}
|
||||||
132
server/api/synology.py
Normal file
132
server/api/synology.py
Normal file
@@ -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}
|
||||||
@@ -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.auth_routes import router as auth_router
|
||||||
from server.api.ssh_ws import router as ssh_router
|
from server.api.ssh_ws import router as ssh_router
|
||||||
from server.api.pc import router as pc_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.monitor import monitor_loop
|
||||||
from server.services.telegram_bot import start_bot, stop_bot
|
from server.services.telegram_bot import start_bot, stop_bot
|
||||||
from server.services.alerter import check_alerts
|
from server.services.alerter import check_alerts
|
||||||
@@ -74,6 +77,9 @@ app.include_router(servers_router)
|
|||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(ssh_router)
|
app.include_router(ssh_router)
|
||||||
app.include_router(pc_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)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ previous_states: Dict[str, bool] = {}
|
|||||||
|
|
||||||
async def check_alerts():
|
async def check_alerts():
|
||||||
"""Check metrics against thresholds and send 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
|
from server.api.auth_routes import mute_until
|
||||||
|
|
||||||
# Check mute
|
# Check mute
|
||||||
@@ -38,9 +38,15 @@ async def check_alerts():
|
|||||||
# Online/Offline state change
|
# Online/Offline state change
|
||||||
if prev_online is not None and prev_online != current_online:
|
if prev_online is not None and prev_online != current_online:
|
||||||
if current_online:
|
if current_online:
|
||||||
await send_alert(f"🟢 **{name}** ({host}) — снова онлайн")
|
await send_notification(
|
||||||
|
f"🟢 **{name}** ({host}) — снова онлайн",
|
||||||
|
subject=f"{name} Online", category="servers"
|
||||||
|
)
|
||||||
else:
|
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
|
previous_states[host] = current_online
|
||||||
|
|
||||||
@@ -50,12 +56,21 @@ async def check_alerts():
|
|||||||
# Threshold alerts
|
# Threshold alerts
|
||||||
cpu = m.get("cpu_percent", 0)
|
cpu = m.get("cpu_percent", 0)
|
||||||
if cpu >= cpu_threshold:
|
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)
|
ram = m.get("ram_percent", 0)
|
||||||
if ram >= ram_threshold:
|
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)
|
disk = m.get("disk_percent", 0)
|
||||||
if disk >= disk_threshold:
|
if disk >= disk_threshold:
|
||||||
await send_alert(f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)")
|
await send_notification(
|
||||||
|
f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)",
|
||||||
|
subject=f"{name} Disk {disk}%", category="servers"
|
||||||
|
)
|
||||||
|
|||||||
226
server/services/ha_client.py
Normal file
226
server/services/ha_client.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""Home Assistant REST API client for monitoring."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeAssistantClient:
|
||||||
|
"""Client for Home Assistant REST API."""
|
||||||
|
|
||||||
|
def __init__(self, url: str, token: str):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
url: HA base URL, e.g. http://192.168.1.100:8123
|
||||||
|
token: Long-Lived Access Token (from Profile → Security → Long-Lived Access Tokens)
|
||||||
|
"""
|
||||||
|
self.base_url = url.rstrip("/")
|
||||||
|
self.token = token
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get(self, path: str) -> Optional[Any]:
|
||||||
|
"""GET request to HA API."""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{self.base_url}/api{path}",
|
||||||
|
headers=self.headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=15),
|
||||||
|
ssl=False,
|
||||||
|
) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return await resp.json()
|
||||||
|
logger.error(f"HA API {path} returned {resp.status}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"HA API error ({path}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def check_connection(self) -> bool:
|
||||||
|
"""Check if HA is reachable."""
|
||||||
|
data = await self._get("/")
|
||||||
|
return data is not None and data.get("message") == "API running."
|
||||||
|
|
||||||
|
async def get_config(self) -> Optional[dict]:
|
||||||
|
"""Get HA configuration (name, version, components, location)."""
|
||||||
|
return await self._get("/config")
|
||||||
|
|
||||||
|
async def get_states(self) -> Optional[List[dict]]:
|
||||||
|
"""Get all entity states."""
|
||||||
|
return await self._get("/states")
|
||||||
|
|
||||||
|
async def get_services(self) -> Optional[List[dict]]:
|
||||||
|
"""Get available services."""
|
||||||
|
return await self._get("/services")
|
||||||
|
|
||||||
|
async def collect_all_metrics(self) -> dict:
|
||||||
|
"""Collect key metrics from Home Assistant."""
|
||||||
|
result = {
|
||||||
|
"online": False,
|
||||||
|
"version": "",
|
||||||
|
"location_name": "",
|
||||||
|
"components_count": 0,
|
||||||
|
"entities_count": 0,
|
||||||
|
"automations": [],
|
||||||
|
"sensors": {
|
||||||
|
"temperature": [],
|
||||||
|
"humidity": [],
|
||||||
|
"pressure": [],
|
||||||
|
"motion": [],
|
||||||
|
"door": [],
|
||||||
|
"light": [],
|
||||||
|
"battery": [],
|
||||||
|
},
|
||||||
|
"climate": [],
|
||||||
|
"media_players": [],
|
||||||
|
"persons": [],
|
||||||
|
"updates_available": [],
|
||||||
|
"problem_entities": [],
|
||||||
|
"uptime": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check connection
|
||||||
|
if not await self.check_connection():
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["online"] = True
|
||||||
|
|
||||||
|
# Config
|
||||||
|
config = await self.get_config()
|
||||||
|
if config:
|
||||||
|
result["version"] = config.get("version", "")
|
||||||
|
result["location_name"] = config.get("location_name", "")
|
||||||
|
result["components_count"] = len(config.get("components", []))
|
||||||
|
|
||||||
|
# States
|
||||||
|
states = await self.get_states()
|
||||||
|
if not states:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["entities_count"] = len(states)
|
||||||
|
|
||||||
|
for entity in states:
|
||||||
|
eid = entity.get("entity_id", "")
|
||||||
|
state = entity.get("state", "")
|
||||||
|
attrs = entity.get("attributes", {})
|
||||||
|
friendly = attrs.get("friendly_name", eid)
|
||||||
|
|
||||||
|
# Automations
|
||||||
|
if eid.startswith("automation."):
|
||||||
|
result["automations"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state, # on/off
|
||||||
|
"last_triggered": attrs.get("last_triggered", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Temperature sensors
|
||||||
|
elif eid.startswith("sensor.") and attrs.get("device_class") == "temperature":
|
||||||
|
try:
|
||||||
|
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||||
|
result["sensors"]["temperature"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"value": val,
|
||||||
|
"unit": attrs.get("unit_of_measurement", "°C"),
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Humidity sensors
|
||||||
|
elif eid.startswith("sensor.") and attrs.get("device_class") == "humidity":
|
||||||
|
try:
|
||||||
|
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||||
|
result["sensors"]["humidity"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"value": val,
|
||||||
|
"unit": attrs.get("unit_of_measurement", "%"),
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Battery sensors
|
||||||
|
elif eid.startswith("sensor.") and attrs.get("device_class") == "battery":
|
||||||
|
try:
|
||||||
|
val = float(state) if state not in ("unknown", "unavailable") else None
|
||||||
|
result["sensors"]["battery"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"value": val,
|
||||||
|
"unit": "%",
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Motion / occupancy
|
||||||
|
elif eid.startswith("binary_sensor.") and attrs.get("device_class") in ("motion", "occupancy"):
|
||||||
|
result["sensors"]["motion"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state, # on/off
|
||||||
|
})
|
||||||
|
|
||||||
|
# Door / window
|
||||||
|
elif eid.startswith("binary_sensor.") and attrs.get("device_class") in ("door", "window", "opening"):
|
||||||
|
result["sensors"]["door"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state, # on = open, off = closed
|
||||||
|
})
|
||||||
|
|
||||||
|
# Lights
|
||||||
|
elif eid.startswith("light."):
|
||||||
|
result["sensors"]["light"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state,
|
||||||
|
"brightness": attrs.get("brightness"),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Climate
|
||||||
|
elif eid.startswith("climate."):
|
||||||
|
result["climate"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state,
|
||||||
|
"current_temp": attrs.get("current_temperature"),
|
||||||
|
"target_temp": attrs.get("temperature"),
|
||||||
|
"hvac_action": attrs.get("hvac_action", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Media players
|
||||||
|
elif eid.startswith("media_player."):
|
||||||
|
result["media_players"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state,
|
||||||
|
"source": attrs.get("source", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Person
|
||||||
|
elif eid.startswith("person."):
|
||||||
|
result["persons"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"state": state, # home/not_home/zone
|
||||||
|
})
|
||||||
|
|
||||||
|
# Updates available
|
||||||
|
elif eid.startswith("update.") and state == "on":
|
||||||
|
result["updates_available"].append({
|
||||||
|
"name": friendly,
|
||||||
|
"installed": attrs.get("installed_version", ""),
|
||||||
|
"latest": attrs.get("latest_version", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Problem entities (unavailable/unknown)
|
||||||
|
if state in ("unavailable", "unknown") and not eid.startswith(("update.", "scene.")):
|
||||||
|
result["problem_entities"].append({
|
||||||
|
"entity_id": eid,
|
||||||
|
"name": friendly,
|
||||||
|
"state": state,
|
||||||
|
})
|
||||||
|
|
||||||
|
# HA uptime sensor
|
||||||
|
if eid == "sensor.uptime" or eid.endswith("_uptime"):
|
||||||
|
if attrs.get("device_class") == "timestamp" and state not in ("unknown", "unavailable"):
|
||||||
|
result["uptime"] = state
|
||||||
|
|
||||||
|
return result
|
||||||
147
server/services/notifier.py
Normal file
147
server/services/notifier.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Unified notification sender: Telegram, Email, WhatsApp (CallMeBot)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from server.config import load_settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_telegram(message: str, parse_mode: str = "Markdown"):
|
||||||
|
"""Send message via Telegram bot."""
|
||||||
|
settings = load_settings()
|
||||||
|
token = settings.get("telegram_bot_token")
|
||||||
|
chat_id = settings.get("telegram_chat_id")
|
||||||
|
if not token or not chat_id:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
async with session.post(url, json={
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": message,
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
}) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram send error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def send_email(subject: str, body: str):
|
||||||
|
"""Send email via SMTP."""
|
||||||
|
settings = load_settings()
|
||||||
|
smtp_host = settings.get("smtp_host", "")
|
||||||
|
smtp_port = int(settings.get("smtp_port", 587))
|
||||||
|
smtp_user = settings.get("smtp_user", "")
|
||||||
|
smtp_password = settings.get("smtp_password", "")
|
||||||
|
email_to = settings.get("email_to", "")
|
||||||
|
|
||||||
|
if not all([smtp_host, smtp_user, smtp_password, email_to]):
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["From"] = smtp_user
|
||||||
|
msg["To"] = email_to
|
||||||
|
msg["Subject"] = f"🖥 VPS Monitor: {subject}"
|
||||||
|
|
||||||
|
# HTML body
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;
|
||||||
|
background:#1a1a3e;color:#e8e8ff;padding:24px;border-radius:12px">
|
||||||
|
<h2 style="color:#6366f1;margin-bottom:16px">🖥 VPS Monitoring</h2>
|
||||||
|
<div style="background:#0f0f23;padding:16px;border-radius:8px;
|
||||||
|
border-left:4px solid #6366f1;margin-bottom:16px">
|
||||||
|
{body.replace(chr(10), '<br>')}
|
||||||
|
</div>
|
||||||
|
<p style="color:#a0a0cc;font-size:12px">Автоматическое уведомление VPS Monitoring</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
msg.attach(MIMEText(html, "html"))
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.run_in_executor(None, _smtp_send, smtp_host, smtp_port, smtp_user, smtp_password, email_to, msg)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Email send error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_send(host, port, user, password, to, msg):
|
||||||
|
"""Blocking SMTP send (run in executor)."""
|
||||||
|
with smtplib.SMTP(host, port, timeout=15) as server:
|
||||||
|
server.starttls()
|
||||||
|
server.login(user, password)
|
||||||
|
server.sendmail(user, to, msg.as_string())
|
||||||
|
|
||||||
|
|
||||||
|
async def send_whatsapp(message: str):
|
||||||
|
"""Send WhatsApp message via CallMeBot API.
|
||||||
|
|
||||||
|
Setup: User sends "I allow callmebot to send me messages" to
|
||||||
|
+34 644 71 85 23 on WhatsApp, gets apikey.
|
||||||
|
Store phone + apikey in settings.
|
||||||
|
"""
|
||||||
|
settings = load_settings()
|
||||||
|
wa_phone = settings.get("whatsapp_phone", "")
|
||||||
|
wa_apikey = settings.get("whatsapp_apikey", "")
|
||||||
|
|
||||||
|
if not wa_phone or not wa_apikey:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Clean message for URL
|
||||||
|
clean_msg = message.replace("**", "*")
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
url = "https://api.callmebot.com/whatsapp.php"
|
||||||
|
params = {
|
||||||
|
"phone": wa_phone,
|
||||||
|
"text": clean_msg,
|
||||||
|
"apikey": wa_apikey,
|
||||||
|
}
|
||||||
|
async with session.get(url, params=params) as resp:
|
||||||
|
return resp.status == 200
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WhatsApp send error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def send_notification(message: str, subject: str = "Alert", category: str = "servers"):
|
||||||
|
"""Send notification via all enabled channels for given category.
|
||||||
|
|
||||||
|
Categories: servers, pc, synology, ha
|
||||||
|
Settings key: notify_{category}_{channel} = true/false
|
||||||
|
Channels: telegram, email, whatsapp
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from server.api.auth_routes import mute_until
|
||||||
|
|
||||||
|
# Check global mute
|
||||||
|
if mute_until and datetime.now() < mute_until:
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = load_settings()
|
||||||
|
notify_prefs = settings.get("notifications", {})
|
||||||
|
|
||||||
|
# Default: telegram enabled for all
|
||||||
|
cat_prefs = notify_prefs.get(category, {"telegram": True, "email": False, "whatsapp": False})
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
if cat_prefs.get("telegram", True):
|
||||||
|
tasks.append(send_telegram(message))
|
||||||
|
if cat_prefs.get("email", False):
|
||||||
|
tasks.append(send_email(subject, message))
|
||||||
|
if cat_prefs.get("whatsapp", False):
|
||||||
|
tasks.append(send_whatsapp(message))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
242
server/services/synology_client.py
Normal file
242
server/services/synology_client.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
"""Synology DSM API client for NAS monitoring."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SynologyClient:
|
||||||
|
"""Client for Synology DSM 6/7 REST API."""
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int = 5000, https: bool = False,
|
||||||
|
username: str = "", password: str = ""):
|
||||||
|
proto = "https" if https else "http"
|
||||||
|
self.base_url = f"{proto}://{host}:{port}"
|
||||||
|
self.username = username
|
||||||
|
self.password = password
|
||||||
|
self.sid: Optional[str] = None
|
||||||
|
|
||||||
|
async def login(self) -> bool:
|
||||||
|
"""Authenticate and get session ID."""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/webapi/auth.cgi"
|
||||||
|
params = {
|
||||||
|
"api": "SYNO.API.Auth",
|
||||||
|
"version": "6",
|
||||||
|
"method": "login",
|
||||||
|
"account": self.username,
|
||||||
|
"passwd": self.password,
|
||||||
|
"session": "VPSMonitor",
|
||||||
|
"format": "sid",
|
||||||
|
}
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10),
|
||||||
|
ssl=False) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
if data.get("success"):
|
||||||
|
self.sid = data["data"]["sid"]
|
||||||
|
return True
|
||||||
|
logger.error(f"Synology login failed: {data}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Synology login error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _api_call(self, cgi: str, api: str, version: str = "1",
|
||||||
|
method: str = "get", extra_params: dict = None) -> Optional[dict]:
|
||||||
|
"""Make authenticated API call."""
|
||||||
|
if not self.sid:
|
||||||
|
if not await self.login():
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = f"{self.base_url}/webapi/{cgi}"
|
||||||
|
params = {
|
||||||
|
"api": api,
|
||||||
|
"version": version,
|
||||||
|
"method": method,
|
||||||
|
"_sid": self.sid,
|
||||||
|
}
|
||||||
|
if extra_params:
|
||||||
|
params.update(extra_params)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15),
|
||||||
|
ssl=False) as resp:
|
||||||
|
data = await resp.json()
|
||||||
|
if data.get("success"):
|
||||||
|
return data.get("data", {})
|
||||||
|
# Session expired? Re-login once
|
||||||
|
if data.get("error", {}).get("code") == 119:
|
||||||
|
self.sid = None
|
||||||
|
if await self.login():
|
||||||
|
params["_sid"] = self.sid
|
||||||
|
async with session.get(url, params=params, ssl=False) as resp2:
|
||||||
|
data2 = await resp2.json()
|
||||||
|
if data2.get("success"):
|
||||||
|
return data2.get("data", {})
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Synology API error ({api}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_system_info(self) -> Optional[dict]:
|
||||||
|
"""Get DSM system info (model, version, uptime, temperature)."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.DSM.Info", version="2", method="getinfo")
|
||||||
|
|
||||||
|
async def get_cpu_memory(self) -> Optional[dict]:
|
||||||
|
"""Get CPU and memory utilization."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Core.System.Utilization", version="1")
|
||||||
|
|
||||||
|
async def get_storage(self) -> Optional[dict]:
|
||||||
|
"""Get storage pool and volume info."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Storage.CGI.Storage", version="1",
|
||||||
|
method="load_info")
|
||||||
|
|
||||||
|
async def get_network(self) -> Optional[dict]:
|
||||||
|
"""Get network interface info."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Core.System.Utilization", version="1")
|
||||||
|
|
||||||
|
async def get_vms(self) -> Optional[dict]:
|
||||||
|
"""Get Virtual Machine Manager guests (VMM package required)."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Virtualization.API.Guest", version="1",
|
||||||
|
method="list", extra_params={"offset": "0", "limit": "50"})
|
||||||
|
|
||||||
|
async def get_docker_containers(self) -> Optional[dict]:
|
||||||
|
"""Get Docker/Container Manager containers."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Docker.Container", version="1",
|
||||||
|
method="list", extra_params={"offset": "0", "limit": "50"})
|
||||||
|
|
||||||
|
async def get_disk_info(self) -> Optional[dict]:
|
||||||
|
"""Get physical disk info (SMART, temperature)."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Storage.CGI.Storage", version="1",
|
||||||
|
method="load_info")
|
||||||
|
|
||||||
|
async def get_services(self) -> Optional[dict]:
|
||||||
|
"""Get running services/packages."""
|
||||||
|
return await self._api_call("entry.cgi", "SYNO.Core.Package", version="2",
|
||||||
|
method="list")
|
||||||
|
|
||||||
|
async def collect_all_metrics(self) -> dict:
|
||||||
|
"""Collect all metrics in one go."""
|
||||||
|
result = {
|
||||||
|
"online": False,
|
||||||
|
"system": {},
|
||||||
|
"cpu_percent": 0,
|
||||||
|
"ram_percent": 0,
|
||||||
|
"ram_used_mb": 0,
|
||||||
|
"ram_total_mb": 0,
|
||||||
|
"volumes": [],
|
||||||
|
"disks": [],
|
||||||
|
"vms": [],
|
||||||
|
"docker": [],
|
||||||
|
"temperature": 0,
|
||||||
|
"uptime": "",
|
||||||
|
"model": "",
|
||||||
|
"dsm_version": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# System info
|
||||||
|
sys_info = await self.get_system_info()
|
||||||
|
if sys_info is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
result["online"] = True
|
||||||
|
result["model"] = sys_info.get("model", "")
|
||||||
|
result["dsm_version"] = f"DSM {sys_info.get('version_string', '')}"
|
||||||
|
result["temperature"] = sys_info.get("temperature", 0)
|
||||||
|
result["uptime"] = _format_uptime(sys_info.get("up_time", 0))
|
||||||
|
|
||||||
|
# CPU / Memory
|
||||||
|
util = await self.get_cpu_memory()
|
||||||
|
if util:
|
||||||
|
cpu_data = util.get("cpu", {})
|
||||||
|
if cpu_data:
|
||||||
|
# CPU total = user + system
|
||||||
|
user = cpu_data.get("user_load", 0)
|
||||||
|
sys_load = cpu_data.get("system_load", 0)
|
||||||
|
result["cpu_percent"] = user + sys_load
|
||||||
|
|
||||||
|
mem_data = util.get("memory", {})
|
||||||
|
if mem_data:
|
||||||
|
total = mem_data.get("memory_size", 0) / 1024 # KB to MB
|
||||||
|
avail = mem_data.get("avail_swap", 0)
|
||||||
|
real_use = mem_data.get("real_usage", 0)
|
||||||
|
result["ram_total_mb"] = round(total)
|
||||||
|
result["ram_percent"] = real_use
|
||||||
|
result["ram_used_mb"] = round(total * real_use / 100)
|
||||||
|
|
||||||
|
# Storage
|
||||||
|
storage = await self.get_storage()
|
||||||
|
if storage:
|
||||||
|
for vol in storage.get("volumes", []):
|
||||||
|
total_bytes = vol.get("size", {}).get("total", 0)
|
||||||
|
used_bytes = vol.get("size", {}).get("used", 0)
|
||||||
|
total_gb = total_bytes / (1024 ** 3) if total_bytes else 0
|
||||||
|
used_gb = used_bytes / (1024 ** 3) if used_bytes else 0
|
||||||
|
percent = round(used_gb / total_gb * 100, 1) if total_gb > 0 else 0
|
||||||
|
result["volumes"].append({
|
||||||
|
"name": vol.get("display_name", vol.get("id", "")),
|
||||||
|
"total_gb": round(total_gb, 1),
|
||||||
|
"used_gb": round(used_gb, 1),
|
||||||
|
"percent": percent,
|
||||||
|
"status": vol.get("status", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
for disk in storage.get("disks", []):
|
||||||
|
result["disks"].append({
|
||||||
|
"name": disk.get("name", ""),
|
||||||
|
"model": disk.get("model", ""),
|
||||||
|
"temp": disk.get("temp", 0),
|
||||||
|
"status": disk.get("status", ""),
|
||||||
|
"size_gb": round(int(disk.get("size_total", 0)) / (1024 ** 3), 1),
|
||||||
|
"smart_status": disk.get("smart_status", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# VMs
|
||||||
|
vms = await self.get_vms()
|
||||||
|
if vms:
|
||||||
|
for vm in vms.get("guests", []):
|
||||||
|
result["vms"].append({
|
||||||
|
"name": vm.get("guest_name", ""),
|
||||||
|
"status": vm.get("status", ""),
|
||||||
|
"vcpu": vm.get("vcpu_num", 0),
|
||||||
|
"ram_mb": vm.get("vram_size", 0),
|
||||||
|
"autorun": vm.get("autorun", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
docker = await self.get_docker_containers()
|
||||||
|
if docker:
|
||||||
|
for c in docker.get("containers", []):
|
||||||
|
result["docker"].append({
|
||||||
|
"name": c.get("name", ""),
|
||||||
|
"status": c.get("status", ""),
|
||||||
|
"image": c.get("image", ""),
|
||||||
|
"state": c.get("state", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def logout(self):
|
||||||
|
"""Close session."""
|
||||||
|
if self.sid:
|
||||||
|
try:
|
||||||
|
await self._api_call("auth.cgi", "SYNO.API.Auth", version="6", method="logout")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self.sid = None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_uptime(seconds: int) -> str:
|
||||||
|
"""Format seconds to human-readable uptime."""
|
||||||
|
if not seconds:
|
||||||
|
return "N/A"
|
||||||
|
days = seconds // 86400
|
||||||
|
hours = (seconds % 86400) // 3600
|
||||||
|
if days > 0:
|
||||||
|
return f"{days}d {hours}h"
|
||||||
|
return f"{hours}h {(seconds % 3600) // 60}m"
|
||||||
@@ -633,6 +633,75 @@ main {
|
|||||||
color: white;
|
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 */
|
/* Footer */
|
||||||
.app-footer {
|
.app-footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -660,4 +729,7 @@ main {
|
|||||||
.stats-bar { flex-direction: column; gap: 8px; }
|
.stats-bar { flex-direction: column; gap: 8px; }
|
||||||
header { padding: 12px 16px; }
|
header { padding: 12px 16px; }
|
||||||
.header-right { gap: 4px; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,9 +197,10 @@ function showInstructions() {
|
|||||||
<li>POST /api/servers — add server</li>
|
<li>POST /api/servers — add server</li>
|
||||||
<li>DELETE /api/servers/{id} — remove</li>
|
<li>DELETE /api/servers/{id} — remove</li>
|
||||||
<li>POST /api/servers/{id}/reboot — reboot</li>
|
<li>POST /api/servers/{id}/reboot — reboot</li>
|
||||||
<li>POST /api/servers/{id}/exec — run command</li>
|
<li>GET /api/synology/list — Synology NAS</li>
|
||||||
<li>GET /api/settings — get settings</li>
|
<li>GET /api/ha/list — Home Assistant</li>
|
||||||
<li>PUT /api/settings — update settings</li>
|
<li>GET /api/pc/list — PC agents</li>
|
||||||
|
<li>GET /api/notifications/settings</li>
|
||||||
<li>WS /ws/ssh/{id} — SSH terminal</li>
|
<li>WS /ws/ssh/{id} — SSH terminal</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -224,18 +225,13 @@ function openSSH(id, name) {
|
|||||||
|
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
theme: {
|
theme: { background: '#000000', foreground: '#e8e8ff', cursor: '#6366f1' },
|
||||||
background: '#000000',
|
|
||||||
foreground: '#e8e8ff',
|
|
||||||
cursor: '#6366f1',
|
|
||||||
},
|
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
});
|
});
|
||||||
|
|
||||||
term.open(container);
|
term.open(container);
|
||||||
|
|
||||||
// Fit addon
|
|
||||||
if (typeof FitAddon !== 'undefined') {
|
if (typeof FitAddon !== 'undefined') {
|
||||||
const fitAddon = new FitAddon.FitAddon();
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
@@ -249,38 +245,16 @@ function openSSH(id, name) {
|
|||||||
const ws = new WebSocket(`${protocol}//${location.host}/ws/ssh/${id}`);
|
const ws = new WebSocket(`${protocol}//${location.host}/ws/ssh/${id}`);
|
||||||
currentWs = ws;
|
currentWs = ws;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => term.write('\r\n\x1b[32mConnecting...\x1b[0m\r\n');
|
||||||
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');
|
||||||
ws.onmessage = (event) => {
|
term.onData(data => { if (ws.readyState === WebSocket.OPEN) ws.send(data); });
|
||||||
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() {
|
function closeSSH() {
|
||||||
if (currentWs) {
|
if (currentWs) { currentWs.close(); currentWs = null; }
|
||||||
currentWs.close();
|
if (currentTerminal) { currentTerminal.dispose(); currentTerminal = null; }
|
||||||
currentWs = null;
|
|
||||||
}
|
|
||||||
if (currentTerminal) {
|
|
||||||
currentTerminal.dispose();
|
|
||||||
currentTerminal = null;
|
|
||||||
}
|
|
||||||
closeModal('sshModal');
|
closeModal('sshModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,9 +264,7 @@ async function rebootServer(id) {
|
|||||||
try {
|
try {
|
||||||
await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'});
|
await fetch(`/api/servers/${id}/reboot`, {method: 'POST', credentials: 'include'});
|
||||||
alert(t('rebootSent'));
|
alert(t('rebootSent'));
|
||||||
} catch (e) {
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
alert('Error: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteServer(id) {
|
async function deleteServer(id) {
|
||||||
@@ -300,9 +272,7 @@ async function deleteServer(id) {
|
|||||||
try {
|
try {
|
||||||
await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'});
|
await fetch(`/api/servers/${id}`, {method: 'DELETE', credentials: 'include'});
|
||||||
loadServers();
|
loadServers();
|
||||||
} catch (e) {
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
alert('Error: ' + e.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add server
|
// Add server
|
||||||
@@ -337,9 +307,7 @@ document.getElementById('addServerForm')?.addEventListener('submit', async (e) =
|
|||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
alert(err.detail || 'Error');
|
alert(err.detail || 'Error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
alert('Error: ' + e.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
@@ -383,9 +351,7 @@ document.getElementById('settingsForm')?.addEventListener('submit', async (e) =>
|
|||||||
});
|
});
|
||||||
closeModal('settingsModal');
|
closeModal('settingsModal');
|
||||||
alert(t('settingsSaved'));
|
alert(t('settingsSaved'));
|
||||||
} catch (e) {
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
alert('Error: ' + e.message);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
@@ -394,7 +360,10 @@ function closeModal(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshAll() {
|
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() {
|
async function logout() {
|
||||||
@@ -402,31 +371,43 @@ async function logout() {
|
|||||||
window.location.href = '/login';
|
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
|
// Click outside modal to close
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (e.target.classList.contains('modal')) {
|
if (e.target.classList.contains('modal')) {
|
||||||
if (e.target.id === 'sshModal') {
|
if (e.target.id === 'sshModal') closeSSH();
|
||||||
closeSSH();
|
else e.target.style.display = 'none';
|
||||||
} else {
|
|
||||||
e.target.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tabs
|
// ===================== TABS =====================
|
||||||
let activeTab = 'servers';
|
let activeTab = 'servers';
|
||||||
|
const allTabs = ['servers', 'pc', 'synology', 'ha'];
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
document.getElementById('tab-servers').classList.toggle('active', tab === 'servers');
|
allTabs.forEach(t => {
|
||||||
document.getElementById('tab-pc').classList.toggle('active', tab === 'pc');
|
const tabBtn = document.getElementById('tab-' + t);
|
||||||
document.getElementById('panel-servers').style.display = tab === 'servers' ? '' : 'none';
|
const panel = document.getElementById('panel-' + t);
|
||||||
document.getElementById('panel-pc').style.display = tab === 'pc' ? '' : 'none';
|
if (tabBtn) tabBtn.classList.toggle('active', t === tab);
|
||||||
|
if (panel) panel.style.display = t === tab ? '' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
if (tab === 'pc') loadPCs();
|
if (tab === 'pc') loadPCs();
|
||||||
|
else if (tab === 'synology') loadSynology();
|
||||||
|
else if (tab === 'ha') loadHA();
|
||||||
}
|
}
|
||||||
|
|
||||||
// PC monitoring
|
// ===================== PC MONITORING =====================
|
||||||
let pcAgents = [];
|
let pcAgents = [];
|
||||||
|
|
||||||
async function loadPCs() {
|
async function loadPCs() {
|
||||||
@@ -435,16 +416,14 @@ async function loadPCs() {
|
|||||||
if (resp.status === 401) return;
|
if (resp.status === 401) return;
|
||||||
pcAgents = await resp.json();
|
pcAgents = await resp.json();
|
||||||
renderPCs();
|
renderPCs();
|
||||||
} catch (e) {
|
} catch (e) { console.error('PC load error:', e); }
|
||||||
console.error('PC load error:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPCs() {
|
function renderPCs() {
|
||||||
const grid = document.getElementById('pc-grid');
|
const grid = document.getElementById('pc-grid');
|
||||||
|
|
||||||
if (!pcAgents.length) {
|
if (!pcAgents.length) {
|
||||||
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет подключённых ПК. Установите агент.</div>';
|
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет подключённых ПК. Нажмите "📥 Скачать агент".</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +501,556 @@ function showPCSetup() {
|
|||||||
document.getElementById('pcSetupModal').style.display = 'flex';
|
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 = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет Synology NAS. Нажмите "+ Добавить NAS".</div>';
|
||||||
|
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 `<div style="font-size:11px;margin-top:4px">
|
||||||
|
${v.name}: ${v.used_gb}/${v.total_gb} GB
|
||||||
|
<div class="progress-bar"><div class="fill ${vc || 'ok'}" style="width:${v.percent}%"></div></div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showSynologyDetail('${dev.name}')">
|
||||||
|
<div class="server-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="name">📦 ${dev.name}</div>
|
||||||
|
<div class="host">${dev.host}:${dev.port || 5000} ${m.model ? '• ' + m.model : ''}</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||||
|
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||||
|
${isOnline ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${isOnline ? `
|
||||||
|
<div class="metrics-grid">
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="label">CPU</span>
|
||||||
|
<span class="value ${cpuClass}">${cpu}%</span>
|
||||||
|
<div class="progress-bar"><div class="fill ${cpuClass || 'ok'}" style="width:${cpu}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="label">RAM</span>
|
||||||
|
<span class="value ${ramClass}">${ram}%</span>
|
||||||
|
<div class="progress-bar"><div class="fill ${ramClass || 'ok'}" style="width:${ram}%"></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-item">
|
||||||
|
<span class="label">🌡️</span>
|
||||||
|
<span class="value ${tempClass}">${temp}°C</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${m.dsm_version ? `<div style="font-size:11px;color:var(--text-secondary);margin-bottom:4px">${m.dsm_version} • ${m.uptime || ''}</div>` : ''}
|
||||||
|
${vmsCount ? `<div style="font-size:11px;color:var(--accent)">🖥 ${vmsCount} VM</div>` : ''}
|
||||||
|
${dockerCount ? `<div style="font-size:11px;color:var(--accent)">🐳 ${dockerCount} контейнеров</div>` : ''}
|
||||||
|
${volsHtml}
|
||||||
|
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных — нажмите 🔄</div>'}
|
||||||
|
<div class="server-card-actions">
|
||||||
|
<button onclick="event.stopPropagation(); refreshSynology('${dev.name}')">🔄 Обновить</button>
|
||||||
|
<button class="danger" onclick="event.stopPropagation(); deleteSynology('${dev.name}')">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).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 = `<div style="display:grid;gap:12px">`;
|
||||||
|
|
||||||
|
// System
|
||||||
|
html += `<div class="instr-card"><h4>💻 Система</h4>
|
||||||
|
<p>Модель: ${m.model || 'N/A'}<br>${m.dsm_version || ''}<br>Uptime: ${m.uptime || 'N/A'}<br>
|
||||||
|
Температура: ${m.temperature || 0}°C<br>CPU: ${m.cpu_percent || 0}% | RAM: ${m.ram_percent || 0}% (${m.ram_used_mb || 0}/${m.ram_total_mb || 0} MB)</p></div>`;
|
||||||
|
|
||||||
|
// Volumes
|
||||||
|
if (m.volumes?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>💾 Тома</h4>`;
|
||||||
|
m.volumes.forEach(v => {
|
||||||
|
const vc = v.percent > 90 ? 'crit' : v.percent > 70 ? 'warn' : 'ok';
|
||||||
|
html += `<div style="margin-bottom:8px"><b>${v.name}</b> — ${v.used_gb}/${v.total_gb} GB (${v.percent}%) ${v.status ? '• ' + v.status : ''}
|
||||||
|
<div class="progress-bar"><div class="fill ${vc}" style="width:${v.percent}%"></div></div></div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disks
|
||||||
|
if (m.disks?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🔩 Диски</h4>`;
|
||||||
|
m.disks.forEach(d => {
|
||||||
|
html += `<div style="margin-bottom:4px">${d.name} — ${d.model || ''} (${d.size_gb} GB) 🌡${d.temp}°C ${d.smart_status ? '• SMART: ' + d.smart_status : ''} ${d.status || ''}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// VMs
|
||||||
|
if (m.vms?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🖥 Виртуальные машины</h4>`;
|
||||||
|
m.vms.forEach(vm => {
|
||||||
|
const st = vm.status === 'running' ? '🟢' : '🔴';
|
||||||
|
html += `<div style="margin-bottom:4px">${st} ${vm.name} — ${vm.vcpu} vCPU, ${vm.ram_mb} MB RAM ${vm.autorun ? '• AutoStart' : ''}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Docker
|
||||||
|
if (m.docker?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🐳 Docker контейнеры</h4>`;
|
||||||
|
m.docker.forEach(c => {
|
||||||
|
const st = c.state === 'running' ? '🟢' : '🔴';
|
||||||
|
html += `<div style="margin-bottom:4px">${st} ${c.name} — ${c.image || ''}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
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 = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет Home Assistant. Нажмите "+ Добавить HA".</div>';
|
||||||
|
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 `
|
||||||
|
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showHADetail('${inst.name}')">
|
||||||
|
<div class="server-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="name">🏠 ${inst.name}</div>
|
||||||
|
<div class="host">${m.location_name || inst.url || ''} ${m.version ? '• v' + m.version : ''}</div>
|
||||||
|
</div>
|
||||||
|
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||||
|
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
|
||||||
|
${isOnline ? 'Online' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${isOnline ? `
|
||||||
|
<div class="ha-summary">
|
||||||
|
<div class="ha-stat">📡 ${m.entities_count || 0} сущностей</div>
|
||||||
|
${persons.length ? `<div class="ha-stat">👤 ${persons.map(p => `${p.name}: ${p.state === 'home' ? '🏠' : '🚗'}`).join(', ')}</div>` : ''}
|
||||||
|
${temps.length ? `<div class="ha-stat">🌡 ${temps.map(s => s.name.substring(0, 15) + ': ' + s.value + s.unit).join(' | ')}</div>` : ''}
|
||||||
|
${humidity.length ? `<div class="ha-stat">💧 ${humidity.map(s => s.name.substring(0, 15) + ': ' + s.value + '%').join(' | ')}</div>` : ''}
|
||||||
|
${doors.length ? `<div class="ha-stat">🚪 ${doors.filter(d => d.state === 'on').length ? '<span style="color:var(--warning)">' + doors.filter(d => d.state === 'on').length + ' открыто</span>' : 'все закрыты ✅'}</div>` : ''}
|
||||||
|
${motion.length ? `<div class="ha-stat">🏃 ${motion.filter(m => m.state === 'on').length ? '<span style="color:var(--warning)">движение!</span>' : 'нет движения'}</div>` : ''}
|
||||||
|
${battery.length ? `<div class="ha-stat" style="color:var(--danger)">🔋 ${battery.length} устройств с низким зарядом</div>` : ''}
|
||||||
|
${problems ? `<div class="ha-stat" style="color:var(--danger)">⚠️ ${problems} проблемных сущностей</div>` : ''}
|
||||||
|
${updates ? `<div class="ha-stat" style="color:var(--warning)">📦 ${updates} обновлений</div>` : ''}
|
||||||
|
</div>
|
||||||
|
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных — нажмите 🔄</div>'}
|
||||||
|
<div class="server-card-actions">
|
||||||
|
<button onclick="event.stopPropagation(); refreshHA('${inst.name}')">🔄 Обновить</button>
|
||||||
|
<button class="danger" onclick="event.stopPropagation(); deleteHA('${inst.name}')">🗑</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).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 = `<div style="display:grid;gap:12px">`;
|
||||||
|
|
||||||
|
// System
|
||||||
|
html += `<div class="instr-card"><h4>🔧 Система</h4>
|
||||||
|
<p>Версия: ${m.version || 'N/A'}<br>Локация: ${m.location_name || 'N/A'}<br>
|
||||||
|
Компоненты: ${m.components_count || 0}<br>Сущности: ${m.entities_count || 0}</p></div>`;
|
||||||
|
|
||||||
|
// Temperatures
|
||||||
|
const temps = m.sensors?.temperature || [];
|
||||||
|
if (temps.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🌡️ Температура</h4>`;
|
||||||
|
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 += `<div ${cls}>${s.name}: <b>${s.value}${s.unit}</b></div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Humidity
|
||||||
|
const humid = m.sensors?.humidity || [];
|
||||||
|
if (humid.length) {
|
||||||
|
html += `<div class="instr-card"><h4>💧 Влажность</h4>`;
|
||||||
|
humid.forEach(s => { if (s.value !== null) html += `<div>${s.name}: <b>${s.value}%</b></div>`; });
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doors/Windows
|
||||||
|
const doors = m.sensors?.door || [];
|
||||||
|
if (doors.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🚪 Двери / Окна</h4>`;
|
||||||
|
doors.forEach(d => {
|
||||||
|
const icon = d.state === 'on' ? '🔓 Открыто' : '🔒 Закрыто';
|
||||||
|
const cls = d.state === 'on' ? 'style="color:var(--warning)"' : '';
|
||||||
|
html += `<div ${cls}>${d.name}: ${icon}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motion
|
||||||
|
const motion = m.sensors?.motion || [];
|
||||||
|
if (motion.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🏃 Движение</h4>`;
|
||||||
|
motion.forEach(s => {
|
||||||
|
const cls = s.state === 'on' ? 'style="color:var(--warning)"' : '';
|
||||||
|
html += `<div ${cls}>${s.name}: ${s.state === 'on' ? '⚡ Обнаружено' : '—'}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery
|
||||||
|
const battery = m.sensors?.battery || [];
|
||||||
|
if (battery.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🔋 Батареи</h4>`;
|
||||||
|
battery.forEach(b => {
|
||||||
|
if (b.value === null) return;
|
||||||
|
const cls = b.value < 20 ? 'crit' : b.value < 50 ? 'warn' : '';
|
||||||
|
html += `<div><span class="value ${cls}">${b.value}%</span> ${b.name}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Climate
|
||||||
|
if (m.climate?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>🌡 Климат</h4>`;
|
||||||
|
m.climate.forEach(c => {
|
||||||
|
html += `<div>${c.name}: ${c.state} (${c.current_temp || '?'}° → ${c.target_temp || '?'}°) ${c.hvac_action}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persons
|
||||||
|
if (m.persons?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>👤 Люди</h4>`;
|
||||||
|
m.persons.forEach(p => {
|
||||||
|
const icon = p.state === 'home' ? '🏠 Дома' : '🚗 Не дома';
|
||||||
|
html += `<div>${p.name}: ${icon}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automations
|
||||||
|
if (m.automations?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>⚙️ Автоматизации (${m.automations.length})</h4>`;
|
||||||
|
m.automations.slice(0, 20).forEach(a => {
|
||||||
|
const st = a.state === 'on' ? '🟢' : '🔴';
|
||||||
|
html += `<div style="font-size:12px">${st} ${a.name}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates
|
||||||
|
if (m.updates_available?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>📦 Доступные обновления</h4>`;
|
||||||
|
m.updates_available.forEach(u => {
|
||||||
|
html += `<div>${u.name}: ${u.installed} → ${u.latest}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Problems
|
||||||
|
if (m.problem_entities?.length) {
|
||||||
|
html += `<div class="instr-card"><h4>⚠️ Проблемные сущности</h4>`;
|
||||||
|
m.problem_entities.slice(0, 20).forEach(p => {
|
||||||
|
html += `<div style="font-size:12px;color:var(--danger)">${p.entity_id}: ${p.state}</div>`;
|
||||||
|
});
|
||||||
|
html += `</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `</div>`;
|
||||||
|
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
|
||||||
|
? '<span style="color:var(--success)">✅ Настроен</span>'
|
||||||
|
: '<span style="color:var(--danger)">❌ Не настроен (укажите Bot Token и Chat ID в ⚙️)</span>';
|
||||||
|
|
||||||
|
} 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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
applyTheme(currentTheme);
|
applyTheme(currentTheme);
|
||||||
renderPage();
|
renderPage();
|
||||||
@@ -531,5 +1059,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (activeTab === 'servers') loadServers();
|
if (activeTab === 'servers') loadServers();
|
||||||
else loadPCs();
|
else if (activeTab === 'pc') loadPCs();
|
||||||
|
else if (activeTab === 'synology') loadSynology();
|
||||||
|
else if (activeTab === 'ha') loadHA();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
@@ -73,6 +73,8 @@ const translations = {
|
|||||||
instr_api: "API endpoints",
|
instr_api: "API endpoints",
|
||||||
instr_thresholds: "Пороги алертов",
|
instr_thresholds: "Пороги алертов",
|
||||||
instr_thresholds_text: "Настройки → Мониторинг. По умолчанию: CPU 90%, RAM 90%, Disk 90%",
|
instr_thresholds_text: "Настройки → Мониторинг. По умолчанию: CPU 90%, RAM 90%, Disk 90%",
|
||||||
|
tabServers: "Серверы",
|
||||||
|
tabPC: "ПК",
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
title: "VPS Monitoring",
|
title: "VPS Monitoring",
|
||||||
@@ -148,6 +150,8 @@ const translations = {
|
|||||||
instr_api: "API endpoints",
|
instr_api: "API endpoints",
|
||||||
instr_thresholds: "Alert thresholds",
|
instr_thresholds: "Alert thresholds",
|
||||||
instr_thresholds_text: "Settings → Monitoring. Default: CPU 90%, RAM 90%, Disk 90%",
|
instr_thresholds_text: "Settings → Monitoring. Default: CPU 90%, RAM 90%, Disk 90%",
|
||||||
|
tabServers: "Servers",
|
||||||
|
tabPC: "PC",
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
<button class="btn-icon" id="themeToggleBtn" onclick="toggleTheme()" title="Theme">🌙</button>
|
<button class="btn-icon" id="themeToggleBtn" onclick="toggleTheme()" title="Theme">🌙</button>
|
||||||
<button class="btn-icon" onclick="refreshAll()" title="Refresh">🔄</button>
|
<button class="btn-icon" onclick="refreshAll()" title="Refresh">🔄</button>
|
||||||
<button class="btn-icon" onclick="showInstructions()" title="Instructions">📖</button>
|
<button class="btn-icon" onclick="showInstructions()" title="Instructions">📖</button>
|
||||||
|
<button class="btn-icon" onclick="showNotificationSettings()" title="Notifications">🔔</button>
|
||||||
<button class="btn-icon" onclick="showSettings()" title="Settings">⚙️</button>
|
<button class="btn-icon" onclick="showSettings()" title="Settings">⚙️</button>
|
||||||
<button class="btn-icon" onclick="logout()" title="Logout">🚪</button>
|
<button class="btn-icon" onclick="logout()" title="Logout">🚪</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,8 +43,10 @@
|
|||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 Серверы</button>
|
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 <span data-i18n="tabServers">Серверы</span></button>
|
||||||
<button class="tab" id="tab-pc" onclick="switchTab('pc')">💻 ПК</button>
|
<button class="tab" id="tab-pc" onclick="switchTab('pc')">💻 <span data-i18n="tabPC">ПК</span></button>
|
||||||
|
<button class="tab" id="tab-synology" onclick="switchTab('synology')">📦 Synology</button>
|
||||||
|
<button class="tab" id="tab-ha" onclick="switchTab('ha')">🏠 Home Assistant</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Servers Tab -->
|
<!-- Servers Tab -->
|
||||||
@@ -57,11 +60,30 @@
|
|||||||
<!-- PC Tab -->
|
<!-- PC Tab -->
|
||||||
<div id="panel-pc" style="display:none">
|
<div id="panel-pc" style="display:none">
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn-secondary" onclick="showPCSetup()">📋 Инструкция установки</button>
|
<button class="btn-primary" onclick="showPCDownload()">📥 Скачать агент</button>
|
||||||
|
<button class="btn-secondary" onclick="showPCSetup()">📋 Инструкция</button>
|
||||||
<button class="btn-secondary" onclick="loadPCs()">🔄 Обновить</button>
|
<button class="btn-secondary" onclick="loadPCs()">🔄 Обновить</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="servers-grid" id="pc-grid"></div>
|
<div class="servers-grid" id="pc-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Synology Tab -->
|
||||||
|
<div id="panel-synology" style="display:none">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-primary" onclick="showAddSynology()">+ Добавить NAS</button>
|
||||||
|
<button class="btn-secondary" onclick="refreshAllSynology()">🔄 Обновить все</button>
|
||||||
|
</div>
|
||||||
|
<div class="servers-grid" id="synology-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Home Assistant Tab -->
|
||||||
|
<div id="panel-ha" style="display:none">
|
||||||
|
<div class="toolbar">
|
||||||
|
<button class="btn-primary" onclick="showAddHA()">+ Добавить HA</button>
|
||||||
|
<button class="btn-secondary" onclick="refreshAllHA()">🔄 Обновить все</button>
|
||||||
|
</div>
|
||||||
|
<div class="servers-grid" id="ha-grid"></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Add Server Modal -->
|
<!-- Add Server Modal -->
|
||||||
@@ -101,6 +123,195 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Synology Modal -->
|
||||||
|
<div class="modal" id="addSynologyModal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>📦 Добавить Synology NAS</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('addSynologyModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="addSynologyForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" name="name" required placeholder="My NAS">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>IP / Host</label>
|
||||||
|
<input type="text" name="host" required placeholder="192.168.1.50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порт DSM</label>
|
||||||
|
<input type="number" name="port" value="5000">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="display:flex;align-items:center;gap:8px">
|
||||||
|
<input type="checkbox" name="https" id="syn-https" style="width:auto">
|
||||||
|
<label for="syn-https" style="margin:0">HTTPS</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Логин DSM</label>
|
||||||
|
<input type="text" name="username" required placeholder="admin">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль DSM</label>
|
||||||
|
<input type="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add HA Modal -->
|
||||||
|
<div class="modal" id="addHAModal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🏠 Добавить Home Assistant</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('addHAModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<form id="addHAForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Название</label>
|
||||||
|
<input type="text" name="name" required placeholder="Home">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>URL</label>
|
||||||
|
<input type="text" name="url" required placeholder="http://192.168.1.100:8123">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Long-Lived Access Token</label>
|
||||||
|
<textarea name="token" required rows="3" placeholder="Профиль → Безопасность → Долгоживущие токены доступа" style="resize:vertical"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">Добавить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PC Download Modal -->
|
||||||
|
<div class="modal" id="pcDownloadModal" style="display:none">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>📥 Скачать агент для ПК</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('pcDownloadModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Имя компьютера (агента)</label>
|
||||||
|
<input type="text" id="pc-agent-name" placeholder="MyPC" value="">
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="generatePCCommand()" style="margin-bottom:16px">🔧 Сгенерировать команду</button>
|
||||||
|
<div id="pc-generated-cmd" style="display:none">
|
||||||
|
<div class="instr-card">
|
||||||
|
<h4>📋 Команда установки (PowerShell от Администратора)</h4>
|
||||||
|
<code id="pc-gen-code" style="cursor:pointer" onclick="copyToClipboard(this.textContent)" title="Нажмите чтобы скопировать"></code>
|
||||||
|
<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">Нажмите на команду чтобы скопировать</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Settings Modal -->
|
||||||
|
<div class="modal" id="notifyModal" style="display:none">
|
||||||
|
<div class="modal-content modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>🔔 Настройки уведомлений</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('notifyModal')">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Channel configs -->
|
||||||
|
<h3>📨 Каналы доставки</h3>
|
||||||
|
<div class="notify-channels">
|
||||||
|
<div class="instr-card">
|
||||||
|
<h4>📱 Telegram</h4>
|
||||||
|
<p style="font-size:12px;color:var(--text-secondary)">Настраивается в Настройках (⚙️) — Bot Token и Chat ID</p>
|
||||||
|
<div id="tg-status" style="margin-top:8px"></div>
|
||||||
|
<button class="btn-sm" onclick="testNotification('telegram')" style="margin-top:8px">🧪 Тест</button>
|
||||||
|
</div>
|
||||||
|
<div class="instr-card">
|
||||||
|
<h4>📧 Email (SMTP)</h4>
|
||||||
|
<div class="form-group" style="margin-top:8px">
|
||||||
|
<label>SMTP сервер</label>
|
||||||
|
<input type="text" id="n-smtp-host" placeholder="smtp.gmail.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Порт</label>
|
||||||
|
<input type="number" id="n-smtp-port" value="587">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Email (логин)</label>
|
||||||
|
<input type="text" id="n-smtp-user" placeholder="user@gmail.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Пароль приложения</label>
|
||||||
|
<input type="password" id="n-smtp-pass" placeholder="App Password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Получатель</label>
|
||||||
|
<input type="text" id="n-email-to" placeholder="alerts@example.com">
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm" onclick="testNotification('email')" style="margin-top:4px">🧪 Тест</button>
|
||||||
|
</div>
|
||||||
|
<div class="instr-card">
|
||||||
|
<h4>💬 WhatsApp (CallMeBot)</h4>
|
||||||
|
<p style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
|
||||||
|
1. Отправьте "I allow callmebot to send me messages" на <b>+34 644 71 85 23</b> в WhatsApp<br>
|
||||||
|
2. Получите apikey в ответном сообщении<br>
|
||||||
|
3. Введите данные ниже
|
||||||
|
</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Телефон (с кодом страны)</label>
|
||||||
|
<input type="text" id="n-wa-phone" placeholder="+79001234567">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>API Key</label>
|
||||||
|
<input type="text" id="n-wa-apikey" placeholder="От CallMeBot">
|
||||||
|
</div>
|
||||||
|
<button class="btn-sm" onclick="testNotification('whatsapp')" style="margin-top:4px">🧪 Тест</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-category toggles -->
|
||||||
|
<h3>📊 Какие уведомления отправлять</h3>
|
||||||
|
<div class="notify-matrix">
|
||||||
|
<table class="notify-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>📱 Telegram</th>
|
||||||
|
<th>📧 Email</th>
|
||||||
|
<th>💬 WhatsApp</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>🖥 Серверы</td>
|
||||||
|
<td><input type="checkbox" id="n-servers-tg" checked></td>
|
||||||
|
<td><input type="checkbox" id="n-servers-email"></td>
|
||||||
|
<td><input type="checkbox" id="n-servers-wa"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>💻 ПК</td>
|
||||||
|
<td><input type="checkbox" id="n-pc-tg" checked></td>
|
||||||
|
<td><input type="checkbox" id="n-pc-email"></td>
|
||||||
|
<td><input type="checkbox" id="n-pc-wa"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>📦 Synology</td>
|
||||||
|
<td><input type="checkbox" id="n-synology-tg" checked></td>
|
||||||
|
<td><input type="checkbox" id="n-synology-email"></td>
|
||||||
|
<td><input type="checkbox" id="n-synology-wa"></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>🏠 Home Assistant</td>
|
||||||
|
<td><input type="checkbox" id="n-ha-tg" checked></td>
|
||||||
|
<td><input type="checkbox" id="n-ha-email"></td>
|
||||||
|
<td><input type="checkbox" id="n-ha-wa"></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn-primary" onclick="saveNotificationSettings()" style="margin-top:20px">💾 Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
<!-- Settings Modal -->
|
||||||
<div class="modal" id="settingsModal" style="display:none">
|
<div class="modal" id="settingsModal" style="display:none">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -182,6 +393,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Synology Detail Modal -->
|
||||||
|
<div class="modal" id="synDetailModal" style="display:none">
|
||||||
|
<div class="modal-content modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="syn-detail-title">Synology</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('synDetailModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="syn-detail-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HA Detail Modal -->
|
||||||
|
<div class="modal" id="haDetailModal" style="display:none">
|
||||||
|
<div class="modal-content modal-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="ha-detail-title">Home Assistant</h2>
|
||||||
|
<button class="btn-close" onclick="closeModal('haDetailModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="ha-detail-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- PC Setup Modal -->
|
<!-- PC Setup Modal -->
|
||||||
<div class="modal" id="pcSetupModal" style="display:none">
|
<div class="modal" id="pcSetupModal" style="display:none">
|
||||||
<div class="modal-content modal-wide">
|
<div class="modal-content modal-wide">
|
||||||
@@ -213,11 +446,6 @@
|
|||||||
<li>Unregister-ScheduledTask 'VPS-Monitor-Agent'</li>
|
<li>Unregister-ScheduledTask 'VPS-Monitor-Agent'</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="instr-card">
|
|
||||||
<h4>📁 Файлы</h4>
|
|
||||||
<p>Агент: <code style="display:inline">C:\VPS-Monitor\vps_monitor_agent.ps1</code><br>
|
|
||||||
Конфиг: <code style="display:inline">C:\VPS-Monitor\agent_config.json</code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user