mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
feat: keenetic integration, VPN alerts, docs
Wire keenetic monitor loop and router into main app. Add deduplicated Telegram alerts for router offline, internet, CPU/RAM, and VPN down. Update README with KeenDNS edit, import format, troubleshooting. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Telegram alerts (also set in systemd unit or data/settings.json)
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# Optional overrides (defaults in data/settings.json)
|
||||||
|
# MONITOR_INTERVAL=60
|
||||||
|
# KEENETIC_INTERVAL=60
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
data/*.json.bak
|
||||||
|
*.log
|
||||||
195
README.md
195
README.md
@@ -1,18 +1,16 @@
|
|||||||
# 🖥 VPS Monitoring
|
# VPS Monitoring
|
||||||
|
|
||||||
Платформа мониторинга VPS серверов с веб-интерфейсом, SSH терминалом и Telegram ботом.
|
Платформа мониторинга VPS, Keenetic-роутеров, Synology, Home Assistant и Windows PC с веб-панелью, SSH-терминалом и Telegram-ботом.
|
||||||
|
|
||||||
## Возможности
|
## Возможности
|
||||||
|
|
||||||
- **Мониторинг** — CPU, RAM, Disk, Network, Uptime, Load Average
|
- **VPS** — CPU, RAM, Disk, Network, Uptime, Load Average, SSH-терминал
|
||||||
- **SSH терминал** — подключение к серверам прямо из браузера
|
- **Keenetic** — мониторинг роутеров через KeenDNS/RCI API, VPN-туннели, AnyDesk-ссылки
|
||||||
- **Telegram бот** — статус, алерты, управление (перезагрузка)
|
- **Synology / HA / PC** — дополнительные вкладки мониторинга
|
||||||
- **Telegram Mini App** — мобильная панель прямо в Telegram
|
- **Telegram** — бот, Mini App, алерты (offline, CPU/RAM, VPN down 5+ мин)
|
||||||
- **Алерты** — уведомления при превышении порогов (CPU/RAM/Disk)
|
- **KeenDNS edit** — изменение web URL прямо из карточки роутера
|
||||||
- **Управление** — добавление/удаление серверов, перезагрузка
|
|
||||||
- **Авторизация** — защита паролем с возможностью смены
|
|
||||||
|
|
||||||
## Установка (одна команда)
|
## Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/install.sh | bash
|
curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/install.sh | bash
|
||||||
@@ -24,78 +22,141 @@ curl -sSL https://raw.githubusercontent.com/andrey271192/vps_monitoring/main/ins
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- Ubuntu 20.04+ / Debian 11+
|
- Ubuntu 20.04+ / Debian 11+
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- 512 MB RAM минимум
|
- 512 MB RAM минимум
|
||||||
|
|
||||||
|
### Ручная установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/andrey271192/vps_monitoring.git /opt/vps-monitoring
|
||||||
|
cd /opt/vps-monitoring
|
||||||
|
python3 -m venv venv && source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env # заполните TELEGRAM_*
|
||||||
|
mkdir -p data
|
||||||
|
```
|
||||||
|
|
||||||
|
Systemd unit (`/etc/systemd/system/vps-monitoring.service`):
|
||||||
|
|
||||||
|
- `WorkingDirectory=/opt/vps-monitoring`
|
||||||
|
- `ExecStart=/opt/vps-monitoring/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port 7272`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl daemon-reload && systemctl enable --now vps-monitoring
|
||||||
|
```
|
||||||
|
|
||||||
## После установки
|
## После установки
|
||||||
|
|
||||||
- **Панель:** `http://YOUR_IP:7272`
|
- **Панель:** `http://YOUR_IP:7272`
|
||||||
- **Логин:** `admin` / `admin`
|
- **Логин:** `admin` / `admin` (смените в настройках)
|
||||||
- **Telegram:** бот запускается автоматически
|
- **Health:** `GET /api/health`
|
||||||
|
|
||||||
## Управление
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Статус
|
|
||||||
systemctl status vps-monitoring
|
|
||||||
|
|
||||||
# Перезапуск
|
|
||||||
systemctl restart vps-monitoring
|
|
||||||
|
|
||||||
# Логи
|
|
||||||
journalctl -u vps-monitoring -f
|
|
||||||
|
|
||||||
# Обновление
|
|
||||||
cd /opt/vps-monitoring && git pull && systemctl restart vps-monitoring
|
|
||||||
```
|
|
||||||
|
|
||||||
## Telegram бот
|
|
||||||
|
|
||||||
Команды:
|
|
||||||
- `/start` — главное меню
|
|
||||||
- Кнопки: статус серверов, управление, перезагрузка
|
|
||||||
- Mini App: полная панель в Telegram
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| Endpoint | Method | Описание |
|
|
||||||
|----------|--------|----------|
|
|
||||||
| `/api/servers` | GET | Список серверов |
|
|
||||||
| `/api/servers` | POST | Добавить сервер |
|
|
||||||
| `/api/servers/{id}` | DELETE | Удалить сервер |
|
|
||||||
| `/api/servers/{id}/reboot` | POST | Перезагрузить |
|
|
||||||
| `/api/servers/{id}/exec` | POST | Выполнить команду |
|
|
||||||
| `/api/settings` | GET/PUT | Настройки |
|
|
||||||
| `/api/health` | GET | Health check |
|
|
||||||
| `/ws/ssh/{id}` | WS | SSH терминал |
|
|
||||||
|
|
||||||
## Архитектура
|
|
||||||
|
|
||||||
```
|
|
||||||
FastAPI (Python) → SSH (asyncssh) → Target Servers
|
|
||||||
↕ ↕
|
|
||||||
Web UI (HTML/JS) Telegram Bot
|
|
||||||
↕
|
|
||||||
xterm.js (SSH in browser)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
| Переменная | Описание |
|
| Переменная | Описание |
|
||||||
|-----------|----------|
|
|-----------|----------|
|
||||||
| `TELEGRAM_BOT_TOKEN` | Токен Telegram бота |
|
| `TELEGRAM_BOT_TOKEN` | Токен Telegram-бота |
|
||||||
| `TELEGRAM_CHAT_ID` | ID чата для алертов |
|
| `TELEGRAM_CHAT_ID` | ID чата для алертов |
|
||||||
|
|
||||||
|
Задаются в systemd unit (`Environment=...`) или в `data/settings.json`. См. `.env.example`.
|
||||||
|
|
||||||
|
Дополнительные настройки в `data/settings.json`: пороги CPU/RAM/Disk, `keenetic_interval`, `muted_devices`.
|
||||||
|
|
||||||
|
## Keenetic — импорт роутеров
|
||||||
|
|
||||||
|
### Формат import (TSV/CSV)
|
||||||
|
|
||||||
|
Колонки: `Address`, `Keenetic`, `AnyDesk` (заголовок опционален).
|
||||||
|
|
||||||
|
```
|
||||||
|
Address Keenetic AnyDesk
|
||||||
|
Москва Сити https://moscowcity.netcraze.pro:5443
|
||||||
|
Лофт https://loftliliana.netcraze.pro 1020687391
|
||||||
|
Подмосковный http://95.165.93.46:777 1527495291
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /api/keenetic/import` — body: `{"login":"admin","password":"...","tsv":"..."}`.
|
||||||
|
|
||||||
|
### Скрипт синхронизации (24 роутера)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./venv/bin/python3 scripts/sync_keenetic_import.py /opt/vps-monitoring/data/keenetic.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### HTTP/HTTPS и порты
|
||||||
|
|
||||||
|
- URL сохраняется как есть: `https://host:5443`, `http://91.77.164.164`
|
||||||
|
- Схема по умолчанию — `https` для KeenDNS, `http` для IP
|
||||||
|
- Порт входит в `host` и `web_url` (например `malahovka1.netcraze.pro:5083`)
|
||||||
|
- Кнопка **Веб** на карточке использует точный `web_url`
|
||||||
|
|
||||||
|
### KeenDNS edit (dashboard)
|
||||||
|
|
||||||
|
1. Карточка роутера → **✏️ KeenDNS**
|
||||||
|
2. Введите URL (`https://example.netcraze.pro:8443`)
|
||||||
|
3. **Сохранить** → `PATCH /api/keenetic/{name}` обновляет `keenetic.json` и делает refresh
|
||||||
|
|
||||||
|
### Проверка доступности с VPS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vps-monitoring
|
||||||
|
./venv/bin/python3 scripts/quick_auth_check.py # быстрая auth-проверка
|
||||||
|
./venv/bin/python3 scripts/check_keenetic.py # полная (медленнее)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Telegram алерты
|
||||||
|
|
||||||
|
Настройка: `TELEGRAM_BOT_TOKEN` + `TELEGRAM_CHAT_ID` в systemd или settings.
|
||||||
|
|
||||||
|
Keenetic алерты:
|
||||||
|
- Router offline / back online
|
||||||
|
- No internet
|
||||||
|
- CPU/RAM > 90%
|
||||||
|
- **VPN down 5+ минут** (sustained, deduplicated)
|
||||||
|
|
||||||
|
Per-device mute: 🔔/🔕 на карточке → `POST /api/notifications/mute-device`.
|
||||||
|
|
||||||
|
Тест: `POST /api/notifications/test/telegram`.
|
||||||
|
|
||||||
|
## API (основное)
|
||||||
|
|
||||||
|
| Endpoint | Method | Описание |
|
||||||
|
|----------|--------|----------|
|
||||||
|
| `/api/login` | POST | Авторизация |
|
||||||
|
| `/api/keenetic/list` | GET | Список роутеров |
|
||||||
|
| `/api/keenetic/import` | POST | Импорт TSV/CSV |
|
||||||
|
| `/api/keenetic/{name}` | PATCH | Обновить web_url |
|
||||||
|
| `/api/keenetic/refresh/{name}` | POST | Обновить один |
|
||||||
|
| `/api/keenetic/refresh-all` | POST | Обновить все |
|
||||||
|
| `/api/servers` | GET | VPS серверы |
|
||||||
|
| `/api/health` | GET | Health check |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Симптом | Причина | Действие |
|
||||||
|
|---------|---------|----------|
|
||||||
|
| `ConnectionTimeoutError` | VPS не достучался до роутера (сеть/VPN/firewall) | Проверить KeenDNS, порт, доступность с VPS: `curl -m 10 URL` |
|
||||||
|
| `Authentication failed` / 401 | Неверный login/password | Проверить credentials в keenetic.json |
|
||||||
|
| `Name or service not known` | DNS не резолвится | Проверить KeenDNS имя, TTL |
|
||||||
|
| Панель не отвечает | Сервис упал / перегрузка polling | `systemctl restart vps-monitoring`, `journalctl -u vps-monitoring -n 50` |
|
||||||
|
| Медленный refresh-all | 24 роутера × 3 retry × timeout | Использовать `quick_auth_check.py`, увеличить `keenetic_interval` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl status vps-monitoring
|
||||||
|
journalctl -u vps-monitoring --since "1 hour ago" -p err
|
||||||
|
ss -tlnp | grep 7272
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/vps-monitoring && git pull && systemctl restart vps-monitoring
|
||||||
|
```
|
||||||
|
|
||||||
## Автор
|
## Автор
|
||||||
|
|
||||||
**@Iot_andrey**
|
**@Iot_andrey** — [GitHub](https://github.com/andrey271192) · [Telegram](https://t.me/Iot_andrey)
|
||||||
|
|
||||||
- [GitHub](https://github.com/andrey271192)
|
|
||||||
- [Boosty](https://boosty.to/iot_andrey)
|
|
||||||
- [Поддержка](https://t.me/Iot_andrey)
|
|
||||||
- Telegram: [@Iot_andrey](https://t.me/Iot_andrey)
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
@@ -88,6 +88,38 @@ async def test_notification(channel: str, request: Request, user: str = Depends(
|
|||||||
return {"status": "ok" if ok else "error", "channel": channel}
|
return {"status": "ok" if ok else "error", "channel": channel}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/muted-devices")
|
||||||
|
async def get_muted_devices(request: Request, user: str = Depends(require_auth)):
|
||||||
|
"""Get list of muted device keys."""
|
||||||
|
settings = load_settings()
|
||||||
|
return {"muted": settings.get("muted_devices", [])}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/mute-device")
|
||||||
|
async def toggle_mute_device(request: Request, user: str = Depends(require_auth)):
|
||||||
|
"""Toggle mute for a device. Body: {category, name}. Key format: 'servers:SGA_3'."""
|
||||||
|
body = await request.json()
|
||||||
|
category = body.get("category", "")
|
||||||
|
name = body.get("name", "")
|
||||||
|
if not category or not name:
|
||||||
|
return {"status": "error", "detail": "category and name required"}
|
||||||
|
|
||||||
|
key = f"{category}:{name}"
|
||||||
|
settings = load_settings()
|
||||||
|
muted = settings.get("muted_devices", [])
|
||||||
|
|
||||||
|
if key in muted:
|
||||||
|
muted.remove(key)
|
||||||
|
is_muted = False
|
||||||
|
else:
|
||||||
|
muted.append(key)
|
||||||
|
is_muted = True
|
||||||
|
|
||||||
|
settings["muted_devices"] = muted
|
||||||
|
save_settings(settings)
|
||||||
|
return {"status": "ok", "muted": is_muted, "key": key}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/generate-pc-agent")
|
@router.post("/generate-pc-agent")
|
||||||
async def generate_pc_agent(request: Request, user: str = Depends(require_auth)):
|
async def generate_pc_agent(request: Request, user: str = Depends(require_auth)):
|
||||||
"""Generate personalized PC agent install command."""
|
"""Generate personalized PC agent install command."""
|
||||||
@@ -96,16 +128,20 @@ async def generate_pc_agent(request: Request, user: str = Depends(require_auth))
|
|||||||
server_url = body.get("server_url", "").strip()
|
server_url = body.get("server_url", "").strip()
|
||||||
|
|
||||||
if not server_url:
|
if not server_url:
|
||||||
# Auto-detect
|
# Auto-detect: use IP on port 80 (HTTP, works on all Windows)
|
||||||
host = request.headers.get("host", "localhost:7272")
|
host = request.headers.get("host", "localhost")
|
||||||
proto = "http"
|
# Strip port if present, use plain HTTP port 80
|
||||||
server_url = f"{proto}://{host}"
|
host_ip = host.split(":")[0]
|
||||||
|
server_url = f"http://{host_ip}"
|
||||||
|
|
||||||
cmd = (
|
# Command for use directly inside PowerShell (Admin)
|
||||||
f'powershell -ExecutionPolicy Bypass -Command '
|
# Uses HTTP port 80 (nginx proxy) — no SSL issues on old PowerShell
|
||||||
f'"Invoke-WebRequest -Uri \'{server_url}/static/downloads/install_agent.ps1\' '
|
ps_cmd = (
|
||||||
f'-OutFile install_agent.ps1; .\\install_agent.ps1 '
|
f"Set-ExecutionPolicy Bypass -Scope Process -Force; "
|
||||||
f'-ServerUrl \'{server_url}\' -AgentName \'{agent_name}\'"'
|
f"$ProgressPreference = 'SilentlyContinue'; "
|
||||||
|
f"(New-Object Net.WebClient).DownloadFile('{server_url}/static/downloads/install_agent.ps1', "
|
||||||
|
f"\"$PWD\\install_agent.ps1\"); "
|
||||||
|
f"& \"$PWD\\install_agent.ps1\" -ServerUrl '{server_url}' -AgentName '{agent_name}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "ok", "command": cmd, "agent_name": agent_name}
|
return {"status": "ok", "command": ps_cmd, "agent_name": agent_name}
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
"""Synology NAS monitoring API endpoints."""
|
"""Synology NAS monitoring API endpoints."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Dict, List
|
from typing import Dict, List
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
from server.auth import require_auth
|
from server.auth import require_auth
|
||||||
from server.config import DATA_DIR
|
from server.config import DATA_DIR, BASE_DIR
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/synology", tags=["synology"])
|
router = APIRouter(prefix="/api/synology", tags=["synology"])
|
||||||
|
|
||||||
|
# Tunnel config
|
||||||
|
TUNNEL_KEY_DIR = Path(BASE_DIR) / "tunnel_keys"
|
||||||
|
TUNNEL_VPS_PORT = 15000 # VPS listens on this port, tunneled to Synology
|
||||||
|
|
||||||
# In-memory cache
|
# In-memory cache
|
||||||
synology_metrics: Dict[str, dict] = {}
|
synology_metrics: Dict[str, dict] = {}
|
||||||
|
|
||||||
@@ -99,6 +106,8 @@ async def synology_refresh(name: str, request: Request, user: str = Depends(requ
|
|||||||
metrics["last_updated"] = datetime.now().isoformat()
|
metrics["last_updated"] = datetime.now().isoformat()
|
||||||
synology_metrics[name] = metrics
|
synology_metrics[name] = metrics
|
||||||
await client.logout()
|
await client.logout()
|
||||||
|
if not metrics["online"] and metrics.get("error"):
|
||||||
|
return {"status": "error", "detail": metrics["error"], "metrics": metrics}
|
||||||
return {"status": "ok", "metrics": metrics}
|
return {"status": "ok", "metrics": metrics}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"status": "error", "detail": str(e)}
|
return {"status": "error", "detail": str(e)}
|
||||||
@@ -130,3 +139,88 @@ async def synology_refresh_all(request: Request, user: str = Depends(require_aut
|
|||||||
results.append({"name": dev["name"], "online": False, "error": str(e)})
|
results.append({"name": dev["name"], "online": False, "error": str(e)})
|
||||||
|
|
||||||
return {"status": "ok", "results": results}
|
return {"status": "ok", "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tunnel/enable/{name}")
|
||||||
|
async def synology_enable_tunnel(name: str, request: Request, user: str = Depends(require_auth)):
|
||||||
|
"""Enable tunnel mode for a Synology device — VPS connects via localhost tunnel."""
|
||||||
|
body = await request.json()
|
||||||
|
local_ip = body.get("local_ip", "192.168.88.6")
|
||||||
|
local_port = body.get("local_port", 5000)
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
# Save original host and switch to tunnel
|
||||||
|
dev["original_host"] = dev.get("original_host", dev["host"])
|
||||||
|
dev["host"] = "127.0.0.1"
|
||||||
|
dev["port"] = TUNNEL_VPS_PORT
|
||||||
|
dev["https"] = False
|
||||||
|
dev["tunnel"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"local_ip": local_ip,
|
||||||
|
"local_port": local_port,
|
||||||
|
"vps_port": TUNNEL_VPS_PORT,
|
||||||
|
}
|
||||||
|
_save_synology(devices)
|
||||||
|
|
||||||
|
return {"status": "ok", "vps_port": TUNNEL_VPS_PORT}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tunnel/disable/{name}")
|
||||||
|
async def synology_disable_tunnel(name: str, request: Request, user: str = Depends(require_auth)):
|
||||||
|
"""Disable tunnel mode, revert to direct host."""
|
||||||
|
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"}
|
||||||
|
|
||||||
|
if dev.get("original_host"):
|
||||||
|
dev["host"] = dev["original_host"]
|
||||||
|
dev["port"] = dev.get("tunnel", {}).get("local_port", 5000)
|
||||||
|
dev.pop("tunnel", None)
|
||||||
|
dev.pop("original_host", None)
|
||||||
|
_save_synology(devices)
|
||||||
|
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tunnel/key")
|
||||||
|
async def get_tunnel_key(request: Request, user: str = Depends(require_auth)):
|
||||||
|
"""Download SSH private key for tunnel setup."""
|
||||||
|
key_file = TUNNEL_KEY_DIR / "synology_tunnel"
|
||||||
|
if not key_file.exists():
|
||||||
|
return {"status": "error", "detail": "tunnel key not generated"}
|
||||||
|
|
||||||
|
key_content = key_file.read_text()
|
||||||
|
return PlainTextResponse(content=key_content, media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=synology_tunnel"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tunnel/setup-command")
|
||||||
|
async def get_tunnel_setup_command(name: str, request: Request, user: str = Depends(require_auth)):
|
||||||
|
"""Generate PowerShell command to set up Synology tunnel on Windows PC."""
|
||||||
|
devices = _load_synology()
|
||||||
|
dev = next((d for d in devices if d["name"] == name), None)
|
||||||
|
|
||||||
|
tunnel = dev.get("tunnel", {}) if dev else {}
|
||||||
|
local_ip = tunnel.get("local_ip", "192.168.88.6")
|
||||||
|
local_port = tunnel.get("local_port", 5000)
|
||||||
|
vps_port = tunnel.get("vps_port", TUNNEL_VPS_PORT)
|
||||||
|
|
||||||
|
host = request.headers.get("host", "77.239.126.123").split(":")[0]
|
||||||
|
server_url = f"http://{host}"
|
||||||
|
|
||||||
|
ps_cmd = (
|
||||||
|
f"Set-ExecutionPolicy Bypass -Scope Process -Force; "
|
||||||
|
f"$ProgressPreference = 'SilentlyContinue'; "
|
||||||
|
f"(New-Object Net.WebClient).DownloadFile('{server_url}/static/downloads/synology_tunnel.ps1', "
|
||||||
|
f"\"$PWD\\synology_tunnel.ps1\"); "
|
||||||
|
f"& \"$PWD\\synology_tunnel.ps1\" -ServerUrl '{server_url}' "
|
||||||
|
f"-LocalTarget '{local_ip}:{local_port}' -VpsPort {vps_port}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "ok", "command": ps_cmd, "local_ip": local_ip,
|
||||||
|
"local_port": local_port, "vps_port": vps_port}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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.synology import router as synology_router
|
||||||
from server.api.ha import router as ha_router
|
from server.api.ha import router as ha_router
|
||||||
|
from server.api.keenetic import router as keenetic_router, keenetic_monitor_loop
|
||||||
from server.api.notifications import router as notifications_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
|
||||||
@@ -30,6 +31,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
monitor_task = None
|
monitor_task = None
|
||||||
alert_task = None
|
alert_task = None
|
||||||
|
keenetic_task = None
|
||||||
|
|
||||||
|
|
||||||
async def alert_loop():
|
async def alert_loop():
|
||||||
@@ -44,18 +46,19 @@ async def alert_loop():
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
global monitor_task, alert_task
|
global monitor_task, alert_task, keenetic_task
|
||||||
# Start background tasks
|
|
||||||
monitor_task = asyncio.create_task(monitor_loop())
|
monitor_task = asyncio.create_task(monitor_loop())
|
||||||
alert_task = asyncio.create_task(alert_loop())
|
alert_task = asyncio.create_task(alert_loop())
|
||||||
|
keenetic_task = asyncio.create_task(keenetic_monitor_loop())
|
||||||
await start_bot()
|
await start_bot()
|
||||||
logger.info("VPS Monitoring started")
|
logger.info("VPS Monitoring started")
|
||||||
yield
|
yield
|
||||||
# Cleanup
|
|
||||||
if monitor_task:
|
if monitor_task:
|
||||||
monitor_task.cancel()
|
monitor_task.cancel()
|
||||||
if alert_task:
|
if alert_task:
|
||||||
alert_task.cancel()
|
alert_task.cancel()
|
||||||
|
if keenetic_task:
|
||||||
|
keenetic_task.cancel()
|
||||||
await stop_bot()
|
await stop_bot()
|
||||||
|
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ app.include_router(ssh_router)
|
|||||||
app.include_router(pc_router)
|
app.include_router(pc_router)
|
||||||
app.include_router(synology_router)
|
app.include_router(synology_router)
|
||||||
app.include_router(ha_router)
|
app.include_router(ha_router)
|
||||||
|
app.include_router(keenetic_router)
|
||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +1,125 @@
|
|||||||
|
"""Smart alert system with deduplication for all monitoring tabs.
|
||||||
|
|
||||||
|
Logic per issue:
|
||||||
|
1. Problem detected -> send 1 alert
|
||||||
|
2. Problem continues -> stay silent (no spam)
|
||||||
|
3. Problem fixed -> send "resolved" message
|
||||||
|
4. New different problem -> send new alert
|
||||||
|
|
||||||
|
State tracked per (source, issue_key) pair.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
from server.config import load_settings, load_servers
|
from server.config import load_settings, load_servers, DATA_DIR
|
||||||
from server.services.monitor import get_all_metrics
|
from server.services.monitor import get_all_metrics
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
previous_states: Dict[str, bool] = {}
|
# Active issues: {(category, source_name, issue_key): datetime_first_seen}
|
||||||
|
active_issues: Dict[Tuple[str, str, str], datetime] = {}
|
||||||
|
# Sustained problems waiting for threshold: {issue_key: datetime_first_seen}
|
||||||
|
_pending_sustain: Dict[Tuple[str, str, str], datetime] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_key(category: str, source: str, key: str) -> tuple:
|
||||||
|
return (category, source, key)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fire_alert(message: str, subject: str, category: str):
|
||||||
|
"""Send notification via configured channels."""
|
||||||
|
from server.services.notifier import send_notification
|
||||||
|
await send_notification(message, subject=subject, category=category)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_device_muted(category: str, source: str) -> bool:
|
||||||
|
"""Check if device is muted in settings."""
|
||||||
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
muted = settings.get("muted_devices", [])
|
||||||
|
return f"{category}:{source}" in muted
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_issue(category: str, source: str, key: str, is_problem: bool,
|
||||||
|
alert_msg: str, resolve_msg: str, subject: str):
|
||||||
|
"""Core dedup logic for one issue."""
|
||||||
|
ik = _issue_key(category, source, key)
|
||||||
|
was_active = ik in active_issues
|
||||||
|
|
||||||
|
if is_problem and not was_active:
|
||||||
|
active_issues[ik] = datetime.now()
|
||||||
|
# Skip notification if device muted (still track state)
|
||||||
|
if not _is_device_muted(category, source):
|
||||||
|
await _fire_alert(alert_msg, subject, category)
|
||||||
|
logger.info(f"Alert fired: {category}/{source}/{key}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Alert suppressed (muted): {category}/{source}/{key}")
|
||||||
|
|
||||||
|
elif not is_problem and was_active:
|
||||||
|
del active_issues[ik]
|
||||||
|
if not _is_device_muted(category, source):
|
||||||
|
await _fire_alert(resolve_msg, f"{subject} resolved", category)
|
||||||
|
logger.info(f"Resolved: {category}/{source}/{key}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Resolved (muted): {category}/{source}/{key}")
|
||||||
|
|
||||||
|
# Problem continues or no problem - stay silent
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_sustained_issue(category: str, source: str, key: str, is_problem: bool,
|
||||||
|
alert_msg: str, resolve_msg: str, subject: str,
|
||||||
|
sustain_seconds: int = 300):
|
||||||
|
"""Alert only after problem persists for sustain_seconds (default 5 min)."""
|
||||||
|
ik = _issue_key(category, source, key)
|
||||||
|
was_active = ik in active_issues
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
if is_problem:
|
||||||
|
if ik not in _pending_sustain:
|
||||||
|
_pending_sustain[ik] = now
|
||||||
|
elapsed = (now - _pending_sustain[ik]).total_seconds()
|
||||||
|
if elapsed >= sustain_seconds and not was_active:
|
||||||
|
active_issues[ik] = _pending_sustain[ik]
|
||||||
|
if not _is_device_muted(category, source):
|
||||||
|
await _fire_alert(alert_msg, subject, category)
|
||||||
|
logger.info(f"Sustained alert: {category}/{source}/{key}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Sustained alert suppressed (muted): {category}/{source}/{key}")
|
||||||
|
else:
|
||||||
|
_pending_sustain.pop(ik, None)
|
||||||
|
if was_active:
|
||||||
|
del active_issues[ik]
|
||||||
|
if not _is_device_muted(category, source):
|
||||||
|
await _fire_alert(resolve_msg, f"{subject} resolved", category)
|
||||||
|
logger.info(f"Sustained resolved: {category}/{source}/{key}")
|
||||||
|
|
||||||
|
|
||||||
async def check_alerts():
|
async def check_alerts():
|
||||||
"""Check metrics against thresholds and send alerts."""
|
"""Check all 5 tabs for issues."""
|
||||||
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 global mute
|
||||||
if mute_until and datetime.now() < mute_until:
|
if mute_until and datetime.now() < mute_until:
|
||||||
return
|
return
|
||||||
|
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
|
|
||||||
|
await _check_servers(settings)
|
||||||
|
await _check_pc(settings)
|
||||||
|
await _check_synology(settings)
|
||||||
|
await _check_ha(settings)
|
||||||
|
await _check_keenetic(settings)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== SERVERS ====================
|
||||||
|
|
||||||
|
async def _check_servers(settings: dict):
|
||||||
|
"""Check VPS servers for offline/threshold issues."""
|
||||||
servers = load_servers()
|
servers = load_servers()
|
||||||
metrics = get_all_metrics()
|
metrics = get_all_metrics()
|
||||||
|
|
||||||
@@ -29,48 +129,381 @@ async def check_alerts():
|
|||||||
|
|
||||||
for srv in servers:
|
for srv in servers:
|
||||||
host = srv["host"]
|
host = srv["host"]
|
||||||
m = metrics.get(host, {})
|
|
||||||
name = srv.get("name", host)
|
name = srv.get("name", host)
|
||||||
|
m = metrics.get(host, {})
|
||||||
|
online = m.get("online", False)
|
||||||
|
|
||||||
current_online = m.get("online", False)
|
# Online/Offline
|
||||||
prev_online = previous_states.get(host)
|
await _check_issue(
|
||||||
|
"servers", name, "offline",
|
||||||
# Online/Offline state change
|
is_problem=not online,
|
||||||
if prev_online is not None and prev_online != current_online:
|
alert_msg=f"🔴 *{name}* ({host}) - OFFLINE!",
|
||||||
if current_online:
|
resolve_msg=f"🟢 *{name}* ({host}) - back online",
|
||||||
await send_notification(
|
subject=f"{name} offline",
|
||||||
f"🟢 **{name}** ({host}) — снова онлайн",
|
|
||||||
subject=f"{name} Online", category="servers"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await send_notification(
|
|
||||||
f"🔴 **{name}** ({host}) — OFFLINE!",
|
|
||||||
subject=f"{name} OFFLINE", category="servers"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
previous_states[host] = current_online
|
if not online:
|
||||||
|
|
||||||
if not current_online:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Threshold alerts
|
# CPU
|
||||||
cpu = m.get("cpu_percent", 0)
|
cpu = m.get("cpu_percent", 0)
|
||||||
if cpu >= cpu_threshold:
|
await _check_issue(
|
||||||
await send_notification(
|
"servers", name, "cpu_high",
|
||||||
f"⚠️ **{name}** — CPU: {cpu}% (порог: {cpu_threshold}%)",
|
is_problem=cpu >= cpu_threshold,
|
||||||
subject=f"{name} CPU {cpu}%", category="servers"
|
alert_msg=f"⚠️ *{name}* CPU: {cpu}% (threshold: {cpu_threshold}%)",
|
||||||
|
resolve_msg=f"✅ *{name}* CPU normalized: {cpu}%",
|
||||||
|
subject=f"{name} CPU",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# RAM
|
||||||
ram = m.get("ram_percent", 0)
|
ram = m.get("ram_percent", 0)
|
||||||
if ram >= ram_threshold:
|
await _check_issue(
|
||||||
await send_notification(
|
"servers", name, "ram_high",
|
||||||
f"⚠️ **{name}** — RAM: {ram}% (порог: {ram_threshold}%)",
|
is_problem=ram >= ram_threshold,
|
||||||
subject=f"{name} RAM {ram}%", category="servers"
|
alert_msg=f"⚠️ *{name}* RAM: {ram}% (threshold: {ram_threshold}%)",
|
||||||
|
resolve_msg=f"✅ *{name}* RAM normalized: {ram}%",
|
||||||
|
subject=f"{name} RAM",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Disk
|
||||||
disk = m.get("disk_percent", 0)
|
disk = m.get("disk_percent", 0)
|
||||||
if disk >= disk_threshold:
|
await _check_issue(
|
||||||
await send_notification(
|
"servers", name, "disk_high",
|
||||||
f"⚠️ **{name}** — Disk: {disk}% (порог: {disk_threshold}%)",
|
is_problem=disk >= disk_threshold,
|
||||||
subject=f"{name} Disk {disk}%", category="servers"
|
alert_msg=f"⚠️ *{name}* Disk: {disk}% (threshold: {disk_threshold}%)",
|
||||||
|
resolve_msg=f"✅ *{name}* Disk normalized: {disk}%",
|
||||||
|
subject=f"{name} Disk",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== PC AGENTS ====================
|
||||||
|
|
||||||
|
async def _check_pc(settings: dict):
|
||||||
|
"""Check PC agents for offline status."""
|
||||||
|
pc_file = DATA_DIR / "pc_agents.json"
|
||||||
|
if not pc_file.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(pc_file) as f:
|
||||||
|
pc_data = json.load(f)
|
||||||
|
|
||||||
|
now = datetime.now()
|
||||||
|
|
||||||
|
for name, data in pc_data.items():
|
||||||
|
last_seen = data.get("last_seen", "")
|
||||||
|
try:
|
||||||
|
last_dt = datetime.fromisoformat(last_seen)
|
||||||
|
stale = (now - last_dt).total_seconds() > 180 # 3 min
|
||||||
|
except Exception:
|
||||||
|
stale = True
|
||||||
|
|
||||||
|
await _check_issue(
|
||||||
|
"pc", name, "offline",
|
||||||
|
is_problem=stale,
|
||||||
|
alert_msg=f"🔴 *PC {name}* - no heartbeat for 3+ min",
|
||||||
|
resolve_msg=f"🟢 *PC {name}* - back online",
|
||||||
|
subject=f"PC {name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== SYNOLOGY ====================
|
||||||
|
|
||||||
|
async def _check_synology(settings: dict):
|
||||||
|
"""Check Synology NAS devices."""
|
||||||
|
from server.api.synology import synology_metrics, _load_synology
|
||||||
|
|
||||||
|
devices = _load_synology()
|
||||||
|
|
||||||
|
for dev in devices:
|
||||||
|
name = dev["name"]
|
||||||
|
m = synology_metrics.get(name, {})
|
||||||
|
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
online = m.get("online", False)
|
||||||
|
|
||||||
|
await _check_issue(
|
||||||
|
"synology", name, "offline",
|
||||||
|
is_problem=not online,
|
||||||
|
alert_msg=f"🔴 *NAS {name}* - OFFLINE!",
|
||||||
|
resolve_msg=f"🟢 *NAS {name}* - back online",
|
||||||
|
subject=f"NAS {name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not online:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Temperature
|
||||||
|
temp = m.get("temperature", 0)
|
||||||
|
await _check_issue(
|
||||||
|
"synology", name, "temp_high",
|
||||||
|
is_problem=temp >= 65,
|
||||||
|
alert_msg=f"🌡 *NAS {name}* temperature: {temp}C (critical!)",
|
||||||
|
resolve_msg=f"✅ *NAS {name}* temperature normalized: {temp}C",
|
||||||
|
subject=f"NAS {name} temp",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Volume usage
|
||||||
|
for vol in m.get("volumes", []):
|
||||||
|
vol_name = vol.get("name", "?")
|
||||||
|
pct = vol.get("percent", 0)
|
||||||
|
await _check_issue(
|
||||||
|
"synology", name, f"vol_{vol_name}_full",
|
||||||
|
is_problem=pct >= 90,
|
||||||
|
alert_msg=f"💾 *NAS {name}* volume {vol_name}: {pct}% full!",
|
||||||
|
resolve_msg=f"✅ *NAS {name}* volume {vol_name} freed: {pct}%",
|
||||||
|
subject=f"NAS {name} disk",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Disk SMART
|
||||||
|
for disk in m.get("disks", []):
|
||||||
|
disk_name = disk.get("name", "?")
|
||||||
|
smart = disk.get("smart_status", "normal")
|
||||||
|
await _check_issue(
|
||||||
|
"synology", name, f"smart_{disk_name}",
|
||||||
|
is_problem=smart not in ("normal", ""),
|
||||||
|
alert_msg=f"🔩 *NAS {name}* disk {disk_name} SMART: {smart}!",
|
||||||
|
resolve_msg=f"✅ *NAS {name}* disk {disk_name} SMART normal",
|
||||||
|
subject=f"NAS {name} SMART",
|
||||||
|
)
|
||||||
|
|
||||||
|
# RAM
|
||||||
|
ram_pct = m.get("ram_percent", 0)
|
||||||
|
await _check_issue(
|
||||||
|
"synology", name, "ram_high",
|
||||||
|
is_problem=ram_pct >= 90,
|
||||||
|
alert_msg=f"⚠️ *NAS {name}* RAM: {ram_pct}%",
|
||||||
|
resolve_msg=f"✅ *NAS {name}* RAM normalized: {ram_pct}%",
|
||||||
|
subject=f"NAS {name} RAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== HOME ASSISTANT ====================
|
||||||
|
|
||||||
|
async def _check_ha(settings: dict):
|
||||||
|
"""Check Home Assistant instances."""
|
||||||
|
from server.api.ha import ha_metrics, _load_ha
|
||||||
|
|
||||||
|
instances = _load_ha()
|
||||||
|
|
||||||
|
for inst in instances:
|
||||||
|
name = inst["name"]
|
||||||
|
m = ha_metrics.get(name, {})
|
||||||
|
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
online = m.get("online", False)
|
||||||
|
|
||||||
|
await _check_issue(
|
||||||
|
"ha", name, "offline",
|
||||||
|
is_problem=not online,
|
||||||
|
alert_msg=f"🔴 *HA {name}* - OFFLINE!",
|
||||||
|
resolve_msg=f"🟢 *HA {name}* - back online",
|
||||||
|
subject=f"HA {name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not online:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Problem entities
|
||||||
|
problems = m.get("problem_entities", [])
|
||||||
|
await _check_issue(
|
||||||
|
"ha", name, "problems",
|
||||||
|
is_problem=len(problems) > 0,
|
||||||
|
alert_msg=f"⚠️ *HA {name}* {len(problems)} problem entities",
|
||||||
|
resolve_msg=f"✅ *HA {name}* all entities OK",
|
||||||
|
subject=f"HA {name} problems",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Low battery devices
|
||||||
|
battery_sensors = m.get("sensors", {}).get("battery", [])
|
||||||
|
low_battery = [b for b in battery_sensors if b.get("value") is not None and b["value"] < 10]
|
||||||
|
await _check_issue(
|
||||||
|
"ha", name, "low_battery",
|
||||||
|
is_problem=len(low_battery) > 0,
|
||||||
|
alert_msg=f"🔋 *HA {name}* {len(low_battery)} devices critical battery (<10%)",
|
||||||
|
resolve_msg=f"✅ *HA {name}* all batteries OK",
|
||||||
|
subject=f"HA {name} battery",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== KEENETIC ====================
|
||||||
|
|
||||||
|
async def _check_keenetic(settings: dict):
|
||||||
|
"""Check Keenetic routers."""
|
||||||
|
from server.api.keenetic import keenetic_metrics, _load_keenetic
|
||||||
|
|
||||||
|
devices = _load_keenetic()
|
||||||
|
|
||||||
|
for dev in devices:
|
||||||
|
name = dev["name"]
|
||||||
|
m = keenetic_metrics.get(name, {})
|
||||||
|
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
|
||||||
|
online = m.get("online", False)
|
||||||
|
|
||||||
|
await _check_issue(
|
||||||
|
"keenetic", name, "offline",
|
||||||
|
is_problem=not online,
|
||||||
|
alert_msg=f"🔴 *Router {name}* - OFFLINE!",
|
||||||
|
resolve_msg=f"🟢 *Router {name}* - back online",
|
||||||
|
subject=f"Router {name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not online:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Internet connectivity
|
||||||
|
internet = m.get("internet", True)
|
||||||
|
await _check_issue(
|
||||||
|
"keenetic", name, "no_internet",
|
||||||
|
is_problem=not internet,
|
||||||
|
alert_msg=f"🌐 *Router {name}* - NO INTERNET!",
|
||||||
|
resolve_msg=f"✅ *Router {name}* - internet restored",
|
||||||
|
subject=f"Router {name} internet",
|
||||||
|
)
|
||||||
|
|
||||||
|
# High CPU
|
||||||
|
cpuload = m.get("cpuload", 0)
|
||||||
|
await _check_issue(
|
||||||
|
"keenetic", name, "cpu_high",
|
||||||
|
is_problem=cpuload >= 90,
|
||||||
|
alert_msg=f"⚠️ *Router {name}* CPU: {cpuload}%",
|
||||||
|
resolve_msg=f"✅ *Router {name}* CPU normalized: {cpuload}%",
|
||||||
|
subject=f"Router {name} CPU",
|
||||||
|
)
|
||||||
|
|
||||||
|
# High memory
|
||||||
|
mem_pct = m.get("mem_percent", 0)
|
||||||
|
await _check_issue(
|
||||||
|
"keenetic", name, "mem_high",
|
||||||
|
is_problem=mem_pct >= 90,
|
||||||
|
alert_msg=f"⚠️ *Router {name}* RAM: {mem_pct}%",
|
||||||
|
resolve_msg=f"✅ *Router {name}* RAM normalized: {mem_pct}%",
|
||||||
|
subject=f"Router {name} RAM",
|
||||||
|
)
|
||||||
|
|
||||||
|
# VPN down for 5+ minutes (per tunnel)
|
||||||
|
host = dev.get("host", "")
|
||||||
|
for vpn in m.get("vpn", []):
|
||||||
|
vpn_name = vpn.get("name", "vpn")
|
||||||
|
vpn_label = vpn.get("description") or vpn_name
|
||||||
|
vpn_type = vpn.get("type", "")
|
||||||
|
state = (vpn.get("state") or "").lower()
|
||||||
|
is_down = state != "up"
|
||||||
|
ts = datetime.now().strftime("%H:%M %d.%m.%Y")
|
||||||
|
await _check_sustained_issue(
|
||||||
|
"keenetic", name, f"vpn_{vpn_name}",
|
||||||
|
is_problem=is_down,
|
||||||
|
alert_msg=(
|
||||||
|
f"🔻 *VPN недоступен 5+ мин*\n"
|
||||||
|
f"Роутер: *{name}*\n"
|
||||||
|
f"VPN: `{vpn_label}` ({vpn_type})\n"
|
||||||
|
f"Состояние: `{state or 'down'}`\n"
|
||||||
|
f"Хост: `{host}`\n"
|
||||||
|
f"Время: {ts}"
|
||||||
|
),
|
||||||
|
resolve_msg=(
|
||||||
|
f"✅ *VPN восстановлен*\n"
|
||||||
|
f"Роутер: *{name}*\n"
|
||||||
|
f"VPN: `{vpn_label}` ({vpn_type})"
|
||||||
|
),
|
||||||
|
subject=f"VPN {name} {vpn_label}",
|
||||||
|
sustain_seconds=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== STATUS REQUEST ====================
|
||||||
|
|
||||||
|
async def get_full_status() -> str:
|
||||||
|
"""Generate full status summary for Telegram /status command."""
|
||||||
|
lines = ["📊 *VPS Monitoring Status*\n"]
|
||||||
|
|
||||||
|
# Servers
|
||||||
|
servers = load_servers()
|
||||||
|
metrics = get_all_metrics()
|
||||||
|
if servers:
|
||||||
|
lines.append("*VPS Servers:*")
|
||||||
|
for srv in servers:
|
||||||
|
m = metrics.get(srv["host"], {})
|
||||||
|
name = srv.get("name", srv["host"])
|
||||||
|
if m.get("online"):
|
||||||
|
cpu = m.get("cpu_percent", 0)
|
||||||
|
ram = m.get("ram_percent", 0)
|
||||||
|
disk = m.get("disk_percent", 0)
|
||||||
|
lines.append(f" 🟢 {name}: CPU {cpu}% RAM {ram}% Disk {disk}%")
|
||||||
|
else:
|
||||||
|
lines.append(f" 🔴 {name}: OFFLINE")
|
||||||
|
|
||||||
|
# PC
|
||||||
|
pc_file = DATA_DIR / "pc_agents.json"
|
||||||
|
if pc_file.exists():
|
||||||
|
with open(pc_file) as f:
|
||||||
|
pc_data = json.load(f)
|
||||||
|
if pc_data:
|
||||||
|
lines.append("\n*PC Agents:*")
|
||||||
|
now = datetime.now()
|
||||||
|
for name, data in pc_data.items():
|
||||||
|
last_seen = data.get("last_seen", "")
|
||||||
|
try:
|
||||||
|
last_dt = datetime.fromisoformat(last_seen)
|
||||||
|
stale = (now - last_dt).total_seconds() > 180
|
||||||
|
except Exception:
|
||||||
|
stale = True
|
||||||
|
m = data.get("metrics", {})
|
||||||
|
if not stale:
|
||||||
|
lines.append(f" 🟢 {name}: CPU {m.get('cpu_percent', 0)}% RAM {m.get('ram_percent', 0)}%")
|
||||||
|
else:
|
||||||
|
lines.append(f" 🔴 {name}: offline")
|
||||||
|
|
||||||
|
# Synology
|
||||||
|
from server.api.synology import synology_metrics, _load_synology
|
||||||
|
syn_devs = _load_synology()
|
||||||
|
if syn_devs:
|
||||||
|
lines.append("\n*Synology NAS:*")
|
||||||
|
for dev in syn_devs:
|
||||||
|
m = synology_metrics.get(dev["name"], {})
|
||||||
|
if m.get("online"):
|
||||||
|
lines.append(f" 🟢 {dev['name']}: {m.get('model', '')} CPU {m.get('cpu_percent', 0)}% RAM {m.get('ram_percent', 0)}% {m.get('temperature', 0)}C")
|
||||||
|
else:
|
||||||
|
lines.append(f" 🔴 {dev['name']}: offline")
|
||||||
|
|
||||||
|
# HA
|
||||||
|
from server.api.ha import ha_metrics, _load_ha
|
||||||
|
ha_insts = _load_ha()
|
||||||
|
if ha_insts:
|
||||||
|
lines.append("\n*Home Assistant:*")
|
||||||
|
for inst in ha_insts:
|
||||||
|
m = ha_metrics.get(inst["name"], {})
|
||||||
|
if m.get("online"):
|
||||||
|
ent = m.get("entities_count", 0)
|
||||||
|
lines.append(f" 🟢 {inst['name']}: v{m.get('version', '?')} ({ent} entities)")
|
||||||
|
else:
|
||||||
|
lines.append(f" 🔴 {inst['name']}: offline")
|
||||||
|
|
||||||
|
# Keenetic
|
||||||
|
from server.api.keenetic import keenetic_metrics, _load_keenetic
|
||||||
|
keen_devs = _load_keenetic()
|
||||||
|
if keen_devs:
|
||||||
|
lines.append("\n*Keenetic Routers:*")
|
||||||
|
for dev in keen_devs:
|
||||||
|
m = keenetic_metrics.get(dev["name"], {})
|
||||||
|
if m.get("online"):
|
||||||
|
inet = "🌐" if m.get("internet") else "⚠️NO-INET"
|
||||||
|
lines.append(f" 🟢 {dev['name']}: {m.get('model', '')} CPU {m.get('cpuload', 0)}% RAM {m.get('mem_percent', 0)}% {inet} {m.get('clients_count', 0)} clients")
|
||||||
|
else:
|
||||||
|
lines.append(f" 🔴 {dev['name']}: offline")
|
||||||
|
|
||||||
|
# Active issues
|
||||||
|
if active_issues:
|
||||||
|
lines.append(f"\n⚠️ *Active issues: {len(active_issues)}*")
|
||||||
|
for (cat, src, key), since in active_issues.items():
|
||||||
|
lines.append(f" - {cat}/{src}: {key}")
|
||||||
|
else:
|
||||||
|
lines.append("\n✅ *No active issues*")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|||||||
@@ -30,7 +30,13 @@ async def send_telegram(message: str, parse_mode: str = "Markdown"):
|
|||||||
"text": message,
|
"text": message,
|
||||||
"parse_mode": parse_mode,
|
"parse_mode": parse_mode,
|
||||||
}) as resp:
|
}) as resp:
|
||||||
return resp.status == 200
|
ok = resp.status == 200
|
||||||
|
if ok:
|
||||||
|
logger.info(f"Telegram sent: {message[:80]}")
|
||||||
|
else:
|
||||||
|
body = await resp.text()
|
||||||
|
logger.error(f"Telegram send HTTP {resp.status}: {body[:200]}")
|
||||||
|
return ok
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Telegram send error: {e}")
|
logger.error(f"Telegram send error: {e}")
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Synology DSM API client for NAS monitoring."""
|
"""Synology DSM API client for NAS monitoring."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -13,16 +14,28 @@ class SynologyClient:
|
|||||||
|
|
||||||
def __init__(self, host: str, port: int = 5000, https: bool = False,
|
def __init__(self, host: str, port: int = 5000, https: bool = False,
|
||||||
username: str = "", password: str = ""):
|
username: str = "", password: str = ""):
|
||||||
|
# Handle case where host already contains protocol
|
||||||
|
if host.startswith("http://") or host.startswith("https://"):
|
||||||
|
clean = host.replace("https://", "").replace("http://", "").rstrip("/")
|
||||||
|
if ":" in clean:
|
||||||
|
host = clean.split(":")[0]
|
||||||
|
port = int(clean.split(":")[1])
|
||||||
|
else:
|
||||||
|
host = clean
|
||||||
|
if "https" in host:
|
||||||
|
https = True
|
||||||
|
|
||||||
proto = "https" if https else "http"
|
proto = "https" if https else "http"
|
||||||
self.base_url = f"{proto}://{host}:{port}"
|
self.base_url = f"{proto}://{host}:{port}"
|
||||||
self.username = username
|
self.username = username
|
||||||
self.password = password
|
self.password = password
|
||||||
self.sid: Optional[str] = None
|
self.sid: Optional[str] = None
|
||||||
|
logger.info(f"SynologyClient init: base_url={self.base_url}")
|
||||||
|
|
||||||
async def login(self) -> bool:
|
async def login(self) -> bool:
|
||||||
"""Authenticate and get session ID."""
|
"""Authenticate and get session ID."""
|
||||||
try:
|
try:
|
||||||
url = f"{self.base_url}/webapi/auth.cgi"
|
url = f"{self.base_url}/webapi/entry.cgi"
|
||||||
params = {
|
params = {
|
||||||
"api": "SYNO.API.Auth",
|
"api": "SYNO.API.Auth",
|
||||||
"version": "6",
|
"version": "6",
|
||||||
@@ -32,17 +45,20 @@ class SynologyClient:
|
|||||||
"session": "VPSMonitor",
|
"session": "VPSMonitor",
|
||||||
"format": "sid",
|
"format": "sid",
|
||||||
}
|
}
|
||||||
|
logger.info(f"Synology login attempt: {url}")
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10),
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10),
|
||||||
ssl=False) as resp:
|
ssl=False) as resp:
|
||||||
data = await resp.json()
|
# content_type=None — Synology may return JSON as text/plain
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
if data.get("success"):
|
if data.get("success"):
|
||||||
self.sid = data["data"]["sid"]
|
self.sid = data["data"]["sid"]
|
||||||
|
logger.info("Synology login OK")
|
||||||
return True
|
return True
|
||||||
logger.error(f"Synology login failed: {data}")
|
logger.error(f"Synology login failed: {data}")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Synology login error: {e}")
|
logger.error(f"Synology login error: {type(e).__name__}: {e}\n{traceback.format_exc()}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def _api_call(self, cgi: str, api: str, version: str = "1",
|
async def _api_call(self, cgi: str, api: str, version: str = "1",
|
||||||
@@ -66,7 +82,7 @@ class SynologyClient:
|
|||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15),
|
async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=15),
|
||||||
ssl=False) as resp:
|
ssl=False) as resp:
|
||||||
data = await resp.json()
|
data = await resp.json(content_type=None)
|
||||||
if data.get("success"):
|
if data.get("success"):
|
||||||
return data.get("data", {})
|
return data.get("data", {})
|
||||||
# Session expired? Re-login once
|
# Session expired? Re-login once
|
||||||
@@ -75,12 +91,13 @@ class SynologyClient:
|
|||||||
if await self.login():
|
if await self.login():
|
||||||
params["_sid"] = self.sid
|
params["_sid"] = self.sid
|
||||||
async with session.get(url, params=params, ssl=False) as resp2:
|
async with session.get(url, params=params, ssl=False) as resp2:
|
||||||
data2 = await resp2.json()
|
data2 = await resp2.json(content_type=None)
|
||||||
if data2.get("success"):
|
if data2.get("success"):
|
||||||
return data2.get("data", {})
|
return data2.get("data", {})
|
||||||
|
logger.warning(f"Synology API {api} returned: {data}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Synology API error ({api}): {e}")
|
logger.error(f"Synology API error ({api}): {type(e).__name__}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def get_system_info(self) -> Optional[dict]:
|
async def get_system_info(self) -> Optional[dict]:
|
||||||
@@ -124,6 +141,7 @@ class SynologyClient:
|
|||||||
"""Collect all metrics in one go."""
|
"""Collect all metrics in one go."""
|
||||||
result = {
|
result = {
|
||||||
"online": False,
|
"online": False,
|
||||||
|
"error": "",
|
||||||
"system": {},
|
"system": {},
|
||||||
"cpu_percent": 0,
|
"cpu_percent": 0,
|
||||||
"ram_percent": 0,
|
"ram_percent": 0,
|
||||||
@@ -139,16 +157,29 @@ class SynologyClient:
|
|||||||
"dsm_version": "",
|
"dsm_version": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Login first to get clear error
|
||||||
|
if not self.sid:
|
||||||
|
try:
|
||||||
|
logged_in = await self.login()
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"Login error: {type(e).__name__}"
|
||||||
|
return result
|
||||||
|
if not logged_in:
|
||||||
|
result["error"] = "Login failed — check host/port/credentials"
|
||||||
|
return result
|
||||||
|
|
||||||
# System info
|
# System info
|
||||||
sys_info = await self.get_system_info()
|
sys_info = await self.get_system_info()
|
||||||
if sys_info is None:
|
if sys_info is None:
|
||||||
|
result["error"] = "Failed to get system info"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
result["online"] = True
|
result["online"] = True
|
||||||
result["model"] = sys_info.get("model", "")
|
result["model"] = sys_info.get("model", "")
|
||||||
result["dsm_version"] = f"DSM {sys_info.get('version_string', '')}"
|
ver_str = sys_info.get("version_string", "")
|
||||||
|
result["dsm_version"] = ver_str if ver_str.startswith("DSM") else f"DSM {ver_str}"
|
||||||
result["temperature"] = sys_info.get("temperature", 0)
|
result["temperature"] = sys_info.get("temperature", 0)
|
||||||
result["uptime"] = _format_uptime(sys_info.get("up_time", 0))
|
result["uptime"] = _format_uptime(sys_info.get("uptime", sys_info.get("up_time", 0)))
|
||||||
|
|
||||||
# CPU / Memory
|
# CPU / Memory
|
||||||
util = await self.get_cpu_memory()
|
util = await self.get_cpu_memory()
|
||||||
@@ -173,8 +204,8 @@ class SynologyClient:
|
|||||||
storage = await self.get_storage()
|
storage = await self.get_storage()
|
||||||
if storage:
|
if storage:
|
||||||
for vol in storage.get("volumes", []):
|
for vol in storage.get("volumes", []):
|
||||||
total_bytes = vol.get("size", {}).get("total", 0)
|
total_bytes = int(vol.get("size", {}).get("total", 0) or 0)
|
||||||
used_bytes = vol.get("size", {}).get("used", 0)
|
used_bytes = int(vol.get("size", {}).get("used", 0) or 0)
|
||||||
total_gb = total_bytes / (1024 ** 3) if total_bytes else 0
|
total_gb = total_bytes / (1024 ** 3) if total_bytes else 0
|
||||||
used_gb = used_bytes / (1024 ** 3) if used_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
|
percent = round(used_gb / total_gb * 100, 1) if total_gb > 0 else 0
|
||||||
@@ -225,7 +256,7 @@ class SynologyClient:
|
|||||||
"""Close session."""
|
"""Close session."""
|
||||||
if self.sid:
|
if self.sid:
|
||||||
try:
|
try:
|
||||||
await self._api_call("auth.cgi", "SYNO.API.Auth", version="6", method="logout")
|
await self._api_call("entry.cgi", "SYNO.API.Auth", version="6", method="logout")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self.sid = None
|
self.sid = None
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ bot_app: Optional[Application] = None
|
|||||||
|
|
||||||
|
|
||||||
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
settings = load_settings()
|
|
||||||
servers = load_servers()
|
servers = load_servers()
|
||||||
metrics = get_all_metrics()
|
metrics = get_all_metrics()
|
||||||
|
|
||||||
@@ -28,17 +27,17 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
offline = len(servers) - online
|
offline = len(servers) - online
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
f"🖥 **VPS Monitoring**\n\n"
|
f"🖥 *VPS Monitoring*\n\n"
|
||||||
f"Серверов: {len(servers)}\n"
|
f"Servers: {len(servers)}\n"
|
||||||
f"🟢 Online: {online}\n"
|
f"🟢 Online: {online}\n"
|
||||||
f"🔴 Offline: {offline}\n\n"
|
f"🔴 Offline: {offline}\n\n"
|
||||||
f"Последнее обновление: {datetime.now().strftime('%H:%M:%S')}"
|
f"Updated: {datetime.now().strftime('%H:%M:%S')}"
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard = [
|
keyboard = [
|
||||||
[InlineKeyboardButton("📊 Статус серверов", callback_data="status")],
|
[InlineKeyboardButton("📊 Full Status", callback_data="fullstatus")],
|
||||||
[InlineKeyboardButton("🔄 Обновить", callback_data="refresh")],
|
[InlineKeyboardButton("🔄 Refresh", callback_data="refresh")],
|
||||||
[InlineKeyboardButton("⚙️ Управление", callback_data="manage")],
|
[InlineKeyboardButton("⚙️ Manage", callback_data="manage")],
|
||||||
]
|
]
|
||||||
|
|
||||||
# WebApp button (HTTPS via nip.io for valid SSL)
|
# WebApp button (HTTPS via nip.io for valid SSL)
|
||||||
@@ -57,6 +56,32 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
await update.message.reply_text(text, reply_markup=reply_markup, parse_mode="Markdown")
|
await update.message.reply_text(text, reply_markup=reply_markup, parse_mode="Markdown")
|
||||||
|
|
||||||
|
|
||||||
|
async def status_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Handle /status command - full status of all systems."""
|
||||||
|
from server.services.alerter import get_full_status
|
||||||
|
text = await get_full_status()
|
||||||
|
keyboard = [[InlineKeyboardButton("🔄 Refresh", callback_data="fullstatus")]]
|
||||||
|
await update.message.reply_text(
|
||||||
|
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def fullstatus_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
|
"""Show full status across all 4 tabs."""
|
||||||
|
query = update.callback_query
|
||||||
|
await query.answer()
|
||||||
|
|
||||||
|
from server.services.alerter import get_full_status
|
||||||
|
text = await get_full_status()
|
||||||
|
keyboard = [
|
||||||
|
[InlineKeyboardButton("🔄 Refresh", callback_data="fullstatus")],
|
||||||
|
[InlineKeyboardButton("◀️ Back", callback_data="back")],
|
||||||
|
]
|
||||||
|
await query.edit_message_text(
|
||||||
|
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
query = update.callback_query
|
query = update.callback_query
|
||||||
await query.answer()
|
await query.answer()
|
||||||
@@ -65,10 +90,10 @@ async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
metrics = get_all_metrics()
|
metrics = get_all_metrics()
|
||||||
|
|
||||||
if not servers:
|
if not servers:
|
||||||
await query.edit_message_text("Нет добавленных серверов")
|
await query.edit_message_text("No servers added")
|
||||||
return
|
return
|
||||||
|
|
||||||
text = "📊 **Статус серверов:**\n\n"
|
text = "📊 *VPS Servers:*\n\n"
|
||||||
for srv in servers:
|
for srv in servers:
|
||||||
m = metrics.get(srv["host"], {})
|
m = metrics.get(srv["host"], {})
|
||||||
status = "🟢" if m.get("online") else "🔴"
|
status = "🟢" if m.get("online") else "🔴"
|
||||||
@@ -76,13 +101,13 @@ async def status_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|||||||
ram = m.get("ram_percent", 0)
|
ram = m.get("ram_percent", 0)
|
||||||
disk = m.get("disk_percent", 0)
|
disk = m.get("disk_percent", 0)
|
||||||
text += (
|
text += (
|
||||||
f"{status} **{srv['name']}** ({srv['host']})\n"
|
f"{status} *{srv['name']}* ({srv['host']})\n"
|
||||||
f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n"
|
f" CPU: {cpu}% | RAM: {ram}% | Disk: {disk}%\n\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
keyboard = [
|
keyboard = [
|
||||||
[InlineKeyboardButton("🔄 Обновить", callback_data="status")],
|
[InlineKeyboardButton("🔄 Refresh", callback_data="status")],
|
||||||
[InlineKeyboardButton("◀️ Назад", callback_data="back")],
|
[InlineKeyboardButton("◀️ Back", callback_data="back")],
|
||||||
]
|
]
|
||||||
await query.edit_message_text(
|
await query.edit_message_text(
|
||||||
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
text, reply_markup=InlineKeyboardMarkup(keyboard), parse_mode="Markdown"
|
||||||
@@ -281,14 +306,20 @@ async def start_bot():
|
|||||||
bot_app = Application.builder().token(token).build()
|
bot_app = Application.builder().token(token).build()
|
||||||
|
|
||||||
bot_app.add_handler(CommandHandler("start", start_command))
|
bot_app.add_handler(CommandHandler("start", start_command))
|
||||||
|
bot_app.add_handler(CommandHandler("status", status_command))
|
||||||
|
bot_app.add_handler(CallbackQueryHandler(fullstatus_callback, pattern="^fullstatus$"))
|
||||||
bot_app.add_handler(CallbackQueryHandler(status_callback, pattern="^status$"))
|
bot_app.add_handler(CallbackQueryHandler(status_callback, pattern="^status$"))
|
||||||
bot_app.add_handler(CallbackQueryHandler(manage_callback, pattern="^manage$"))
|
bot_app.add_handler(CallbackQueryHandler(manage_callback, pattern="^manage$"))
|
||||||
bot_app.add_handler(CallbackQueryHandler(server_action_callback, pattern="^(server_|reboot_|confirm_reboot_|refresh_|refresh$|back$)"))
|
bot_app.add_handler(CallbackQueryHandler(server_action_callback, pattern="^(server_|reboot_|confirm_reboot_|refresh_|refresh$|back$)"))
|
||||||
|
|
||||||
|
try:
|
||||||
await bot_app.initialize()
|
await bot_app.initialize()
|
||||||
await bot_app.start()
|
await bot_app.start()
|
||||||
await bot_app.updater.start_polling(drop_pending_updates=True)
|
await bot_app.updater.start_polling(drop_pending_updates=True)
|
||||||
logger.info("Telegram bot started")
|
logger.info("Telegram bot started")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Telegram bot failed to start: {e}")
|
||||||
|
bot_app = None
|
||||||
|
|
||||||
|
|
||||||
async def stop_bot():
|
async def stop_bot():
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# VPS Monitor — Windows Agent Installer
|
# VPS Monitor - Windows Agent Installer
|
||||||
# Usage: powershell -ExecutionPolicy Bypass -File install_agent.ps1 -ServerUrl "http://IP:7272" -AgentName "MyPC"
|
# Usage (PowerShell Admin): .\install_agent.ps1 -ServerUrl "http://IP" -AgentName "MyPC"
|
||||||
|
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
@@ -14,7 +14,7 @@ $InstallDir = "C:\VPS-Monitor"
|
|||||||
$TaskName = "VPS-Monitor-Agent"
|
$TaskName = "VPS-Monitor-Agent"
|
||||||
|
|
||||||
Write-Host "================================================" -ForegroundColor Cyan
|
Write-Host "================================================" -ForegroundColor Cyan
|
||||||
Write-Host " VPS Monitor — Agent Installer" -ForegroundColor Cyan
|
Write-Host " VPS Monitor - Agent Installer" -ForegroundColor Cyan
|
||||||
Write-Host "================================================" -ForegroundColor Cyan
|
Write-Host "================================================" -ForegroundColor Cyan
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " Server: $ServerUrl" -ForegroundColor White
|
Write-Host " Server: $ServerUrl" -ForegroundColor White
|
||||||
@@ -32,15 +32,17 @@ if (-not (Test-Path $InstallDir)) {
|
|||||||
# Download agent script
|
# Download agent script
|
||||||
Write-Host "[*] Downloading agent..." -ForegroundColor Yellow
|
Write-Host "[*] Downloading agent..." -ForegroundColor Yellow
|
||||||
$AgentUrl = "$ServerUrl/static/downloads/vps_monitor_agent.ps1"
|
$AgentUrl = "$ServerUrl/static/downloads/vps_monitor_agent.ps1"
|
||||||
|
$AgentDest = "$InstallDir\vps_monitor_agent.ps1"
|
||||||
try {
|
try {
|
||||||
Invoke-WebRequest -Uri $AgentUrl -OutFile "$InstallDir\vps_monitor_agent.ps1" -UseBasicParsing
|
$wc = New-Object System.Net.WebClient
|
||||||
|
$wc.Encoding = [System.Text.Encoding]::UTF8
|
||||||
|
$wc.DownloadFile($AgentUrl, $AgentDest)
|
||||||
Write-Host "[+] Agent downloaded" -ForegroundColor Green
|
Write-Host "[+] Agent downloaded" -ForegroundColor Green
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host "[!] Download failed, using local copy..." -ForegroundColor Yellow
|
Write-Host "[!] Download failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
# Fallback: copy from same directory
|
|
||||||
$localAgent = Join-Path $PSScriptRoot "vps_monitor_agent.ps1"
|
$localAgent = Join-Path $PSScriptRoot "vps_monitor_agent.ps1"
|
||||||
if (Test-Path $localAgent) {
|
if (Test-Path $localAgent) {
|
||||||
Copy-Item $localAgent "$InstallDir\vps_monitor_agent.ps1"
|
Copy-Item $localAgent $AgentDest
|
||||||
Write-Host "[+] Local copy used" -ForegroundColor Green
|
Write-Host "[+] Local copy used" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
Write-Host "[-] No agent script found!" -ForegroundColor Red
|
Write-Host "[-] No agent script found!" -ForegroundColor Red
|
||||||
@@ -55,10 +57,10 @@ $config = @{
|
|||||||
interval = $Interval
|
interval = $Interval
|
||||||
} | ConvertTo-Json
|
} | ConvertTo-Json
|
||||||
|
|
||||||
Set-Content -Path "$InstallDir\agent_config.json" -Value $config
|
Set-Content -Path "$InstallDir\agent_config.json" -Value $config -Encoding UTF8
|
||||||
Write-Host "[+] Config saved" -ForegroundColor Green
|
Write-Host "[+] Config saved" -ForegroundColor Green
|
||||||
|
|
||||||
# Create scheduled task (run at startup + every 5 min check)
|
# Create scheduled task
|
||||||
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
if ($existingTask) {
|
if ($existingTask) {
|
||||||
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||||
@@ -71,7 +73,6 @@ $action = New-ScheduledTaskAction `
|
|||||||
-WorkingDirectory $InstallDir
|
-WorkingDirectory $InstallDir
|
||||||
|
|
||||||
$triggerStartup = New-ScheduledTaskTrigger -AtStartup
|
$triggerStartup = New-ScheduledTaskTrigger -AtStartup
|
||||||
$triggerNow = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 1)
|
|
||||||
|
|
||||||
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@ Register-ScheduledTask `
|
|||||||
-Trigger $triggerStartup `
|
-Trigger $triggerStartup `
|
||||||
-Principal $principal `
|
-Principal $principal `
|
||||||
-Settings $settings `
|
-Settings $settings `
|
||||||
-Description "VPS Monitor PC Agent — sends metrics to $ServerUrl" | Out-Null
|
-Description "VPS Monitor PC Agent" | Out-Null
|
||||||
|
|
||||||
Write-Host "[+] Scheduled task created" -ForegroundColor Green
|
Write-Host "[+] Scheduled task created" -ForegroundColor Green
|
||||||
|
|
||||||
@@ -106,9 +107,9 @@ Write-Host " Task: $TaskName" -ForegroundColor White
|
|||||||
Write-Host " Status: Running" -ForegroundColor White
|
Write-Host " Status: Running" -ForegroundColor White
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " Commands:" -ForegroundColor Gray
|
Write-Host " Commands:" -ForegroundColor Gray
|
||||||
Write-Host " Check: Get-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
Write-Host " Check: Get-ScheduledTask $TaskName" -ForegroundColor Gray
|
||||||
Write-Host " Stop: Stop-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
Write-Host " Stop: Stop-ScheduledTask $TaskName" -ForegroundColor Gray
|
||||||
Write-Host " Start: Start-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
Write-Host " Start: Start-ScheduledTask $TaskName" -ForegroundColor Gray
|
||||||
Write-Host " Remove: Unregister-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
Write-Host " Remove: Unregister-ScheduledTask $TaskName" -ForegroundColor Gray
|
||||||
Write-Host " Logs: type $InstallDir\agent_config.json" -ForegroundColor Gray
|
Write-Host " Config: type $InstallDir\agent_config.json" -ForegroundColor Gray
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
166
windows/synology_tunnel.ps1
Normal file
166
windows/synology_tunnel.ps1
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# VPS Monitor — Synology SSH Tunnel
|
||||||
|
# Creates a reverse SSH tunnel so VPS can reach local Synology NAS
|
||||||
|
#
|
||||||
|
# Usage: .\synology_tunnel.ps1 -ServerUrl "http://IP" -LocalTarget "192.168.88.6:5000" -VpsPort 15000
|
||||||
|
|
||||||
|
param(
|
||||||
|
[string]$ServerUrl = "",
|
||||||
|
[string]$LocalTarget = "192.168.88.6:5000",
|
||||||
|
[int]$VpsPort = 15000,
|
||||||
|
[switch]$Install
|
||||||
|
)
|
||||||
|
|
||||||
|
$InstallDir = "C:\VPS-Monitor"
|
||||||
|
$TaskName = "VPS-Monitor-SynologyTunnel"
|
||||||
|
$KeyFile = "$InstallDir\synology_tunnel_key"
|
||||||
|
$ConfigFile = "$InstallDir\tunnel_config.json"
|
||||||
|
|
||||||
|
# Load config if exists
|
||||||
|
if (Test-Path $ConfigFile) {
|
||||||
|
$config = Get-Content $ConfigFile | ConvertFrom-Json
|
||||||
|
if (-not $ServerUrl) { $ServerUrl = $config.server_url }
|
||||||
|
if ($config.local_target) { $LocalTarget = $config.local_target }
|
||||||
|
if ($config.vps_port) { $VpsPort = $config.vps_port }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $ServerUrl) {
|
||||||
|
Write-Host "ERROR: -ServerUrl required" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Extract VPS IP from ServerUrl
|
||||||
|
$VpsHost = ($ServerUrl -replace "https?://", "") -replace ":[0-9]+$", ""
|
||||||
|
|
||||||
|
Write-Host "================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " VPS Monitor — Synology Tunnel" -ForegroundColor Cyan
|
||||||
|
Write-Host "================================================" -ForegroundColor Cyan
|
||||||
|
Write-Host " VPS: $VpsHost" -ForegroundColor White
|
||||||
|
Write-Host " Target: $LocalTarget" -ForegroundColor White
|
||||||
|
Write-Host " Port: $VpsPort (on VPS)" -ForegroundColor White
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
# Ensure install directory
|
||||||
|
if (-not (Test-Path $InstallDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Download SSH key from VPS
|
||||||
|
if (-not (Test-Path $KeyFile)) {
|
||||||
|
Write-Host "[*] Downloading tunnel SSH key..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$wc = New-Object System.Net.WebClient
|
||||||
|
$wc.DownloadFile("$ServerUrl/static/downloads/synology_tunnel_key", $KeyFile)
|
||||||
|
Write-Host "[+] Key downloaded" -ForegroundColor Green
|
||||||
|
} catch {
|
||||||
|
Write-Host "[!] Key download failed: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host " Make sure you're logged into the dashboard first" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fix key permissions (Windows — icacls)
|
||||||
|
icacls $KeyFile /inheritance:r /grant:r "${env:USERNAME}:(R)" 2>$null | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save config
|
||||||
|
$tunnelConfig = @{
|
||||||
|
server_url = $ServerUrl
|
||||||
|
local_target = $LocalTarget
|
||||||
|
vps_port = $VpsPort
|
||||||
|
vps_host = $VpsHost
|
||||||
|
} | ConvertTo-Json
|
||||||
|
Set-Content -Path $ConfigFile -Value $tunnelConfig
|
||||||
|
|
||||||
|
# Install mode — create scheduled task
|
||||||
|
if ($Install) {
|
||||||
|
Write-Host "[*] Creating scheduled task..." -ForegroundColor Yellow
|
||||||
|
|
||||||
|
$existingTask = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
|
||||||
|
if ($existingTask) {
|
||||||
|
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction `
|
||||||
|
-Execute "powershell.exe" `
|
||||||
|
-Argument "-ExecutionPolicy Bypass -WindowStyle Hidden -File `"$InstallDir\synology_tunnel.ps1`"" `
|
||||||
|
-WorkingDirectory $InstallDir
|
||||||
|
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtStartup
|
||||||
|
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
|
||||||
|
|
||||||
|
$settings = New-ScheduledTaskSettingsSet `
|
||||||
|
-AllowStartIfOnBatteries `
|
||||||
|
-DontStopIfGoingOnBatteries `
|
||||||
|
-RestartCount 999 `
|
||||||
|
-RestartInterval (New-TimeSpan -Minutes 1) `
|
||||||
|
-ExecutionTimeLimit (New-TimeSpan -Days 365)
|
||||||
|
|
||||||
|
Register-ScheduledTask `
|
||||||
|
-TaskName $TaskName `
|
||||||
|
-Action $action `
|
||||||
|
-Trigger $trigger `
|
||||||
|
-Principal $principal `
|
||||||
|
-Settings $settings `
|
||||||
|
-Description "VPS Monitor — SSH tunnel to Synology NAS via $VpsHost" | Out-Null
|
||||||
|
|
||||||
|
Write-Host "[+] Scheduled task created: $TaskName" -ForegroundColor Green
|
||||||
|
Start-ScheduledTask -TaskName $TaskName
|
||||||
|
Write-Host "[+] Tunnel started!" -ForegroundColor Green
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host " Commands:" -ForegroundColor Gray
|
||||||
|
Write-Host " Check: Get-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
||||||
|
Write-Host " Stop: Stop-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
||||||
|
Write-Host " Remove: Unregister-ScheduledTask '$TaskName'" -ForegroundColor Gray
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main loop — maintain SSH tunnel
|
||||||
|
Write-Host "[*] Starting tunnel loop..." -ForegroundColor Green
|
||||||
|
Write-Host " VPS:$VpsPort -> Local:$LocalTarget" -ForegroundColor Gray
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
$LocalIP = $LocalTarget.Split(":")[0]
|
||||||
|
$LocalPort = $LocalTarget.Split(":")[1]
|
||||||
|
|
||||||
|
while ($true) {
|
||||||
|
$ts = Get-Date -Format "HH:mm:ss"
|
||||||
|
|
||||||
|
# Check if ssh.exe available (Windows 10+)
|
||||||
|
$sshExe = Get-Command ssh.exe -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
if ($sshExe) {
|
||||||
|
Write-Host "[$ts] Connecting via ssh.exe..." -ForegroundColor Cyan
|
||||||
|
# ssh -N = no command, -R = reverse tunnel, -o = options for stability
|
||||||
|
& ssh.exe -N `
|
||||||
|
-o "StrictHostKeyChecking=no" `
|
||||||
|
-o "ServerAliveInterval=30" `
|
||||||
|
-o "ServerAliveCountMax=3" `
|
||||||
|
-o "ExitOnForwardFailure=yes" `
|
||||||
|
-o "BatchMode=yes" `
|
||||||
|
-i $KeyFile `
|
||||||
|
-R "127.0.0.1:${VpsPort}:${LocalIP}:${LocalPort}" `
|
||||||
|
"root@${VpsHost}" 2>&1
|
||||||
|
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
Write-Host "[$ts] SSH disconnected (exit: $exitCode)" -ForegroundColor Yellow
|
||||||
|
} else {
|
||||||
|
# Fallback to plink
|
||||||
|
$plinkPath = "$InstallDir\plink.exe"
|
||||||
|
if (-not (Test-Path $plinkPath)) {
|
||||||
|
Write-Host "[$ts] No ssh.exe or plink.exe found. Install OpenSSH or download plink." -ForegroundColor Red
|
||||||
|
Start-Sleep -Seconds 60
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "[$ts] Connecting via plink..." -ForegroundColor Cyan
|
||||||
|
echo y | & $plinkPath -N -batch `
|
||||||
|
-i $KeyFile `
|
||||||
|
-R "127.0.0.1:${VpsPort}:${LocalIP}:${LocalPort}" `
|
||||||
|
"root@${VpsHost}" 2>&1
|
||||||
|
|
||||||
|
Write-Host "[$ts] Plink disconnected" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Wait before reconnect
|
||||||
|
Write-Host "[$ts] Reconnecting in 10s..." -ForegroundColor Gray
|
||||||
|
Start-Sleep -Seconds 10
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# VPS Monitor — Windows PC Agent
|
# VPS Monitor - Windows PC Agent
|
||||||
# Sends system metrics to VPS Monitoring server
|
# Sends system metrics to VPS Monitoring server
|
||||||
#
|
#
|
||||||
# Install: Run install_agent.ps1
|
# Install: Run install_agent.ps1
|
||||||
@@ -13,7 +13,7 @@ param(
|
|||||||
# Load config
|
# Load config
|
||||||
$ConfigPath = Join-Path $PSScriptRoot "agent_config.json"
|
$ConfigPath = Join-Path $PSScriptRoot "agent_config.json"
|
||||||
if (Test-Path $ConfigPath) {
|
if (Test-Path $ConfigPath) {
|
||||||
$config = Get-Content $ConfigPath | ConvertFrom-Json
|
$config = Get-Content $ConfigPath -Raw | ConvertFrom-Json
|
||||||
if (-not $ServerUrl) { $ServerUrl = $config.server_url }
|
if (-not $ServerUrl) { $ServerUrl = $config.server_url }
|
||||||
if (-not $AgentName) { $AgentName = $config.agent_name }
|
if (-not $AgentName) { $AgentName = $config.agent_name }
|
||||||
if ($config.interval) { $Interval = $config.interval }
|
if ($config.interval) { $Interval = $config.interval }
|
||||||
@@ -26,7 +26,7 @@ if (-not $ServerUrl -or -not $AgentName) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "================================================" -ForegroundColor Cyan
|
Write-Host "================================================" -ForegroundColor Cyan
|
||||||
Write-Host " VPS Monitor — PC Agent" -ForegroundColor Cyan
|
Write-Host " VPS Monitor - PC Agent" -ForegroundColor Cyan
|
||||||
Write-Host " Server: $ServerUrl" -ForegroundColor Gray
|
Write-Host " Server: $ServerUrl" -ForegroundColor Gray
|
||||||
Write-Host " Name: $AgentName" -ForegroundColor Gray
|
Write-Host " Name: $AgentName" -ForegroundColor Gray
|
||||||
Write-Host " Interval: ${Interval}s" -ForegroundColor Gray
|
Write-Host " Interval: ${Interval}s" -ForegroundColor Gray
|
||||||
@@ -44,9 +44,19 @@ function Get-SystemMetrics {
|
|||||||
$metrics["os"] = "$($os.Caption) $($os.Version)"
|
$metrics["os"] = "$($os.Caption) $($os.Version)"
|
||||||
|
|
||||||
# CPU
|
# CPU
|
||||||
$cpu = Get-CimInstance Win32_Processor
|
try {
|
||||||
|
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
|
||||||
$metrics["cpu_name"] = $cpu.Name
|
$metrics["cpu_name"] = $cpu.Name
|
||||||
$metrics["cpu_percent"] = [math]::Round((Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction SilentlyContinue).CounterSamples[0].CookedValue, 1)
|
$counter = Get-Counter '\Processor(_Total)\% Processor Time' -ErrorAction SilentlyContinue
|
||||||
|
if ($counter) {
|
||||||
|
$metrics["cpu_percent"] = [math]::Round($counter.CounterSamples[0].CookedValue, 1)
|
||||||
|
} else {
|
||||||
|
$metrics["cpu_percent"] = 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
$metrics["cpu_percent"] = 0
|
||||||
|
$metrics["cpu_name"] = "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
# RAM
|
# RAM
|
||||||
$totalRam = [math]::Round($os.TotalVisibleMemorySize / 1024, 0)
|
$totalRam = [math]::Round($os.TotalVisibleMemorySize / 1024, 0)
|
||||||
@@ -82,18 +92,24 @@ function Get-SystemMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Network
|
# Network
|
||||||
|
try {
|
||||||
$adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" }
|
$adapters = Get-NetAdapter -Physical -ErrorAction SilentlyContinue | Where-Object { $_.Status -eq "Up" }
|
||||||
$netInfo = @()
|
$netInfo = @()
|
||||||
foreach ($a in $adapters) {
|
foreach ($a in $adapters) {
|
||||||
$stats = Get-NetAdapterStatistics -Name $a.Name -ErrorAction SilentlyContinue
|
$stats = Get-NetAdapterStatistics -Name $a.Name -ErrorAction SilentlyContinue
|
||||||
|
$speed = 0
|
||||||
|
try { $speed = [math]::Round($a.LinkSpeed.Replace(" Gbps","000").Replace(" Mbps","") -as [double], 0) } catch {}
|
||||||
$netInfo += @{
|
$netInfo += @{
|
||||||
name = $a.Name
|
name = $a.Name
|
||||||
speed_mbps = [math]::Round($a.LinkSpeed.Replace(" Gbps","000").Replace(" Mbps","") -as [double], 0)
|
speed_mbps = $speed
|
||||||
sent_mb = if ($stats) { [math]::Round($stats.SentBytes / 1MB, 0) } else { 0 }
|
sent_mb = if ($stats) { [math]::Round($stats.SentBytes / 1MB, 0) } else { 0 }
|
||||||
received_mb = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB, 0) } else { 0 }
|
received_mb = if ($stats) { [math]::Round($stats.ReceivedBytes / 1MB, 0) } else { 0 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$metrics["network"] = $netInfo
|
$metrics["network"] = $netInfo
|
||||||
|
} catch {
|
||||||
|
$metrics["network"] = @()
|
||||||
|
}
|
||||||
|
|
||||||
# Uptime
|
# Uptime
|
||||||
$uptime = (Get-Date) - $os.LastBootUpTime
|
$uptime = (Get-Date) - $os.LastBootUpTime
|
||||||
@@ -110,9 +126,13 @@ function Get-SystemMetrics {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
# Top processes by CPU
|
# Top processes by CPU
|
||||||
|
try {
|
||||||
$topProcs = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 |
|
$topProcs = Get-Process | Sort-Object CPU -Descending | Select-Object -First 5 |
|
||||||
ForEach-Object { @{ name = $_.ProcessName; cpu = [math]::Round($_.CPU, 1); ram_mb = [math]::Round($_.WorkingSet64 / 1MB, 0) } }
|
ForEach-Object { @{ name = $_.ProcessName; cpu = [math]::Round($_.CPU, 1); ram_mb = [math]::Round($_.WorkingSet64 / 1MB, 0) } }
|
||||||
$metrics["top_processes"] = $topProcs
|
$metrics["top_processes"] = $topProcs
|
||||||
|
} catch {
|
||||||
|
$metrics["top_processes"] = @()
|
||||||
|
}
|
||||||
|
|
||||||
return $metrics
|
return $metrics
|
||||||
}
|
}
|
||||||
@@ -125,12 +145,10 @@ function Send-Metrics($metrics) {
|
|||||||
} | ConvertTo-Json -Depth 5
|
} | ConvertTo-Json -Depth 5
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = Invoke-RestMethod -Uri "$ServerUrl/api/pc/heartbeat" `
|
$wc = New-Object System.Net.WebClient
|
||||||
-Method POST `
|
$wc.Headers.Add("Content-Type", "application/json")
|
||||||
-Body $body `
|
$wc.Encoding = [System.Text.Encoding]::UTF8
|
||||||
-ContentType "application/json" `
|
$null = $wc.UploadString("$ServerUrl/api/pc/heartbeat", "POST", $body)
|
||||||
-TimeoutSec 15
|
|
||||||
|
|
||||||
return $true
|
return $true
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host " [!] Send failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
Write-Host " [!] Send failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
@@ -150,7 +168,7 @@ while ($true) {
|
|||||||
$ok = Send-Metrics $metrics
|
$ok = Send-Metrics $metrics
|
||||||
|
|
||||||
if ($ok) {
|
if ($ok) {
|
||||||
Write-Host "[$ts] OK — CPU: $($metrics.cpu_percent)% RAM: $($metrics.ram_percent)% Disk: $($metrics.disk_percent)%" -ForegroundColor Green
|
Write-Host "[$ts] OK - CPU: $($metrics.cpu_percent)% RAM: $($metrics.ram_percent)% Disk: $($metrics.disk_percent)%" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host "[$ts] ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
Write-Host "[$ts] ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
|||||||
Reference in New Issue
Block a user